diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f7f3bcf0..5c22ae025 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.4b1] - Unreleased
### Added
+- **"Not Printed" / "Printed" collections on the Archives page** ([#1153](https://github.com/maziggy/bambuddy/issues/1153)) — Virtual-printer uploads land in the archives view with `status='archived'` (uploaded but never sent to a printer), but the existing `Collection` sidebar only had `All / Recent / This Week / This Month / Favorites / Failed / Duplicates` so there was no way to surface "what's still queued in my library that I haven't printed yet" vs "what already went to a printer." Two new collections fill that gap: **Not Printed** filters to `status === 'archived'` (the VP upload state); **Printed** filters to any final-status archive — `completed`, `failed`, `aborted`, `cancelled`, `stopped` — so a user can see every archive that had a print attempt regardless of outcome (the existing "Failed" collection covers just the failure subset). Frontend-only — the data has always been there, just no UI handle for it. 2 new tests in `ArchivesPage.test.tsx::Not Printed / Printed collections` pin the filter behaviour against a fixture covering all 4 status states (archived / completed / failed / cancelled).
- **Virtual-printer archive name source toggle** ([#1152](https://github.com/maziggy/bambuddy/issues/1152)) — Slicer-uploaded archives picked up their display name from the 3MF's embedded `print_name` metadata, which is whatever the original creator set; users who renamed a job in BambuStudio's "Send to printer" dialog never saw that name surface in Bambuddy because the FTP-uploaded filename was only ever used as a fallback when the metadata was empty. Settings → Virtual Printer now exposes an **Archive name source** toggle (Metadata / Filename, default Metadata, preserves existing behaviour) at the top of the page that flips precedence in `ArchiveService.archive_print` for every VP-sourced archive — `_archive_file`, `_add_to_print_queue`, `POST /pending-uploads/archive-all`, and `POST /pending-uploads/{id}/archive` all read the new `virtual_printer_archive_name_source` setting and forward `prefer_filename_for_name` accordingly. Backend validates the value to `metadata`/`filename` only. Strict locales (en/de/zh-CN/zh-TW) get full translations; 4 unit tests parametrised over `filename` / `metadata` / unset / empty-string pin the precedence rule end-to-end through `_archive_file`. Existing post-archive `PATCH /archives/{id}` rename path is unchanged.
- **Multi-color slicing in the Slice modal, with per-plate filament discovery for unsliced project files** — Initial slice support assumed a single filament profile per slice; multi-color 3MFs were silently truncated to the first slot, producing wrong colours on every non-trivial print. The Slice modal now (1) opens a plate-picker step first when the source is a multi-plate 3MF, (2) renders one filament dropdown per AMS slot the picked plate actually uses, with each dropdown auto-populated against the user's local + standard presets by `(filament_type, filament_colour)` match, and (3) submits the user's picks as an ordered `filament_presets: PresetRef[]` array which is forwarded as repeated `filamentProfile` multipart parts to the slicer sidecar (the CLI joins them with `;` for `--load-filaments`). **Per-plate filament list source-of-truth chain**: for a sliced archive the modal reads `Metadata/slice_info.config` directly (existing path); for an unsliced project file (where `slice_info.config` is empty until Bambu Studio actually slices), the new `slice_preview` service runs a fast preview-slice via the sidecar's `slice_without_profiles` (the project's embedded settings drive the slice; we throw away the gcode and only parse the resulting slice_info), and the result is cached by `(kind, source_id, plate_id, content_hash)` with LRU eviction at 256 entries — repeat opens of the same plate are instant. If the sidecar isn't reachable the modal falls back to a heuristic that reads `Metadata/project_settings.config` for the AMS slot config and intersects it with the plate's painted-face data (`paint_color` quadtree leaves on per-object .model files, scanned with a 5% noise threshold to drop single-leaf edit accidents). **SliceModal-only tier priority is now `local → cloud → standard`** (was `cloud → local → standard`): imported profiles win because they carry parsed type/colour metadata in the response, while cloud entries don't (the per-preset detail endpoint rate-limits at ~10/sec per token and 50+ parallel fetches returned 429 on every request). The unified-listing endpoint's dedup pass now backfills metadata cross-tier — if a cloud entry wins dedup over a same-named local entry, the cloud entry inherits the local's `filament_type` / `filament_colour` so the Slice modal's metadata-aware pre-pick keeps working for users who have presets both cloud-synced and locally imported. Other consumers of `/slicer/presets` (Profiles page, etc.) retain the existing cloud-first dedup. **Sidecar** (orca-slicer-api fork, `bambuddy/profile-resolver` branch): `/slice` now accepts up to 16 repeated `filamentProfile` parts (was hard-capped at 1), the slicing service materializes each as `filament_N.json` and joins paths into a single `--load-filaments "a.json;b.json;c.json"` invocation; `/profiles/bundled` listing was extended with `filament_type` and `filament_colour` per leaf so the bundled tier carries metadata into the modal. **Sliced-archive card now reflects the actually-used filament list, not the project-wide AMS config**: `slice_and_persist_as_archive` previously copied `filament_type` and `filament_color` from the unsliced source archive verbatim, which inherited every project-wide AMS slot (16+ swatches on the card for a 2-color print). The new archive now reads those fields from the sliced output's `slice_info.config` via `ThreeMFParser` (which already gates on `used_g > 0`), falling back to the source archive's values only if parsing failed. **Backwards compatibility**: `SliceRequest` schema accepts three shapes — legacy `filament_preset_id: int`, source-aware singular `filament_preset: PresetRef`, multi-color array `filament_presets: list[PresetRef]` — the validator promotes any of them into a populated `filament_presets` list before the route handler runs, and stale browser tabs from before this change keep working unchanged. **Permissions**: no new endpoint paths added; the preview-slice runs inside `/filament-requirements` (gated on `LIBRARY_READ` / `ARCHIVES_READ`) and the multi-filament dispatch runs inside `POST /slice` (gated on `LIBRARY_UPLOAD`) — no auth surface widened. **Tests**: 6 schema tests for `SliceRequest` covering the multi-filament list shape and legacy-vs-new precedence; 9 unit tests for `slice_preview` covering happy path, content-hash invalidation, sidecar-failure no-cache-poison, concurrent-call thundering-herd guard via per-key `asyncio.Lock`, and LRU eviction-with-lock-cleanup; 15 unit tests for `extract_project_filaments_from_3mf` (5 cases) and `extract_plate_extruder_set_from_3mf` (10 cases including the 60/40 painted-threshold pin); a multi-filament wire-format test on `slice_with_profiles` pinning that N filament profiles produce N repeated multipart parts in submission order; 22 frontend SliceModal tests covering the plate picker step, multi-color rendering, metadata-aware pre-pick, manual slot override, archive-vs-library routing, and the new tier order. Localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation per the project's existing flow).
diff --git a/frontend/src/__tests__/pages/ArchivesPage.test.tsx b/frontend/src/__tests__/pages/ArchivesPage.test.tsx
index f9d74aab8..961ec4777 100644
--- a/frontend/src/__tests__/pages/ArchivesPage.test.tsx
+++ b/frontend/src/__tests__/pages/ArchivesPage.test.tsx
@@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { ArchivesPage } from '../../pages/ArchivesPage';
import { http, HttpResponse } from 'msw';
@@ -372,4 +372,59 @@ describe('ArchivesPage', () => {
// Upload Timelapse should also be disabled
});
});
+
+ // #1153 — Sylvain wanted to differentiate VP-uploaded archives (status='archived',
+ // never sent to a printer) from those that have been printed at least once.
+ describe('Not Printed / Printed collections', () => {
+ const mixedStatusArchives = [
+ { ...mockArchives[0], id: 100, print_name: 'NeverPrinted', status: 'archived', started_at: null, completed_at: null },
+ { ...mockArchives[0], id: 101, print_name: 'WasPrinted', status: 'completed' },
+ { ...mockArchives[0], id: 102, print_name: 'WasFailed', status: 'failed' },
+ { ...mockArchives[0], id: 103, print_name: 'WasCancelled', status: 'cancelled' },
+ ];
+
+ beforeEach(() => {
+ // Reset persisted collection so each test starts on "All Archives".
+ window.localStorage.removeItem('archiveCollection');
+ server.use(
+ http.get('/api/v1/archives/', () => HttpResponse.json(mixedStatusArchives))
+ );
+ });
+
+ it('shows only archived (never-printed) entries when "Not Printed" is selected', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
+ });
+
+ const collectionSelect = screen.getByDisplayValue('All Archives');
+ fireEvent.change(collectionSelect, { target: { value: 'not-printed' } });
+
+ await waitFor(() => {
+ expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
+ expect(screen.queryByText('WasPrinted')).not.toBeInTheDocument();
+ expect(screen.queryByText('WasFailed')).not.toBeInTheDocument();
+ expect(screen.queryByText('WasCancelled')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows only print-attempted entries (any final status) when "Printed" is selected', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('NeverPrinted')).toBeInTheDocument();
+ });
+
+ const collectionSelect = screen.getByDisplayValue('All Archives');
+ fireEvent.change(collectionSelect, { target: { value: 'printed' } });
+
+ await waitFor(() => {
+ expect(screen.queryByText('NeverPrinted')).not.toBeInTheDocument();
+ expect(screen.getByText('WasPrinted')).toBeInTheDocument();
+ expect(screen.getByText('WasFailed')).toBeInTheDocument();
+ expect(screen.getByText('WasCancelled')).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx
index 78fc6dd76..1fd4f88e1 100644
--- a/frontend/src/pages/ArchivesPage.tsx
+++ b/frontend/src/pages/ArchivesPage.tsx
@@ -2405,7 +2405,11 @@ function ArchiveListRow({
type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
type ViewMode = 'grid' | 'list' | 'calendar' | 'log';
-type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
+type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'not-printed' | 'printed' | 'failed' | 'duplicates';
+
+// status values that indicate a print attempt has finished (regardless of outcome).
+// `archived` is the only status that means "uploaded but never sent to a printer."
+const PRINTED_STATUSES = ['completed', 'failed', 'aborted', 'cancelled', 'stopped'] as const;
const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
{ id: 'all', label: 'All Archives', icon: },
@@ -2413,6 +2417,8 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
{ id: 'this-week', label: 'This Week', icon: },
{ id: 'this-month', label: 'This Month', icon: },
{ id: 'favorites', label: 'Favorites', icon: },
+ { id: 'not-printed', label: 'Not Printed', icon: },
+ { id: 'printed', label: 'Printed', icon: },
{ id: 'failed', label: 'Failed Prints', icon: },
{ id: 'duplicates', label: 'Duplicates', icon: },
];
@@ -2759,6 +2765,12 @@ export function ArchivesPage() {
case 'favorites':
matchesCollection = a.is_favorite === true;
break;
+ case 'not-printed':
+ matchesCollection = a.status === 'archived';
+ break;
+ case 'printed':
+ matchesCollection = (PRINTED_STATUSES as readonly string[]).includes(a.status);
+ break;
case 'failed':
matchesCollection = a.status === 'failed' || a.status === 'aborted';
break;
diff --git a/static/assets/index-5P0GaR3D.js b/static/assets/index-CeqwraNQ.js
similarity index 98%
rename from static/assets/index-5P0GaR3D.js
rename to static/assets/index-CeqwraNQ.js
index f58888ce5..ba7aa1260 100644
--- a/static/assets/index-5P0GaR3D.js
+++ b/static/assets/index-CeqwraNQ.js
@@ -5,8 +5,8 @@ function tue(t,e){for(var n=0;nG||lt[j]!==vt[G]){var Rt=`
`+lt[j].replace(" at new "," at ");return h.displayName&&Rt.includes("")&&(Rt=Rt.replace("",h.displayName)),Rt}while(1<=j&&0<=G);break}}}finally{W=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?O(_):""}function H(h,y){switch(h.tag){case 26:case 27:case 5:return O(h.type);case 16:return O("Lazy");case 13:return h.child!==y&&y!==null?O("Suspense Fallback"):O("Suspense");case 19:return O("SuspenseList");case 0:case 15:return V(h.type,!1);case 11:return V(h.type.render,!1);case 1:return V(h.type,!0);case 31:return O("Activity");default:return""}}function J(h){try{var y="",_=null;do y+=H(h,_),_=h,h=h.return;while(h);return y}catch(j){return`
Error generating stack: `+j.message+`
-`+j.stack}}var ie=Object.prototype.hasOwnProperty,ge=t.unstable_scheduleCallback,pe=t.unstable_cancelCallback,he=t.unstable_shouldYield,q=t.unstable_requestPaint,R=t.unstable_now,te=t.unstable_getCurrentPriorityLevel,fe=t.unstable_ImmediatePriority,ke=t.unstable_UserBlockingPriority,je=t.unstable_NormalPriority,Ae=t.unstable_LowPriority,ye=t.unstable_IdlePriority,Re=t.log,Be=t.unstable_setDisableYieldValue,Ce=null,ze=null;function Me(h){if(typeof Re=="function"&&Be(h),ze&&typeof ze.setStrictMode=="function")try{ze.setStrictMode(Ce,h)}catch{}}var Ie=Math.clz32?Math.clz32:ve,Ge=Math.log,et=Math.LN2;function ve(h){return h>>>=0,h===0?32:31-(Ge(h)/et|0)|0}var Xe=256,Pe=262144,Ve=4194304;function we(h){var y=h&42;if(y!==0)return y;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function de(h,y,_){var j=h.pendingLanes;if(j===0)return 0;var G=0,Z=h.suspendedLanes,Ne=h.pingedLanes;h=h.warmLanes;var qe=j&134217727;return qe!==0?(j=qe&~Z,j!==0?G=we(j):(Ne&=qe,Ne!==0?G=we(Ne):_||(_=qe&~h,_!==0&&(G=we(_))))):(qe=j&~Z,qe!==0?G=we(qe):Ne!==0?G=we(Ne):_||(_=j&~h,_!==0&&(G=we(_)))),G===0?0:y!==0&&y!==G&&(y&Z)===0&&(Z=G&-G,_=y&-y,Z>=_||Z===32&&(_&4194048)!==0)?y:G}function Se(h,y){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&y)===0}function $e(h,y){switch(h){case 1:case 2:case 4:case 8:case 64:return y+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return y+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function st(){var h=Ve;return Ve<<=1,(Ve&62914560)===0&&(Ve=4194304),h}function He(h){for(var y=[],_=0;31>_;_++)y.push(h);return y}function rt(h,y){h.pendingLanes|=y,y!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function ht(h,y,_,j,G,Z){var Ne=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var qe=h.entanglements,lt=h.expirationTimes,vt=h.hiddenUpdates;for(_=Ne&~_;0<_;){var Rt=31-Ie(_),Ut=1<