소스 검색

fix(archive): HTML-unescape 3MF Title metadata; correct VP name tooltip (#1658)

  ThreeMFParser._parse_3dmodel left XML-escaped values raw, so a Title of
  "PCB Vise & Solder Station" landed in the DB as the literal "&" and
  React re-escaped it on render to "&". Apply the same
  loop-until-stable html.unescape() the sibling ProjectPageParser already
  uses, uniformly across all <metadata> values.

  Same drop: rewrite the VP archive-name-source tooltip in all 11 locales.
  BambuStudio 2.7.x (PrintJob.cpp:314-325) overwrites the user-typed
  Send-dialog name with the slugified 3MF Title field whenever one is
  present, so the previous "handy if you renamed the job in the send dialog"
  copy was false. New text spells out the actual behavior; both Filename
  and Metadata modes often produce the same string for that reason.
maziggy 3 달 전
부모
커밋
96ce403554

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
CHANGELOG.md


+ 13 - 1
backend/app/services/archive.py

@@ -1,4 +1,5 @@
 import hashlib
+import html
 import json
 import logging
 import os
@@ -506,9 +507,20 @@ class ThreeMFParser:
             metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
             matches = re.findall(metadata_pattern, content)
 
+            # 3MF metadata values are XML-encoded — `&` becomes `&amp;`, etc.
+            # ProjectPageParser learned this the hard way: BambuStudio sometimes
+            # writes triple-encoded payloads (`&amp;amp;amp;`), so we unescape
+            # in a loop until the string stabilises. Without this, a Title like
+            # "Foo & Bar" lands in the DB as raw "Foo &amp; Bar" and React then
+            # double-escapes it on render to "Foo &amp;amp; Bar" (#1658).
             makerworld_fields = {}
             for name, value in matches:
-                makerworld_fields[name] = value.strip()
+                decoded = value.strip()
+                prev = None
+                while prev != decoded:
+                    prev = decoded
+                    decoded = html.unescape(decoded)
+                makerworld_fields[name] = decoded
 
             # Check for direct MakerWorld URL in content
             url_pattern = r'https?://makerworld\.com/[^\s<>"\']+/models/(\d+)'

+ 63 - 0
backend/tests/unit/services/test_archive_service.py

@@ -255,6 +255,69 @@ class TestArchiveThumbnails:
         assert parsed.get("_thumbnail_data") == b"PLATE1"
 
 
+class TestThreeMFMetadataHTMLUnescape:
+    """3MF `<metadata name="Title">…</metadata>` values are XML-encoded.
+    BambuStudio sometimes writes triple-encoded payloads (the
+    ProjectPageParser comment documents this). Without an unescape loop,
+    a Title like ``Foo & Bar`` lands in the DB as raw ``Foo &amp; Bar`` and
+    React then escapes the `&` on render to ``Foo &amp;amp; Bar`` — the
+    user-visible symptom reported on #1658."""
+
+    def test_title_with_ampersand_is_unescaped(self, tmp_path):
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        threemf_path = tmp_path / "ampersand.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr(
+                "3D/3dmodel.model",
+                '<?xml version="1.0" encoding="UTF-8"?>\n'
+                '<model><metadata name="Title">PCB Vise &amp; Solder Station</metadata>'
+                '<metadata name="Designer">Chefkoch</metadata></model>',
+            )
+
+        parsed = ThreeMFParser(str(threemf_path)).parse()
+        assert parsed.get("print_name") == "PCB Vise & Solder Station"
+        assert parsed.get("designer") == "Chefkoch"
+
+    def test_title_with_triple_encoded_ampersand_is_fully_unescaped(self, tmp_path):
+        """BambuStudio has been observed writing triple-encoded payloads
+        (`&amp;amp;amp;`). The decoder loops until the string stops changing
+        so all layers get peeled in one pass."""
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        threemf_path = tmp_path / "triple.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr(
+                "3D/3dmodel.model",
+                '<?xml version="1.0" encoding="UTF-8"?>\n'
+                '<model><metadata name="Title">Foo &amp;amp;amp; Bar</metadata></model>',
+            )
+
+        parsed = ThreeMFParser(str(threemf_path)).parse()
+        assert parsed.get("print_name") == "Foo & Bar"
+
+    def test_title_without_entities_passes_through_unchanged(self, tmp_path):
+        """The unescape loop must be a no-op when there's nothing to unescape —
+        regression guard against accidentally munging plain ASCII titles."""
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        threemf_path = tmp_path / "plain.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr(
+                "3D/3dmodel.model",
+                '<?xml version="1.0" encoding="UTF-8"?>\n<model><metadata name="Title">Benchy</metadata></model>',
+            )
+
+        parsed = ThreeMFParser(str(threemf_path)).parse()
+        assert parsed.get("print_name") == "Benchy"
+
+
 class TestPrintableObjectsExtraction:
     """Tests for extracting printable objects count from 3MF files."""
 

+ 469 - 0
docs/onboarding-tour-plan.md

@@ -0,0 +1,469 @@
+# Bambuddy Onboarding Tour - Detailed Plan
+
+**Status:** Draft for review
+**Owner:** Onboarding admin
+**Audience:** Designer + frontend dev implementing the tour overlay
+**Scope:** Welcome modal + step-by-step in-app tour for fresh installs
+**Mascot:** BB (poses + expressions reference in the character sheet shipped 2026-06-07)
+
+---
+
+## Goals
+
+1. Eliminate the most common cause of closed-as-`invalid` issues: add-printer setup confusion (access code / LAN mode / Developer mode / discovery).
+2. Walk a brand-new user from zero to "first print sent via Bambuddy" in under 10 minutes.
+3. Surface all major Bambuddy features at the right depth (overview, not full docs) so users discover what's available without reading the wiki cover-to-cover.
+4. Hand off cleanly to existing self-service surfaces (Connection Diagnostic, Log Health Scanner, System page, wiki, Discord) — the tour points; the diagnostic surfaces do the work.
+
+## Out of scope
+
+- Per-feature settings panels (Auth providers, Postgres migration, Tailscale wiring) — covered by feature-specific docs.
+- AI/ML feature deep dives (Obico detection, failure response) — covered by per-printer opt-in flows that already exist.
+- The detection heuristic (per-user DB flag + backfill migration) — decided separately.
+
+---
+
+## Phase 0 - First contact
+
+### Step 0.1 - Welcome modal
+**Anchor:** centered modal, no DOM anchor
+**Conditions to show:** `users.onboarding_status IS NULL`
+**Content:**
+- BB mascot, "Let's get started" pose (1)
+- Headline: "Hallo! Welcome to Bambuddy."
+- Body: "Bambuddy replaces the Bambu Lab cloud with a local-first dashboard. Your data, prints, spools, and timelapses stay on your hardware. Want a 5-minute tour?"
+- Three buttons:
+  - `Tour starten` (primary, green)
+  - `Ich bin erfahren` (secondary, ghost)
+  - `Später erinnern` (text link, 7-day snooze)
+**Execute on action:**
+- Tour → continue to 0.2
+- Experienced → `PATCH /api/users/me/onboarding {status: "dismissed"}`, close
+- Snooze → write `onboarding_snoozed_until = now + 7d`, close
+
+### Step 0.2 - What Bambuddy is (and isn't)
+**Anchor:** modal, BB "Let me walk you through it" pose (2)
+**Content:**
+- Two-column comparison:
+  - **What Bambuddy does:** local cloud replacement, AMS + inventory + RFID, print queue, archives, slicer integration via Virtual Printer, multi-user, HomeAssistant, optional Tailscale.
+  - **What Bambuddy isn't (today):** not a slicer (uses BambuStudio/OrcaSlicer), not a cloud service, not a printer firmware tool, not a Klipper UI.
+- One-line privacy note: "No telemetry. No accounts. bambuddy.cool only serves the docs."
+**Links:** wiki home, GitHub repo, Discord, sponsor portal
+**Buttons:** `Weiter` / `Überspringen`
+
+---
+
+## Phase 1 - Critical setup (everyone needs these)
+
+### Step 1.1 - Authentication setup
+**Anchor:** Settings → Auth tab (`/settings?tab=auth`, `[data-tour="auth-card"]`)
+**Conditions to show:** `auth_enabled === false` AND user is first admin
+**Content:**
+- BB "Thinking" expression
+- Headline: "Lock the front door first"
+- Body: "Bambuddy can run with or without authentication. If anyone else on your network (or your tailnet, or your reverse proxy) can reach this URL, turn auth on now — passwords, OIDC, SAML, and MFA are all built in."
+- Inline severity callout (yellow): "Bambuddy can also control your printers, manage files, and read your camera feeds. Treat the URL like an admin panel."
+**Buttons:**
+- `Enable auth now` → navigates to `/settings?tab=auth`, tour pauses, resumes on success
+- `Later (I'm on a private network)` → continue
+**Links:** wiki/security/authentication
+
+### Step 1.2 - Add your first printer (LOAD-BEARING)
+**Anchor:** Printers page (`/`), `[data-tour="add-printer-button"]`
+**Conditions to show:** `printers.count === 0`
+**Content:**
+- BB "Almost there!" pose (3)
+- Headline: "Add your first printer"
+- Body: "You'll need three things: model, IP address, and access code."
+- Inline checklist with info popovers:
+  1. **Model** — auto-detected on discovery, or pick manually (A1, A1 Mini, P1S, P1P, X1C, X1E, H2D, H2C, P2S).
+  2. **IP address** — found on the printer LCD under Settings → WLAN. Tip: assign a DHCP reservation in your router so it doesn't change.
+  3. **Access code** — printer LCD, model-specific path (see access-code popover below).
+- **Embedded "Where's my access code?" popover** — one model-specific image + path per model:
+  - A1 / A1 Mini: Settings → WLAN → info icon
+  - P1S / P1P: Settings → General → LAN-only Mode → access code displayed
+  - X1C / X1E: Settings → Network → LAN-only Mode → access code
+  - H2D / H2C: same as X1 family
+  - P2S: Settings → Network → LAN Mode
+- **Critical pre-flight warnings (red border):**
+  - "Enable **LAN-only mode** on the printer (X1 / H2 / P2S family) — without this, MQTT/FTP are blocked."
+  - "Enable **Developer Mode** on the printer LCD — required for MQTT control on most models."
+  - "Docker bridge mode users: discovery may not find the printer. Use **Add manually by IP**."
+**Buttons:**
+- `Add via discovery` → opens Add Printer modal with SSDP scan running
+- `Add manually by IP` → opens Add Printer modal in manual mode
+**On success (printer row appears in DB):** advance to 1.3
+**Links:** wiki/getting-started/add-printer, wiki/troubleshooting/discovery
+**Issue evidence this step prevents:** #1641, #1453, #1411, #1487, #1524, #1405
+
+### Step 1.3 - Verify the connection
+**Anchor:** newly-added printer card, `[data-tour="printer-status-pill"]`
+**Conditions to show:** at least one printer just added in this session
+**Content:**
+- BB "Focused" expression
+- Headline: "Let's make sure Bambuddy can talk to it"
+- Live status pills animate as the connection establishes:
+  - MQTT connect (port 8883)
+  - Camera stream (RTSPS 322, X1 / H2 / P2S only)
+  - File transfer (FTP 990)
+- "All green within 30 seconds" → next button enables
+- If yellow / red after 30s: surface a `Run full diagnostic` button
+**Execute on action:**
+- `POST /api/printers/{id}/diagnostic` → opens the existing Connection Diagnostic modal (the layer-1-through-8 triage feature shipped 2026-05-21).
+- Tour parses the diagnostic verdict and either shows "All green — let's continue" or "Found {N} issues — open diagnostic for details" with a deep link.
+**Links:** Connection Diagnostic, Log Health Scanner, wiki/troubleshooting
+
+### Step 1.4 - Tour the printer card
+**Anchor:** printer card, sequential highlights of each region
+**Content — 5 sub-highlights:**
+1. **Status row** — printer state, ETA, current stage. "Your at-a-glance status."
+2. **AMS row** — slots, RFID auto-detection, drying button. "Bambuddy reads your AMS slot config live — colors and types come from RFID, the rest from your inventory."
+3. **Camera tile** — live feed via RTSPS proxy. "Same stream BambuStudio uses, but local — no cloud round-trip."
+4. **Controls** — pause / resume / cancel, lights, fans. "Same controls as the printer LCD."
+5. **Customization** — "Right-click the card to rearrange tiles or hide what you don't need" (Printer Card Customization, see wiki/features/printer-card).
+**Buttons:** `Weiter` / `Überspringen Rest des Tours`
+
+---
+
+## Phase 2 - Core workflows (everyone benefits)
+
+### Step 2.1 - Inventory mode pick (irreversible)
+**Anchor:** Inventory page (`/inventory`)
+**Conditions to show:** `inventory_mode IS NULL` (not yet chosen)
+**Content:**
+- BB "Need help?" pose (4)
+- Headline: "Track your filament"
+- Body: "Bambuddy can keep tabs on your spools. Pick a mode now — switching later loses data."
+- Three large radio cards:
+  - **Internal (recommended)** — built-in inventory, mirrors AMS, reads RFID, auto-decrements weight as you print. Best for most users.
+  - **Spoolman** — point at an existing Spoolman instance, Bambuddy syncs from it. Best if you already run Spoolman.
+  - **None** — skip filament tracking entirely. You can change this later, but historical data won't backfill.
+- Inline note (yellow): "#1556 footgun — switching modes later does not migrate data."
+**Execute on action:** `PATCH /api/settings/inventory_mode {mode: "internal" | "spoolman" | "none"}`
+**Links:** wiki/features/inventory, wiki/features/spoolman
+**Issue evidence:** #1556, #1644, #1517, #1607, #1456
+
+### Step 2.2 - Add your first spool
+**Anchor:** Inventory page, `[data-tour="add-spool-button"]`
+**Conditions to show:** `inventory_mode === "internal"` AND `spools.count === 0`
+**Content:**
+- "Add a spool the way that suits you:"
+  - **RFID scan** (Bambu spools) — load it in the AMS, Bambuddy detects automatically. No manual entry needed.
+  - **SpoolBuddy kiosk** — if you have a SpoolBuddy box, scan RFID write tag for non-Bambu spools.
+  - **Manual entry** — brand, material, color, weight.
+- One-line note: "Bambuddy ships with a color catalog covering the major brands — names autocomplete as you type."
+**Buttons:** `Add manually` → opens Add Spool modal / `Use RFID` → goes to printer card highlighting AMS row / `Skip` → continue
+**Links:** wiki/features/inventory, wiki/features/spoolbuddy
+
+### Step 2.2b - Spoolman sync setup
+**Anchor:** Settings → Spoolman card (`#card-spoolman`)
+**Conditions to show:** `inventory_mode === "spoolman"`
+**Content:**
+- "Tell Bambuddy where Spoolman lives."
+- Inline form: Spoolman URL + sync direction (Spoolman → Bambuddy, or bi-directional).
+- "Bambuddy will pull your existing spool library and keep it in sync. RFID scans still work — they create new spools in Spoolman."
+**Execute on action:** `POST /api/settings/spoolman/test` → green = continue, red = stay on step with error.
+
+### Step 2.3 - Profile management (Bambu cloud sync)
+**Anchor:** Profiles page (`/profiles`), `[data-tour="bambu-cloud-sync"]`
+**Content:**
+- BB "Helpful" pose
+- Headline: "Sync your filament + print profiles from Bambu Lab"
+- Body: "If you've created custom filament or print profiles in BambuStudio + the Bambu cloud, Bambuddy can pull them down so they're available everywhere — assigned via the web UI, sent via Virtual Printer, used by the queue."
+- Inline form: Bambu Lab account email + password (or "Sign in later")
+- One-line warning: "Bambuddy stores credentials encrypted at rest and only uses them against the official Bambu API. Source is open."
+**Buttons:** `Sign in to Bambu` / `Skip (use built-in defaults)`
+**Links:** wiki/features/profiles, wiki/security/credential-storage
+
+### Step 2.4 - The print queue
+**Anchor:** Queue page (`/queue`), `[data-tour="add-to-queue-button"]`
+**Content:**
+- "Queue prints across all your printers."
+- Three things the queue can do, with a one-line example each:
+  1. **Manual queue** — drag-and-drop files, pick which printer runs them.
+  2. **Auto-dispatch** — Bambuddy assigns queued jobs to idle printers automatically based on AMS / build-plate / capacity.
+  3. **Auto-drying** — queued PETG / PA jobs trigger AMS pre-drying so the spool is ready when dispatch fires (Queue Auto-Drying, see wiki/features/queue-drying).
+- "Power features for later: dependencies (`require_previous_success`), scheduled prints, batch jobs."
+**Buttons:** `Weiter` / `Show me how to add my first job` → opens Add to Queue modal
+**Links:** wiki/features/queue, wiki/features/queue-drying
+
+### Step 2.5 - Archives + statistics
+**Anchor:** Archives page (`/archives`), then Stats (`/stats`)
+**Content:**
+- "Every finished print is archived automatically."
+- Two-screen mini-tour:
+  - **Archives** — thumbnail, timelapse video, finish photo, gcode, sliced 3MF, runtime, weight, filaments used per slot. "Re-print directly from any archive."
+  - **Statistics** — print hours, filament used (by brand / material / color), energy cost, time-saved, success rate.
+- "Cost tracking pulls electricity price from settings — set it once and stats compute energy spend per print."
+**Buttons:** `Weiter`
+**Links:** wiki/features/archives, wiki/features/statistics
+
+### Step 2.6 - Maintenance tracking
+**Anchor:** Maintenance page (`/maintenance`), `[data-tour="add-maintenance-task"]`
+**Content:**
+- BB "Helpful" pose
+- "Bambuddy tracks consumables and maintenance per printer."
+- Examples: nozzle wear (by print hours), belt tension (by month), hotend swap (by filament weight), grease (by print count).
+- "Built-in tasks cover the standard intervals — add your own for custom maintenance."
+- Notifications fire via the same channel as print events (see Step 3.8).
+**Buttons:** `Weiter` / `Show me the defaults` → highlights default-task list
+**Links:** wiki/features/maintenance
+
+### Step 2.7 - File library + projects
+**Anchor:** File Manager (`/files`)
+**Content:**
+- "Your library lives here — upload 3MF, gcode, STL; group into projects; send to any printer."
+- Two sub-highlights:
+  - **Files page** — flat browser, upload, tag, search, send-to.
+  - **Projects page** — group files into a logical project (multi-plate models, multi-part assemblies). Track which plates are printed; mark project complete when done.
+- "External library folders (see Phase 3) let you mount a NAS share if your files don't live inside the container."
+**Buttons:** `Weiter`
+**Links:** wiki/features/library, wiki/features/projects
+
+---
+
+## Phase 3 - Power features (offer, don't push)
+
+Each Phase 3 step starts with an "Interested?" gate — if the user clicks `Skip`, they jump to the next step without seeing the detail. The mascot uses the "Curious" expression for these.
+
+### Step 3.1 - Virtual Printer (intro only)
+**Anchor:** Settings → Virtual Printer card
+**Content:**
+- "Want BambuStudio / OrcaSlicer to send prints to Bambuddy instead of the cloud?"
+- Four-mode decision tree (one sentence each):
+  - **Bridge** — drop-in cloud replacement; slicer sends, Bambuddy forwards to the real printer.
+  - **Queue** — slicer sends to a virtual collector; Bambuddy queues for dispatch.
+  - **Proxy** — slicer points at Bambuddy, Bambuddy passes through with full MQTT/FTP/RTSP rewrite (best for multi-slicer setups).
+  - **Archive / Review** — slicer sends, Bambuddy stores but doesn't print. Audit / approval workflows.
+- "VP picks a free IP on your bind interface so it looks like a real printer to the slicer."
+- One-line warning: "Docker bridge mode needs port exposure — see the Docker wiki page for the FTP passive port slicing (#1646)."
+**Buttons:** `Set up a Virtual Printer` → opens VP wizard / `Show me later`
+**Links:** wiki/features/virtual-printer
+**Issue evidence:** #1652, #1604, #1594, #1612, #1527
+
+### Step 3.2 - Slicer API sidecar
+**Anchor:** Settings → Slicer API card
+**Conditions to show:** sidecar URL not configured
+**Content:**
+- "Slice directly inside Bambuddy from MakerWorld URLs or your library — no BambuStudio needed."
+- "Requires the orca-slicer-api sidecar container (separate docker-compose, link below). Bambuddy talks to it over HTTP."
+- One-line note: "Status: still maturing upstream (segfault on multi-filament 3MF being patched). Solid for single-filament / single-plate jobs today."
+**Buttons:** `Configure sidecar` → opens slicer URL field / `Skip`
+**Links:** github.com/maziggy/orca-slicer-api, wiki/features/slicer-api
+
+### Step 3.3 - External library folders
+**Anchor:** Settings → Library / external roots
+**Content:**
+- "Mount a NAS share, an external SSD, or a project drive — Bambuddy reads files in-place."
+- "Set `BAMBUDDY_EXTERNAL_ROOTS` in `docker-compose.yml`, bind-mount the host path. Bambuddy auto-shows folders in the File Manager."
+- One-line warning (red): "Use `:ro` (read-only) unless you specifically want users uploading back to the share."
+**Buttons:** `Weiter`
+**Links:** wiki/getting-started/docker, wiki/features/library-external
+
+### Step 3.4 - MakerWorld integration
+**Anchor:** MakerWorld page (`/makerworld`)
+**Conditions to show:** `permissions.has("makerworld:view")`
+**Content:**
+- "Paste any MakerWorld URL — Bambuddy downloads the 3MF, adds it to your library."
+- One-line note: "Direct search inside the UI was cut for this release — paste the URL from the MakerWorld site."
+- "Imports respect your external-folder layout — pick where the file lands."
+**Buttons:** `Try it now` → opens MakerWorld page / `Skip`
+**Links:** wiki/features/makerworld
+
+### Step 3.5 - Obico ML failure detection (opt-in per printer)
+**Anchor:** printer card → settings → Obico section
+**Content:**
+- "Self-hosted ML print-failure detection — no Obico cloud account, no telemetry."
+- "Bambuddy talks directly to your self-hosted Obico ML server. Opt-in per printer; off by default."
+- One-line note: "Smoothing / dead-zone tuning lives on the printer's Obico panel."
+**Buttons:** `Weiter`
+**Links:** wiki/features/obico, github.com/TheSpaghettiDetective/obico-server
+
+### Step 3.6 - HomeAssistant + webhooks
+**Anchor:** Settings → Integrations (`#card-integrations`)
+**Content:**
+- "Bambuddy ships first-class HomeAssistant integration — sensors for every printer (state, temp, ETA, AMS slots), services to start / pause / cancel."
+- "Webhooks fire on print events, queue events, archive events — useful for Discord bots, NodeRED, custom dashboards."
+- One-line note: "Webhook signing secret in Settings → Integrations."
+**Buttons:** `Weiter`
+**Links:** wiki/features/homeassistant, wiki/features/webhooks
+
+### Step 3.7 - Tailscale / remote access
+**Anchor:** Settings → Tailscale card
+**Conditions to show:** `/var/run/tailscale/tailscaled.sock` mounted OR `tailscale` binary detected on host
+**Content:**
+- "Access Bambuddy from anywhere via your tailnet — no port forwarding, no public exposure."
+- "MagicDNS HTTPS via Let's Encrypt (Bambuddy requests certs via `tailscale cert`)."
+- One-line note: "Read the Tailscale blog post about Bambuddy at [link] for the full setup walkthrough."
+**Buttons:** `Open Tailscale settings` / `Skip`
+**Links:** wiki/features/tailscale, tailscale blog post
+
+### Step 3.8 - Notifications
+**Anchor:** Notifications page (`/notifications`) and Settings → Notifications
+**Content:**
+- "Get told when prints finish, fail, or need attention."
+- Channels: in-app, browser push, Discord, Telegram, Pushover, Gotify, ntfy, email (SMTP), webhook.
+- "Per-event filters — only ping me on failures, route AMS humidity warnings to Discord, send finish photos via Telegram, etc."
+**Buttons:** `Configure now` / `Skip`
+**Links:** wiki/features/notifications
+
+---
+
+## Phase 4 - Multi-user setup (conditional)
+
+### Step 4.1 - Invite users
+**Anchor:** Settings → Users tab (`/settings?tab=users`)
+**Conditions to show:** `auth_enabled === true` AND `users.count === 1`
+**Content:**
+- "Add accounts for the rest of your household / team."
+- "Each user has their own permissions, print history, and notification settings. Print log shows who started which job (#1670 fix)."
+**Buttons:** `Add user` / `Skip`
+**Links:** wiki/features/multi-user
+
+### Step 4.2 - Groups & permissions
+**Anchor:** Settings → Users tab → Groups section
+**Content:**
+- "Group users by role. Bambuddy ships with default groups: Admin, Operator, Viewer."
+- Quick permission matrix: who can add printers / send prints / view archives / change settings.
+- "Build your own groups for custom roles (read-only kid account, full-access partner, etc.)."
+**Buttons:** `Weiter`
+**Links:** wiki/features/permissions
+
+### Step 4.3 - SSO (OIDC / SAML) and MFA
+**Anchor:** Settings → Auth tab
+**Conditions to show:** more than 3 users OR admin opens this section explicitly
+**Content:**
+- "Bambuddy supports OIDC (Authentik, Authelia, Keycloak, Google, GitHub) and SAML 2.0 for org SSO."
+- "Per-user MFA (TOTP). Encryption key auto-generates on first start (see #1219), override via env var for secret-manager workflows."
+**Buttons:** `Configure OIDC` / `Configure SAML` / `Enable MFA on my account` / `Skip`
+**Links:** wiki/security/authentication, wiki/security/oidc, wiki/security/saml, wiki/security/mfa
+
+---
+
+## Phase 5 - Outro
+
+### Step 5.1 - Where help lives
+**Anchor:** modal, BB "All set!" pose (5)
+**Content:**
+- Headline: "You're all set! Here's where to go when something's off."
+- Quick reference card (icon + one line each):
+  - **System page** (`/system`) — version, logs, debug bundle, support export.
+  - **Connection Diagnostic** — printer won't connect / camera black / FTP fails — open from the printer card menu.
+  - **Log Health Scanner** — recurring runtime issues with known-fix suggestions (shipped 2026-05-22).
+  - **Wiki** — wiki.bambuddy.cool, full feature docs.
+  - **Discord** — community help, faster than GitHub for usage questions.
+  - **GitHub Issues** — actual bugs and feature requests.
+**Buttons:** `Done`
+
+### Step 5.2 - Dismiss + sidebar re-entry
+**Anchor:** sidebar bottom, `[data-tour="help-icon"]`
+**Content:**
+- "Need to see this tour again? It lives here at the bottom of the sidebar (BB icon)."
+- Single highlight on the BB icon for 3 seconds, then close.
+**Execute on close:** `PATCH /api/users/me/onboarding {status: "completed_tour"}`
+
+---
+
+## Appendix A - Anchor selector strategy
+
+All anchors use stable `data-tour="<step-id>"` attributes added to the underlying components, NOT text matching against translated strings.
+
+Required selectors (full list — track in code review):
+- `[data-tour="add-printer-button"]` — PrintersPage
+- `[data-tour="printer-status-pill"]` — PrinterCard
+- `[data-tour="auth-card"]` — SettingsPage auth tab
+- `[data-tour="add-spool-button"]` — InventoryPage
+- `[data-tour="bambu-cloud-sync"]` — ProfilesPage
+- `[data-tour="add-to-queue-button"]` — QueuePage
+- `[data-tour="add-maintenance-task"]` — MaintenancePage
+- `[data-tour="help-icon"]` — Sidebar bottom (NEW, to be added)
+
+A vitest test walks the tour against the rendered DOM and asserts every anchor resolves. PRs that change a component carrying a tour anchor have to either keep the anchor or update the tour script.
+
+---
+
+## Appendix B - Tour state model
+
+```typescript
+type OnboardingStatus =
+  | null                          // never seen the modal
+  | "dismissed"                   // skipped at welcome modal
+  | "snoozed"                     // 7-day defer (see onboarding_snoozed_until)
+  | "completed_tour"              // finished phase 5
+  | "tour_in_progress:<step_id>"  // resume from here on next session
+  | "dismissed_at_migration";     // backfilled for existing installs
+
+// PATCH /api/users/me/onboarding
+//   body: { status: OnboardingStatus, snoozed_until?: ISO8601 }
+```
+
+- Mid-tour close = same as dismissed (`completed_tour`). No partial resume in v1.
+- Sidebar re-entry ignores the flag and restarts from Step 0.2 (skipping the welcome modal — they've already seen it).
+- Step 1.2 (Add Printer) and Step 2.1 (Inventory mode) skip themselves if the underlying state is already set (printer exists / inventory mode chosen) — useful when re-entering the tour after partial setup.
+
+---
+
+## Appendix C - i18n
+
+All step text, button labels, and tooltip strings live in `frontend/src/i18n/locales/*.ts` under a new `onboarding.*` namespace.
+
+Locales required at ship: de, en, es, fr, it, ja, ko, pt-BR, tr, zh-CN, zh-TW.
+
+CI gate: `check-i18n-parity.mjs` Check 4 fails on any English leak. NO `IDENTICAL_TO_EN_ALLOWED` entries for onboarding strings — the tour body is exactly the surface where users notice missing translations.
+
+---
+
+## Appendix D - Backend additions needed
+
+1. `users.onboarding_status` column (TEXT NULL) + Alembic migration that backfills `'dismissed_at_migration'` for all existing rows.
+2. `users.onboarding_snoozed_until` column (TIMESTAMP NULL).
+3. `PATCH /api/users/me/onboarding` route — body: `{status, snoozed_until?}`.
+4. `GET /api/users/me/onboarding` route — returns current status (frontend polls on app boot).
+5. Branch on `is_sqlite()` for the column types — `TEXT` and `TIMESTAMP` differ Postgres vs SQLite (`feedback_postgres_migration_types`, `feedback_sqlite_and_postgres_upfront`).
+
+---
+
+## Appendix E - Mascot asset inventory
+
+Poses needed (from the character sheet):
+1. Let's get started — Step 0.1, 5.1
+2. Let me walk you through it — Step 0.2
+3. Almost there — Step 1.2
+4. Need help? — Step 2.1
+5. All set — Step 5.1
+
+Expressions needed:
+- Happy — Phase 0, Phase 5
+- Thinking — Step 1.1
+- Focused — Step 1.3
+- Excited — Step 2.2 ("first spool added!" celebration)
+- Helpful — Steps 2.3, 2.6, 3.5
+- Curious — Phase 3 gates
+
+Branding elements: BB logo, leaf, filament spool, guidance arrow, setup checklist, foundation block — all already on the sheet.
+
+---
+
+## Appendix F - Issue evidence trail
+
+GitHub `invalid`-tagged issues this tour explicitly addresses:
+
+| Cluster | Tour step | Closed issues |
+|---------|-----------|---------------|
+| Access code / LAN mode / Dev mode | 1.2 | #1641, #1453, #1411, #1487, #1524, #1405 |
+| Connection failure not triaged | 1.3 → Diagnostic | #1527, #1612, #1604 |
+| VP mode confusion | 3.1 | #1652, #1604, #1594, #1612, #1527 |
+| Inventory mode switch destructive | 2.1 | #1556 |
+| Inventory not reflecting reality | 2.1 + 2.2 | #1644, #1517, #1607, #1456 |
+| Slicer-side mistaken as Bambuddy | 5.1 → "is this Bambuddy?" | #1597, #1525, #1582, #1579, #1578 |
+| Docker volume / data-loss | 1.2 inline warning | #1524, #1517, #1409 |
+
+---
+
+## Open questions
+
+1. Should Step 1.2 include an interactive "test the access code without saving" button (calls a one-shot MQTT connect with the entered creds), so users get instant feedback before committing the printer row?
+2. Should Phase 3 be entirely opt-in (the user clicks "Show me power features" from Phase 5) instead of inline at the end of Phase 2?
+3. Should the SpoolBuddy steps (kiosk-related) appear in the main tour, or only after SpoolBuddy hardware is detected on the network?
+4. What's the right balance between "tour the page" (Phase 2.4 - 2.7) and "tooltips on the page itself"? Some of these could be inline help instead of tour steps.
+5. Does the mascot character set need a "wrong" / "warning" expression for the inline red callouts in Step 1.2, or do plain icons work?

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -4466,7 +4466,7 @@ export default {
     },
     archiveNameSource: {
       title: 'Quelle des Archivnamens',
-      description: 'Lege fest, wie neue Archive benannt werden, wenn Dateien über den virtuellen Drucker eintreffen. "Metadaten" verwendet den im 3MF eingebetteten Titel des Slicers (Standard). "Dateiname" nutzt den Dateinamen, den Bambu Studio per FTP gesendet hat — praktisch, wenn der Job im Dialog "Zum Drucker senden" umbenannt wurde.',
+      description: 'Lege fest, wie neue Archive benannt werden, wenn Dateien über den virtuellen Drucker eintreffen. "Metadaten" verwendet den im 3MF eingebetteten Titel des Slicers (Standard). "Dateiname" nutzt den Dateinamen, den Bambu Studio per FTP gesendet hat. Hinweis: Bambu Studio überschreibt den im Dialog "Zum Drucker senden" eingegebenen Namen mit dem Titelfeld der 3MF, sofern eines vorhanden ist — beide Modi liefern daher oft denselben Wert.',
       metadata: 'Metadaten',
       filename: 'Dateiname',
     },

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -4479,7 +4479,7 @@ export default {
     },
     archiveNameSource: {
       title: 'Archive name source',
-      description: 'Choose how new archives are named when files arrive via the virtual printer. "Metadata" uses the slicer-embedded title from the 3MF (default). "Filename" uses the filename Bambu Studio sent over FTP — handy if you renamed the job in the "send to printer" dialog.',
+      description: 'Choose how new archives are named when files arrive via the virtual printer. "Metadata" uses the slicer-embedded Title from the 3MF (default). "Filename" uses the filename Bambu Studio sent over FTP. Note: Bambu Studio overwrites the name you type in the "send to printer" dialog with the 3MF\'s Title field whenever one is present, so both modes often produce the same string.',
       metadata: 'Metadata',
       filename: 'Filename',
     },

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -4475,7 +4475,7 @@ export default {
     },
     archiveNameSource: {
       title: 'Origen del nombre del archivo',
-      description: 'Elija cómo se nombran los archivos nuevos cuando llegan a través de la impresora virtual. «Metadatos» usa el título incrustado por el laminador del 3MF (predeterminado). «Nombre de archivo» usa el nombre de archivo que Bambu Studio envió por FTP — útil si renombró el trabajo en el diálogo de «enviar a la impresora».',
+      description: 'Elija cómo se nombran los archivos nuevos cuando llegan a través de la impresora virtual. «Metadatos» usa el título incrustado por el laminador del 3MF (predeterminado). «Nombre de archivo» usa el nombre de archivo que Bambu Studio envió por FTP. Nota: Bambu Studio sobrescribe el nombre que escribe en el diálogo «enviar a la impresora» con el campo Título del 3MF cuando existe, por lo que ambos modos suelen producir la misma cadena.',
       metadata: 'Metadatos',
       filename: 'Nombre de archivo',
     },

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -4514,7 +4514,7 @@ export default {
     },
     archiveNameSource: {
       title: 'Source du nom d\'archive',
-      description: 'Choisissez comment les nouvelles archives sont nommées lorsque les fichiers arrivent via l\'imprimante virtuelle. "Métadonnées" utilise le titre intégré par le slicer dans le 3MF (par défaut). "Nom de fichier" utilise le nom envoyé par Bambu Studio en FTP — pratique si vous avez renommé le job dans la boîte "envoyer à l\'imprimante".',
+      description: 'Choisissez comment les nouvelles archives sont nommées lorsque les fichiers arrivent via l\'imprimante virtuelle. "Métadonnées" utilise le titre intégré par le slicer dans le 3MF (par défaut). "Nom de fichier" utilise le nom envoyé par Bambu Studio en FTP. Note : Bambu Studio écrase le nom que vous saisissez dans la boîte "envoyer à l\'imprimante" avec le champ Titre du 3MF lorsque celui-ci est présent, les deux modes produisent donc souvent la même chaîne.',
       metadata: 'Métadonnées',
       filename: 'Nom de fichier',
     },

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -4513,7 +4513,7 @@ export default {
     },
     archiveNameSource: {
       title: 'Origine nome archivio',
-      description: 'Scegli come vengono nominati i nuovi archivi quando i file arrivano tramite la stampante virtuale. "Metadati" utilizza il titolo incorporato nello slicer dal 3MF (default). "Nome file" utilizza il nome che Bambu Studio ha inviato via FTP — utile se hai rinominato il job nella finestra "invia a stampante".',
+      description: 'Scegli come vengono nominati i nuovi archivi quando i file arrivano tramite la stampante virtuale. "Metadati" utilizza il titolo incorporato nello slicer dal 3MF (default). "Nome file" utilizza il nome che Bambu Studio ha inviato via FTP. Nota: Bambu Studio sovrascrive il nome digitato nella finestra "invia a stampante" con il campo Titolo del 3MF quando presente, quindi entrambe le modalità producono spesso la stessa stringa.',
       metadata: 'Metadati',
       filename: 'Nome file',
     },

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -4525,7 +4525,7 @@ export default {
     },
     archiveNameSource: {
       title: 'アーカイブ名のソース',
-      description: '仮想プリンター経由でファイルが到着したときに、新しいアーカイブをどのように命名するかを選択します。「メタデータ」は3MFに埋め込まれたスライサータイトル(デフォルト)を使用します。「ファイル名」はBambu StudioがFTPで送信したファイル名を使用します — 「プリンターに送信」ダイアログでジョブ名を変更した場合に便利です。',
+      description: '仮想プリンター経由でファイルが到着したときに、新しいアーカイブをどのように命名するかを選択します。「メタデータ」は3MFに埋め込まれたスライサータイトル(デフォルト)を使用します。「ファイル名」はBambu StudioがFTPで送信したファイル名を使用します。注: Bambu Studioは、3MFにタイトルフィールドが存在する場合、「プリンターに送信」ダイアログで入力した名前をその値で上書きするため、両方のモードで同じ文字列になることが多くあります。',
       metadata: 'メタデータ',
       filename: 'ファイル名',
     },

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -4206,7 +4206,7 @@ export default {
     },
     archiveNameSource: {
       title: '아카이브 이름 출처',
-      description: '가상 프린터를 통해 파일이 도착할 때 새 아카이브의 이름 지정 방법을 선택합니다. "메타데이터"는 3MF의 슬라이서 내장 제목을 사용합니다(기본값). "파일 이름"은 Bambu Studio가 FTP를 통해 전송한 파일 이름을 사용합니다.',
+      description: '가상 프린터를 통해 파일이 도착할 때 새 아카이브의 이름 지정 방법을 선택합니다. "메타데이터"는 3MF의 슬라이서 내장 제목을 사용합니다(기본값). "파일 이름"은 Bambu Studio가 FTP를 통해 전송한 파일 이름을 사용합니다. 참고: Bambu Studio는 3MF에 제목 필드가 있을 경우 "프린터로 보내기" 대화 상자에 입력한 이름을 해당 값으로 덮어쓰므로, 두 모드 모두 동일한 문자열이 표시되는 경우가 많습니다.',
       metadata: '메타데이터',
       filename: '파일 이름'
     },

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -4513,7 +4513,7 @@ export default {
     },
     archiveNameSource: {
       title: 'Origem do nome do arquivo',
-      description: 'Escolha como os novos arquivos são nomeados quando chegam pela impressora virtual. "Metadados" usa o título embutido pelo slicer no 3MF (padrão). "Nome do arquivo" usa o nome que o Bambu Studio enviou via FTP — útil se você renomeou o job no diálogo "enviar para impressora".',
+      description: 'Escolha como os novos arquivos são nomeados quando chegam pela impressora virtual. "Metadados" usa o título embutido pelo slicer no 3MF (padrão). "Nome do arquivo" usa o nome que o Bambu Studio enviou via FTP. Nota: o Bambu Studio sobrescreve o nome digitado no diálogo "enviar para impressora" com o campo Título do 3MF quando presente, portanto ambos os modos costumam produzir a mesma string.',
       metadata: 'Metadados',
       filename: 'Nome do arquivo',
     },

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -4430,7 +4430,7 @@ export default {
     },
     archiveNameSource: {
       title: 'Arşiv ad kaynağı',
-      description: 'Dosyalar sanal yazıcı aracılığıyla geldiğinde yeni arşivlerin nasıl adlandırılacağını seçin. "Meta veri" 3MF\'den dilimleyici-gömülü başlığı kullanır (varsayılan). "Dosya adı" Bambu Studio\'nun FTP üzerinden gönderdiği dosya adını kullanır — "yazıcıya gönder" iletişim kutusunda işi yeniden adlandırdıysanız kullanışlıdır.',
+      description: 'Dosyalar sanal yazıcı aracılığıyla geldiğinde yeni arşivlerin nasıl adlandırılacağını seçin. "Meta veri" 3MF\'den dilimleyici-gömülü başlığı kullanır (varsayılan). "Dosya adı" Bambu Studio\'nun FTP üzerinden gönderdiği dosya adını kullanır. Not: Bambu Studio, 3MF\'de bir Başlık alanı varsa "yazıcıya gönder" iletişim kutusunda yazdığınız adı bu alanla geçersiz kılar, bu nedenle her iki mod da genellikle aynı dizeyi üretir.',
       metadata: 'Meta veri',
       filename: 'Dosya adı',
     },

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -4455,7 +4455,7 @@ export default {
     },
     archiveNameSource: {
       title: '存档名称来源',
-      description: '选择通过虚拟打印机接收文件时新存档的命名方式。"元数据"使用 3MF 中嵌入的切片标题(默认)。"文件名"使用 Bambu Studio 通过 FTP 发送的文件名 — 当您在"发送到打印机"对话框中重命名作业时非常有用。',
+      description: '选择通过虚拟打印机接收文件时新存档的命名方式。"元数据"使用 3MF 中嵌入的切片标题(默认)。"文件名"使用 Bambu Studio 通过 FTP 发送的文件名。注意:当 3MF 包含标题字段时,Bambu Studio 会用该字段覆盖您在"发送到打印机"对话框中输入的名称,因此两种模式通常会产生相同的字符串。',
       metadata: '元数据',
       filename: '文件名',
     },

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -4455,7 +4455,7 @@ export default {
     },
     archiveNameSource: {
       title: '存檔名稱來源',
-      description: '選擇透過虛擬印表機接收檔案時新存檔的命名方式。"元資料"使用 3MF 中嵌入的切片標題(預設)。"檔名"使用 Bambu Studio 透過 FTP 傳送的檔案名稱 — 當您在"傳送到印表機"對話方塊中重新命名工作時非常有用。',
+      description: '選擇透過虛擬印表機接收檔案時新存檔的命名方式。"元資料"使用 3MF 中嵌入的切片標題(預設)。"檔名"使用 Bambu Studio 透過 FTP 傳送的檔案名稱。注意:當 3MF 包含標題欄位時,Bambu Studio 會以該欄位覆寫您在"傳送到印表機"對話方塊中輸入的名稱,因此兩種模式通常會產生相同的字串。',
       metadata: '元資料',
       filename: '檔名',
     },

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-B-fcW9HO.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-SS1wDI23.js"></script>
+    <script type="module" crossorigin src="/assets/index-B-fcW9HO.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DgecYhis.css">
   </head>
   <body>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.