From a25177097df8eac53e2b280ac9e8bc8b550a46e1 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 11 May 2026 12:29:53 +0200 Subject: [PATCH] Post work PR #1255 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db3ddb50..18b02dbeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to Bambuddy will be documented in this file. - **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head). ### Fixed +- **Gitea backups silently failed after the first run; Forgejo v15 token-scope quirk broke "Test Connection"; many failure paths surfaced cryptic one-word errors** ([#1224](https://github.com/maziggy/bambuddy/issues/1224) reported by @rtadams89, [#1239](https://github.com/maziggy/bambuddy/issues/1239) + [PR #1255](https://github.com/maziggy/bambuddy/pull/1255) by @BurntOutHylian) — Two intertwined problem clusters on the Git-backup path, fixed as one PR. **(1) Gitea backups quietly stopped after run #1.** The Git backup service used GitHub's Git Data API (`POST /git/blobs` → `/trees` → `/commits` → `PATCH /refs`) for every push. Gitea does not implement these write endpoints on modern versions, so every blob POST returned 404; the loop's `continue`-on-non-201 pattern left the change list empty and the route returned `{"status": "skipped"}` instead of committing — no toast, no log row, just "no changes" forever. The first run only worked because the empty-repo path already used the Contents API. **Fix:** `GiteaBackend.push_files` is overridden to use `POST /repos/{owner}/{repo}/contents` with a `files` array — every changed file is sent as `operation: "update"` (with its current blob SHA) or `operation: "create"`, the whole batch commits in a single round-trip, no partial-commit failure mode possible. `_create_branch_and_push` switched from the unimplemented `POST /git/refs` to `POST /branches` with `{new_branch_name, old_ref_name}`. **(2) Forgejo v15+ returns 404 (not 403) for private repos when the token lacks repository scope**, indistinguishable on the wire from "repo not found / token typo" — Test Connection's existing 404 branch said "Repository not found", which sent users chasing the wrong cause. **Fix:** new `ForgejoBackend` (inherits `GiteaBackend`) overrides `test_connection` to GET `/user` first; 401 = bad token, 403 = zero-scope token ("read:user scope missing"), 404 on the subsequent `/repos/` call surfaces the v15-specific "private repo with scope mismatch" hint instead of the generic message. **Hardening pass on the broader backup stack** (B18–B26 review round): every `response.json()[...]` indexing in `github.py` (9 sites: ref/commit/blob/tree/commit/ref across `push_files` + `_create_branch_and_push` + `_create_initial_commit`) now routes through a new `base.py::_read_sha(response, *path)` helper that returns `(sha, error_reason)` — a malformed body no longer bubbles `KeyError('object')` through the catch-all to surface as the cryptic one-word string `"'object'"` in `last_backup_message`. Tree-fetch failures (GitHub side, mirroring the Gitea side) now return `failed` with status code + truncated body instead of letting `existing_files` silently stay empty (which forced every file to re-upload and produced a downstream 422 with no hint at the real cause). GitHub's `_create_branch_and_push` failure message includes the HTTP status code (an empty-body 422 now produces a diagnostic message instead of `"Failed to create branch: "`). Both backends detect `truncated: true` on the tree-listing response (GitHub's tree API truncates at >7MB / >100k entries) and fail loudly asking the operator to rotate the backup repo — previously a truncated listing made the SHA-equality dedup miss and silently re-uploaded every file each run. `test_connection` failure messages now include `str(e)[:200]` alongside the exception class name, so the UI surfaces `"Connection failed: ConnectError: certificate verify failed: hostname mismatch"` instead of just `"ConnectError"`. Gitea's 409-on-`/contents` message was softened from "stale blob SHAs" (one possible cause) to "the branch likely advanced concurrently (web-UI edit, another backup run, or path-vs-tree collision)". Every status-code branch in `github.py` and `gitea.py` mid-push now emits a `logger.warning` with owner/repo context (previously only the outer `except` logged, so a 403/404/422 left a DB row with no application-log entry). Recursive `push_files` re-entry after branch create now logs `"Re-entering push_files after branch create owner/repo -> branch"` at info level so replication-lag second-pass failures are debuggable. **Tests:** +17 new unit tests in `test_git_providers.py` covering the GitHub robustness paths (tree-fetch failure, truncated tree, malformed JSON for ref/commit/blob, 403/422 on `_create_branch_and_push`), the Gitea round-2 hardening (truncated tree, status code in `get_current_commit` / `extract_tree_SHA` / `get_repo_info` failures, log marker emission), and the Forgejo connection-failure detail. Existing 86 → 103 tests, all pass; full backend suite + integration backup tests green; ruff clean. Tested by @BurntOutHylian against Gitea 1.24.7 / 1.25.4 / 1.26.1 and Forgejo v11 / v15 LTS. Companion wiki update at [maziggy/bambuddy-wiki#28](https://github.com/maziggy/bambuddy-wiki/pull/28). + - **Printer card's "Show on Printer Card" smart-plug button toggled power without confirmation** ([#1260](https://github.com/maziggy/bambuddy/issues/1260), reported by @thkl) — Smart plugs with the "Show on Printer Card" option enabled appear as a clickable chip in the printer card's HA-entities row (below the main Smart Plug controls). One click cut power to the printer instantly — including mid-print — even though the main Off button next to it already routes through a `ConfirmModal` and shows an additional running-print warning. **Fix:** the HA-row click handler in `frontend/src/pages/PrintersPage.tsx` now branches on entity type — `script.*` entities keep firing instantly (a script is a fire-once trigger, not a power switch, and the existing semantic of "Run" matches user expectation), but switch/light/anything-else entities now open a new `ConfirmModal` first. The modal reuses the same `variant="danger"` + running-print warning shape as the existing power-off confirmation: when `status?.state === 'RUNNING'` it shows the "WARNING: is currently printing! Toggling may cut power and interrupt the print" copy, and renders the default-variant "Toggle the Home Assistant entity ?" message otherwise. The entity name comes from `ha_entity_id` (with `name` fallback) so the modal disambiguates which of multiple plugs the click was on. **i18n:** new `printers.confirm.{haToggleTitle, haToggleMessage, haToggleWarning, haToggleButton}` keys added across all 8 locales (en + de + fr + it + ja + pt-BR + zh-CN + zh-TW translated to native, no English-fallback seeding). Full PrintersPage frontend suite (49 tests) still passes; build clean. - **X2D / H2D dual-nozzle without AMS: filament mapping reported "Required filament type not found in printer" even when the spools were physically loaded** ([#1257](https://github.com/maziggy/bambuddy/issues/1257)) — Repro: X2D with 0 AMS units, two external spools (Ext-L feeding left extruder, Ext-R feeding right), print job specifies `nozzle_id` per filament. The Schedule Print modal showed the orange "Filament Mapping (Type not found)" header and a forced manual slot picker, even though the matching PETG was sitting right there in the external spool holder. **Cause:** `frontend/src/hooks/useFilamentMapping.ts:18-19` derived dual-nozzle status solely from `printerStatus.ams_extruder_map` being non-empty. That map is populated from AMS units' info bits, so a dual-nozzle printer with zero AMS units gets an empty map → `hasDualNozzle = false` → external spools' `extruderId` falls through to `undefined` (line 64 ternary fallback). The downstream nozzle-aware filter at lines 117 / 377 (`available.filter((f) => f.extruderId === req.nozzle_id)`) then rejected every loaded filament because `undefined !== 0/1` for any non-null `nozzle_id`. The PETG was loaded, just incorrectly stripped from the candidate set during matching. **Fix:** widen the dual-nozzle inference to three independent signals OR'd together: (1) `nozzles[1].nozzle_diameter` populated — the most direct signal, set by `bambu_mqtt.py:2619-2621` only when the printer reports a `right_nozzle_diameter` MQTT field, so a populated value always implies real second-nozzle hardware; (2) `ams_extruder_map` non-empty — preserved as fallback for the dual-nozzle-with-AMS case the original code already handled; (3) `vt_tray.length > 1` — single-nozzle printers (P1S / A1 / X1C) only have one external feed, so multiple external trays only exist on dual-nozzle hardware. The first signal alone is *not* sufficient because the backend `state.nozzles` defaults to a 2-entry list with empty `NozzleInfo()` stubs (`bambu_mqtt.py:160`) on every printer, single-nozzle included — `nozzles.length` would always be 2 on the wire and would have regressed every single-nozzle install. Affects all dual-nozzle printers running without AMS: X2D, H2D, X2 Pro. **Tests:** two new regressions in `src/__tests__/hooks/useFilamentMapping.test.ts`. `matches external spools per-extruder on dual-nozzle without AMS` pins the bug fix — asserts each external spool gets the correct `extruderId` (1 for Ext-L id=254, 0 for Ext-R id=255) and `computeAmsMapping` picks Ext-L for a left-nozzle requirement. `does not fabricate extruderId for single-nozzle with stub nozzles[1]` is the matching guard — asserts that a P1S / A1 / X1C-shape PrinterStatus (with the default-stub second nozzle entry the backend always emits) does NOT trip the dual-nozzle inference, so single-nozzle external spools keep `extruderId=undefined` exactly as they did pre-fix. Together they pin both directions: a future change that re-breaks the X2D path fails CI, and one that mistakenly turns single-nozzle printers into dual-nozzle also fails CI. Full frontend suite (1891 tests across 138 files) green.