fix(library): "All Files" view now shows files inside subfolders (#1499)

The File Manager sidebar's "All Files" entry was passing
  include_root=true to GET /library/files because the boolean was
  derived from `selectedFolderId === null`. The backend's include_root
  flag means *root files only* when folder_id is null, so a library
  where every file lived in a subfolder rendered as empty.

  Pass include_root=false from "All Files" so the backend returns every
  active file across folders. Adds a regression test that mounts the
  page, mocks the endpoint to distinguish include_root=true vs false,
  and asserts both a root file and a nested file appear.
This commit is contained in:
maziggy
2026-05-23 08:20:50 +02:00
parent 6b87cdefd7
commit 32fcd85827
5 changed files with 58 additions and 3 deletions
+1
View File
@@ -23,6 +23,7 @@ All notable changes to Bambuddy will be documented in this file.
- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean).
### Fixed
- **File Manager "All Files" view showed nothing when every file lived in a subfolder (#1499)** — The sidebar entry was meant to list every file across the library but instead returned only files at the library root, so a library with two folders and three files (all nested) appeared empty. Cause was an inverted boolean on the React Query call: `getLibraryFiles(selectedFolderId, selectedFolderId === null)` passed `include_root=true` for the "All Files" selection, which on the backend (`library.py` `list_files`) means *root files only* — the opposite of what the UI wanted. **Fix**: pass `include_root=false` for "All Files" so the backend returns every active file across folders (it remains a no-op when a specific folder is selected — `folder_id` takes precedence). A new vitest regression case renders the page with one root file and one nested file and asserts both appear, and that the request goes out with `include_root=false`. 48/48 FileManagerPage tests green; frontend build clean.
- **Archive filament colour now reflects the assigned inventory spool, not the slicer's 3MF (#1494, reported by @IndividualGhost1905)** — A user added a `#000000` black filament to the built-in inventory, assigned it to the printer, and printed from the desktop slicer; the print, AMS and inventory all showed it as black and the correct spool's weight decremented — but the resulting archive (and the Color Distribution graph) showed `#161616`. Root cause is two independent colour sources: an archive's `filament_color` is parsed verbatim from the print job's 3MF (`archive.py` `_extract_filament_info` reads `filament_colour` from `project_settings.config`), which carries the *slicer's* filament-slot colour — a value the user picks separately from the exact hex they curate on the Bambuddy inventory spool. The two are "close but not equal" (slicer near-black `#161616` vs inventory `#000000`), which is exactly the "always a similar colour, never an unrelated one" pattern the report describes. **Fix**: once usage tracking has resolved the print's filament slots to inventory spools, the spool colours are authoritative — `_track_from_3mf` (built-in inventory) and `report_usage` (Spoolman mode) now overwrite the archive's `filament_color` with the slot-ordered, de-duplicated colours of the matched spools. The rewrite is **all-or-nothing**: it only applies when *every* used slot resolved to a spool that carries a colour, so a partially-mapped multi-colour print never silently loses the unmatched slots' colours (the 3MF value is kept). Shipped for both inventory modes in the same drop — built-in spools read `Spool.rgba`, Spoolman spools read the spool's `filament.color_hex` (fetched for tag-less slot-assignment matches). New helpers `_spool_color_to_hex` / `_archive_colors_from_spools` in `usage_tracker.py`, reused by `spoolman_tracking.py` via `_apply_spool_colors_to_archive`. **Tests**: 12 new in `test_usage_tracker.py` (hex normalisation, the all-or-nothing slot-colour rule across single/multi/partial/no-colour/AMS-fallback cases, and end-to-end that a `#000000` spool rewrites a `#161616` archive) + 4 in `test_spoolman_tracking.py` (the Spoolman-mode rewrite, empty/partial/missing-archive no-ops). 70 usage + Spoolman tracking tests green; backend ruff clean.
- **Re-slicing across the single-nozzle / dual-nozzle boundary now fails fast with a clear message** — Re-slicing a model that was sliced for a single-nozzle printer (X1C, P1S, A1, …) onto a dual-nozzle printer (H2D / H2D Pro), or vice versa, produced a cryptic BambuStudio rejection — *"temperature difference of the filaments used is too large"* or *"G-code in unprintable area of multi-extruder printers"* — and an attempt at automatically converting the embedded single-nozzle layout segfaults the slicer CLI. Full cross-nozzle-class re-slicing isn't supported yet (it needs a proper dual-nozzle `project_settings` reconciliation — tracked separately). Until then, both slice routes (`POST /archives/{id}/slice`, `POST /library/files/{id}/slice`) now detect a nozzle-class mismatch up front and reject it synchronously with a `400` and a plain explanation, instead of letting the user wait for a confusing slicer failure. The guard is fail-open — it only fires when both the source's `sliced_for_model` and the target printer resolve to known models of *different* nozzle class; an un-sliced source (first-time slice) or an undeterminable target is never blocked. As part of this, the dual-nozzle model classification — previously an inline `("H2D", "H2D PRO", …)` tuple **duplicated across three call sites** (`start_print`, the two K-profile routes) — is centralized into `DUAL_NOZZLE_MODELS` + `is_dual_nozzle_model()` in `printer_models.py`, and all three sites plus the new guard now consume that single source of truth. **Tests**: `is_dual_nozzle_model` (H2D/Pro/internal-codes dual, X1C/P1S/A1/H2S single, None safe); `_canonical_printer_model` (strips the `"# "` clone prefix and `" 0.4 nozzle"` suffix); `guard_nozzle_class_reslice` (blocks single→dual and dual→single, allows same-class, no-ops on an un-sliced source); and an end-to-end test that an X1C archive re-sliced for an H2D preset returns `400` before any job is enqueued. 305 slicer/MQTT/K-profile tests + 31 slice-API + the printer-model suite all green; backend ruff clean.
- **Sliced files no longer report "0 g" filament usage** — A slice result — and the re-sliced archive's card — showed `filament_used_g: 0` (and `0 mm`) even for a real multi-hour print, while the print time came through fine. Bambuddy reads filament totals from the slicer sidecar's `X-Filament-Used-G` / `X-Filament-Used-Mm` response headers, and some sidecar builds simply don't populate them. **Fix**: `ThreeMFParser._parse_gcode_header` now also reads the slicer's own totals — `; total filament weight [g] : …` and `; total filament length [mm] : …` — from the produced 3MF's G-code header (verified against a real sliced output: 126.26 g / 41661.4 mm extracted correctly). Both slice-persist paths (`slice_and_persist` for library files, `slice_and_persist_as_archive`) now fall back to those parsed totals when the sidecar header is 0, applying the corrected figure to the stored metadata, the archive's `filament_used_grams` column, and the slice response. The G-code-header read is a fallback only — `slice_info.config` still wins when it carries per-filament `used_g`. **Tests**: 2 new in `test_archive_service.py` (`_parse_gcode_header` extracts weight + length; absent header lines leave the keys unset). 36 archive-service + 23 slice-API tests green; backend ruff clean.
@@ -932,4 +932,55 @@ describe('FileManagerPage', () => {
expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'false');
});
});
describe('"All Files" view (#1499)', () => {
it('requests every file (include_root=false) so subfolder contents are visible', async () => {
const rootFile = {
id: 10,
filename: 'root-file.3mf',
file_path: '/library/root-file.3mf',
file_size: 1024,
file_type: '3mf',
folder_id: null,
thumbnail_path: null,
print_name: 'Root File',
print_time_seconds: 0,
print_count: 0,
duplicate_count: 0,
created_at: '2024-01-01T00:00:00Z',
};
const nestedFile = {
...rootFile,
id: 11,
filename: 'nested-file.3mf',
file_path: '/library/Functional Parts/nested-file.3mf',
folder_id: 1,
print_name: 'Nested File',
};
const includeRootValues: string[] = [];
server.use(
http.get('/api/v1/library/files', ({ request }) => {
const url = new URL(request.url);
const includeRoot = url.searchParams.get('include_root');
includeRootValues.push(includeRoot ?? '');
// Mirror the backend: include_root=false returns everything; true
// returns only files with folder_id IS NULL.
if (includeRoot === 'false') {
return HttpResponse.json([rootFile, nestedFile]);
}
return HttpResponse.json([rootFile]);
}),
);
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Root File')).toBeInTheDocument();
expect(screen.getByText('Nested File')).toBeInTheDocument();
});
// Sanity-check: the buggy call would have sent include_root=true here.
expect(includeRootValues).toContain('false');
});
});
});
+4 -1
View File
@@ -1049,7 +1049,10 @@ export function FileManagerPage() {
const { data: files, isLoading: filesLoading } = useQuery({
queryKey: ['library-files', selectedFolderId],
queryFn: () => api.getLibraryFiles(selectedFolderId, selectedFolderId === null),
// "All Files" (selectedFolderId === null) lists every file across folders,
// so include_root must be false — true would scope the result to files at
// the library root only and hide everything nested in subfolders (#1499).
queryFn: () => api.getLibraryFiles(selectedFolderId, false),
});
const { data: stats } = useQuery({
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BL4kzp1A.js"></script>
<script type="module" crossorigin src="/assets/index-B5qvYtQq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BzucE4G0.css">
</head>
<body>