Commit Graph
547 Commits
Author SHA1 Message Date
MartinNYHC b30a283184 Feature/spoolman inventory UI (#1241)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-08 11:52:42 +02:00
Sn0rrii 90743cfa39 feat(encryption): MFA at-rest encryption auto-bootstrap with status UI (#1219) (#1231)
chore(i18n): extend parity gate to all locales with strict/info tiers

  Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
  drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
  STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
  report informationally until their drift is caught up. ja notably has 27 real
  placeholder bugs worth fixing before promotion to strict.
2026-05-08 09:01:51 +02:00
maziggy 4c0a12b95e fix(label-picker): pack templates into a 2x2 grid so all 4 plus Cancel fit on tight viewports (issue #1230)
The earlier `min-h-0` fix on the spool list (61314cf2) made the
  shrinkable child shrinkable, but on @elit3ge's 838px viewport the
  four stacked templates (~310px) plus footer still blew past
  max-h-[90vh] once Brave's browser chrome ate into vh, and
  overflow-hidden on the modal clipped Avery 5160 mid-row with the
  Cancel button entirely below the clipped bottom edge — no scroll
  path. The screenshot showed the spool list at ~5 visible rows with
  its own scrollbar still active, confirming the templates section's
  natural height was the dominant problem, not the spool list.

  Templates now render as a responsive grid (grid-cols-1
  sm:grid-cols-2 gap-2) so the four buttons pack into a 2x2 grid
  above the sm breakpoint, trimming ~150px of vertical. Per-cell
  padding tightens to p-2.5, labels/hints get text-sm + truncate,
  and the full strings are reachable via title="<label> — <hint>"
  on each button. Footer drops py-3 to py-2 for a few extra pixels.
  The min-h-0 on the spool list is kept as a belt-and-braces shrink
  for any viewport tighter still. Mobile (<sm) keeps the stacked
  layout — no regression there.
2026-05-08 07:27:35 +02:00
maziggy 20fa8fbfdc fix(configure-ams-slot): expand long filament profile names inline on hover (issue #1237)
Long preset names like "SUNLU PETG GLOW IN THE DARK GEN2 @Bambu Lab
  H2C 0.4 nozzle" were visually clipped in the Configure AMS Slot
  modal's preset picker. With several near-identical entries differing
  only in nozzle size, users had to open browser dev tools to tell
  them apart.

  A `title={preset.name}` alone was too slow visually — browsers wait
  500-1000ms before rendering native tooltips. The row now un-truncates
  inline on hover via group-hover:whitespace-normal + break-all, so the
  full name appears the moment the cursor enters the row. `truncate`
  stays as the default to keep the list compact when scanning.

  The native `title={preset.name}` is also kept as a belt-and-braces
  fallback for assistive tech and touch devices where :hover doesn't
  fire. Both desktop and mobile layouts updated.

  Test: new ConfigureAmsSlotModal.test.tsx regression that pins the
  truncate / group-hover:whitespace-normal / group-hover:break-all
  classes on the span, the title attribute, and the `group` class on
  the parent button — so a future refactor that drops any of those
  fails CI.
2026-05-08 07:15:23 +02:00
maziggy 61314cf20b fix(label-picker): allow spool list to shrink so all 4 templates and Cancel stay visible (issue #1230)
The Print Labels modal used a flex column with overflow-hidden on the
  outer container, the spool list as the flex-1 shrinkable child, and the
  templates + footer as fixed siblings below it. The spool list had
  min-h-[160px], which combined with the implicit min-height: auto on
  flex items meant it could not yield space when the modal was tight —
  templates and the Cancel button overflowed the modal's max-h-[90vh] and
  got clipped. Reproducible on Windows 11 + Brave at 1080p with browser
  chrome / DPI scaling reducing the effective viewport.

  Switching to min-h-0 both removes the explicit floor and overrides
  min-height: auto so flex shrinking actually works; the spool list now
  yields height to keep all four templates and the Cancel button visible
  on constrained viewports. Larger viewports behave identically since
  flex-1 still grows to fill.

  Adds a regression test that asserts all four template names + the
  Cancel button render in the DOM and pins the structural fix by
  checking the spool list scroller has min-h-0 with no min-h-[…] literal.
2026-05-07 11:53:22 +02:00
MartinNYHC dac2a31192 Revert "feat(inventory): unified Spoolman inventory UI + AMS slot assignments…" (#1232)
This reverts commit 55d71498e9.
2026-05-07 11:30:31 +02:00
Sn0rrii 55d71498e9 feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support + Spoolman Filament Catalog Picker (#1114)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-07 11:15:24 +02:00
maziggy a50958e426 feat(slice-modal): Bundle tier for picking presets from imported .bbscfg
Closes the loop on the bundle work: users who imported a Printer
  Preset Bundle via Settings → Slicer Bundles can now pick it in the
  SliceModal and slice through the bundle dispatch path the backend
  already supports.

  UX:
  - New "Slicer bundle" picker at the top of the modal, rendered only
    when at least one bundle is imported (GET /slicer/bundles non-empty)
  - Selecting a bundle replaces cloud/local/standard preset dropdowns
    with bundle-scoped pickers (process + per-slot filament names from
    the bundle). Printer is implicit (each .bbscfg has exactly one).
  - Submit routes through SliceRequest.bundle so the backend skips
    PresetRef resolution and asks the sidecar to materialise the JSON
    triplet from the stored bundle by name.
  - "None" leaves the modal on the original preset triplet path.

  Frontend types: SliceBundleSpec + bundle?: SliceBundleSpec on SliceRequest.
2026-05-06 09:55:14 +02:00
maziggy a64cfbbd36 fix(slice-modal): disable Slice button until preview slice resolves
Until the preview slice / embedded-metadata read returns the per-plate
  filament list, the modal renders a synthetic single-slot fallback so
  the auto-pick has something to bind against. That made the Slice button
  enabled the moment the modal opened, even before the slicer had told us
  which AMS slots the plate actually consumes — clicking would dispatch
  against opaque defaults.

  Add filamentReqsQuery.isSuccess to the isReady chain so the button
  stays disabled while the preview slice is in flight (or before the
  backend's /filament-requirements call settles for sliced files) and
  flips to enabled the moment the real slot list lands and auto-pick
  fills it.
2026-05-06 09:27:47 +02:00
maziggy 8a31397171 feat(slicer): bundle-aware preview slice for accurate gram estimates
The SliceModal's preview slice runs against unsliced project files to
  discover per-plate AMS slot consumption. Until now it always used
  slice_without_profiles — accurate slot mapping (a model property) but
  gram numbers were derived from the file's embedded process settings,
  which can drift from the triplet the real print will use.

  When the caller provides a bundle id + printer/process/filament preset
  names, get_preview_filaments now routes through slice_with_bundle so
  the preview's gram numbers match what the real print will produce.
  Cache key picks up a bundle-context fingerprint so different bundle
  picks on the same file occupy distinct entries.

  Backend:
  - slice_preview.get_preview_filaments: optional bundle_* params
  - library.py + archives.py: forward params via /filament-requirements

  Frontend (forward-compat for the upcoming SliceModal Bundle tier):
  - api.getLibraryFileFilamentRequirements / getArchiveFilamentRequirements
    accept an optional 4th-arg bundle context object
2026-05-06 09:10:35 +02:00
Keybored 37c9d5f26d [Feature] Add Stock forecasting and Logistics view (#1184) 2026-05-05 16:23:42 +02:00
maziggy 864e5c990e feat(inventory): printable PDF spool labels in 4 sizes (#809)
Closes the longest-standing inventory gap — finding a specific spool
  in a closet of 50 partials. Per-spool icon button on every inventory
  card and table row, plus a "Print labels..." header action that opens
  a multi-select picker pre-loaded with the currently filtered spools.

  Four pre-built templates: AMS holder (30 x 15 mm) for the popular
  Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
  for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
  sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
  the colour swatch (with multi-colour gradient stripes for spools
  with extra_colors set), brand, material, name, the *spool ID*
  (bsaunder's articulated user-need: telling 8 spools of "PLA White"
  apart, especially partials), and a QR code that deep-links to
  /inventory?spool=<id> for phone-scan round-trips. Box-label adds
  storage location; AMS-holder drops the QR — at 30 x 15 mm there is
  no room for swatch + text + QR without truncating away the spool ID,
  and AMS-bay identification is at arm's length where the swatch and
  ID are enough.

  Server-side rendering via ReportLab + qrcode (already a dep). Pure
  Python, no headless browser, no system libs. Output is byte-identical
  across browsers, Avery sheets align to <0.1 mm, and bulk export is
  one click for one PDF. Two endpoints — POST /inventory/labels (local
  DB) and POST /spoolman/labels (Spoolman-backed) — gated on
  INVENTORY_READ, capped at 500 spools per request, returning
  application/pdf via StreamingResponse. The renderer is decoupled
  from the SQLAlchemy model via a LabelData dataclass so the same code
  path serves both modes.

  Modal picker scales to large libraries: search (substring match
  across name / brand / #ID), material filter chips derived from the
  visible spools, additive Select-all-visible / Deselect-visible /
  Clear-all actions so selections survive filter changes. Restyled
  twice in development — first cut used generic Tailwind which clashed
  with the inventory's bambu-dark palette; second cut switched to
  bambu-dark-secondary / bambu-green / bambu-gray to match.

  Two render bugs found during visual inspection of generated PDFs and
  fixed before commit:

    1. AMS-30x15 template originally produced labels with only swatch
       + QR and no text at all — the side-by-side layout left <5 mm
       for the text column, so the renderer bailed without drawing
       anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
       regimes; tight regime drops the QR and gives the right column
       to brand + material + a 13pt-bold spool ID.

    2. Box-62x29 template aggressively truncated text — swatch + QR
       each at ~14 mm on a 26mm-tall label squeezed the text column
       to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
       "Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
       at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
       leaving the text column ~30 mm — full names render without
       truncation.

  Both bugs pinned by regression tests in test_label_renderer.py that
  render with pageCompression=0 so the resulting PDF bytes contain the
  text as ASCII and `assert b"Polymaker" in pdf` works.
2026-05-05 12:29:57 +02:00
Martha Augsburger 7baf1caf75 UI/multi color handling (#1205) 2026-05-05 09:34:16 +02:00
maziggy b42aaca521 fix(spool-assign): defer MQTT for empty AMS slot, replay on physical insert
The SpoolBuddy "weigh-then-assign" workflow tried to configure an empty AMS
  slot at assign time, but Bambu firmware silently drops ams_filament_setting
  and extrusion_cali_sel for unloaded slots — the MQTT calls completed and the
  modal closed, yet BambuStudio kept showing the slot as default-PLA forever.

  assign_spool now detects an empty target slot (fingerprint_type empty) and
  persists the SpoolAssignment without publishing MQTT, returning a new
  pending_config flag so the frontend can swap "Assigned!" for "Slot will
  configure when you insert the spool." on_ams_change watches for the slot
  to load (state == 11, which fires for 3rd-party tags too even when
  tray_type stays empty) and replays the deferred ams_filament_setting +
  extrusion_cali_sel — including the printer-kp realignment that converts
  PFUS-prefix cloud user presets to the P-prefix local-preset filament_id
  the slicer actually accepts.

  The full assign-time MQTT block was extracted into
  apply_spool_to_slot_via_mqtt so both the assign endpoint and the
  on_ams_change replay path use the same resolution logic; the helper takes
  ~270 lines of duplication out of assign_spool.
2026-05-04 16:02:18 +02:00
maziggy 64899a8ca4 refactor(virtual-printer): drop Tailscale LE cert path, keep toggle informational
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:

  - Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
    dialog (IP-only).
  - Their printer-MQTT trust path validates only against the bundled BBL CA
    store (`printer.cer`), NOT the system trust store. Confirmed against
    ClusterM/open-bambu-networking's clean-room reimplementation:
    `mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
    chain validation against BBL CA only, hostname check intentionally
    skipped (because Bambu's printer cert CN is the device serial).
  - LE certs don't chain to BBL CA, so the slicer rejects with the
    well-known "-1" before any hostname/IP logic runs.

The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:

  - Toggle stays as an informational marker — when ON, the VP card surfaces
    the host's Tailscale IP + MagicDNS hostname so users know what to paste
    into the slicer.
  - Cert is always self-signed (signed by `bbl_ca`).
  - Tailscale exposure is via the existing bind_ip dropdown, which already
    includes `tailscale0` IPs.
  - Tailscale's role is strictly network reach — same trust burden as LAN.

Backend cuts:

  - `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
    `_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
    `cryptography` import. Keep `get_status` and `TailscaleStatus`.
  - `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
  - `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
    `_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
    `_cancel_renewal_task`, `_cancel_restart_task`. Simplify
    `_resolve_cert_and_advertise` to a sync method that just generates the
    self-signed cert. Drop `tailscale_disabled` from the change-detection
    diff (toggle is informational — no service restart needed).
  - `routes/virtual_printers.py` + `routes/settings.py`: drop the
    `tailscale_not_available` 409 guard on toggle-enable.

Frontend cuts:

  - `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
    `multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
    is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
    no longer populated). Drop the `tailscale_not_available` toast handler.
  - `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
  - i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
    the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.

Docs:

  - Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
    — remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
    steps, document the toggle as informational, keep the Docker socket
    mount + LXC TUN troubleshooting (those still apply for daemon
    reachability).
  - README: drop "the Tailscale benefit here is the tunnel, not cert-import
    elimination" framing in favour of "surfaces the IP for paste into
    slicer; CA import unchanged because BBL CA store, not system trust
    store, is what gets validated".

Tests:

  - `test_tailscale.py`: reduced to surviving `get_status` cases (binary
    missing, command fails, success, empty DNSName, malformed JSON).
  - `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
    → `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
    informational; `remove_instance` must NOT be called).
  - `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
    `TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
    directions succeed and daemon is never consulted).
  - `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
    FQDN-copy block drives data through that query.

DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.

Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
2026-05-03 14:58:29 +02:00
BurntOutHylian e45f967616 feat(backup): Extend Backup to other Git providers (#1160) 2026-05-03 08:12:36 +02:00
Sn0rrii d0d0be89ea fix(oidc): use preferred_username/name claim for auto-created username (#1173) (#1176)
fix(oidc): use preferred_username/name claim for auto-created username

When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
2026-05-02 12:14:32 +02:00
maziggy 459cfdc51f fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
  filament requirements from the 3MF and writes
  required_filament_types + filament_overrides + ams_mapping onto the
  queue item, but the VP queue-mode write path skipped all of that.
  Net effect: scheduler reached its model-only-matching fallback and
  auto-dispatched onto whatever printer was free regardless of loaded
  colour.

  Extract the scheduler's existing _get_filament_requirements 3MF
  parser into a shared helper so the VP path can reuse it. VP's
  _add_to_print_queue now populates required_filament_types
  unconditionally (cheap; helps the scheduler reject obvious type
  mismatches) and writes filament_overrides with force_color_match:
  true per consumed slot when a new per-VP queue_force_color_match
  toggle is on. Default off to preserve current behaviour for
  upgraders.

  UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
  mirroring the existing auto-dispatch toggle. i18n: en + de
  translated, other 6 locales seeded with English copy.

  Schema: one nullable column on virtual_printers
  (queue_force_color_match BOOLEAN, default 0/FALSE).

  11 new backend tests (8 for the extracted parser, 3 for the VP
  write path) + 6 new frontend tests (toggle render gating, default
  state, click posts queue_force_color_match in update body).
  Existing scheduler tests pass against the refactored helper.
  README, CHANGELOG, website features page, and wiki virtual-printer
  page all updated.
2026-05-02 08:38:46 +02:00
maziggy 86b54026f6 fix(inventory): hydrate Extra Colours; render Dual Color as bars; punch up Sparkle + checkerboard (#1154)
@maugsburger surfaced four bugs against the original #1154 multi-colour
  swatch work:

  1. Editing an existing spool always opened with the Extra Colours field
     blank, even when the COLOR preview banner above it was rendering
     correctly from the saved data. ColorSection seeded its local
     ``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
     mount time, but SpoolFormModal opens *before* its own useEffect
     populates ``formData`` from the spool record — so by the time the
     saved value landed, the input had already locked onto ''. The user
     then had to retype the value before saving anything else.

  2. Dual Color and Gradient produced the same diagonal blend
     (``linear-gradient(135deg, A, B)``), so the two variants were
     visually indistinguishable. The whole point of the Dual Color variant
     is that the spool has two distinct bars on the reel — a smooth blend
     defeats it.

  3. Sparkle was almost invisible on card-sized swatches. The original
     4-dot pattern (each ~1px) read fine on the inline 20×20 swatch but
     disappeared on the 60-pixel inventory card banners — exactly where
     the user actually identifies a spool.

  4. Checkerboard cell density scaled with the swatch — the same 4-cell
     pattern was either tiny squares on a small swatch or four huge
     squares on a card-sized banner. The user couldn't tell a translucent
     filament from a multi-colour one because the indicator changed shape.

  Fix:

  - ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
    whenever the parent's ``formData.extra_colors`` changes via an
    external update. ``commitExtraColors`` updates the ref before
    calling ``updateField`` so live user typing is round-tripped without
    the resync useEffect clobbering it.

  - ``filamentSwatchHelpers.ts: buildColorLayer``: branch on
    ``effect_type``. ``dual-color`` and ``tri-color`` produce
    ``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
    double-position stops (hard line, not blend) and equal-width
    segments. ``gradient`` keeps the original 135° smooth blend. The
    ``multicolor`` conic-gradient path is untouched.

  - ``filamentSwatchHelpers.ts: EFFECT_OVERLAYS.sparkle``: bumped from 4
    dots to 13 flecks in mixed sizes (1 / 1.5 / 2 px) and varying
    opacity (0.65 → 1.0) for a depth-of-field "metal flake" feel.

  - ``filamentSwatchHelpers.ts: buildFilamentBackground``: now returns
    ``{ backgroundImage, backgroundSize }`` so per-layer sizes can be
    applied — painted layers stay ``cover``, the checkerboard gets a
    fixed 12px tile so cell density is constant regardless of element
    size. Updated the three existing call sites (``InventoryPage`` group
    banner + spool card, ``ColorSection`` preview) to spread the style
    object directly. ``FilamentSwatch.tsx`` composes the same per-layer
    sizing inline so its output stays in lockstep.

  Tests: 8 new frontend cases pinning the four fixes — Dual/Tri Color
  hard-split (3 tests + 1 regression guard that Dual ≠ Gradient for the
  same stops), Sparkle prominence (≥ 10 distinct radial-gradient layers
  in the rendered background), checkerboard density (last backgroundSize
  layer is a fixed pixel value, not ``cover``), 4 hydration cases (fills
  when formData arrives via parent update, resyncs when the spool
  changes mid-form, doesn't clobber live user typing, clears when the
  new spool has no extra_colors). Existing buildFilamentBackground tests
  updated for the new return-object shape. Full frontend suite: 1610
  passed; full backend suite: 3598 passed; no regressions.
2026-05-01 13:04:10 +02:00
maziggy 6a1e3454db fix(inventory): hydrate Extra Colours on edit + render Dual Color as hard-split bars (#1154)
@maugsburger surfaced two bugs against the original #1154 multi-colour
  swatch work:

  1. Editing an existing spool always opened with the Extra Colours field
     blank, even when the COLOR preview banner above it was rendering
     correctly from the saved data. ColorSection seeded its local
     ``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
     mount time, but SpoolFormModal opens *before* its own useEffect
     populates ``formData`` from the spool record — so by the time the
     saved value landed, the input had already locked onto ''. The user
     then had to retype the value before saving anything else.

  2. Dual Color and Gradient produced the same diagonal blend
     (``linear-gradient(135deg, A, B)``), so the two variants were
     visually indistinguishable. The whole point of the Dual Color variant
     is that the spool has two distinct bars on the reel — a smooth blend
     defeats it.

  Fix:

  - ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
    whenever the parent's ``formData.extra_colors`` changes via an
    external update (modal opening with a spool, or switching to a
    different spool mid-form). ``commitExtraColors`` updates the ref
    before calling ``updateField`` so the user's own typing is round-
    tripped without the resync useEffect clobbering it.

  - ``filamentSwatchHelpers.ts``: ``buildColorLayer`` now branches on
    ``effect_type``. ``dual-color`` and ``tri-color`` produce
    ``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
    double-position stops — the colour change is a hard vertical line
    rather than a blend region — and equal-width segments across N stops.
    ``gradient`` keeps the original 135° smooth blend. The
    ``multicolor`` conic-gradient path is untouched.

  Tests: 4 new ``FilamentSwatch.test.tsx`` cases pinning the hard-split
  contract (Dual Color uses ``to right`` not ``135deg``; Tri Color
  renders 3 equal hard-split bars; ``gradient`` keeps the smooth
  diagonal; explicit regression guard that Dual Color and Gradient never
  produce the same CSS string for the same stops). 4 new
  ``ColorSectionExtraColorsHydration.test.tsx`` cases pinning the input
  hydration (fills when formData arrives via parent update, resyncs when
  the spool changes mid-form, doesn't clobber live user typing, clears
  when the new spool has no extra_colors). Full frontend suite: 1608
  passed; full backend suite: 3598 passed; no regressions.

  The minor "Sparkle could be more prominent / checkerboard denser"
  feedback in the same comment is deferred to a separate cosmetic pass —
  the reporter flagged it as finetuning.
2026-05-01 12:53:03 +02:00
maziggy 01a7e6ee93 fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)
@smandon retested the original #1152 fix on the latest daily and surfaced
  two distinct holes:

  1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
     default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
     ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.

  2. The pending-uploads review card always showed the raw FTP filename,
     while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
     embedded title (or, with the toggle on ``filename``, the stripped stem).
     Net effect: same upload showed two different names depending on which
     view you were looking at, with no way for the toggle to flip both
     views in lockstep.

  Three changes:

  - ``resolve_display_stem`` helper in ``services/archive.py`` strips
    ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
    the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
    every flow that produces a ``PrintArchive`` row.

  - ``PendingUpload.metadata_print_name`` (new nullable column) is
    populated at FTP-receive time by peeking at the 3MF's embedded title
    via the existing ``ThreeMFParser``. Read happens once per upload —
    the list endpoint then doesn't have to reopen each 3MF on every
    render. Parser failures are swallowed and the column stays NULL;
    the response model gracefully falls back to the stripped filename.

  - ``PendingUploadResponse.display_name`` is a computed field that
    mirrors ``archive_print``'s exact precedence — ``filename`` toggle
    → stripped stem; ``metadata`` toggle (default) → cached title or
    stripped stem. The frontend's review card reads it (with
    ``upload.filename`` as a defensive fallback) and surfaces the raw
    FTP filename via tooltip so users can still inspect what arrived.

  Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
  metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
  rows have NULL and degrade to filename-stem behaviour without any
  operator action.

  Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
  canonical normalisation rules (Bambu Studio default name, mixed case,
  dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
  inputs); 6 integration tests in ``test_pending_upload_display_name.py``
  pinning the response contract (default toggle uses metadata title when
  present, falls back to stripped stem when absent, ``filename`` toggle
  overrides metadata, ``filename`` toggle still strips the double suffix,
  ``GET /{id}`` exposes the same field, whitespace-only metadata behaves
  like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
  pinning the review card's render path (resolved name shown, fallback
  to filename when display_name is empty, raw filename available via
  tooltip). Full backend suite: 3598 passed; frontend build clean; no
  regressions in any flow that previously processed ``.3mf`` /
  ``.gcode`` / non-3D filenames.
2026-05-01 12:34:56 +02:00
maziggy 32b5c42dfb fix(permissions): hide MakerWorld nav entry from users without makerworld:view (#1175)
Backend routes were already gated on makerworld:view, the permission
  was granted to admin + standard-user role defaults, and the frontend
  Permission type union already included 'makerworld:view' — but the
  sidebar's hand-maintained navPermissions map in Layout.tsx had no
  entry for `makerworld`. So `isHidden('makerworld')` always returned
  false, the entry rendered for every authenticated user regardless
  of group permissions, and the only way the user found out they
  couldn't use it was by clicking and getting 403'd by every API call.

  Fix is two lines:

  - Layout.tsx: add `makerworld: 'makerworld:view'` to navPermissions,
    matching every other sidebar entry's gating shape.
  - App.tsx: wrap the /makerworld route in PermissionRoute for defence
    in depth, so a user who knows the URL can no longer reach the page
    directly. Same pattern already used by settings, groups/new, and
    groups/:id/edit two lines below.

  Two new Layout tests pin the contract: with auth enabled and a user
  lacking makerworld:view, the sidebar <a href="/makerworld"> link is
  absent while other links still render; with the permission granted,
  the link renders.
2026-05-01 08:31:19 +02:00
maziggy 59c58362ba fix(printers): copy buttons in Printer Info modal work on plain HTTP (#1174)
PrinterInfoModal's CopyButton only tried navigator.clipboard.writeText(),
  which is gated by the secure-context requirement (HTTPS or localhost).
  On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN —
  navigator.clipboard is undefined; the existing try/catch swallowed the
  TypeError, the icon never flipped to the tick, and nothing landed on
  the user's clipboard.

  Fixed by adding the same off-screen-textarea + document.execCommand('copy')
  fallback that CameraTokensPage's plaintext-token modal already uses for
  plain-HTTP LAN deployments. Gate on `navigator.clipboard && window.isSecureContext`,
  fall back to the legacy path otherwise, and surface the success-tick only
  when the copy actually landed (return early without flipping `copied` if
  execCommand returns false). The try/finally around the textarea guarantees
  DOM cleanup even when the browser throws on a restricted context.

  3 new component tests in PrinterInfoModal.test.tsx cover the secure-context
  happy path (navigator.clipboard.writeText is called with the correct value),
  the plain-HTTP fallback path (execCommand is invoked, no leaked textarea
  left in the DOM), and the finally cleanup when execCommand throws
  synthetically.
2026-05-01 08:21:37 +02:00
maziggy 4aea4be2bd feat(updates): detect HA Supervisor addon and defer update UI to it (#1167)
Bambuddy already supports running as a Home Assistant addon
  (HA_URL/HA_TOKEN env-var integration since #283, community addon at
  hobbypunk90/homeassistant-addon-bambuddy), but the update UI was
  oblivious to it: HA addon users saw the in-app "Update available"
  banner and, on Settings, the docker-compose snippet — neither of
  which they can act on, since the HA Supervisor owns the addon
  lifecycle.

  Detection uses the SUPERVISOR_TOKEN env var that HA Supervisor
  injects into every addon container; no other environment sets it,
  so the check has zero false-positive surface.

  Backend:
    - new _is_ha_addon() helper in routes/updates.py
    - /updates/check now returns is_ha_addon: bool and extends
      update_method to 'git' | 'docker' | 'ha_addon'
    - /updates/apply checks HA before Docker (HA addons ARE Docker
      containers, so checking docker first would mis-classify) and
      returns an HA-specific message that points to Settings →
      Add-ons → Bambuddy in HA
    - response keeps is_docker: true alongside is_ha_addon: true so
      older frontend bundles still hit a managed-deployment branch
      instead of rendering an Install button that can't work

  Frontend:
    - SettingsPage update card branches on is_ha_addon BEFORE
      is_docker; HA users get a Supervisor-targeted message instead
      of the docker-compose snippet
    - Layout update banner is suppressed for HA addons — HA
      Supervisor surfaces its own update notification natively, so
      Bambuddy's banner would be duplicate noise linking to a page
      that just says "update via HA"
    - Plain Docker deployments are unaffected

  i18n: settings.updateViaHomeAssistant added to all 8 locales with
  full native translations.

  Tests: 3 backend unit tests for _is_ha_addon (present, absent,
  empty-string treated as unset), 3 backend integration tests
  (HA-precedes-Docker rejection on apply; HA branch on check; plain
  Docker branch on check), 2 SettingsPage tests pinning the
  mutually-exclusive UI rendering, 2 Layout tests pinning banner
  suppression for HA and retention for plain Docker.
2026-05-01 08:01:19 +02:00
maziggy b45ca2a662 feat(printer): support Filament Track Switch (FTS) accessory in print modal (#1162)
The FTS routes any AMS slot to either extruder, so AMS info reports
  bits 8-11 = 0xE (uninitialized) and ams_extruder_map ends up empty.
  The print modal's per-nozzle dropdown filter then hides every loaded
  slot, leaving the user with an empty filament dropdown.

  Detection: parse print.device.fila_switch from MQTT push_status into a
  new FilaSwitchState dataclass on PrinterState; surface it through the
  GET /printers/{id}/status response as a nullable FilaSwitchResponse.

  Frontend: useFilamentMapping and FilamentMapping skip the per-extruder
  filter when fila_switch.installed is true. Slots currently fed into a
  track display an [L]/[R] routing badge in the dropdown so the user
  can see where the FTS is currently routing them.

  Tests: 4 backend unit (TestFilamentTrackSwitchDetection), 2 backend
  integration (status route), 2 hook regression, 2 component regression.
2026-04-29 16:18:45 +02:00
maziggy a34beaa599 feat(inventory): multi-colour gradients, transparency, visual effects (#1154)
Spool and color_catalog rows carry extra_colors (comma-separated hex
  stops) and effect_type (14 visual variants: surface effects, sheen,
  structural). The shared FilamentSwatch component renders gradient,
  conic, effect overlay, and alpha-checkerboard consistently across the
  inventory grid, table, group banner, card, ColorSection preview, and
  catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries
  can carry transparency too.

  The paste field accepts the exact format 3dfilamentprofiles.com puts on
  its filament details pages, so users can copy a multi-colour combo
  directly. The effect dropdown spans the full filament-variant
  vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen
  variants (silk/galaxy/rainbow/metal/translucent), and structural
  variants (gradient/dual-color/tri-color/multicolor). None of these
  fields touch MQTT/firmware -- pure visual hint.

  Spool group-key extended to include extra_colors + effect_type so
  "Group similar" no longer collapses visually distinct spools.

  Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus
  ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores
  VARCHAR length).

  Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14
  FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping
  regression). 3522 backend + 1582 frontend tests pass; ruff clean.
  Localised across all 8 UI locales.
2026-04-29 09:13:33 +02:00
maziggy c2e7f8eb4b feat(vp): add archive name source toggle (metadata/filename) (#1152)
Slicer-uploaded archives picked up their display name from the 3MF's
  embedded print_name (the creator-baked title); users who renamed a job
  in BambuStudio's "Send to printer" dialog never saw that name surface
  because the FTP filename was only used as a fallback when metadata was
  empty.

  Settings -> Virtual Printer now exposes an Archive name source toggle
  (Metadata / Filename, default Metadata) that flips precedence in
  ArchiveService.archive_print via a new prefer_filename_for_name param.
  All four VP-sourced archive paths read the new
  virtual_printer_archive_name_source setting and forward the flag:
  _archive_file, _add_to_print_queue, POST /pending-uploads/archive-all,
  POST /pending-uploads/{id}/archive.
2026-04-29 06:54:08 +02:00
Sn0rrii 78408856cd fix(oidc): Allow auto_link_existing_accounts with custom email claims (Azure Entra ID) (#1142)
chore(i18n): extend parity gate to all locales with strict/info tiers
2026-04-28 17:37:48 +02:00
maziggy 59b714e857 fix(archives): unbreak project-picker scroll, sort + search (#1151)
- ContextMenu's capture-phase document.scroll listener was firing on
    internal submenu scrolls too, slamming the whole menu shut on any
    wheel / arrow-key / scrollbar interaction past the 300px max-height.
    Handler now ignores scrolls whose target is inside menuRef so only
    page-level scrolls dismiss.

  - Sort projects alphabetically (localeCompare) at every project-picker
    site: Archives context-menu submenu (x2), BatchProjectModal,
    EditArchiveModal, PendingUploadsPanel, FileManagerPage. Native
    <select> sites sort once via react-query's select option; custom
    button lists sort inline.

  - Add filter-by-name search to the Archives "Add to Project" submenus
    (new submenuSearchPlaceholder prop on ContextMenuItem) and to
    BatchProjectModal. Both gated on >5 projects so small libraries
    stay uncluttered. Enter picks the first match.

  - New archives.menu.searchProjects i18n key in all 8 locales (en/de
    translated; six others seeded with English copies pending native
    translation, matching the existing flow).
2026-04-28 16:54:24 +02:00
maziggy d5153f1de3 feat(slicer): live progress + filament discovery polish + OrcaSlicer warning
End-to-end live progress, two correctness fixes, and a UX warning around
  the upstream OrcaSlicer bugs we discovered while testing.

  LIVE PROGRESS
  =============

  Wire OrcaSlicer / BambuStudio's --pipe progress channel through the
  sidecar -> Bambuddy -> persistent toast so a user-initiated slice shows
  "{name} -- Generating G-code (75%) -- 47s" instead of just elapsed time.
  The same wiring covers the SliceModal's filament-analysis preview slice
  (the real slice that fires before profile picking, used to discover
  which AMS slots an unsliced plate consumes) and the embedded-settings
  fallback path triggered by Orca's --load-settings segfault on complex
  H2D models.

  - Sidecar (orca-slicer-api/bambuddy/profile-resolver, separate commit):
    switch /slice from execFile to spawn, mkfifo per request, parse the
    CLI's structured JSON progress events into a per-process
    ProgressStore, expose GET /slice/progress/:requestId.
  - Bambuddy backend: slicer_api.slice_with_profiles + slice_without_profiles
    accept request_id + on_progress, spawn a 1Hz parallel poller that
    forwards each snapshot via SliceDispatchService.set_progress(job_id,
    ...) onto the matching SliceJob; GET /slice-jobs/:id includes the
    latest snapshot on every poll. The 404 from the early-race window
    (POST fired before sidecar's progressStore.start) is treated as a
    retry rather than terminal -- otherwise the poller bailed before any
    progress could ever arrive.
  - /api/v1/slicer/preview-progress/:requestId proxies the sidecar's
    progress endpoint for the modal's filament-discovery flow (the
    /filament-requirements call is server-originated; the browser can't
    reach the sidecar directly).
  - Frontend: SliceJobTrackerContext re-renders the persistent toast with
    the new format when a useful progress frame is present, falls back
    to elapsed-time-only when the sidecar hasn't emitted yet or doesn't
    support progress. SliceModal.FilamentAnalysisSpinner generates a
    per-(source, plate) UUID, polls the proxy at 1Hz, and mirrors the
    inline spinner contents into a separate persistent toast so the
    preview slice doesn't feel silent either.

  CORRECTNESS FIXES
  =================

  - MakerWorld imports were persisting URL-encoded filenames verbatim
    ("stormtrooper-helmet%20h2d.3mf"). Backend now urllib.parse.unquote
    s the manifest-supplied name and the URL path-tail fallback before
    passing to save_3mf_bytes_to_library; frontend defensively
    decodeURIComponent s in the slice toast / analysis spinner so
    already-imported rows display cleanly without a backfill migration.
  - The fallback path's slice_without_profiles call now forwards the
    same request_id + on_progress as the primary slice_with_profiles
    call so the toast keeps updating across the segfault -> embedded-
    settings retry boundary instead of going blank.

  ORCASLICER WARNING
  ==================

  Verified two upstream OrcaSlicer CLI bugs reproduce on the latest
  nightly (2.4.0-dev, 2026-04-28) with the help of an isolated AppImage
  extract and a minimal sentinel-value-injected cube fixture:
    - OrcaSlicer/OrcaSlicer#12426 -- SIGSEGV in
      update_values_to_printer_extruders_for_multiple_filaments on
      painted multi-extruder 3MFs (commented on the existing thread,
      not a new issue)
    - OrcaSlicer/OrcaSlicer#13386 -- CLI strict-validates parameter
      values BambuStudio writes by default (solid_infill_filament: 0,
      tree_support_wall_count: -1, prime_tower_brim_width: -1) and
      rejects with exit 238, even though Orca's own GUI tolerates
      them (filed by us alongside this change)

  Settings -> Workflow -> Slicer card renders an amber inline warning
  under the preferred-slicer dropdown when orcaslicer is selected,
  linking both upstream issues and recommending BambuStudio until the
  fixes land. Option stays pickable -- users who only slice STLs aren't
  affected by either bug.
2026-04-28 16:36:49 +02:00
maziggy 3fa0b621dd fix(slicer): correct multi-color slice output + UX polish across the slice pipeline
The slicer CLI silently substitutes embedded defaults for any AMS slot
  the user didn't supply a profile for. When a multi-color project (e.g.
  a MakerWorld helmet with white shell + grey support filament configured
  project-wide) was sliced for a "single-color" plate, the CLI took the
  user's white pick for slot 1 and quietly filled slot 2 with the source
  3MF's embedded grey support filament — producing a slice the user never
  asked for. Same silent-fallback class as the strip-removal bug.

  Backend `/filament-requirements` now returns the FULL project AMS slot
  list (from project_settings.config) with a `used_in_plate: bool` flag
  per entry. The flag comes from the cached preview slice for unsliced
  files; sliced files (where slice_info.config already pre-filters by
  used_g > 0) get used_in_plate=true on every entry. SliceModal renders
  one dropdown per project slot — slots flagged used_in_plate=true are
  editable, slots flagged false are auto-picked from project metadata
  via the existing colour-match scoring and disabled with a
  "-- not used by this plate" suffix. The wire format always carries a
  profile per project slot, so the CLI never falls back to embedded
  defaults.

  Adjacent UX fixes in the same session, since they all hit the same
  flow:

  - Persistent slice-progress toast: SliceJobTrackerProvider now opens
    a persistent loading toast per active job ("Slicing X -- 47s") with
    a 1Hz elapsed-time tick and replaces it with the existing transient
    success/error toast on terminal state. The previous start+finish
    toast pair left a UX dead zone where users couldn't tell whether a
    long slice was still running.

  - "Analyzing plate filaments..." spinner now shows elapsed seconds and,
    after 5s, a hint that the wait is a one-time preview slice (cached;
    re-opens are instant). Addresses "is anything happening?" on first
    open of an unsliced complex multi-color 3MF.

  - Pre-slice printer-mismatch warning + disabled Slice button: the
    plates response now exposes `source_printer_model` from
    project_settings.config; SliceModal compares against the picked
    printer profile name and surfaces an inline warning + disables Slice
    on mismatch. The CLI rejects cross-printer slices (rc=-16) and used
    to fall back to embedded settings, producing wrong-printer g-code
    that errored at print dispatch.

  - Sliced-archive card now reflects the actually-used filament list,
    not the source's project-wide AMS config:
    slice_and_persist_as_archive reads filament_type / filament_color
    from the sliced output's slice_info.config (which already gates on
    used_g > 0) instead of inheriting from the source archive. A 16+
    swatch card on what was actually a 2-color print was the visible
    symptom.

  - MakerWorld URL-paste resolver enriches each instance with
    `compatibility` + `otherCompatibility` from
    design.instances[].extention.modelInfo. The /instances/hits payload
    omits this so every instance row used to look identical; users
    blindly picked the first one regardless of whether it matched their
    printer.

  Tests: 27 SliceModal tests (2 new for the disabled-row contract +
  3 for printer-mismatch + 4 for multi-color rendering); 4 new
  SliceJobTrackerContext tests for the persistent-toast lifecycle;
  backend filament-requirements / slice-preview / threemf-tools
  suites green.

  i18n: new keys slice.queuedToast, slice.runningToast,
  slice.analyzingPlateFilamentsHint, slice.notUsedByPlate,
  slice.printerMismatch, makerworld.slicedFor, makerworld.alsoCompatible
  across all 8 UI languages (English + German fully translated, the six
  others seeded with English copies pending native translation, matching
  the project's existing flow for newly-added user-facing features).
2026-04-28 14:50:28 +02:00
maziggy c1f69ee0cc fix(slicer): wrong-printer slicing + sliced-archive filament list + per-instance MakerWorld compat
Five stacked slice-pipeline bugs that each made the modal's profile picker
  theatrical for 3MF inputs:

  (1) `_strip_3mf_embedded_settings` removed `model_settings.config` /
      `slice_info.config` / `cut_information.xml` along with
      `project_settings.config`. The CLI silently exited after
      "Initializing StaticPrintConfigs" — exit 0, no result.json — and
      Bambuddy masked the failure by re-running with embedded settings
      and the source's bound printer. Strip removed from the dispatch
      path entirely.

  (2) Standard-tier preset stubs lacked the `type` field, so the CLI
      rejected `--load-settings` with rc=-5 ("input preset file is
      invalid") and the same masking fallback fired. Added
      `_SLOT_TO_PROFILE_TYPE` so each stub carries the right
      machine/process/filament discriminator.

  (3) Sliced-archive cards listed every project-wide AMS slot (16+
      swatches for a 2-color print). `slice_and_persist_as_archive` now
      reads `filament_type` / `filament_color` from the sliced output's
      `slice_info.config` (which `ThreeMFParser` already gates on
      `used_g > 0`) instead of inheriting from the source archive.

  (4) SliceModal had no warning when the picked printer profile didn't
      match the source 3MF — the CLI rejects cross-printer slices
      (rc=-16) and fell back to embedded settings, producing wrong-printer
      g-code that errored at print dispatch. Plates response now exposes
      `source_printer_model`; the modal compares against the picked
      profile name and disables Slice + shows an inline warning on
      mismatch.

  (5) MakerWorld URL-paste resolver listed plate instances without
      showing which printer each was sliced for (`/instances/hits`
      omits compatibility info that lives on `design.instances[]
      .extention.modelInfo`). The resolve route now joins both payloads
      by instance ID and forwards `compatibility` + `otherCompatibility`
      onto each hit; the MakerWorld page renders "Sliced for {primary}"
      + "Also marked compatible: ..." per row.

  Tests: 6 unit tests for `extract_source_printer_model_from_3mf`, 1 for
  filtered filament metadata via ThreeMFParser, 2 for makerworld resolve
  compat-merge (happy path + missing modelInfo), 3 frontend SliceModal
  tests for the printer-mismatch warning + Slice-disabled gate. New i18n
  keys `slice.printerMismatch`, `makerworld.slicedFor`,
  `makerworld.alsoCompatible` across all 8 locales.
2026-04-28 13:58:56 +02:00
maziggy 988c00554e feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
  silently truncated multi-color 3MFs to a single profile, producing wrong
  colours on every multi-filament print. End-to-end fix across sidecar,
  backend, and frontend.

  Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
    - /slice accepts up to 16 repeated filamentProfile parts; slicing
      service materializes each and joins paths with `;` for
      --load-filaments.
    - /profiles/bundled emits filament_type and filament_colour per leaf
      so the bundled tier carries metadata into the modal.

  Bambuddy backend:
    - SliceRequest gains filament_presets: list[PresetRef]. Validator
      accepts three shapes (multi-color array, source-aware singular,
      legacy bare-int id) and lands them all on a populated array before
      the route handler runs — fully backwards-compatible.
    - SlicerApiService.slice_with_profiles takes filament_profile_jsons:
      list[str] and sends one filamentProfile multipart part per profile
      (in submission order) so the sidecar receives N profiles cleanly.
    - New service slice_preview runs the sidecar's slice_without_profiles
      against an unsliced project file's embedded settings, parses the
      result's slice_info.config, and returns the canonical per-plate
      filament list. Cached by (kind, source_id, plate_id, content_hash)
      with LRU eviction at 256 entries, per-key asyncio.Lock prevents
      thundering-herd; transient sidecar failures are NOT cached so they
      retry naturally; parse failures ARE cached (deterministic property
      of the input, no point re-running).
    - /filament-requirements endpoint chain: slice_info.config (existing,
      sliced files) → preview-slice (new, unsliced project files) →
      project_settings.config + painted-face heuristic with 5% noise
      threshold (sidecar-down fallback).
    - threemf_tools gains extract_project_filaments_from_3mf and
      extract_plate_extruder_set_from_3mf — the latter unions object
      top-level extruder, per-part overrides, and painted-face quadtree
      leaves (1-E nibbles in paint_color attrs of <triangle> elements
      inside per-object .model files).
    - Cloud preset listing no longer fetches per-preset detail (Bambu's
      rate limit at ~10/sec returns 429 on every request for users with
      50+ presets). Unified-listing dedup pass instead backfills metadata
      cross-tier so a cloud entry that wins dedup over a same-named local
      entry inherits the local's filament_type / filament_colour.

  Frontend:
    - SliceModal multi-step: plate-picker first when the source is a
      multi-plate 3MF, then preset dropdowns. One filament dropdown per
      AMS slot the plate actually uses, each pre-picked by metadata
      match against user's local + standard presets via existing
      colorsAreSimilar / normalizeColorForCompare utils.
    - SliceModal-only tier priority is now local → cloud → standard
      (was cloud → local → standard). Other consumers of /slicer/presets
      keep the existing cloud-first order.
    - Submits filament_presets array; backfills the legacy singular
      filament_preset from the array's first entry for stale-tab
      compatibility.
    - i18n keys added across all 8 locales: slice.filamentSlot,
      slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
      expired,unreachable}, slice.noPresetsForSlot,
      slice.allPresetsRequired (en + de fully translated; six others
      seeded with English copies pending native translation, matching
      the project's existing flow).

  Permissions: no new endpoint paths added. Preview-slice runs inside
  /filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
  dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
  widened.

  Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
  precedence; 9 unit tests for slice_preview cache behaviour (LRU
  eviction with lock cleanup, content-hash invalidation, concurrent
  thundering-herd guard, no-cache-poison on transient sidecar failure);
  15 unit tests for the two new threemf_tools helpers (5 + 10 cases
  including the 60/40 painted-threshold regression pin); a multi-filament
  wire-format test pinning the multipart part count + order; 22 frontend
  SliceModal tests covering plate picker, multi-color render,
  metadata-aware pre-pick, manual override, and the new tier order.
2026-04-28 12:20:59 +02:00
maziggy 61c15aac03 feat(slicer): unified Cloud/local/standard presets + harden 3MF profile path
UNIFIED PRESET LISTING (the main feature)

  The initial slicer integration only saw DB-backed local imports — users
  without imported profiles got an empty Slice modal even when their
  Bambu Cloud account or the slicer sidecar carried perfectly usable
  presets. The Slice modal now pulls from three tiers in priority order:

    - cloud:    user's own Bambu Cloud presets, fetched live.
    - local:    DB-backed imports.
    - standard: slicer-bundled stock profiles via the sidecar's new
                GET /profiles/bundled endpoint.

  Listing endpoint: GET /api/v1/slicer/presets

    - Name-based dedup, cloud > local > standard, within-tier order
      preserved exactly. A preset that exists in multiple tiers only
      renders in the highest-priority one.
    - cloud_status (ok / not_authenticated / expired / unreachable)
      drives a precise modal banner instead of an unexplained empty
      list.
    - Cloud branch: per-user cache, 5 min TTL, key
      (user_id, sha256(token)[:16]) so logout/login or token rotation
      auto-invalidates without callback wiring from the cloud-auth
      routes.
    - Bundled branch: global cache, 1 h TTL.
    - Bundled URL respects preferred_slicer (bambu_studio vs orcaslicer)
      so BambuStudio installs see the bambu sidecar's bundled list, not
      OrcaSlicer's.

  Slicing endpoint: POST /library/files/{id}/slice + /archives/{id}/slice

    - Body now accepts source-aware {source, id} triplets per slot:
        printer_preset:  PresetRef
        process_preset:  PresetRef
        filament_preset: PresetRef
    - Legacy *_preset_id integer fields kept for backwards-compat. The
      schema validator normalises bare ints into
      PresetRef(source='local', id=str(int)) so the route handler only
      deals with one shape.

  New preset_resolver service fetches the JSON content per source:

    - cloud:    BambuCloudService.get_setting_detail(id), unwraps the
                `setting` envelope (falls back to top-level for minor
                shape variants).
    - local:    DB read with preset_type slot validation (existing path,
                factored into the new helper).
    - standard: minimal {name, inherits, from: "system"} stub — the
                sidecar's profile-resolver flattens it against
                BUNDLED_PROFILES_PATH/<category>/<name>.json with no
                preset-content round-trip from Bambuddy.

  PERMISSIONS

    - Listing route gate: LIBRARY_UPLOAD (matches the slice action — any
      user who can slice can populate the dropdowns).
    - Cloud branch in BOTH the listing helper and the resolver checks
      CLOUD_AUTH independently — a user with LIBRARY_UPLOAD but not
      CLOUD_AUTH doesn't see the cloud tier (returns 403 if they try
      to slice with a cloud preset) even if a leftover User.cloud_token
      survived a permission revocation. Cloud listing path
      short-circuits the token lookup entirely on the gate-fail branch.

  FRONTEND — SliceModal

    - Calls api.getSlicerPresets() instead of api.getLocalPresets().
    - Dropdowns render <optgroup> per tier with localised section
      labels (Cloud / Imported / Standard).
    - Default selection follows cloud > local > standard priority on
      first load (auto-pick fires once when the data arrives, manual
      choices stick after that).
    - Cloud-status banner renders three variants
      (sign-in / expired / unreachable) only when status != 'ok'.
    - Slice button submits source-aware refs; legacy integer payload
      is preserved server-side for older clients.

  3MF PROFILE-PATH HARDENING (shipped together because they touch the
  same code paths)

  (1) Strip widened. _strip_3mf_embedded_settings only removed
      Metadata/project_settings.config. Real-world Bambu Studio /
      OrcaSlicer 3MFs also carry model_settings.config, slice_info.config,
      and cut_information.xml — any single leftover trips the CLI's
      input validation and the slice falls back to embedded settings,
      making the SliceModal's profile picker theatrical for 3MF inputs.
      Now removes all four configs via a centralised
      _STRIPPABLE_3MF_CONFIGS frozenset with per-file rationale;
      geometry (3D/3dmodel.model), thumbnails, multi-part data
      preserved.

  (2) Sidecar 5xx error capture. slicer_api.py was reading only
      `message` from sidecar 5xx responses and dropping `details`, so
      every CLI failure surfaced as the unhelpful generic
      "Failed to slice the model". New _format_sidecar_error helper
      combines both fields, falls back to plain-text body for
      non-JSON 5xx (nginx 502s, gateway timeouts), replaces the four
      duplicated extraction blocks. Pairs with the orca-slicer-api
      fork's bambuddy/profile-resolver branch which now emits
      `details` on AppError responses (d9c6121) and captures CLI
      stderr in the failure path (fb928c8).

  CARE TAKEN — additive on existing surfaces

    - main.py:               +1 import, +1 router register
    - slicer_api.py:         +list_bundled_profiles, +_format_sidecar_error
                             (dedupes the 4 message-extraction blocks);
                             no existing method behaviour changed
    - library.py:            resolver swap inside _run_slicer_with_fallback,
                             user_id threaded through two callers,
                             strip widened
    - schemas/slicer.py:     PresetRef added, *_preset fields added,
                             legacy *_preset_id kept; validator normalises
    - 4 new files:           schema, route, resolver, tests
    - No existing route URL changed, no existing field removed, no
      behaviour change for clients still sending bare integer ids.

  TESTS

    - 17 unit tests for the listing endpoint helpers
    - 11 unit tests for the source-aware resolver
    - 6 schema tests for SliceRequest legacy + new shapes
    - 3 unit tests for the new sidecar error-detail capture
    - Strip integration test extended to assert all 4 configs go and
      geometry stays
    - 12 frontend tests for SliceModal covering tier-priority
      auto-selection, <optgroup> grouping, fallback paths, source-aware
      payload on submit, manual override across tiers, archive vs
      library routing, error display, all three banner variants

  Verified: 3394 backend + 1531 frontend tests pass, ruff clean,
  frontend production build clean.

  Pairs with three already-pushed commits on the orca-slicer-api fork's
  bambuddy/profile-resolver branch:

    - 5fd6bc6  feat(profiles): add GET /profiles/bundled
    - d9c6121  fix(error): include causeMessage in JSON response as `details`
    - fb928c8  fix(slicing): include CLI stdout/stderr in failure causeMessage
2026-04-27 19:15:31 +02:00
maziggy 6deaa513af ● feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
  Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
  Slicing runs as an in-memory background job (POST returns 202 + job_id,
  polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
  longer pins the modal; result lands as a new .gcode.3mf in the same
  folder (or new archive for archive sources) with the embedded
  thumbnail extracted.

  Backend
  - New services: slice_dispatch (in-memory dispatcher, 30min retention
    sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
    split that drives the 3MF embedded-settings fallback retry path).
  - New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
    SliceJobEnqueueResponse.
  - New routes: POST /library/files/{id}/slice,
    POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
    LIBRARY_READ since job IDs are sequential and the body leaks source
    filenames and result IDs).
  - AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
    bambu_studio_api_url. DB-stored values override env defaults.

  Frontend
  - New SliceModal handles preset gating; enqueues then closes
    immediately.
  - New SliceJobTrackerProvider polls active jobs at app level, surfaces
    a single toast per job (queued -> running -> completed / failed)
    and invalidates library/archives queries on terminal status.
  - Settings -> Workflow -> Slicer card: preferred slicer dropdown,
    Use Slicer API toggle, contextual sidecar URL field.
  - File Manager / Archives / MakerWorld get a Slice button gated on
    the Use Slicer API setting.
  - gcode-viewer adapter learns ?library_file=<id> so sliced library
    files preview inline.

  i18n
  - New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
    bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
    + fileManager.noPermissionSlice keys across all 8 locales (en, de,
    fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
    fully translated, the other six seeded with English fallbacks
    pending native translation.

  Tests
  - 10 backend integration tests in test_library_slice_api.py covering
    validation (404/400), happy-path enqueue, sidecar-down, 3MF
    embedded-settings fallback, STL no-fallback, and preset-error ->
    failed job paths.
  - New unit tests in test_slicer_api.py for the HTTP bridge.
  - 5 new SliceModal frontend tests covering preset gating, library +
    archive enqueue paths, error surface, and preset-load failure.
  - Existing SettingsPage tests adjusted: slicer dropdown asserts now
    switch to the Workflow tab first; added a beforeEach URL reset so
    one test's tab click doesn't bleed into sibling tests.

  Sidecar
  - New slicer-api/ folder is self-contained and optional. Two services
    (orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
    bambu) build via Docker git-build-context from
    maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
    the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
    resolver, from:User -> system rewrite, '# ' clone-prefix strip,
    sentinel-value strip) empirically required to slice real GUI
    exports without segfaulting the CLI.

  Docs
  - CHANGELOG entry under [0.2.4b1] - Unreleased Added.
  - README File Manager bullet for the new server-side Slice button.
  - bambuddy-website features.html: new card under "Configurable Slicer".
  - bambuddy-wiki: new page features/slicer-api.md + nav entry +
    features index card.

  Notes
  - Opt-in: with Use Slicer API off, the existing "open in desktop
    slicer via URI" flow is the default and unchanged.
  - 3MF inputs that segfault the CLI on --load-settings transparently
    retry with embedded settings; the resulting job carries
    used_embedded_settings: true.
  - Sliced files always export as .gcode.3mf so File Manager picks up
    the embedded thumbnail; file_type is set to "gcode" (blue badge).
2026-04-27 15:28:37 +02:00
maziggy d92f48e3c6 feat(spoolbuddy): plate-clear pills on kiosk dashboard + toast UX polish
Adds a finger-friendly amber pill row under the printer status badges
  on the SpoolBuddy kiosk dashboard. When any printer reports
  awaiting_plate_clear=true, a compact pill appears showing the printer
  name plus a "Clear" action; tapping it calls POST /printers/{id}/clear-plate
  and optimistically removes the pill before the WebSocket round-trip
  lands. Multiple pending printers wrap inline via flex-wrap so the
  dashboard stays compact when several finish at once. Pill dimensions
  match the existing online/offline printer badges (px-2.5 py-1, text-xs).

  The kiosk auth path (X-API-Key) already passes the printers:clear_plate
  permission gate via the existing _APIKEY_DENIED_PERMISSIONS denylist
  (the permission is intentionally not denied — clear-plate is an
  inventory-flow operation, not an admin one), so no auth wiring changes
  were needed.

  Two adjacent fixes shipped in the same change:

  - SpoolBuddyLayout suppresses the global toast viewport while mounted.
    The global ToastProvider in App.tsx wraps both the main app and the
    kiosk routes, which meant the background-dispatch progress overlay
    was rendering on the kiosk display alongside any in-flight prints.
    Added setViewportSuppressed(bool) on the toast context; the layout
    flips it via useEffect and restores on unmount. State machine and
    dispatch-event subscription are untouched — only the visible
    viewport is hidden.

  - Dispatch toast no longer reads as "frozen at 100%" for fast uploads.
    Small files complete FTP in <500ms but the bar would sit at 100%
    while the printer's MQTT confirmation landed. When uploadProgressPct
    >= 99.9 and status is still 'processing', the byte counter is
    replaced with "Awaiting printer..." and the bar gets animate-pulse.
2026-04-27 08:42:03 +02:00
maziggy 88b5f56eb2 fix: cancel = layer shift, stuck "1 problem", and dropped child-logger logs
Three bugs that surfaced together while debugging an H2D cancel:

  1. Cancelling a print stamped failure_reason="Layer shift" in archives
     AND left the printer card stuck on "1 problem" forever. Four causes:
     (a) POST /printers/{id}/print/stop never set the user-stopped flag, so
         on_print_complete couldn't override "failed" -> "cancelled".
     (b) HMS-derived failure_reason heuristic mapped any module-0x0C HMS to
         "Layer shift". Module 0x0C is "Motion Controller" broadly (includes
         cameras, markers, AND the cancel-sequence echo 0C00_001B). Real
         layer-shift codes live in module 0x03. Same false-positive class
         existed for "Filament runout" (any 0x07) and "Clogged nozzle" (any
         0x05). Replaced with a 23-code curated short-code map; unknowns
         leave failure_reason=None.
     (c) Cancel-echo HMS codes (0300_400C "The task was canceled.",
         0500_400E "Printing was cancelled.") were polluting state.hms_errors
         via both the hms[] and print_error parse paths. Filter them at
         parse time so the frontend never sees them.
     (d) Frontend bucketed gcode_state="FAILED" as a problem unconditionally.
         Real failures attach an HMS error; user-cancels don't — so FAILED-
         without-HMS now buckets as "finished" and only escalates to "error"
         when there's an active known HMS.

  2. logs/bambuddy.log was silently dropping records from named child
     loggers. TraceIDFilter was attached to root_logger, but Python's
     logging only invokes a Logger's filters on records originating at that
     logger — propagated child-logger records skipped it, formatter raised
     KeyError, handler.handleError dropped the record. Moved the filter
     from root_logger.addFilter() to handler.addFilter() on each handler,
     matching the filter's own docstring guidance.

  derive_failure_reason() extracted as a pure function for testability.
  status="cancelled" now symmetrically yields "User cancelled" alongside
  "aborted".

  20 regression tests across:
  - backend/tests/unit/test_failure_reason_derivation.py (11)
  - backend/tests/unit/services/test_bambu_mqtt.py::TestHMSUserActionFiltering (4)
  - backend/tests/unit/test_trace.py::TestFilterMustBeAttachedToHandlerNotLogger (1)
  - frontend/src/__tests__/pages/PrintersPageBucketing.test.ts (5; includes
    the H2D-cancel-echo "FAILED + only unknown HMS" case)
2026-04-26 14:24:28 +02:00
maziggy 11fe68578e fix(#1133): list every spool in the AMS-slot inventory picker
The picker that opens from <FilamentHoverCard> / SpoolBuddy's slot-action
  sheet had two stacked filters that together blocked a real workflow:

  1. AssignSpoolModal only listed spools whose tag_uid AND tray_uuid were
     both null, hiding any Bambu Lab spool that had been auto-created from
     RFID or scanned via SpoolBuddy NFC.
  2. FilamentHoverCard rendered its inventory section only when the slot's
     vendor was not 'Bambu Lab', so even with #1 fixed the assign button
     wasn't visible on a BL slot.

  Both filters blocked the same use case: a user has a Bambu Lab spool in
  inventory but doesn't want to scan via SpoolBuddy NFC every time and
  just wants to pick it from the list.

  Both gates removed. Modal lists every spool that isn't already taken by
  another (printer/ams_id/tray_id) tuple. Hover-card inventory section
  renders for every vendor including Bambu Lab. The AMS-vs-external
  special-case in the modal collapsed too — external slots used to be the
  only path that allowed picking a tagged spool, that distinction is gone.

  Empty slots lost their assign affordance entirely. A physically empty
  slot has no spool to attach an inventory record to, and offering the
  action there only led to users assigning the wrong spool to a slot the
  printer hadn't actually loaded yet. Bambuddy: EmptySlotHoverCard's
  inventory prop removed; PrintersPage drops the matching inventory
  props on three sites. SpoolBuddy: slot-action picker gates the
  assign/unassign block on slotActionPicker.tray !== null.

  Modal also gets defensive hardening from the rollout investigation:
  its own dedicated cache key (['inventory-spools', 'assign-modal']) so
  it can't be poisoned by other components calling getSpools() with
  different includeArchived args; getSpools(true) + client-side
  !archived_at filter so the picker sees the full inventory regardless
  of cache priming order; "Show all spools" toggle now bypasses BOTH
  filters (was only bypassing material/profile, label was a lie); a
  small "X fetched · Y archived · Z assigned" counter in the empty state
  so future "missing spool" reports are debuggable from screenshots.

  i18n.inventory.noManualSpools renamed to inventory.noAvailableSpools
  with new copy ("No spools available. Add a spool to your inventory or
  unassign one from another slot first.") since the empty-state premise
  changed. Localised across all 8 languages.

  15 frontend tests: assign/unassign render for vendor: 'Bambu Lab',
  non-BL vendors unchanged, EmptySlotHoverCard renders no assign
  affordance, configure button still works on empty slots, picker lists
  BL spools alongside manual ones, picker drops spools assigned
  elsewhere unless toggle is on, picker drops archived spools always,
  toggle escape-hatch shows everything, empty-state copy update.
  SpoolBuddy unassign now invalidates both ['spool-assignments'] and
  ['spool-assignments', printerId] so the modal's cache stays fresh
  (dual cache-key consolidation deferred to its own PR).
2026-04-26 11:45:18 +02:00
Sn0rrii fdaec47378 feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1126)
feat(oidc): add Azure Entra ID support with configurable email claim resolution

Adds two new OIDC provider fields: email_claim and require_email_verified.
2026-04-25 13:32:42 +02:00
maziggy 4304a42542 feat(#729): per-spool category + low-stock threshold override
Two new optional fields on Spool: free-text `category` (max 50) and
  `low_stock_threshold_pct` (1-99). Powers the "differentiate critical
  spools from prototype spools and alert at different thresholds" use
  case from #729 without taking on the full multi-tag taxonomy + auto-
  apply rules + per-tag alert system the ticket originally proposed.

  Form gains:
  - Category input with datalist autocomplete sourced from categories
    already in use, so casing/spelling stays consistent.
  - Per-spool low-stock threshold input. Empty = global default; the
    global value renders as the placeholder.

  Inventory page:
  - New category filter chip (hidden until at least one spool carries
    a category — keeps the chip row uncluttered).
  - Stat-card "Low Stock" count and the "Low Stock" filter both honour
    the per-spool override.

  Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original
  ticket reporter mistook it for a taxonomy-tag delete; the button
  actually clears the RFID UID/UUID off the spool record). Toast key
  renamed from `tagDeleted` to `rfidCleared`.

  i18n: full translations across all 8 locales.

  Tests: 9 new backend schema tests (defaults, partial-update, range
  rejection, max-length); 2 new frontend tests (per-spool threshold
  pulls extra spools into low-stock count, filter chip hidden when no
  categories exist).
2026-04-25 13:24:49 +02:00
maziggy 35edc036bd feat(notifications): per-event ntfy priority headers (#990)
ntfy supports a Priority header (1=min, 2=low, 3=default, 4=high, 5=urgent)
  that controls escalation on the receiving device, but every event was being
  sent at the server default — so a "50% complete" ping looked identical to
  "print failed" or "printer offline". Add a per-event priority dropdown
  section in the Add/Edit Notification modal (visible only for ntfy, listing
  only enabled events); the backend reads config.event_priorities and emits
  the matching Priority header on POST and PUT (image-attachment) paths.
  Unmapped events fall through to the ntfy server default. Out-of-range
  and non-numeric values are dropped, not clamped, so a misconfigured value
  never silently sends at the wrong urgency. Test sends omit the header by
  design so the test path can't accidentally page someone at urgent priority.

  Backward compatible: existing providers without event_priorities behave
  exactly as before. NtfyConfig.event_priorities is optional; the route
  stores config as a JSON blob so no migration is needed.

  i18n: full translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/
  zh-TW). README, CHANGELOG, and the wiki notifications page updated.

  Tests: 6 backend (Priority set on mapped, omitted on unmapped/missing/
  no-priorities, ignored for bad values, propagated through attachment
  path), 6 frontend (section visible only for ntfy, lists only enabled
  events, save round-trip, edit pre-fill, toggle drops row, non-ntfy
  never writes the key).
2026-04-25 12:33:58 +02:00
maziggy 12c01f029d Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)"
This reverts commit 50382006b3.
2026-04-25 11:05:32 +02:00
Sn0rrii 50382006b3 feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)
feat(oidc): add Azure Entra ID support with configurable email claim resolution
2026-04-25 11:02:06 +02:00
maziggy 7f11618e1e Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)"
This reverts commit 365c38483b.
2026-04-24 16:48:59 +02:00
Sn0rrii 365c38483b feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)
feat(oidc): add Azure Entra ID support with configurable email claim resolution
fix(oidc): harden email claim resolution, guards, and test coverage
2026-04-24 16:46:50 +02:00
maziggy 9e938cbc8c Revert "feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)"
This reverts commit 89f14c57ad.
2026-04-24 14:33:33 +02:00
Sn0rrii 89f14c57ad feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)
feat(inventory): replace Spoolman iframe with internal inventory UI

When Spoolman is enabled, the Inventory page now uses the same internal
UI (spool list, create/edit modal, archive, delete, weight sync) backed
by a new proxy layer instead of opening an iframe.
2026-04-24 14:00:45 +02:00
maziggy 9c5c2a765f Post work PR #1070 2026-04-24 13:24:00 +02:00
maziggy c0b6010269 fix(virtual-printer): cert-renewal restart regression + clipboard leak
1. `_cancel_restart_task` self-await guard (manager.py:389-413).
     stop_server() / stop_proxy() are called from inside
     _restart_for_cert_renewal, which runs AS _cert_restart_task.
     Cancelling+awaiting self flagged a CancelledError on the next
     `await` in stop_server, tearing down old listeners but never
     letting start_server run — the VP sat on the expired cert
     until the process was manually restarted, silently defeating
     auto-renewal. Skip when `task is asyncio.current_task()` and
     just clear the reference.

  2. Clipboard fallback textarea leak (VirtualPrinterCard.tsx:66-81).
     The HTTP fallback created a hidden textarea, called
     select() + execCommand('copy'), then removed the textarea.
     If select() or execCommand threw, removal never ran and the
     textarea leaked into the DOM. Move the removal into `finally`
     so it happens regardless of the inner block's outcome.

  Regression tests in test_tailscale.py::TestCancelRestartTaskSelfAwait
  cover both the self-cancel path (must NOT cancel self) and the
  outside-cancel path (must still cancel and await).
2026-04-24 13:08:52 +02:00
maziggy a00ce61064 fix(virtual-printer): clipboard fallback for HTTP (non-secure) context
The Tailscale FQDN copy button used only `navigator.clipboard.writeText`,
  which browsers block when `window.isSecureContext === false` — i.e. when
  Bambuddy is reached over HTTP on a LAN / tailnet IP, which is the
  common case. My catch block swallowed the error and the generic
  "Failed to update settings" toast fired instead of actually copying.

  Add a legacy `document.execCommand('copy')` fallback via a hidden
  textarea for non-secure contexts. New i18n key
  `virtualPrinter.toast.copyFailed` added to all 8 locales for the
  (rare) both-paths-fail case.
2026-04-24 11:50:30 +02:00