Browse Source

VP uploads go to the File Manager, not the archive

- Archive mode becomes File Manager mode (library); old archive and
  immediate VPs are migrated, API clients sending "archive" still work
- Per-VP File Manager folder; default is a shared "Virtual Printer"
  folder; external and other users' private folders are refused
- The same file sent again is reused, except for a queue VP that
  removes its files once they print (own copy, never shared)
- Queue mode queues the File Manager file; the archive is made at
  dispatch. New option: remove from File Manager once it prints
- Review approve stores the file in the VP's folder with tags, notes
  and project; approve-all goes on after a failed file
- Review panel moved to the File Manager page and translated; the
  sidebar badge moved with it
- The slicer AMS pick stays on the queue job with the printer it was
  made on, until dispatch; badge and edit Mapping use it; batch
  copies keep it
- The VP name setting applies to the archive made at dispatch
- New WS event library_files_changed refreshes open File Manager pages
maziggy 2 days ago
parent
commit
b09c5530c3
100 changed files with 2410 additions and 976 deletions
  1. 1 0
      CHANGELOG.md
  2. 17 4
      backend/app/api/routes/library.py
  3. 72 98
      backend/app/api/routes/pending_uploads.py
  4. 8 0
      backend/app/api/routes/print_queue.py
  5. 45 2
      backend/app/api/routes/virtual_printers.py
  6. 28 1
      backend/app/core/database.py
  7. 6 0
      backend/app/core/sqlite_fk.py
  8. 8 0
      backend/app/core/websocket.py
  9. 2 0
      backend/app/models/__init__.py
  10. 6 0
      backend/app/models/pending_upload.py
  11. 11 2
      backend/app/models/print_queue.py
  12. 19 8
      backend/app/models/virtual_printer.py
  13. 4 0
      backend/app/schemas/print_queue.py
  14. 8 4
      backend/app/services/print_batch.py
  15. 37 1
      backend/app/services/print_scheduler.py
  16. 33 0
      backend/app/services/slicer_ams_pick.py
  17. 178 0
      backend/app/services/virtual_printer/library_store.py
  18. 87 110
      backend/app/services/virtual_printer/manager.py
  19. 130 31
      backend/tests/integration/test_pending_upload_archive_project.py
  20. 2 0
      backend/tests/integration/test_print_batch_orders.py
  21. 45 0
      backend/tests/integration/test_print_queue_api.py
  22. 61 1
      backend/tests/integration/test_virtual_printer_api.py
  23. 182 0
      backend/tests/integration/test_vp_library_store.py
  24. 158 361
      backend/tests/unit/services/test_virtual_printer.py
  25. 24 10
      backend/tests/unit/services/test_vp_external_spool_mapping_3237.py
  26. 30 3
      backend/tests/unit/services/test_vp_queue_intake_d2.py
  27. 17 0
      backend/tests/unit/test_library_dispatch_reuses_archive.py
  28. 65 1
      backend/tests/unit/test_scheduler_cleanup_library.py
  29. 12 9
      backend/tests/unit/test_vp_mode_rename_migration.py
  30. 15 0
      frontend/src/__tests__/components/Layout.test.tsx
  31. 27 25
      frontend/src/__tests__/components/PendingUploadsPanelWiring.test.tsx
  32. 74 1
      frontend/src/__tests__/components/VirtualPrinterCard.test.tsx
  33. 30 1
      frontend/src/__tests__/components/archiveAmsMapping.test.ts
  34. 29 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  35. 15 3
      frontend/src/api/client.ts
  36. 5 4
      frontend/src/components/Layout.tsx
  37. 50 51
      frontend/src/components/PendingUploadsPanel.tsx
  38. 25 0
      frontend/src/components/PrintModal/archiveAmsMapping.ts
  39. 11 5
      frontend/src/components/PrintModal/index.tsx
  40. 4 4
      frontend/src/components/VirtualPrinterAddDialog.tsx
  41. 101 12
      frontend/src/components/VirtualPrinterCard.tsx
  42. 7 0
      frontend/src/hooks/useWebSocket.ts
  43. 44 13
      frontend/src/i18n/locales/de.ts
  44. 44 13
      frontend/src/i18n/locales/en.ts
  45. 45 14
      frontend/src/i18n/locales/es.ts
  46. 45 14
      frontend/src/i18n/locales/fr.ts
  47. 44 13
      frontend/src/i18n/locales/it.ts
  48. 44 13
      frontend/src/i18n/locales/ja.ts
  49. 46 15
      frontend/src/i18n/locales/ko.ts
  50. 45 14
      frontend/src/i18n/locales/nl.ts
  51. 44 13
      frontend/src/i18n/locales/pl.ts
  52. 44 13
      frontend/src/i18n/locales/pt-BR.ts
  53. 44 13
      frontend/src/i18n/locales/ru.ts
  54. 45 14
      frontend/src/i18n/locales/sv.ts
  55. 45 14
      frontend/src/i18n/locales/tr.ts
  56. 45 14
      frontend/src/i18n/locales/uk.ts
  57. 45 14
      frontend/src/i18n/locales/zh-CN.ts
  58. 45 14
      frontend/src/i18n/locales/zh-TW.ts
  59. 0 4
      frontend/src/pages/ArchivesPage.tsx
  60. 5 0
      frontend/src/pages/FileManagerPage.tsx
  61. 0 0
      static/assets/ArchivesPage-BT2XUk6S.js
  62. 0 0
      static/assets/ArchivesPage-ByPKTG9t.js
  63. 0 0
      static/assets/CamWallPage-DRDGoScq.js
  64. 0 0
      static/assets/CameraPage-DYByvitA.js
  65. 0 0
      static/assets/ConfirmOutcomeDialog-CBgZux_i.js
  66. 0 1
      static/assets/FileManagerPage-CGQxvG5e.js
  67. 1 0
      static/assets/FileManagerPage-DEXA171M.js
  68. 0 0
      static/assets/InventoryPage-CrONTfif.js
  69. 0 0
      static/assets/Layout-heUoL0ca.js
  70. 0 0
      static/assets/ModelSourcesPage-C9o7oXPY.js
  71. 0 0
      static/assets/PrintModal-CkCV3RPN.js
  72. 0 0
      static/assets/PrintModal-oBcvLlYd.js
  73. 0 0
      static/assets/PrintersPage-BLQFYEZx.js
  74. 0 0
      static/assets/ProjectDetailPage-FlECLtvI.js
  75. 0 0
      static/assets/QueuePage-Db-o5Z__.js
  76. 0 0
      static/assets/RichTextEditor-BkJXXimU.js
  77. 0 0
      static/assets/SettingsPage-C4uiC0kE.js
  78. 0 0
      static/assets/SettingsPage-CZlRSTOS.js
  79. 0 0
      static/assets/SliceModal-B3Mgfq_e.js
  80. 0 0
      static/assets/SpoolBuddyInventoryPage-DPeBuglW.js
  81. 1 1
      static/assets/SpoolBuddySettingsPage-BWe0L8oA.js
  82. 0 0
      static/assets/de-DU-G_Teg.js
  83. 0 0
      static/assets/es-C2Ko7tyR.js
  84. 0 0
      static/assets/fr-DMHDjQWT.js
  85. 0 0
      static/assets/index-BfS_79_Q.js
  86. 0 0
      static/assets/it-C-FKwP1C.js
  87. 0 0
      static/assets/ja-wAtN4Slu.js
  88. 0 0
      static/assets/ko-BCDNJZ63.js
  89. 0 0
      static/assets/locationSensorDefaults-DVHuVP2r.js
  90. 0 0
      static/assets/nl-Dx7NH6eG.js
  91. 0 0
      static/assets/pl-BRfMHxLJ.js
  92. 0 0
      static/assets/pt-BR-BbAdMvr-.js
  93. 0 0
      static/assets/ru-Cp0AuFM6.js
  94. 0 0
      static/assets/spoolCost-BFPKTGkS.js
  95. 0 0
      static/assets/sv-oWe1sIWP.js
  96. 0 0
      static/assets/tr-BHcKh5-R.js
  97. 0 0
      static/assets/uk-DXBSI5eO.js
  98. 0 0
      static/assets/usePageFileDrop-Dzw3tEhV.js
  99. 0 0
      static/assets/zh-CN-UR_WqJm9.js
  100. 0 0
      static/assets/zh-TW-MUDa8Ggq.js

+ 1 - 0
CHANGELOG.md

@@ -10,6 +10,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 
 ### Changed
+- **Virtual printers put uploads in the File Manager, not the archive** — An archive is a print that happened, but a virtual printer archived every file the slicer sent, in Archive, Review and Queue mode, before it ever printed. Uploads now go to the File Manager, and the archive gets the file when it prints, like any other File Manager print. **Archive** mode is now **File Manager** mode; virtual printers set to it switch over on upgrade, and archives they created before stay as they are. Each virtual printer card has a **File Manager folder** (by default a shared top-level folder named **Virtual Printer**; external folders can't be picked). The same file sent again under the same name into that folder doesn't make a second copy. **Review** mode stays: pending uploads now wait at the top of the File Manager page (the sidebar badge moved there too), and **Add to File Manager** stores them in the virtual printer's folder with their tags, notes and project; the panel is translated now too. **Queue** mode stores the file and queues it from there, and a new switch, **Remove from File Manager once it prints** (off by default), takes it out of the File Manager when its print starts. With **Save AMS mapping** on, the slicer's tray pick still reaches the archive for reprints: the queue job keeps it until the print creates the archive, shows the saved-mapping badge meanwhile, and offers it under **Mapping** when edited. The virtual printer's archive-name setting (file name or 3MF title) applies to the archive made when the print starts. Wiki updated.
 - **A queue virtual printer shows where its jobs go, and its target can be cleared** — In Print Queue mode a virtual printer without a target printer sends each job to the next free printer of its model, but the card's target list only said "Select a printer...", and once a printer was picked there was no way back. The first entry now reads **Any available [model]** and switches the virtual printer back to that, and a line under the list says where the jobs go. The access code stays the same, so the slicer stays bound. In the other server modes the entry reads **None**; proxy mode still needs a printer. Wiki updated.
 - **Profile defaults is compact and has Apply all** — Settings → Filament → Profile defaults showed every default as a large box with all its presets and K profiles, so a few products already filled the page. Each default is now one line (product, colour, slicer preset, and how many printer presets and K profiles it has), sorted by product, with a search box; click a line to see its presets and K profiles. The list scrolls on its own. **Apply all** applies every default to the spools in stock in one go, and like Apply to existing spools it only fills in what a spool doesn't have yet. Wiki updated.
 - **Slicer sidecars on Bambu Studio 02.09.01.70 and OrcaSlicer 2.5.0 (#3442)** — The Bambu Studio sidecar now ships the H3C printer presets (0.2, 0.4, 0.6 and 0.8 mm nozzle). OrcaSlicer has no H3C presets yet. OrcaSlicer 2.5.0 ships its bundled presets in a new packed format, so the sidecar image now fetches the same release's presets as JSON for its preset handling. Slicing with a printer preset saved under a name of its own keeps working on 2.5.0; the sliced file then names the Bambu preset it was saved from as its printer preset. Sidecar images built for older slicer versions work as before.

+ 17 - 4
backend/app/api/routes/library.py

@@ -700,7 +700,8 @@ def _without_print_name(metadata: dict | None) -> dict | None:
 async def save_3mf_bytes_to_library(
     db: AsyncSession,
     *,
-    file_bytes: bytes,
+    file_bytes: bytes | None = None,
+    source_path: Path | None = None,
     filename: str,
     folder_id: int | None = None,
     source_type: str | None = None,
@@ -722,7 +723,13 @@ async def save_3mf_bytes_to_library(
     Parses 3MF metadata + thumbnail the same way the multipart upload route
     does, via :class:`ThreeMFParser`, and renders an STL's thumbnail. Paths are stored as relative so the
     library is portable across installs.
+
+    Give the content as ``file_bytes`` or as ``source_path``, a file on disk
+    that is copied without being read into memory (a virtual printer's
+    upload, which can be hundreds of MB).
     """
+    if (file_bytes is None) == (source_path is None):
+        raise ValueError("pass exactly one of file_bytes and source_path")
     # Source-URL-based dedupe: return the existing row untouched.
     if source_url and dedupe_within is not None:
         existing = await db.execute(
@@ -767,8 +774,14 @@ async def save_3mf_bytes_to_library(
 
     file_path, is_external = _resolve_upload_destination(target_folder, filename)
     ext = file_path.suffix.lower() or ".3mf"
-    with open(file_path, "wb") as fh:
-        fh.write(file_bytes)
+    if source_path is not None:
+        await asyncio.to_thread(shutil.copyfile, source_path, file_path)
+        file_size = file_path.stat().st_size
+    else:
+        assert file_bytes is not None  # one of the two, checked above
+        with open(file_path, "wb") as fh:
+            fh.write(file_bytes)
+        file_size = len(file_bytes)
 
     file_hash = calculate_file_hash(file_path)
 
@@ -812,7 +825,7 @@ async def save_3mf_bytes_to_library(
         filename=filename,
         file_path=_stored_file_path(file_path, is_external),
         file_type=classify_file_type(filename, file_path),
-        file_size=len(file_bytes),
+        file_size=file_size,
         file_hash=file_hash,
         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
         file_metadata=_without_print_name(metadata),

+ 72 - 98
backend/app/api/routes/pending_uploads.py

@@ -1,5 +1,6 @@
-"""API routes for pending uploads (virtual printer queue mode)."""
+"""API routes for pending uploads (virtual printer review mode)."""
 
+import logging
 from datetime import datetime, timezone
 from pathlib import Path
 
@@ -11,10 +12,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import ApiKeyActor, RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.project import Project
 from backend.app.models.user import User
-from backend.app.services.archive import ArchiveService, resolve_display_stem
+from backend.app.services.archive import resolve_display_stem
+
+logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
 
@@ -128,64 +132,74 @@ async def get_pending_count(
 # to prevent FastAPI from matching /archive-all as /{upload_id}
 
 
+async def _store_pending(db: AsyncSession, pending: PendingUpload, request: "ArchiveRequest | None"):
+    """Store an approved upload in its virtual printer's File Manager folder.
+
+    The file, its notes, project and tags go to the File Manager; the archive
+    gets it once it prints (an archive is a print that happened). An upload
+    from before the virtual printer was recorded, or whose virtual printer is
+    gone, goes to the shared "Virtual Printer" folder. Commits.
+    """
+    from backend.app.services.virtual_printer.library_store import store_upload, upload_settings
+
+    folder_id = None
+    if pending.virtual_printer_id is not None:
+        folder_id, _ = await upload_settings(db, pending.virtual_printer_id)
+    library_file, _ = await store_upload(
+        db,
+        source_path=Path(pending.file_path),
+        filename=pending.filename,
+        folder_id=folder_id,
+        notes=request.notes if request else None,
+        project_id=(request.project_id or None) if request else None,
+        tags=request.tags if request else None,
+    )
+    pending.status = "approved"
+    pending.archived_at = datetime.now(timezone.utc)
+    if request:
+        pending.tags = request.tags
+        pending.notes = request.notes
+        pending.project_id = request.project_id or None
+    await db.commit()
+    try:
+        Path(pending.file_path).unlink()
+    except OSError:
+        pass  # Best-effort temp file cleanup; the File Manager has its copy
+    return library_file
+
+
 @router.post("/archive-all")
 async def archive_all_pending(
     db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
-    """Archive all pending uploads."""
-    from backend.app.api.routes.settings import get_setting
-
-    result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
-    pending_uploads = result.scalars().all()
+    """Approve every pending upload: each goes to the File Manager."""
+    # By id, each loaded fresh: a failed one is rolled back, and a rollback
+    # expires every row the session holds.
+    pending_ids = (await db.execute(select(PendingUpload.id).where(PendingUpload.status == "pending"))).scalars().all()
 
-    archived = 0
+    stored = 0
     failed = 0
-
-    service = ArchiveService(db)
-    prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
-
-    for pending in pending_uploads:
-        file_path = Path(pending.file_path)
-        if not file_path.exists():
+    for pending_id in pending_ids:
+        pending = await db.get(PendingUpload, pending_id)
+        if pending is None or pending.status != "pending":
+            continue
+        if not Path(pending.file_path).exists():
             pending.status = "discarded"
+            await db.commit()
             failed += 1
             continue
-
         try:
-            archive = await service.archive_print(
-                printer_id=None,
-                source_file=file_path,
-                print_data={
-                    "status": "archived",
-                    "source": "virtual_printer",
-                    "source_ip": pending.source_ip,
-                },
-                prefer_filename_for_name=prefer_filename,
-            )
-
-            if archive:
-                pending.status = "archived"
-                pending.archived_id = archive.id
-                pending.archived_at = datetime.now(timezone.utc)
-                archived += 1
-
-                # Clean up temp file
-                try:
-                    file_path.unlink()
-                except OSError:
-                    pass  # Best-effort temp file cleanup after archiving
-            else:
-                failed += 1
-        except Exception:  # Mixed async DB + archive operations
+            await _store_pending(db, pending, None)
+            stored += 1
+        except Exception:  # noqa: BLE001 -- one bad file must not stop the rest
+            logger.exception("Could not store pending upload %s in the File Manager", pending_id)
+            await db.rollback()
             failed += 1
 
-    await db.commit()
-
-    return {
-        "archived": archived,
-        "failed": failed,
-    }
+    if stored:
+        await ws_manager.send_library_files_changed()
+    return {"stored": stored, "failed": failed}
 
 
 @router.delete("/discard-all")
@@ -243,7 +257,7 @@ async def archive_pending_upload(
     db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
-    """Archive a pending upload."""
+    """Approve a pending upload: it goes to the File Manager."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
     pending = result.scalar_one_or_none()
 
@@ -252,65 +266,25 @@ async def archive_pending_upload(
     if pending.status != "pending":
         raise HTTPException(status_code=400, detail="Upload already processed")
 
-    # Check file exists
-    file_path = Path(pending.file_path)
-    if not file_path.exists():
+    if not Path(pending.file_path).exists():
         raise HTTPException(status_code=404, detail="Upload file not found on disk")
 
-    # The project must exist before anything is archived; 0 means none.
+    # The project must exist before anything is stored; 0 means none.
     project_id = (request.project_id or None) if request else None
     if project_id is not None and await db.get(Project, project_id) is None:
         raise HTTPException(status_code=400, detail="Project not found")
 
-    # Archive the file
-    from backend.app.api.routes.settings import get_setting
-
-    prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
-    service = ArchiveService(db)
-    archive = await service.archive_print(
-        printer_id=None,
-        source_file=file_path,
-        print_data={
-            "status": "archived",
-            "source": "virtual_printer",
-            "source_ip": pending.source_ip,
-        },
-        prefer_filename_for_name=prefer_filename,
-    )
-
-    if not archive:
-        raise HTTPException(status_code=500, detail="Failed to archive file")
-
-    # Apply tags/notes/project from request
-    if request:
-        if request.tags:
-            archive.tags = request.tags
-        if request.notes:
-            archive.notes = request.notes
-        if project_id is not None:
-            archive.project_id = project_id
-
-    # Update pending record
-    pending.status = "archived"
-    pending.archived_id = archive.id
-    pending.archived_at = datetime.now(timezone.utc)
-    if request:
-        pending.tags = request.tags
-        pending.notes = request.notes
-        pending.project_id = project_id
-
-    await db.commit()
-
-    # Clean up temp file
     try:
-        file_path.unlink()
-    except OSError:
-        pass  # Best-effort temp file cleanup after successful archive
+        library_file = await _store_pending(db, pending, request)
+    except Exception as e:
+        logger.exception("Could not store pending upload %s in the File Manager", upload_id)
+        raise HTTPException(status_code=500, detail="Failed to store the file in the File Manager") from e
+    await ws_manager.send_library_files_changed()
 
     return {
-        "id": archive.id,
-        "print_name": archive.print_name,
-        "filename": archive.filename,
+        "id": library_file.id,
+        "filename": library_file.filename,
+        "folder_id": library_file.folder_id,
     }
 
 

+ 8 - 0
backend/app/api/routes/print_queue.py

@@ -59,6 +59,7 @@ from backend.app.schemas.print_queue import (
     QueueVariantCreate,
     QueueVariantSummary,
 )
+from backend.app.services import slicer_ams_pick
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.finance_budget import release_budget_reservation, validate_print_budget
@@ -609,6 +610,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
                         response.filament_used_grams = plate_meta.filament_used_grams
                     if plate_meta.bed_type:
                         response.bed_type = plate_meta.bed_type
+    # No archive yet (a virtual printer's upload is a File Manager file until
+    # it prints), but the job keeps the slicer's pick for it; shown, like the
+    # archive's above, only while the job is on the printer it was made on.
+    pick = slicer_ams_pick.unpack(item.slicer_ams_mapping)
+    if pick is not None and item.printer_id is not None and pick[1] == item.printer_id:
+        response.slicer_ams_mapping = pick[0]
+        response.archive_has_slicer_ams_mapping = True
     if item.library_file:
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None

+ 45 - 2
backend/app/api/routes/virtual_printers.py

@@ -8,7 +8,7 @@ from pydantic import BaseModel
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope
@@ -37,10 +37,12 @@ class TailscaleStatusResponse(BaseModel):
 class VirtualPrinterCreate(BaseModel):
     name: str = "Bambuddy"
     enabled: bool = False
-    mode: str = "archive"
+    mode: str = "library"
     model: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
+    library_folder_id: int | None = None
+    queue_remove_after_print: bool = False
     auto_dispatch: bool = True
     queue_force_color_match: bool = False
     save_ams_mapping: bool = False
@@ -57,6 +59,8 @@ class VirtualPrinterUpdate(IgnoresNullForRequiredColumns):
     model: str | None = None
     access_code: str | None = None
     target_printer_id: int | None = None
+    library_folder_id: int | None = None
+    queue_remove_after_print: bool | None = None
     auto_dispatch: bool | None = None
     queue_force_color_match: bool | None = None
     save_ams_mapping: bool | None = None
@@ -83,6 +87,24 @@ def _resolve_printer_model(printer_model: str | None) -> str | None:
     return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
 
 
+async def _check_upload_folder(db: AsyncSession, folder_id: int, user) -> JSONResponse | None:
+    """None when the virtual printer may store uploads in ``folder_id``.
+
+    The user setting it must be able to add files there themselves (#3201),
+    and it must be an internal folder: an external mount refuses a second file
+    of the same name, and slicer names repeat.
+    """
+    from backend.app.services.library_folder_access import get_writable_folder
+
+    folder = await get_writable_folder(db, folder_id, user)
+    if folder.is_external:
+        return JSONResponse(
+            status_code=400,
+            content={"detail": "Virtual printer uploads can't go to an external folder"},
+        )
+    return None
+
+
 def _target_missing(target_printer_id: int) -> JSONResponse:
     return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
 
@@ -122,6 +144,8 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
         "access_code_set": bool(vp.access_code),
         "serial": serial,
         "target_printer_id": vp.target_printer_id,
+        "library_folder_id": vp.library_folder_id,
+        "queue_remove_after_print": vp.queue_remove_after_print,
         "auto_dispatch": vp.auto_dispatch,
         "queue_force_color_match": vp.queue_force_color_match,
         "save_ams_mapping": vp.save_ams_mapping,
@@ -169,6 +193,8 @@ async def create_virtual_printer(
     db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
+    # Who the #3201 folder check runs against: an API key as its owner.
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
     """Create a new virtual printer."""
     from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
@@ -216,6 +242,11 @@ async def create_virtual_printer(
         if not target_printer or not printer_scope.allows(target_printer.id):
             return _target_missing(body.target_printer_id)
 
+    if body.library_folder_id is not None and (
+        refused := await _check_upload_folder(db, body.library_folder_id, actor)
+    ):
+        return refused
+
     # Validate bind_ip uniqueness (against all enabled VPs)
     if body.bind_ip:
         result = await db.execute(
@@ -263,6 +294,8 @@ async def create_virtual_printer(
         or DEFAULT_VIRTUAL_PRINTER_MODEL,
         access_code=effective_access_code,
         target_printer_id=body.target_printer_id,
+        library_folder_id=body.library_folder_id,
+        queue_remove_after_print=body.queue_remove_after_print,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
         save_ams_mapping=body.save_ams_mapping,
@@ -409,6 +442,8 @@ async def update_virtual_printer(
     db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
+    # Who the #3201 folder check runs against: an API key as its owner.
+    actor: User | ApiKeyActor | None = RequestActor,
 ):
     """Update a virtual printer."""
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -476,6 +511,14 @@ async def update_virtual_printer(
         # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
         if body.model is None and vp.mode == "proxy" and target_printer.model:
             vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
+    if "library_folder_id" in body.model_fields_set and body.library_folder_id is None:
+        vp.library_folder_id = None  # back to the shared "Virtual Printer" folder
+    elif body.library_folder_id is not None:
+        if refused := await _check_upload_folder(db, body.library_folder_id, actor):
+            return refused
+        vp.library_folder_id = body.library_folder_id
+    if body.queue_remove_after_print is not None:
+        vp.queue_remove_after_print = body.queue_remove_after_print
     if body.auto_dispatch is not None:
         vp.auto_dispatch = body.auto_dispatch
     if body.queue_force_color_match is not None:

+ 28 - 1
backend/app/core/database.py

@@ -2856,6 +2856,30 @@ async def _run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
 
+    # Migration: virtual printer uploads go to the File Manager, not the
+    # archive. The folder they land in (NULL: the shared "Virtual Printer"
+    # folder), queue mode's "remove once it prints", the virtual printer a
+    # review-mode upload came in on (its folder on approval), and the slicer's
+    # AMS pick a queue item carries until its print creates the archive.
+    # SQLite ignores REFERENCES on ADD COLUMN; core/sqlite_fk applies the rules.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE virtual_printers ADD COLUMN library_folder_id INTEGER "
+        "REFERENCES library_folders(id) ON DELETE SET NULL",
+    )
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN queue_remove_after_print BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE virtual_printers ADD COLUMN queue_remove_after_print BOOLEAN DEFAULT FALSE"
+        )
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pending_uploads ADD COLUMN virtual_printer_id INTEGER "
+        "REFERENCES virtual_printers(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN slicer_ams_mapping TEXT")
+
     # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
     # slicer-pick preservation (#1780). Opaque JSON-string column carrying
     # BambuStudio's per-filament physical nozzle position IDs, forwarded
@@ -3941,7 +3965,9 @@ async def _run_migrations(conn):
     # value is what changes. Idempotent: re-running the UPDATE on canonical
     # values is a no-op. SQLite and Postgres both accept this statement
     # unchanged (string literal comparison, no driver-specific syntax).
-    vp_mode_renames = [("immediate", "archive"), ("print_queue", "queue")]
+    # `archive` became `library` when uploads moved to the File Manager; after
+    # the `immediate` rename above, in this order.
+    vp_mode_renames = [("immediate", "archive"), ("archive", "library"), ("print_queue", "queue")]
     for old_val, new_val in vp_mode_renames:
         await conn.execute(
             text("UPDATE virtual_printers SET mode = :new WHERE mode = :old"),
@@ -6226,6 +6252,7 @@ _PG_COLUMNS_SET_FROM_MODEL = (
     ("spool", "weight_used_baseline"),
     ("virtual_printers", "gcode_injection"),
     ("virtual_printers", "queue_force_color_match"),
+    ("virtual_printers", "queue_remove_after_print"),
     ("virtual_printers", "save_ams_mapping"),
     ("virtual_printers", "tailscale_disabled"),
 )

+ 6 - 0
backend/app/core/sqlite_fk.py

@@ -66,6 +66,12 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("print_batches", "library_file_id"),
         ("print_queue", "library_file_id"),
         ("print_queue_variants", "library_file_id"),
+        # library_folders: a virtual printer's uploads would go into the next
+        # folder given the id, maybe someone's private one.
+        ("virtual_printers", "library_folder_id"),
+        # virtual_printers: an approved upload would go to the folder of the
+        # next virtual printer given the id.
+        ("pending_uploads", "virtual_printer_id"),
         # print_queue
         ("pipeline_jobs", "queue_entry_id"),
         ("print_log_entries", "queue_item_id"),

+ 8 - 0
backend/app/core/websocket.py

@@ -338,6 +338,14 @@ class ConnectionManager:
             }
         )
 
+    async def send_library_files_changed(self):
+        """Notify clients that files arrived in the File Manager outside a request.
+
+        A virtual printer's upload: the File Manager page refetches its list,
+        which applies the reader's folder visibility, so nothing is sent here.
+        """
+        await self.broadcast({"type": "library_files_changed", "data": {}})
+
     async def send_archive_updated(self, archive: dict):
         """Notify clients that an archive was updated.
 

+ 2 - 0
backend/app/models/__init__.py

@@ -55,6 +55,7 @@ from backend.app.models.user import User
 from backend.app.models.user_email_pref import UserEmailPreference
 from backend.app.models.user_otp_code import UserOTPCode
 from backend.app.models.user_totp import UserTOTP
+from backend.app.models.virtual_printer import VirtualPrinter
 
 __all__ = [
     "Printer",
@@ -89,6 +90,7 @@ __all__ = [
     "LabelTemplate",
     "Location",
     "User",
+    "VirtualPrinter",
     "Group",
     "user_groups",
     "group_locations",

+ 6 - 0
backend/app/models/pending_upload.py

@@ -28,6 +28,12 @@ class PendingUpload(Base):
 
     # Source info
     source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
+    # The virtual printer it came in on: approving stores the file in that
+    # printer's File Manager folder. NULL for uploads from before this was
+    # recorded, or once that virtual printer is deleted.
+    virtual_printer_id: Mapped[int | None] = mapped_column(
+        ForeignKey("virtual_printers.id", ondelete="SET NULL"), nullable=True
+    )
 
     # Status: pending, archived, discarded
     status: Mapped[str] = mapped_column(String(20), default="pending")

+ 11 - 2
backend/app/models/print_queue.py

@@ -57,6 +57,14 @@ class PrintQueueItem(Base):
     # Format: "[5, -1, 2, -1]" where position = slot_id-1, value = global tray ID (-1 = unused)
     ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # The slicer's own AMS pick for a virtual-printer upload with "Save AMS
+    # mapping" on (#2700), same format as ams_mapping. Not used for this
+    # print (ams_mapping is): it goes onto the archive the dispatch creates
+    # (extra_data.slicer_ams_mapping, for printer_id), so a reprint of that
+    # archive can reuse it. Kept apart because "Force color match" leaves
+    # ams_mapping empty and still wants the pick saved.
+    slicer_ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Filament overrides for model-based assignment: JSON array of override objects
     # Format: '[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF"}]'
     # Only slots with overrides are included (sparse). null = use original 3MF values.
@@ -109,8 +117,9 @@ class PrintQueueItem(Base):
     # slot got its colour or nothing could be compared.
     color_substitutions: Mapped[str | None] = mapped_column(Text, nullable=True)
 
-    # Printer-card direct uploads create transient library rows. When this is
-    # true, the scheduler deletes the source row/files after archiving a copy.
+    # Printer-card direct uploads create transient library rows, and a queue
+    # virtual printer can ask for its uploads to go once they print. When this
+    # is true, the scheduler deletes the source row/files after archiving a copy.
     cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state

+ 19 - 8
backend/app/models/virtual_printer.py

@@ -5,26 +5,28 @@ from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
 
-# Canonical VP mode values. The legacy values `immediate` (→ archive) and
-# `print_queue` (→ queue) shipped before the UI labels were aligned with the
-# wire format. `normalize_vp_mode()` translates input from either form and
+# Canonical VP mode values. `library` stores uploads in the File Manager; it
+# was `archive` until uploads stopped going into the archive (an archive is a
+# print that happened), and `immediate` before that. `print_queue` is the old
+# name of `queue`. `normalize_vp_mode()` translates input from any of them and
 # the DB migration in `core/database.py` rewrites existing rows once at boot.
-VP_MODE_ARCHIVE = "archive"
+VP_MODE_LIBRARY = "library"
 VP_MODE_REVIEW = "review"
 VP_MODE_QUEUE = "queue"
 VP_MODE_PROXY = "proxy"
-VP_MODE_VALUES = (VP_MODE_ARCHIVE, VP_MODE_REVIEW, VP_MODE_QUEUE, VP_MODE_PROXY)
+VP_MODE_VALUES = (VP_MODE_LIBRARY, VP_MODE_REVIEW, VP_MODE_QUEUE, VP_MODE_PROXY)
 
 # Legacy → canonical map. Kept narrow on purpose so unrelated typos surface
 # instead of getting silently re-pointed at a default.
 _VP_MODE_ALIASES = {
-    "immediate": VP_MODE_ARCHIVE,
+    "immediate": VP_MODE_LIBRARY,
+    "archive": VP_MODE_LIBRARY,
     "print_queue": VP_MODE_QUEUE,
 }
 
 
 def normalize_vp_mode(value: str | None) -> str | None:
-    """Map legacy wire values (`immediate`, `print_queue`) to canonical names.
+    """Map legacy wire values (`immediate`, `archive`, `print_queue`) to canonical names.
 
     Returns `None` unchanged so callers can decide whether to apply a default.
     Returns unknown values unchanged so validators still see them and reject.
@@ -42,7 +44,7 @@ class VirtualPrinter(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     name: Mapped[str] = mapped_column(String(100), default="Bambuddy")
     enabled: Mapped[bool] = mapped_column(Boolean, default=False)
-    mode: Mapped[str] = mapped_column(String(20), default=VP_MODE_ARCHIVE)  # archive|review|queue|proxy
+    mode: Mapped[str] = mapped_column(String(20), default=VP_MODE_LIBRARY)  # library|review|queue|proxy
     auto_dispatch: Mapped[bool] = mapped_column(Boolean, server_default="true")  # queue mode: auto-start or manual
     queue_force_color_match: Mapped[bool] = mapped_column(
         Boolean, server_default="false"
@@ -66,6 +68,15 @@ class VirtualPrinter(Base):
     )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
     # injection (#1516). Default off so existing gcode_snippets users don't
     # silently start injecting; no-op when no snippets exist for the model.
+    # File Manager folder that uploads land in (library, review and queue
+    # mode). NULL: the shared top-level folder "Virtual Printer".
+    library_folder_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("library_folders.id", ondelete="SET NULL"), nullable=True
+    )
+    # Queue mode: remove the file from File Manager once its print starts (the
+    # archive keeps its own copy). Rides on the queue item's
+    # cleanup_library_after_dispatch.
+    queue_remove_after_print: Mapped[bool] = mapped_column(Boolean, server_default="false")
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     target_printer_id: Mapped[int | None] = mapped_column(

+ 4 - 0
backend/app/schemas/print_queue.py

@@ -287,6 +287,10 @@ class PrintQueueItemResponse(BaseModel):
     # reuses that exact physical spool instead of the scheduler re-deriving one
     # from the file's static type/color.
     archive_has_slicer_ams_mapping: bool = False
+    # A virtual printer's job not printed yet: the slicer's AMS pick it keeps
+    # for the archive its print will create (#2700), when it was resolved
+    # against this row's printer. The badge above is set for it too.
+    slicer_ams_mapping: list[int] | None = None
 
     # User tracking (Issue #206)
     created_by_id: int | None = None

+ 8 - 4
backend/app/services/print_batch.py

@@ -58,6 +58,9 @@ CLONED_SETTING_COLUMNS = (
     "project_id",
     "batch_id",
     "ams_mapping",
+    # A virtual printer job's slicer pick for its archive (#2700): each copy's
+    # print makes or joins an archive too.
+    "slicer_ams_mapping",
     "filament_overrides",
     "plate_id",
     "print_time_seconds",
@@ -512,10 +515,11 @@ def _clone_queue_item(source: PrintQueueItem, *, position: int, created_by_id: i
     either fire immediately (it is in the past) or silently park the new run
     until a moment the user chose for a different print.
 
-    ``cleanup_library_after_dispatch`` is forced off. It only ever comes from
-    the Printers-page direct-print flow, where it deletes the transient library
-    row after dispatch — replaying that on a clone would delete the source file
-    out from under the rest of the order.
+    ``cleanup_library_after_dispatch`` is forced off. It comes from the
+    Printers-page direct-print flow and from a queue virtual printer's "Remove
+    from File Manager once it prints", and deletes the library row at dispatch
+    — replaying that on a clone would delete the source file out from under the
+    rest of the order.
     """
     clone = PrintQueueItem(
         status="pending",

+ 37 - 1
backend/app/services/print_scheduler.py

@@ -33,7 +33,13 @@ from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-from backend.app.services import drying_preflight, kprofile_drift, print_dispatch_context, stock_forecast
+from backend.app.services import (
+    drying_preflight,
+    kprofile_drift,
+    print_dispatch_context,
+    slicer_ams_pick,
+    stock_forecast,
+)
 from backend.app.services.bambu_ftp import (
     FtpFailureKind,
     FtpFailureReport,
@@ -8184,6 +8190,20 @@ class PrintScheduler:
             is not None
         )
 
+    @staticmethod
+    async def _vp_prefers_filename(db: AsyncSession, library_file: LibraryFile) -> bool:
+        """Whether a virtual printer's upload names its archive after the file.
+
+        Settings > Virtual Printer chooses between the file name and the title
+        in the 3MF for the archives of uploads; other files keep the default.
+        """
+        from backend.app.api.routes.settings import get_setting
+        from backend.app.services.virtual_printer.library_store import SOURCE_TYPE
+
+        if library_file.source_type != SOURCE_TYPE:
+            return False
+        return (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
+
     async def _claim_reusable_archive(
         self, db: AsyncSession, item: PrintQueueItem, file_path: Path
     ) -> PrintArchive | None:
@@ -9211,7 +9231,22 @@ class PrintScheduler:
                         if item.plate_id is not None:
                             archive.plate_id = item.plate_id
                         archive.cost_center_id = item.cost_center_id
+                    if (slicer_pick := slicer_ams_pick.unpack(item.slicer_ams_mapping)) is not None:
+                        archive.extra_data = {
+                            **(archive.extra_data or {}),
+                            "slicer_ams_mapping": {"mapping": slicer_pick[0], "printer_id": slicer_pick[1]},
+                        }
                 else:
+                    # A virtual printer's upload: its name setting, and the
+                    # slicer's AMS pick kept for reprints (#2700). Passed only
+                    # when set, as for every other file nothing changes.
+                    vp_extras: dict = {}
+                    if await self._vp_prefers_filename(db, library_file):
+                        vp_extras["prefer_filename_for_name"] = True
+                    if (slicer_pick := slicer_ams_pick.unpack(item.slicer_ams_mapping)) is not None:
+                        # The printer the slicer resolved it on, not the
+                        # job's: the job may have been moved since.
+                        vp_extras["slicer_ams_mapping"], vp_extras["slicer_ams_mapping_printer_id"] = slicer_pick
                     archive_service = ArchiveService(db)
                     archive = await archive_service.archive_print(
                         printer_id=item.printer_id,
@@ -9222,6 +9257,7 @@ class PrintScheduler:
                         cost_center_id=item.cost_center_id,
                         library_file_id=item.library_file_id,  # per-file project progress (#1897)
                         plate_id=item.plate_id,  # selected plate → Print History (#2603)
+                        **vp_extras,
                     )
                 if archive:
                     self._hold_archive(item.id, archive.id)

+ 33 - 0
backend/app/services/slicer_ams_pick.py

@@ -0,0 +1,33 @@
+"""The slicer's AMS pick a virtual printer's queue job keeps for its archive (#2700).
+
+``PrintQueueItem.slicer_ams_mapping`` holds it until the job's print creates
+the archive, as ``{"mapping": [global tray ids], "printer_id": id}``. The
+printer is the one the slicer resolved the trays against: the job can be moved
+to another printer before it prints, and tray numbers mean nothing there, so it
+is never taken from the job's current printer.
+"""
+
+import json
+
+
+def pack(mapping_json: str | None, printer_id: int | None) -> str | None:
+    """The column value for the slicer's ``mapping_json`` resolved on ``printer_id``."""
+    if not mapping_json or printer_id is None:
+        return None
+    return json.dumps({"mapping": json.loads(mapping_json), "printer_id": printer_id})
+
+
+def unpack(raw: str | None) -> tuple[list[int], int] | None:
+    """``(mapping, printer_id)`` from the column, or None when unset or malformed."""
+    if not raw:
+        return None
+    try:
+        value = json.loads(raw)
+    except (TypeError, ValueError):
+        return None
+    if not isinstance(value, dict):
+        return None
+    mapping, printer_id = value.get("mapping"), value.get("printer_id")
+    if not isinstance(mapping, list) or not mapping or not isinstance(printer_id, int):
+        return None
+    return mapping, printer_id

+ 178 - 0
backend/app/services/virtual_printer/library_store.py

@@ -0,0 +1,178 @@
+"""Store a virtual printer's upload in the File Manager.
+
+Every server mode keeps what the slicer sends in the File Manager: library
+mode right away, review mode once the upload is approved, queue mode for the
+queue items that print it. The archive only gets a file once it prints.
+"""
+
+import asyncio
+import logging
+from pathlib import Path
+
+from sqlalchemy import exists, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.library import LibraryFile, LibraryFolder, LibraryTag
+from backend.app.models.print_queue import PrintQueueItem
+
+logger = logging.getLogger(__name__)
+
+SOURCE_TYPE = "virtual_printer"
+DEFAULT_FOLDER_NAME = "Virtual Printer"
+
+
+async def upload_folder(db: AsyncSession, folder_id: int | None) -> LibraryFolder:
+    """The folder a virtual printer's uploads land in.
+
+    Its configured folder, or the shared top-level "Virtual Printer" folder
+    when it has none, the folder was deleted, or it is an external folder (an
+    external mount refuses a second file of the same name, and slicer names
+    like "Plate_1.gcode.3mf" repeat). Only a shared or ownerless folder of
+    that name counts: a user's private one of the same name would take every
+    such upload out of everyone else's sight (#3201). None yet: one is made.
+    """
+    if folder_id is not None:
+        folder = await db.get(LibraryFolder, folder_id)
+        if folder is not None and not folder.is_external:
+            return folder
+        logger.warning("Virtual printer folder %s is gone or external; using '%s'", folder_id, DEFAULT_FOLDER_NAME)
+    shared = (
+        await db.execute(
+            select(LibraryFolder)
+            .where(
+                LibraryFolder.name == DEFAULT_FOLDER_NAME,
+                LibraryFolder.parent_id.is_(None),
+                LibraryFolder.is_external.is_(False),
+                LibraryFolder.shared.is_(True) | LibraryFolder.created_by_id.is_(None),
+            )
+            .order_by(LibraryFolder.id)
+            .limit(1)
+        )
+    ).scalar_one_or_none()
+    if shared is not None:
+        return shared
+    folder = LibraryFolder(name=DEFAULT_FOLDER_NAME, parent_id=None, created_by_id=None, shared=True)
+    db.add(folder)
+    await db.flush()
+    return folder
+
+
+async def _apply_tags(db: AsyncSession, library_file: LibraryFile, tags: str | None) -> None:
+    """Attach the comma-separated ``tags`` (review mode's field), creating missing ones."""
+    from backend.app.api.routes.library_tags import _name_key
+
+    names = []
+    for raw in (tags or "").split(","):
+        name = raw.strip()[:64]
+        if name and _name_key(name) not in {_name_key(n) for n in names}:
+            names.append(name)
+    if not names:
+        return
+    await db.refresh(library_file, ["tags"])
+    have = {tag.name_key for tag in library_file.tags}
+    for name in names:
+        key = _name_key(name)
+        if key in have:
+            continue
+        tag = (await db.execute(select(LibraryTag).where(LibraryTag.name_key == key))).scalar_one_or_none()
+        if tag is None:
+            tag = LibraryTag(name=name, name_key=key)
+            db.add(tag)
+            await db.flush()
+        library_file.tags.append(tag)
+        have.add(key)
+
+
+async def upload_settings(db: AsyncSession, vp_id: int) -> tuple[int | None, bool]:
+    """``(library_folder_id, queue_remove_after_print)`` of a virtual printer.
+
+    Read at each upload rather than held by the running instance, so changing
+    them takes effect without restarting the virtual printer.
+    """
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    row = (
+        await db.execute(
+            select(VirtualPrinter.library_folder_id, VirtualPrinter.queue_remove_after_print).where(
+                VirtualPrinter.id == vp_id
+            )
+        )
+    ).one_or_none()
+    if row is None:
+        return None, False
+    return row[0], bool(row[1])
+
+
+async def store_upload(
+    db: AsyncSession,
+    *,
+    source_path: Path,
+    filename: str,
+    folder_id: int | None,
+    owner_id: int | None = None,
+    notes: str | None = None,
+    project_id: int | None = None,
+    tags: str | None = None,
+    reuse: bool = True,
+) -> tuple[LibraryFile, bool]:
+    """Put an upload into the File Manager; returns ``(file, reused)``.
+
+    The same file sent again under the same name into the same folder is the
+    file already there (``reused``), so sending a plate twice does not leave
+    two copies; a queue gets a job for each send all the same. Only files a
+    virtual printer stored count: a file someone put there by hand is never
+    taken over. Notes, project and tags are applied to the file either way.
+
+    A file a queue job removes once it prints ("Remove from File Manager once
+    it prints") is never shared: such a job asks for a file of its own
+    (``reuse=False``), and no other upload takes that one, or the job's print
+    would remove a file someone else stored to keep. Commits.
+    """
+    from backend.app.api.routes.library import save_3mf_bytes_to_library
+    from backend.app.services.archive import ArchiveService
+
+    folder = await upload_folder(db, folder_id)
+    file_hash = await asyncio.to_thread(ArchiveService.compute_file_hash, source_path)
+    # The job that removes this file at dispatch: not a file to share.
+    removed_on_print = exists().where(
+        PrintQueueItem.library_file_id == LibraryFile.id,
+        PrintQueueItem.cleanup_library_after_dispatch.is_(True),
+    )
+    existing = (
+        None
+        if not reuse
+        else (
+            await db.execute(
+                LibraryFile.active()
+                .where(
+                    LibraryFile.folder_id == folder.id,
+                    LibraryFile.filename == filename,
+                    LibraryFile.file_hash == file_hash,
+                    LibraryFile.source_type == SOURCE_TYPE,
+                    ~removed_on_print,
+                )
+                .order_by(LibraryFile.id)
+                .limit(1)
+            )
+        ).scalar_one_or_none()
+    )
+    if existing is not None:
+        library_file, reused = existing, True
+    else:
+        library_file, reused = await save_3mf_bytes_to_library(
+            db,
+            source_path=source_path,
+            filename=filename,
+            folder_id=folder.id,
+            source_type=SOURCE_TYPE,
+            owner_id=owner_id,
+            # No source URL: duplicates are found by content above.
+            dedupe_within=None,
+        )
+    if notes:
+        library_file.notes = notes
+    if project_id is not None:
+        library_file.project_id = project_id
+    await _apply_tags(db, library_file, tags)
+    await db.commit()
+    return library_file, reused

+ 87 - 110
backend/app/services/virtual_printer/manager.py

@@ -15,11 +15,12 @@ from typing import TYPE_CHECKING
 
 from backend.app.core.config import settings as app_settings
 from backend.app.models.virtual_printer import (
-    VP_MODE_ARCHIVE,
+    VP_MODE_LIBRARY,
     VP_MODE_PROXY,
     VP_MODE_QUEUE,
     normalize_vp_mode,
 )
+from backend.app.services import slicer_ams_pick
 from backend.app.services.bambu_mqtt import resolve_external_spools_in_mapping
 from backend.app.services.virtual_printer.bind_server import BindServer
 from backend.app.services.virtual_printer.certificate import CertificateService
@@ -267,7 +268,7 @@ class VirtualPrinterInstance:
         # canonical values, even when a legacy DB row hasn't been migrated
         # yet (e.g. fresh-from-disk during the boot window before the
         # one-shot migration in `core/database.py` has executed).
-        self.mode = normalize_vp_mode(mode) or VP_MODE_ARCHIVE
+        self.mode = normalize_vp_mode(mode) or VP_MODE_LIBRARY
         self.model = model
         self.access_code = access_code
         self.serial_suffix = serial_suffix
@@ -383,13 +384,13 @@ class VirtualPrinterInstance:
 
         self._pending_files[file_path.name] = file_path
 
-        # Accept both canonical (`archive`/`queue`) and legacy
-        # (`immediate`/`print_queue`) wire values so a stale row that hasn't
-        # been migrated yet still dispatches correctly. Migration in
+        # Accept both canonical (`library`/`queue`) and legacy
+        # (`immediate`/`archive`/`print_queue`) wire values so a stale row that
+        # hasn't been migrated yet still dispatches correctly. Migration in
         # `core/database.py` rewrites existing rows once at boot.
         mode = normalize_vp_mode(self.mode)
-        if mode == VP_MODE_ARCHIVE:
-            await self._archive_file(file_path, source_ip)
+        if mode == VP_MODE_LIBRARY:
+            await self._store_in_library(file_path, source_ip)
         elif mode == VP_MODE_QUEUE:
             await self._add_to_print_queue(file_path, source_ip)
         else:
@@ -673,8 +674,17 @@ class VirtualPrinterInstance:
                         update(PrintQueueItem).where(PrintQueueItem.id.in_(short_ids), *unclaimed).values(**short_patch)
                     )
 
-                # The archive was already created (with no slicer_ams_mapping)
-                # before this late MQTT arrived — see
+                # The queue items keep the pick for the archive their print
+                # makes (a claimed one too: its archive may not exist yet).
+                if ams_mapping_json is not None and rows:
+                    await db.execute(
+                        update(PrintQueueItem)
+                        .where(PrintQueueItem.id.in_([row[0] for row in rows]))
+                        .values(slicer_ams_mapping=slicer_ams_pick.pack(ams_mapping_json, self.target_printer_id))
+                    )
+
+                # An item whose print already started has its archive, made
+                # before this late MQTT arrived (with no slicer_ams_mapping) — see
                 # `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
                 # too so a reprint later still picks up the slicer's pick, and
                 # the "AMS mapping from slicer" badge reflects reality instead
@@ -757,66 +767,44 @@ class VirtualPrinterInstance:
         self._mqtt.set_gcode_state("FINISH", filename=filename, prepare_percent="100")
         logger.debug("[VP %s] Re-set gcode_state=FINISH after project_file ack (%s)", self.name, filename)
 
-    async def _archive_file(self, file_path: Path, source_ip: str) -> None:
-        """Archive file immediately."""
+    async def _store_in_library(self, file_path: Path, source_ip: str) -> None:
+        """Store the upload in the File Manager (library mode)."""
         if not self._session_factory:
-            logger.error("Cannot archive: no database session factory configured")
+            logger.error("Cannot store upload: no database session factory configured")
             self._pending_files.pop(file_path.name, None)
             return
 
-        if file_path.suffix.lower() != ".3mf":
-            logger.debug("Skipping non-3MF file: %s", file_path.name)
-            self._pending_files.pop(file_path.name, None)
-            try:
-                file_path.unlink()
-            except OSError:
-                pass
-            return
-
-        archived = False
         try:
-            from backend.app.api.routes.settings import get_setting
-            from backend.app.services.archive import ArchiveService
+            if file_path.suffix.lower() != ".3mf":
+                logger.debug("Skipping non-3MF file: %s", file_path.name)
+                return
+            from backend.app.services.virtual_printer.library_store import store_upload, upload_settings
 
             async with self._session_factory() as db:
-                name_source = await get_setting(db, "virtual_printer_archive_name_source")
-                prefer_filename = name_source == "filename"
-                service = ArchiveService(db)
-                archive = await service.archive_print(
-                    printer_id=None,
-                    source_file=file_path,
-                    print_data={
-                        "status": "archived",
-                        "source": "virtual_printer",
-                        "source_ip": source_ip,
-                    },
-                    prefer_filename_for_name=prefer_filename,
+                folder_id, _ = await upload_settings(db, self.id)
+                library_file, reused = await store_upload(
+                    db, source_path=file_path, filename=file_path.name, folder_id=folder_id
                 )
-                if archive:
-                    logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
-                    await self._broadcast_archive_created(archive)
-                    archived = True
-                else:
-                    logger.error("Failed to archive file: %s", file_path.name)
+            logger.info(
+                "[VP %s] %s File Manager file %s (%s) from %s",
+                self.name,
+                "Same file as" if reused else "Stored as",
+                library_file.id,
+                library_file.filename,
+                source_ip,
+            )
+            await self._broadcast_library_files_changed()
         except Exception as e:
-            logger.error("Error archiving file: %s", e)
+            logger.error("[VP %s] Error storing %s in the File Manager: %s", self.name, file_path.name, e)
         finally:
-            # Always drop it from the pending count and delete the temp file —
-            # previously the failure paths only logged, the upload_dir filled
-            # up indefinitely, and the slicer received a clean 226 even though
-            # no archive existed (#audit-R2-1).
+            # Always drop it from the pending count and delete the temp file:
+            # the File Manager holds its own copy, and a failed one must not
+            # pile up in the upload dir (#audit-R2-1).
             self._pending_files.pop(file_path.name, None)
-            if archived:
-                try:
-                    file_path.unlink()
-                except OSError:
-                    pass
-            else:
-                # Drop the failed temp file so it doesn't accumulate.
-                try:
-                    file_path.unlink(missing_ok=True)
-                except OSError:
-                    pass
+            try:
+                file_path.unlink(missing_ok=True)
+            except OSError:
+                pass
 
     async def _queue_file(self, file_path: Path, source_ip: str) -> None:
         """Queue file for user review."""
@@ -858,6 +846,7 @@ class VirtualPrinterInstance:
                     file_path=str(file_path),
                     file_size=file_path.stat().st_size,
                     source_ip=source_ip,
+                    virtual_printer_id=self.id,
                     status="pending",
                     uploaded_at=datetime.now(timezone.utc),
                     metadata_print_name=metadata_print_name,
@@ -879,7 +868,7 @@ class VirtualPrinterInstance:
             self._pending_files.pop(file_path.name, None)
 
     async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
-        """Archive file and add to print queue, assigned to target printer or model."""
+        """Store the file in the File Manager and queue it, for the target printer or model."""
         if not self._session_factory:
             logger.error("Cannot add to print queue: no database session factory configured")
             self._pending_files.pop(file_path.name, None)
@@ -933,17 +922,13 @@ class VirtualPrinterInstance:
         try:
             import json
 
-            from backend.app.api.routes.settings import get_setting
             from backend.app.models.print_queue import PrintQueueItem
-            from backend.app.services.archive import ArchiveService
             from backend.app.services.filament_requirements import extract_filament_requirements
             from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
             from backend.app.services.print_defaults import default_print_options
+            from backend.app.services.virtual_printer.library_store import store_upload, upload_settings
 
             async with self._session_factory() as db:
-                name_source = await get_setting(db, "virtual_printer_archive_name_source")
-                prefer_filename = name_source == "filename"
-
                 # Workflow defaults from settings. Without this the
                 # PrintQueueItem below would fall back to the column-level
                 # defaults and ignore the user's workflow preferences (#1235).
@@ -999,7 +984,7 @@ class VirtualPrinterInstance:
                 # above. A plate sent here from Bambu Studio is also one of the
                 # prints `confirm_outcome_external_prints` names — but it
                 # arrives with a queue item, so on_print_start resumes the
-                # archive created below instead of treating it as external, and
+                # archive its dispatch creates instead of treating it as external, and
                 # without this the setting could never reach it (#1898).
                 confirm_outcome = await confirm_outcome_for_new_queue_item(db, started_outside_bambuddy=True)
 
@@ -1090,7 +1075,7 @@ class VirtualPrinterInstance:
                 queue_ams_mapping_json = ams_mapping_json
                 if queue_ams_mapping_json is not None and self.queue_force_color_match:
                     logger.info(
-                        "[VP %s] Saved the slicer's AMS pick to the archive but not onto the queue item(s): "
+                        "[VP %s] Kept the slicer's AMS pick for the archive but not onto the queue item(s): "
                         "'Force color match' is on, so the scheduler matches against live trays for this print.",
                         self.name,
                     )
@@ -1099,27 +1084,26 @@ class VirtualPrinterInstance:
                 # Parsed once for the per-plate length check in the loop below.
                 queue_ams_mapping = json.loads(queue_ams_mapping_json) if queue_ams_mapping_json else None
 
-                service = ArchiveService(db)
-                archive = await service.archive_print(
-                    printer_id=None,
-                    source_file=file_path,
-                    print_data={
-                        "status": "archived",
-                        "source": "virtual_printer",
-                        "source_ip": source_ip,
-                    },
-                    prefer_filename_for_name=prefer_filename,
-                    # Slicer's own live AMS-slot pick -- promoted to
-                    # `extra_data.slicer_ams_mapping` by archive_print() so a
-                    # later reprint can reuse it. Already gated on the per-VP
-                    # `save_ams_mapping` opt-in above. Tagged with the printer
-                    # it was resolved against so a later reprint on a
-                    # *different* printer knows not to reuse it (#2700 review).
-                    slicer_ams_mapping=(json.loads(ams_mapping_json) if ams_mapping_json else None),
-                    slicer_ams_mapping_printer_id=self.target_printer_id,
+                # The file goes to the File Manager; the queue items print it
+                # from there, and the archive is made when one starts printing
+                # (an archive is a print that happened).
+                folder_id, remove_after_print = await upload_settings(db, self.id)
+                # A file its print removes must be this upload's own.
+                library_file, reused = await store_upload(
+                    db,
+                    source_path=file_path,
+                    filename=file_path.name,
+                    folder_id=folder_id,
+                    reuse=not remove_after_print,
                 )
-                if archive:
-                    logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
+                if library_file:
+                    logger.info(
+                        "[VP %s] %s File Manager file %s (%s)",
+                        self.name,
+                        "Same file as" if reused else "Stored as",
+                        library_file.id,
+                        library_file.filename,
+                    )
                     # Assign to specific printer if configured, otherwise use model for "Any X" scheduling
                     target_model = None
                     if not self.target_printer_id and self.model:
@@ -1226,7 +1210,10 @@ class VirtualPrinterInstance:
                         queue_item = PrintQueueItem(
                             printer_id=self.target_printer_id,
                             target_model=target_model,
-                            archive_id=archive.id,
+                            library_file_id=library_file.id,
+                            # "Remove from File Manager once it prints": the
+                            # dispatch moves the file into the archive.
+                            cleanup_library_after_dispatch=remove_after_print,
                             plate_id=plate_id,
                             position=max_pos + offset,
                             status="pending",
@@ -1254,6 +1241,11 @@ class VirtualPrinterInstance:
                             # Slicer's own live AMS-slot pick, when present —
                             # see `_extract_slicer_ams_mapping_json`.
                             ams_mapping=plate_ams_mapping_json,
+                            # The slicer's pick for the archive this print makes,
+                            # so a reprint can reuse it; set with "Force color
+                            # match" too, which keeps it off ams_mapping above.
+                            # Already gated on `save_ams_mapping` and a target.
+                            slicer_ams_mapping=slicer_ams_pick.pack(ams_mapping_json, self.target_printer_id),
                         )
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
@@ -1302,12 +1294,10 @@ class VirtualPrinterInstance:
                             plate_ids,
                             queue_item_ids,
                         )
-                    await self._broadcast_archive_created(archive)
+                    await self._broadcast_library_files_changed()
                     from backend.app.services.queue_announcements import announce_queue_jobs_added
 
                     await announce_queue_jobs_added(db, queue_item_ids)
-                else:
-                    logger.error("Failed to archive file: %s", file_path.name)
         except Exception as e:
             logger.error("Error adding to print queue: %s", e)
         finally:
@@ -1321,27 +1311,14 @@ class VirtualPrinterInstance:
             except OSError:
                 pass
 
-    async def _broadcast_archive_created(self, archive) -> None:
-        """Notify connected clients that a new archive exists.
-
-        Real-printer prints get this from main.py's MQTT print_start handler;
-        VP-uploaded prints need their own broadcast or the Archives page stays
-        stale until the user switches tabs (#1282).
-        """
+    async def _broadcast_library_files_changed(self) -> None:
+        """Let open File Manager pages refetch: a file arrived outside a request."""
         try:
             from backend.app.core.websocket import ws_manager
 
-            await ws_manager.send_archive_created(
-                {
-                    "id": archive.id,
-                    "printer_id": archive.printer_id,
-                    "filename": archive.filename,
-                    "print_name": archive.print_name,
-                    "status": archive.status,
-                }
-            )
-        except Exception as e:
-            logger.debug("[VP %s] archive_created broadcast failed: %s", self.name, e)
+            await ws_manager.send_library_files_changed()
+        except Exception as e:  # noqa: BLE001 -- a broadcast must never fail an upload
+            logger.debug("[VP %s] library_files_changed broadcast failed: %s", self.name, e)
 
     @staticmethod
     def _extract_plate_ids(file_path: Path) -> list[int]:
@@ -1982,7 +1959,7 @@ class VirtualPrinterManager:
         return {
             "enabled": False,
             "running": False,
-            "mode": VP_MODE_ARCHIVE,
+            "mode": VP_MODE_LIBRARY,
             "name": "Bambuddy",
             "serial": "",
             "model": DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -1994,7 +1971,7 @@ class VirtualPrinterManager:
         self,
         enabled: bool,
         access_code: str = "",
-        mode: str = VP_MODE_ARCHIVE,
+        mode: str = VP_MODE_LIBRARY,
         model: str = "",
         target_printer_ip: str = "",
         target_printer_serial: str = "",

+ 130 - 31
backend/tests/integration/test_pending_upload_archive_project.py

@@ -1,31 +1,40 @@
-"""Archiving a pending upload into a project checks the project first.
+"""Approving a pending upload stores it in the File Manager, project checked first.
 
-The project id from the request was stored on the archive and the pending
-upload unchecked, after the file had already been archived: an unknown project
-failed with a server error on PostgreSQL (foreign key) with the archive files
-already written, and was stored as a broken link on SQLite. 0 skipped the
-archive but was stored on the pending upload.
+The project id from the request used to be stored unchecked, after the file
+was already stored: an unknown project failed with a server error on
+PostgreSQL (foreign key) with the files already written, and was kept as a
+broken link on SQLite. 0 means no project.
+
+Approving puts the file in the File Manager folder of the virtual printer it
+came in on (review mode); the archive gets it only once it prints.
 """
 
 from __future__ import annotations
 
-from unittest.mock import AsyncMock, patch
-
 import pytest
 from httpx import AsyncClient
 from sqlalchemy import select
 
-from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.virtual_printer import VirtualPrinter
 
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
 
-async def _pending(db_session, tmp_path) -> PendingUpload:
+@pytest.fixture(autouse=True)
+def _library_in_tmp(tmp_path, monkeypatch):
+    """The File Manager writes below archive_dir: keep it out of the real one."""
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+
+
+async def _pending(db_session, tmp_path, **extra) -> PendingUpload:
     file = tmp_path / "part.3mf"
     file.write_bytes(b"3mf")
     upload = PendingUpload(
-        filename="part.3mf", file_path=str(file), file_size=3, source_ip="192.168.1.100", status="pending"
+        filename="part.3mf", file_path=str(file), file_size=3, source_ip="192.168.1.100", status="pending", **extra
     )
     db_session.add(upload)
     await db_session.commit()
@@ -33,17 +42,13 @@ async def _pending(db_session, tmp_path) -> PendingUpload:
     return upload
 
 
-async def test_an_unknown_project_is_refused_before_anything_is_archived(
-    async_client: AsyncClient, db_session, tmp_path
-):
+async def test_an_unknown_project_is_refused_before_anything_is_stored(async_client: AsyncClient, db_session, tmp_path):
     upload_id = (await _pending(db_session, tmp_path)).id
 
-    with patch("backend.app.api.routes.pending_uploads.ArchiveService.archive_print", new=AsyncMock()) as archive:
-        response = await async_client.post(f"/api/v1/pending-uploads/{upload_id}/archive", json={"project_id": 9999})
+    response = await async_client.post(f"/api/v1/pending-uploads/{upload_id}/archive", json={"project_id": 9999})
 
     assert response.status_code == 400, response.text
     assert response.json()["detail"] == "Project not found"
-    archive.assert_not_awaited()
     db_session.expire_all()
     row = (
         await db_session.execute(
@@ -52,14 +57,7 @@ async def test_an_unknown_project_is_refused_before_anything_is_archived(
     ).one()
     assert tuple(row) == ("pending", None)
     assert (tmp_path / "part.3mf").exists()
-
-
-async def _fake_archive_print(self, **_kwargs) -> PrintArchive:
-    """Stands in for archiving a real 3MF: one archive row in the route's session."""
-    archive = PrintArchive(filename="part.3mf", file_path="archive/part.3mf", file_size=3, status="archived")
-    self.db.add(archive)
-    await self.db.flush()
-    return archive
+    assert (await db_session.execute(select(LibraryFile.id))).first() is None
 
 
 @pytest.mark.parametrize("given", ["known", 0, None])
@@ -69,17 +67,118 @@ async def test_a_known_project_or_none_is_stored(async_client: AsyncClient, db_s
     if given == "known":
         project_id = (await async_client.post("/api/v1/projects/", json={"name": "P"})).json()["id"]
 
-    with patch("backend.app.api.routes.pending_uploads.ArchiveService.archive_print", new=_fake_archive_print):
-        response = await async_client.post(
-            f"/api/v1/pending-uploads/{upload_id}/archive",
-            json={"project_id": project_id if given == "known" else given},
-        )
+    response = await async_client.post(
+        f"/api/v1/pending-uploads/{upload_id}/archive",
+        json={"project_id": project_id if given == "known" else given},
+    )
 
     assert response.status_code == 200, response.text
     db_session.expire_all()
     stored = (
-        await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == response.json()["id"]))
+        await db_session.execute(select(LibraryFile.project_id).where(LibraryFile.id == response.json()["id"]))
     ).scalar()
     pending = (await db_session.execute(select(PendingUpload.project_id).where(PendingUpload.id == upload_id))).scalar()
     assert stored == project_id
     assert pending == project_id
+
+
+async def test_approved_goes_to_the_folder_of_its_virtual_printer_with_notes_and_tags(
+    async_client: AsyncClient, db_session, tmp_path
+):
+    folder = LibraryFolder(name="Class 7b", shared=True)
+    db_session.add(folder)
+    await db_session.flush()
+    vp = VirtualPrinter(name="Classroom", mode="review", library_folder_id=folder.id)
+    db_session.add(vp)
+    await db_session.flush()
+    upload_id = (await _pending(db_session, tmp_path, virtual_printer_id=vp.id)).id
+
+    response = await async_client.post(
+        f"/api/v1/pending-uploads/{upload_id}/archive",
+        json={"notes": "for the science fair", "tags": "Bridge, school ,bridge"},
+    )
+
+    assert response.status_code == 200, response.text
+    assert response.json()["folder_id"] == folder.id
+    db_session.expire_all()
+    library_file = await db_session.get(LibraryFile, response.json()["id"])
+    await db_session.refresh(library_file, ["tags"])
+    assert library_file.filename == "part.3mf"
+    assert library_file.source_type == "virtual_printer"
+    assert library_file.notes == "for the science fair"
+    assert sorted(tag.name for tag in library_file.tags) == ["Bridge", "school"]
+    pending = await db_session.get(PendingUpload, upload_id)
+    assert pending.status == "approved"
+    assert not (tmp_path / "part.3mf").exists()
+
+
+async def test_an_upload_without_its_virtual_printer_goes_to_the_shared_folder(
+    async_client: AsyncClient, db_session, tmp_path
+):
+    upload_id = (await _pending(db_session, tmp_path)).id
+
+    response = await async_client.post(f"/api/v1/pending-uploads/{upload_id}/archive")
+
+    assert response.status_code == 200, response.text
+    folder = await db_session.get(LibraryFolder, response.json()["folder_id"])
+    assert folder.name == "Virtual Printer"
+    assert folder.parent_id is None
+    assert folder.shared is True
+
+
+async def test_approve_all_stores_every_upload(async_client: AsyncClient, db_session, tmp_path):
+    first_id = (await _pending(db_session, tmp_path)).id
+    other = tmp_path / "other.3mf"
+    other.write_bytes(b"other")
+    db_session.add(
+        PendingUpload(
+            filename="other.3mf", file_path=str(other), file_size=5, source_ip="192.168.1.100", status="pending"
+        )
+    )
+    gone = PendingUpload(
+        filename="gone.3mf", file_path=str(tmp_path / "gone.3mf"), file_size=1, source_ip=None, status="pending"
+    )
+    db_session.add(gone)
+    await db_session.commit()
+    gone_id = gone.id
+
+    response = await async_client.post("/api/v1/pending-uploads/archive-all")
+
+    assert response.status_code == 200, response.text
+    assert response.json() == {"stored": 2, "failed": 1}
+    db_session.expire_all()
+    names = sorted((await db_session.execute(select(LibraryFile.filename))).scalars())
+    assert names == ["other.3mf", "part.3mf"]
+    assert (await db_session.get(PendingUpload, first_id)).status == "approved"
+    assert (await db_session.get(PendingUpload, gone_id)).status == "discarded"
+
+
+async def test_approve_all_goes_on_after_one_upload_fails(async_client: AsyncClient, db_session, tmp_path):
+    # A failed one is rolled back; the rest must still be stored (the
+    # rollback used to leave the loop on expired rows: a 500).
+    from unittest.mock import patch
+
+    from backend.app.services.virtual_printer import library_store
+
+    for name in ("a.3mf", "b.3mf", "c.3mf"):
+        path = tmp_path / name
+        path.write_bytes(name.encode())
+        db_session.add(PendingUpload(filename=name, file_path=str(path), file_size=5, source_ip=None, status="pending"))
+    await db_session.commit()
+
+    real_store = library_store.store_upload
+    calls = 0
+
+    async def flaky_store(db, **kwargs):
+        nonlocal calls
+        calls += 1
+        if calls == 1:
+            await db.execute(select(LibraryFile.id))  # the failure comes mid-transaction
+            raise OSError("disk full")
+        return await real_store(db, **kwargs)
+
+    with patch.object(library_store, "store_upload", flaky_store):
+        response = await async_client.post("/api/v1/pending-uploads/archive-all")
+
+    assert response.status_code == 200, response.text
+    assert response.json() == {"stored": 2, "failed": 1}

+ 2 - 0
backend/tests/integration/test_print_batch_orders.py

@@ -590,6 +590,8 @@ class TestBatchOrderDispatch:
         source.waiting_reason = "no idle printer"
         source.error_message = "previous failure"
         source.scheduled_time = datetime(2026, 1, 1, 12, 0, 0)
+        # A setting the request can't set: a virtual printer job's slicer pick.
+        source.slicer_ams_mapping = f'{{"mapping": [4, -1], "printer_id": {printer.id}}}'
         await db_session.commit()
 
         assert (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).status_code == 200

+ 45 - 0
backend/tests/integration/test_print_queue_api.py

@@ -765,6 +765,51 @@ class TestPrintQueueAPI:
         assert model_based.status_code == 200
         assert model_based.json()["archive_has_slicer_ams_mapping"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_vp_job_shows_the_pick_it_keeps_for_its_archive(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """A virtual printer's job prints a File Manager file and has no
+        archive until it prints, but keeps the slicer's pick for it (#2700):
+        the badge shows, and the edit dialog's Mapping button gets the pick."""
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        other_printer = await printer_factory()
+        kept = json.dumps({"mapping": [4, -1, 12], "printer_id": printer.id})
+        library_file = LibraryFile(
+            filename="vp.gcode.3mf",
+            file_path="library/vp.3mf",
+            file_type="3mf",
+            file_size=1,
+            source_type="virtual_printer",
+        )
+        db_session.add(library_file)
+        await db_session.flush()
+        fixed = PrintQueueItem(
+            printer_id=printer.id, library_file_id=library_file.id, status="pending", slicer_ams_mapping=kept
+        )
+        model_based = PrintQueueItem(
+            target_model="X1C", library_file_id=library_file.id, status="pending", slicer_ams_mapping=kept
+        )
+        # Moved to another printer: the trays were resolved on the first one.
+        moved = PrintQueueItem(
+            printer_id=other_printer.id, library_file_id=library_file.id, status="pending", slicer_ams_mapping=kept
+        )
+        db_session.add_all([fixed, model_based, moved])
+        await db_session.commit()
+
+        rows = {row["id"]: row for row in (await async_client.get("/api/v1/queue/")).json()}
+
+        assert rows[fixed.id]["slicer_ams_mapping"] == [4, -1, 12]
+        assert rows[fixed.id]["archive_has_slicer_ams_mapping"] is True
+        assert rows[model_based.id]["slicer_ams_mapping"] is None
+        assert rows[model_based.id]["archive_has_slicer_ams_mapping"] is False
+        assert rows[moved.id]["slicer_ams_mapping"] is None
+        assert rows[moved.id]["archive_has_slicer_ams_mapping"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_with_plate_id(

+ 61 - 1
backend/tests/integration/test_virtual_printer_api.py

@@ -209,7 +209,7 @@ class TestPendingUploadsAPI:
 
         assert response.status_code == 200
         result = response.json()
-        assert "archived" in result
+        assert "stored" in result
         assert "failed" in result
 
     @pytest.mark.asyncio
@@ -750,3 +750,63 @@ class TestVirtualPrinterSerialSurface:
         body = get_resp.json()
         assert body["serial"]  # non-empty
         assert len(body["serial"]) >= 8
+
+
+class TestVirtualPrinterUploadFolder:
+    """Uploads land in a File Manager folder set per virtual printer."""
+
+    @staticmethod
+    async def _folder(db_session, **fields) -> int:
+        from backend.app.models.library import LibraryFolder
+
+        folder = LibraryFolder(**({"name": "Class 7b", "shared": True} | fields))
+        db_session.add(folder)
+        await db_session.commit()
+        return folder.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_on_create_and_cleared_with_null(self, async_client: AsyncClient, db_session):
+        folder_id = await self._folder(db_session)
+
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={"name": "Lab", "mode": "library", "library_folder_id": folder_id, "queue_remove_after_print": True},
+        )
+        assert response.status_code == 200, response.text
+        assert response.json()["library_folder_id"] == folder_id
+        assert response.json()["queue_remove_after_print"] is True
+        vp_id = response.json()["id"]
+
+        response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"auto_dispatch": False})
+        assert response.json()["library_folder_id"] == folder_id
+
+        response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"library_folder_id": None})
+        assert response.status_code == 200
+        assert response.json()["library_folder_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_external_folder_is_refused(self, async_client: AsyncClient, db_session, tmp_path):
+        folder_id = await self._folder(db_session, name="NAS", is_external=True, external_path=str(tmp_path))
+        vp_id = (await async_client.post("/api/v1/virtual-printers", json={"name": "Lab"})).json()["id"]
+
+        response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"library_folder_id": folder_id})
+
+        assert response.status_code == 400
+        assert "external" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_unknown_folder_is_refused(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/virtual-printers", json={"name": "Lab", "library_folder_id": 4242})
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_legacy_archive_mode_is_stored_as_library(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/virtual-printers", json={"name": "Old client", "mode": "archive"})
+
+        assert response.status_code == 200
+        assert response.json()["mode"] == "library"

+ 182 - 0
backend/tests/integration/test_vp_library_store.py

@@ -0,0 +1,182 @@
+"""A virtual printer's upload stored in the File Manager (library_store)."""
+
+from pathlib import Path
+
+import pytest
+from sqlalchemy import func, select
+
+from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.virtual_printer import VirtualPrinter
+from backend.app.services.virtual_printer.library_store import store_upload, upload_folder, upload_settings
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.fixture(autouse=True)
+def _library_in_tmp(tmp_path, monkeypatch):
+    """The File Manager writes below archive_dir: keep it out of the real one."""
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+
+
+def _upload(tmp_path: Path, name: str = "Plate_1.gcode.3mf", content: bytes = b"plate one") -> Path:
+    path = tmp_path / "uploads" / name
+    path.parent.mkdir(exist_ok=True)
+    path.write_bytes(content)
+    return path
+
+
+async def _files(db) -> int:
+    return (await db.execute(select(func.count(LibraryFile.id)))).scalar()
+
+
+async def test_stored_with_its_name_and_copied_from_disk(db_session, tmp_path):
+    source = _upload(tmp_path)
+
+    library_file, reused = await store_upload(db_session, source_path=source, filename=source.name, folder_id=None)
+
+    assert reused is False
+    assert library_file.filename == "Plate_1.gcode.3mf"
+    assert library_file.source_type == "virtual_printer"
+    assert library_file.file_size == len(b"plate one")
+    from backend.app.api.routes.library import get_library_files_dir
+
+    assert (get_library_files_dir() / Path(library_file.file_path).name).read_bytes() == b"plate one"
+    assert source.exists(), "the caller removes its temp file, not the store"
+
+
+async def test_the_same_file_sent_again_is_the_file_already_there(db_session, tmp_path):
+    first, _ = await store_upload(
+        db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None
+    )
+
+    again, reused = await store_upload(
+        db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None
+    )
+
+    assert reused is True
+    assert again.id == first.id
+    assert await _files(db_session) == 1
+
+
+@pytest.mark.parametrize(
+    ("name", "content"),
+    [("Plate_1.gcode.3mf", b"changed plate"), ("Other.gcode.3mf", b"plate one")],
+    ids=["other-content", "other-name"],
+)
+async def test_other_content_or_another_name_is_a_new_file(db_session, tmp_path, name, content):
+    await store_upload(db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None)
+
+    _, reused = await store_upload(
+        db_session, source_path=_upload(tmp_path, name, content), filename=name, folder_id=None
+    )
+
+    assert reused is False
+    assert await _files(db_session) == 2
+
+
+async def test_a_file_put_there_by_hand_is_never_taken_over(db_session, tmp_path):
+    first, _ = await store_upload(
+        db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None
+    )
+    first.source_type = None  # as an upload through the File Manager would be
+    await db_session.commit()
+
+    again, reused = await store_upload(
+        db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None
+    )
+
+    assert reused is False
+    assert again.id != first.id
+
+
+async def test_the_configured_folder_is_used(db_session):
+    folder = LibraryFolder(name="Class 7b", shared=True)
+    db_session.add(folder)
+    await db_session.flush()
+
+    assert (await upload_folder(db_session, folder.id)).id == folder.id
+
+
+@pytest.mark.parametrize("case", ["none", "deleted", "external"])
+async def test_otherwise_the_shared_virtual_printer_folder(db_session, tmp_path, case):
+    folder_id = None
+    if case == "deleted":
+        folder_id = 4242
+    elif case == "external":
+        mount = LibraryFolder(name="NAS", is_external=True, external_path=str(tmp_path), shared=True)
+        db_session.add(mount)
+        await db_session.flush()
+        folder_id = mount.id
+
+    folder = await upload_folder(db_session, folder_id)
+
+    assert folder.name == "Virtual Printer"
+    assert folder.parent_id is None
+    assert folder.is_external is False
+    assert folder.shared is True
+    # One shared folder, not one per upload.
+    assert (await upload_folder(db_session, None)).id == folder.id
+
+
+async def test_upload_settings_are_read_from_the_virtual_printer(db_session):
+    folder = LibraryFolder(name="Queue", shared=True)
+    db_session.add(folder)
+    await db_session.flush()
+    vp = VirtualPrinter(name="Q", mode="queue", library_folder_id=folder.id, queue_remove_after_print=True)
+    db_session.add(vp)
+    await db_session.flush()
+
+    assert await upload_settings(db_session, vp.id) == (folder.id, True)
+    assert await upload_settings(db_session, 9999) == (None, False)
+
+
+async def test_a_users_private_folder_of_that_name_is_not_the_shared_one(db_session):
+    # With auth on, a user may make a private top-level "Virtual Printer"
+    # folder; uploads of every VP without a folder must not vanish into it.
+    from backend.app.models.user import User
+
+    alice = User(username="alice", password_hash="x", role="user")
+    db_session.add(alice)
+    await db_session.flush()
+    private = LibraryFolder(name="Virtual Printer", created_by_id=alice.id, shared=False)
+    db_session.add(private)
+    await db_session.flush()
+
+    folder = await upload_folder(db_session, None)
+
+    assert folder.id != private.id
+    assert folder.shared is True
+    assert folder.created_by_id is None
+
+
+async def test_a_file_a_queue_job_removes_once_it_prints_is_not_shared(db_session, tmp_path):
+    from backend.app.models.print_queue import PrintQueueItem
+
+    # The queue VP with "Remove from File Manager once it prints" gets a file
+    # of its own, even with the same file already in the folder ...
+    kept, _ = await store_upload(
+        db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None
+    )
+    own, reused = await store_upload(
+        db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None, reuse=False
+    )
+    assert reused is False
+    assert own.id != kept.id
+    db_session.add(PrintQueueItem(library_file_id=own.id, status="pending", cleanup_library_after_dispatch=True))
+    await db_session.commit()
+
+    # ... and a later upload of it never takes that one, which its print removes.
+    again, reused = await store_upload(
+        db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None
+    )
+    assert reused is True
+    assert again.id == kept.id
+    await db_session.delete(kept)
+    await db_session.commit()
+    fresh, reused = await store_upload(
+        db_session, source_path=_upload(tmp_path), filename="Plate_1.gcode.3mf", folder_id=None
+    )
+    assert reused is False
+    assert fresh.id not in (kept.id, own.id)

File diff suppressed because it is too large
+ 158 - 361
backend/tests/unit/services/test_virtual_printer.py


+ 24 - 10
backend/tests/unit/services/test_vp_external_spool_mapping_3237.py

@@ -28,6 +28,27 @@ H2C_PAYLOAD = {
 }
 
 
+@pytest.fixture(autouse=True)
+def _fake_file_manager_store():
+    """Uploads go to the File Manager (library_store); its own tests cover it.
+
+    Here it stands in with file id 1 and the default folder settings, so these
+    tests see what the virtual printer does around it.
+    """
+    stored = MagicMock(id=1, filename="upload.3mf", folder_id=None)
+    with (
+        patch(
+            "backend.app.services.virtual_printer.library_store.store_upload",
+            AsyncMock(return_value=(stored, False)),
+        ) as store,
+        patch(
+            "backend.app.services.virtual_printer.library_store.upload_settings",
+            AsyncMock(return_value=(None, False)),
+        ),
+    ):
+        yield store
+
+
 def _extract(data, is_dual_nozzle):
     raw = _extract_slicer_ams_mapping_json(data, "[test]", is_dual_nozzle=is_dual_nozzle)
     return None if raw is None else json.loads(raw)
@@ -129,17 +150,10 @@ async def test_queue_item_and_archive_keep_left_external_spool(tmp_path):
     file_path.write_bytes(b"fake3mf")
     await inst.on_print_command(file_path.name, dict(H2C_PAYLOAD))
 
-    mock_archive = MagicMock(id=1, print_name="test")
-    with (
-        patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
-        patch(
-            "backend.app.services.archive.ArchiveService.archive_print",
-            new_callable=AsyncMock,
-            return_value=mock_archive,
-        ) as archive_print,
-    ):
+    with patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None):
         await inst._add_to_print_queue(file_path, "192.168.1.100")
 
     assert len(added_items) == 1
     assert json.loads(added_items[0].ams_mapping) == [254, 3, 2]
-    assert archive_print.await_args.kwargs["slicer_ams_mapping"] == [254, 3, 2]
+    # Kept on the item for the archive its print makes.
+    assert json.loads(added_items[0].slicer_ams_mapping)["mapping"] == [254, 3, 2]

+ 30 - 3
backend/tests/unit/services/test_vp_queue_intake_d2.py

@@ -15,6 +15,27 @@ import pytest
 from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
 
+@pytest.fixture(autouse=True)
+def _fake_file_manager_store():
+    """Uploads go to the File Manager (library_store); its own tests cover it.
+
+    Here it stands in with file id 1 and the default folder settings, so these
+    tests see what the virtual printer does around it.
+    """
+    stored = MagicMock(id=1, filename="upload.3mf", folder_id=None)
+    with (
+        patch(
+            "backend.app.services.virtual_printer.library_store.store_upload",
+            AsyncMock(return_value=(stored, False)),
+        ) as store,
+        patch(
+            "backend.app.services.virtual_printer.library_store.upload_settings",
+            AsyncMock(return_value=(None, False)),
+        ),
+    ):
+        yield store
+
+
 def _session(added: list, execute_results=None):
     db = AsyncMock()
     db.add = MagicMock(side_effect=lambda item: (added.append(item), setattr(item, "id", 100 + len(added)))[0])
@@ -47,7 +68,6 @@ async def _enqueue(inst, tmp_path, slicer_opts: dict | None, settings: dict | No
     file_path.write_bytes(b"fake3mf")
     if slicer_opts is not None:
         await inst.on_print_command(file_path.name, {"command": "project_file", **slicer_opts})
-    archive = MagicMock(id=1, print_name="test")
     values = settings or {}
 
     async def _setting(_db, key):
@@ -55,10 +75,17 @@ async def _enqueue(inst, tmp_path, slicer_opts: dict | None, settings: dict | No
 
     with (
         patch("backend.app.api.routes.settings.get_setting", side_effect=_setting),
+        patch("backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", 0.05),
+        # The File Manager store has its own tests; here (and for the other
+        # modules that borrow this helper) it stands in with file id 1.
         patch(
-            "backend.app.services.archive.ArchiveService.archive_print", new_callable=AsyncMock, return_value=archive
+            "backend.app.services.virtual_printer.library_store.store_upload",
+            AsyncMock(return_value=(MagicMock(id=1, filename="upload.3mf", folder_id=None), False)),
+        ),
+        patch(
+            "backend.app.services.virtual_printer.library_store.upload_settings",
+            AsyncMock(return_value=(None, False)),
         ),
-        patch("backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", 0.05),
         # Its own query would take a result the session mock holds for the test.
         patch("backend.app.services.queue_announcements.announce_queue_jobs_added", announce or AsyncMock()),
     ):

+ 17 - 0
backend/tests/unit/test_library_dispatch_reuses_archive.py

@@ -13,6 +13,7 @@ always were.
 """
 
 import hashlib
+import json
 from contextlib import ExitStack
 from datetime import datetime
 from pathlib import Path
@@ -415,3 +416,19 @@ def test_the_completions_media_work_holds_the_archive():
 
     assert archive_work.is_held(41)
     spawned[0].close()
+
+
+@pytest.mark.asyncio
+async def test_a_joined_archive_takes_the_slicer_pick_of_a_vp_upload(case):
+    # A virtual printer's queue item keeps the slicer's AMS pick for the
+    # archive its print makes (#2700); joining one, it goes there.
+    existing = await case.archive(extra_data={"keep": 1})
+
+    kept = json.dumps({"mapping": [4, -1], "printer_id": case.printer_id})
+    item, archive = await _start(case, await case.item(slicer_ams_mapping=kept), AsyncMock())
+
+    assert item.archive_id == existing
+    assert archive.extra_data == {
+        "keep": 1,
+        "slicer_ams_mapping": {"mapping": [4, -1], "printer_id": case.printer_id},
+    }

+ 65 - 1
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -1,3 +1,4 @@
+import json
 import logging
 from contextlib import ExitStack
 from pathlib import Path
@@ -31,7 +32,16 @@ async def queue_factory(tmp_path):
     session_maker = async_sessionmaker(engine, expire_on_commit=False)
     case_counter = 0
 
-    async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None, siblings=(), photos=()):
+    async def make_case(
+        *,
+        cleanup=True,
+        is_external=False,
+        thumbnail_path=None,
+        siblings=(),
+        photos=(),
+        source_type=None,
+        slicer_ams_mapping=None,
+    ):
         nonlocal case_counter
         case_counter += 1
 
@@ -76,6 +86,7 @@ async def queue_factory(tmp_path):
                 file_metadata=None,
                 is_external=is_external,
                 photos=list(photos) or None,
+                source_type=source_type,
             )
             db.add_all([printer, library_file])
             await db.flush()
@@ -102,6 +113,10 @@ async def queue_factory(tmp_path):
                 timelapse=False,
                 use_ams=True,
                 nozzle_offset_cali="on",
+                # A callable gets the printer's id (only known here).
+                slicer_ams_mapping=(
+                    slicer_ams_mapping(printer.id) if callable(slicer_ams_mapping) else slicer_ams_mapping
+                ),
             )
             db.add(item)
             await db.flush()
@@ -179,6 +194,7 @@ async def queue_factory(tmp_path):
                 sibling_ids=sibling_ids,
                 other_library_file_id=other_file.id if other_file is not None else None,
                 archive_path=None,
+                archive_kwargs=None,
                 upload=AsyncMock(return_value=True),
                 start_print=MagicMock(return_value=True),
             )
@@ -210,7 +226,9 @@ async def _dispatch_library_item(
         cost_center_id=None,
         plate_id=None,
         library_file_id=None,
+        **extra,
     ):
+        ctx.archive_kwargs = extra
         if archive_failure:
             raise RuntimeError("archive copy failed")
 
@@ -745,3 +763,49 @@ async def test_a_failing_k_profile_check_does_not_stop_the_print(queue_factory):
         await _dispatch_library_item(ctx)
 
     ctx.start_print.assert_called_once()
+
+
+# A virtual printer's upload is a File Manager file (source_type
+# "virtual_printer") that its queue items print. The archive is made at
+# dispatch, so that is where the upload's slicer AMS pick (#2700) and the
+# virtual printer's archive-name setting reach it.
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("moved", [False, True], ids=["same-printer", "moved-to-another"])
+async def test_a_vp_upload_hands_its_slicer_pick_to_the_archive(queue_factory, moved):
+    # The pick stays tagged with the printer the slicer resolved it on, also
+    # when the job was moved to another printer before it printed: its tray
+    # numbers mean nothing there, so a reprint there must not reuse it.
+    resolved_on = 4242 if moved else None
+    ctx = await queue_factory(
+        cleanup=False,
+        source_type="virtual_printer",
+        slicer_ams_mapping=lambda printer_id: json.dumps(
+            {"mapping": [4, -1, 12], "printer_id": resolved_on or printer_id}
+        ),
+    )
+
+    await _dispatch_library_item(ctx)
+
+    assert ctx.archive_kwargs["slicer_ams_mapping"] == [4, -1, 12]
+    assert ctx.archive_kwargs["slicer_ams_mapping_printer_id"] == (resolved_on or ctx.printer_id)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("source_type", "setting", "expected"),
+    [
+        ("virtual_printer", "filename", {"prefer_filename_for_name": True}),
+        ("virtual_printer", "metadata", {}),
+        (None, "filename", {}),
+    ],
+    ids=["vp-filename", "vp-metadata", "other-file"],
+)
+async def test_the_vp_name_setting_names_the_archive_of_its_uploads_only(queue_factory, source_type, setting, expected):
+    ctx = await queue_factory(cleanup=False, source_type=source_type)
+
+    with patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value=setting)):
+        await _dispatch_library_item(ctx)
+
+    assert ctx.archive_kwargs == expected

+ 12 - 9
backend/tests/unit/test_vp_mode_rename_migration.py

@@ -1,7 +1,8 @@
 """Regression test for the VP mode wire-value rename migration (#1429 follow-up).
 
 The UI buttons "Archive" and "Queue" had always saved the wire values
-`immediate` and `print_queue` — confusing in every support bundle. The
+`immediate` and `print_queue` — confusing in every support bundle. Later
+`archive` became `library`, when uploads moved to the File Manager. The
 rename migration in ``run_migrations`` rewrites existing rows to the
 canonical names. This test verifies it on both fresh and legacy schemas
 and confirms it's idempotent so reruns are safe (boot-on-boot).
@@ -79,9 +80,9 @@ async def engine():
 
 @pytest.mark.asyncio
 async def test_legacy_mode_rows_get_canonical_names(engine):
-    """Existing rows with `immediate` / `print_queue` get rewritten to
-    `archive` / `queue` while canonical values and unrelated modes pass
-    through untouched."""
+    """Existing rows with `immediate` / `archive` / `print_queue` get
+    rewritten to `library` / `queue` while canonical values and unrelated
+    modes pass through untouched."""
     async with engine.begin() as conn:
         await conn.execute(
             text(
@@ -91,7 +92,8 @@ async def test_legacy_mode_rows_get_canonical_names(engine):
                 "(3, 'C', 0, 'review', '391800003', 3),"
                 "(4, 'D', 0, 'proxy', '391800004', 4),"
                 "(5, 'E', 0, 'archive', '391800005', 5),"
-                "(6, 'F', 0, 'queue', '391800006', 6)"
+                "(6, 'F', 0, 'queue', '391800006', 6),"
+                "(7, 'G', 0, 'library', '391800007', 7)"
             )
         )
 
@@ -102,12 +104,13 @@ async def test_legacy_mode_rows_get_canonical_names(engine):
         result = await conn.execute(text("SELECT id, mode FROM virtual_printers ORDER BY id"))
         rows = dict(result.fetchall())
 
-    assert rows[1] == "archive"  # immediate → archive
+    assert rows[1] == "library"  # immediate → archive → library
     assert rows[2] == "queue"  # print_queue → queue
     assert rows[3] == "review"  # untouched
     assert rows[4] == "proxy"  # untouched
-    assert rows[5] == "archive"  # already canonical
+    assert rows[5] == "library"  # archive → library
     assert rows[6] == "queue"  # already canonical
+    assert rows[7] == "library"  # already canonical
 
 
 @pytest.mark.asyncio
@@ -125,7 +128,7 @@ async def test_legacy_settings_row_gets_canonical_name(engine):
         result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
         value = result.scalar()
 
-    assert value == "archive"
+    assert value == "library"
 
 
 @pytest.mark.asyncio
@@ -148,4 +151,4 @@ async def test_migration_is_idempotent(engine):
 
     async with engine.connect() as conn:
         result = await conn.execute(text("SELECT mode FROM virtual_printers WHERE id = 1"))
-        assert result.scalar() == "archive"
+        assert result.scalar() == "library"

+ 15 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -77,6 +77,21 @@ describe('Layout', () => {
     );
   });
 
+  describe('pending uploads badge', () => {
+    it('counts review-mode uploads on the File Manager entry, where they wait', async () => {
+      server.use(http.get('/api/v1/pending-uploads/count', () => HttpResponse.json({ count: 3 })));
+      render(<Layout />);
+
+      const files = await waitFor(() => {
+        const link = document.querySelector('a[href="/files"]');
+        if (!link?.textContent?.includes('3')) throw new Error('badge not shown yet');
+        return link;
+      });
+      expect(files.textContent).toContain('3');
+      expect(document.querySelector('a[href="/archives"]')?.textContent).not.toContain('3');
+    });
+  });
+
   describe('rendering', () => {
     it('renders the sidebar', async () => {
       render(<Layout />);

+ 27 - 25
frontend/src/__tests__/components/PendingUploadsPanelWiring.test.tsx

@@ -1,6 +1,7 @@
 /**
- * Wiring tests for PendingUploadsPanel: pin the requests sent for archive
- * (with tags/notes/project), discard, archive-all and discard-all, and that
+ * Wiring tests for PendingUploadsPanel: pin the requests sent for approving
+ * an upload into the File Manager (with tags/notes/project), discard,
+ * approve-all and discard-all, and that
  * cancelling a destructive confirm sends nothing.
  */
 
@@ -59,7 +60,7 @@ beforeEach(() => {
     http.get('*/api/v1/projects/', () => HttpResponse.json(projects)),
     http.post('*/api/v1/pending-uploads/archive-all', ({ request }) => {
       calls.push({ method: 'POST', path: new URL(request.url).pathname });
-      return HttpResponse.json({ archived: 2, failed: 0 });
+      return HttpResponse.json({ stored: 2, failed: 0 });
     }),
     http.delete('*/api/v1/pending-uploads/discard-all', ({ request }) => {
       calls.push({ method: 'DELETE', path: new URL(request.url).pathname });
@@ -71,7 +72,7 @@ beforeEach(() => {
         path: new URL(request.url).pathname,
         body: await request.json(),
       });
-      return HttpResponse.json({ id: 100, print_name: 'First Upload', filename: 'first.gcode.3mf' });
+      return HttpResponse.json({ id: 100, filename: 'first.gcode.3mf', folder_id: 3 });
     }),
     http.delete('*/api/v1/pending-uploads/:id', ({ request }) => {
       calls.push({ method: 'DELETE', path: new URL(request.url).pathname });
@@ -90,7 +91,7 @@ async function rowFor(name: string): Promise<HTMLElement> {
 }
 
 describe('PendingUploadsPanel wiring', () => {
-  it('Archive with tags, notes and a project posts them for that upload id only', async () => {
+  it('Add to File Manager with tags, notes and a project posts them for that upload id only', async () => {
     const user = userEvent.setup();
     render(<PendingUploadsPanel />);
     const row = await rowFor('Second Upload');
@@ -98,7 +99,7 @@ describe('PendingUploadsPanel wiring', () => {
     // Expand toggle is the first (icon-only) button in the row.
     await user.click(within(row).getAllByRole('button')[0]);
     const card = row.parentElement as HTMLElement;
-    await user.type(within(card).getByPlaceholderText('e.g., functional, prototype, gift'), 'gift');
+    await user.type(within(card).getByPlaceholderText('e.g. functional, prototype, gift'), 'gift');
     await user.type(within(card).getByPlaceholderText('Add notes about this print...'), 'for bob');
     const select = within(card).getByRole('combobox');
     await waitFor(() => expect(within(select).getByRole('option', { name: 'Gadgets' })).toBeInTheDocument());
@@ -106,7 +107,7 @@ describe('PendingUploadsPanel wiring', () => {
     expect(within(select).queryByRole('option', { name: 'Archived Proj' })).toBeNull();
     await user.selectOptions(select, '5');
 
-    await user.click(within(row).getByRole('button', { name: /Archive/ }));
+    await user.click(within(row).getByRole('button', { name: /Add to File Manager/ }));
 
     await waitFor(() => expect(calls).toHaveLength(1));
     expect(calls[0]).toEqual({
@@ -116,12 +117,12 @@ describe('PendingUploadsPanel wiring', () => {
     });
   });
 
-  it('Archive without a project omits project_id', async () => {
+  it('Add to File Manager without a project omits project_id', async () => {
     const user = userEvent.setup();
     render(<PendingUploadsPanel />);
     const row = await rowFor('First Upload');
 
-    await user.click(within(row).getByRole('button', { name: /Archive/ }));
+    await user.click(within(row).getByRole('button', { name: /Add to File Manager/ }));
 
     await waitFor(() => expect(calls).toHaveLength(1));
     expect(calls[0].path).toBe('/api/v1/pending-uploads/11/archive');
@@ -135,8 +136,9 @@ describe('PendingUploadsPanel wiring', () => {
 
     const buttons = within(row).getAllByRole('button');
     await user.click(buttons[buttons.length - 1]); // trash icon
-    expect(await screen.findByText('Discard Upload')).toBeInTheDocument();
-    await user.click(screen.getByRole('button', { name: 'Discard' }));
+    const discardTitle = await screen.findByText('Discard upload');
+    const discardDialog = discardTitle.closest('div.flex-1')?.parentElement?.parentElement as HTMLElement;
+    await user.click(within(discardDialog).getByRole('button', { name: 'Discard' }));
 
     await waitFor(() => expect(calls).toHaveLength(1));
     expect(calls[0]).toEqual({ method: 'DELETE', path: '/api/v1/pending-uploads/22' });
@@ -149,51 +151,51 @@ describe('PendingUploadsPanel wiring', () => {
 
     const buttons = within(row).getAllByRole('button');
     await user.click(buttons[buttons.length - 1]);
-    await screen.findByText('Discard Upload');
+    await screen.findByText('Discard upload');
     await user.click(screen.getByRole('button', { name: 'Cancel' }));
 
-    await waitFor(() => expect(screen.queryByText('Discard Upload')).toBeNull());
+    await waitFor(() => expect(screen.queryByText('Discard upload')).toBeNull());
     expect(calls).toHaveLength(0);
   });
 
-  it('Archive All -> confirm posts archive-all', async () => {
+  it('Add all -> confirm posts archive-all', async () => {
     const user = userEvent.setup();
     render(<PendingUploadsPanel />);
     await screen.findByText('First Upload');
 
-    await user.click(screen.getByRole('button', { name: 'Archive All' }));
-    const title = await screen.findByText('Archive All Uploads');
+    await user.click(screen.getByRole('button', { name: 'Add all' }));
+    const title = await screen.findByText('Add all uploads');
     const dialog = title.closest('div.flex-1')?.parentElement?.parentElement as HTMLElement;
-    await user.click(within(dialog).getByRole('button', { name: 'Archive All' }));
+    await user.click(within(dialog).getByRole('button', { name: 'Add all' }));
 
     await waitFor(() => expect(calls).toHaveLength(1));
     expect(calls[0]).toEqual({ method: 'POST', path: '/api/v1/pending-uploads/archive-all' });
   });
 
-  it('Discard All -> confirm sends DELETE discard-all', async () => {
+  it('Discard all -> confirm sends DELETE discard-all', async () => {
     const user = userEvent.setup();
     render(<PendingUploadsPanel />);
     await screen.findByText('First Upload');
 
-    await user.click(screen.getByRole('button', { name: 'Discard All' }));
-    const title = await screen.findByText('Discard All Uploads');
+    await user.click(screen.getByRole('button', { name: 'Discard all' }));
+    const title = await screen.findByText('Discard all uploads');
     const dialog = title.closest('div.flex-1')?.parentElement?.parentElement as HTMLElement;
-    await user.click(within(dialog).getByRole('button', { name: 'Discard All' }));
+    await user.click(within(dialog).getByRole('button', { name: 'Discard all' }));
 
     await waitFor(() => expect(calls).toHaveLength(1));
     expect(calls[0]).toEqual({ method: 'DELETE', path: '/api/v1/pending-uploads/discard-all' });
   });
 
-  it('Discard All -> cancel sends nothing', async () => {
+  it('Discard all -> cancel sends nothing', async () => {
     const user = userEvent.setup();
     render(<PendingUploadsPanel />);
     await screen.findByText('First Upload');
 
-    await user.click(screen.getByRole('button', { name: 'Discard All' }));
-    await screen.findByText('Discard All Uploads');
+    await user.click(screen.getByRole('button', { name: 'Discard all' }));
+    await screen.findByText('Discard all uploads');
     await user.click(screen.getByRole('button', { name: 'Cancel' }));
 
-    await waitFor(() => expect(screen.queryByText('Discard All Uploads')).toBeNull());
+    await waitFor(() => expect(screen.queryByText('Discard all uploads')).toBeNull());
     expect(calls).toHaveLength(0);
   });
 });

+ 74 - 1
frontend/src/__tests__/components/VirtualPrinterCard.test.tsx

@@ -32,6 +32,7 @@ vi.mock('../../api/client', () => ({
     getSettings: vi.fn().mockResolvedValue({}),
     getPrinters: vi.fn().mockResolvedValue([]),
     getNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }),
+    getLibraryFolders: vi.fn().mockResolvedValue([]),
   },
 }));
 
@@ -46,12 +47,14 @@ const createMockPrinter = (overrides: Partial<VirtualPrinterConfig> = {}): Virtu
   id: 1,
   name: 'Test VP',
   enabled: false,
-  mode: 'archive',
+  mode: 'library',
   model: 'BL-P001',
   model_name: 'X1C',
   access_code_set: false,
   serial: '00M00A391800001',
   target_printer_id: null,
+  library_folder_id: null,
+  queue_remove_after_print: false,
   auto_dispatch: true,
   queue_force_color_match: false,
   bind_ip: null,
@@ -630,3 +633,73 @@ describe('VirtualPrinterCard - target printer can be cleared', () => {
     expect(targetSelect().selectedOptions[0].textContent).toBe('None');
   });
 });
+
+
+// Uploads land in a File Manager folder set per virtual printer (not the
+// archive): internal folders the user can add to, or the shared default.
+describe('VirtualPrinterCard - File Manager folder', () => {
+  const folder = (id: number, name: string, extra: Record<string, unknown> = {}) => ({
+    id, name, parent_id: null, is_external: false, can_write: true, children: [], ...extra,
+  });
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
+    vi.mocked(api.getPrinters).mockResolvedValue([]);
+    vi.mocked(api.getLibraryFolders).mockResolvedValue([
+      folder(3, 'School', { children: [folder(4, 'Class 7b')] }),
+      folder(5, 'NAS', { is_external: true }),
+      folder(6, 'Private', { can_write: false }),
+    ] as unknown as Awaited<ReturnType<typeof api.getLibraryFolders>>);
+  });
+
+  const folderSelect = () => screen.getByRole('combobox', { name: 'File Manager folder' }) as HTMLSelectElement;
+
+  it('offers internal folders the user can add to, nested, and the shared default', async () => {
+    render(<VirtualPrinterCard printer={createMockPrinter()} models={models} />);
+
+    await waitFor(() => expect(folderSelect().options).toHaveLength(3));
+    const labels = Array.from(folderSelect().options).map((o) => o.textContent);
+    expect(labels).toEqual(['Virtual Printer (shared)', 'School', '\u00a0\u00a0Class 7b']);
+    expect(screen.getByText('Files sent to this printer are stored in this folder.')).toBeInTheDocument();
+  });
+
+  it('choosing a folder saves it, and the default clears it', async () => {
+    const user = userEvent.setup();
+    render(<VirtualPrinterCard printer={createMockPrinter()} models={models} />);
+    await waitFor(() => expect(folderSelect().options).toHaveLength(3));
+
+    await user.selectOptions(folderSelect(), '4');
+    await waitFor(() => expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { library_folder_id: 4 }));
+
+    await user.selectOptions(folderSelect(), '');
+    await waitFor(() => expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { library_folder_id: null }));
+  });
+
+  it('a proxy keeps no files, so it has no folder', async () => {
+    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'proxy' })} models={models} />);
+
+    await screen.findByText('Target Printer');
+    expect(screen.queryByRole('combobox', { name: 'File Manager folder' })).toBeNull();
+  });
+
+  it('queue mode can remove the file from the File Manager once it prints', async () => {
+    const user = userEvent.setup();
+    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'queue' })} models={models} />);
+
+    const toggle = await screen.findByRole('switch', { name: 'Remove from File Manager once it prints' });
+    expect(toggle.getAttribute('aria-checked')).toBe('false');
+    await user.click(toggle);
+
+    await waitFor(() =>
+      expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { queue_remove_after_print: true }),
+    );
+  });
+
+  it('an old "archive" printer shows as File Manager mode', async () => {
+    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'archive' })} models={models} />);
+
+    const button = await screen.findByText('Store files in the File Manager');
+    expect(button.closest('button')?.className).toContain('border-bambu-green');
+  });
+});

+ 30 - 1
frontend/src/__tests__/components/archiveAmsMapping.test.ts

@@ -10,7 +10,7 @@
  */
 
 import { describe, it, expect } from 'vitest';
-import { resolveArchiveSlicerAmsMapping } from '../../components/PrintModal/archiveAmsMapping';
+import { resolveArchiveSlicerAmsMapping, resolveSlicerAmsMapping } from '../../components/PrintModal/archiveAmsMapping';
 
 const SAVED = { slicer_ams_mapping: { mapping: [4, -1, 12, -1], printer_id: 7 } };
 
@@ -70,3 +70,32 @@ describe('resolveArchiveSlicerAmsMapping', () => {
     ).toBeUndefined();
   });
 });
+
+// A virtual printer's queue job prints a File Manager file and has no archive
+// until it prints: editing it, its own kept pick is the one.
+describe('resolveSlicerAmsMapping', () => {
+  const archive = { slicer_ams_mapping: { mapping: [9, 9], printer_id: 7 } };
+
+  it("takes the job's own pick for the job's printer", () => {
+    const queueItem = { printer_id: 7, slicer_ams_mapping: [4, -1, 12] };
+    expect(resolveSlicerAmsMapping({ queueItem, isLibraryFile: true, archiveExtraData: null, printerId: 7 })).toEqual([
+      4, -1, 12,
+    ]);
+  });
+
+  it('not on another printer, and falls back to the archive there', () => {
+    const queueItem = { printer_id: 7, slicer_ams_mapping: [4, -1, 12] };
+    expect(
+      resolveSlicerAmsMapping({ queueItem, isLibraryFile: true, archiveExtraData: null, printerId: 8 }),
+    ).toBeUndefined();
+    expect(
+      resolveSlicerAmsMapping({ queueItem: { printer_id: 8 }, isLibraryFile: false, archiveExtraData: archive, printerId: 7 }),
+    ).toEqual([9, 9]);
+  });
+
+  it('a library file without a kept pick has none', () => {
+    expect(
+      resolveSlicerAmsMapping({ queueItem: undefined, isLibraryFile: true, archiveExtraData: archive, printerId: 7 }),
+    ).toBeUndefined();
+  });
+});

+ 29 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -381,6 +381,35 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
     });
 
+    it('refetches the File Manager on library_files_changed (a virtual printer upload)', async () => {
+      vi.useFakeTimers();
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+      act(() => {
+        ws.open();
+      });
+      act(() => {
+        ws.simulateMessage({ type: 'library_files_changed', data: {} });
+      });
+      await act(async () => {
+        vi.advanceTimersByTime(5000);
+      });
+
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['library-files'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['library-folders'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['library-stats'] });
+
+      vi.useRealTimers();
+      vi.unstubAllGlobals();
+    });
+
     it('invalidates archives on archive_updated message', async () => {
       vi.useFakeTimers();
       const { useWebSocket } = await import('../../hooks/useWebSocket');

+ 15 - 3
frontend/src/api/client.ts

@@ -2861,6 +2861,9 @@ export interface PrintQueueItem {
   // AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint reuses that
   // exact physical spool instead of re-deriving one from type/color.
   archive_has_slicer_ams_mapping: boolean;
+  // A virtual printer's job not printed yet: the slicer's AMS pick it keeps
+  // for its archive, when made against this row's printer.
+  slicer_ams_mapping?: number[] | null;
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
@@ -9184,7 +9187,8 @@ export const discoveryApi = {
 // `immediate` (→ archive) and `print_queue` (→ queue) names are still
 // accepted by the backend so older API clients keep working, but new code
 // should send the canonical names.
-export type VirtualPrinterMode = 'archive' | 'review' | 'queue' | 'proxy' | 'immediate' | 'print_queue';
+// 'archive' and 'immediate' are older names of 'library', 'print_queue' of 'queue'.
+export type VirtualPrinterMode = 'library' | 'review' | 'queue' | 'proxy' | 'archive' | 'immediate' | 'print_queue';
 
 export interface VirtualPrinterProxyStatus {
   running: boolean;
@@ -9269,6 +9273,10 @@ export interface VirtualPrinterConfig {
   access_code_set: boolean;
   serial: string;
   target_printer_id: number | null;
+  // File Manager folder uploads land in; null = the shared "Virtual Printer" folder.
+  library_folder_id: number | null;
+  // Queue mode: remove the file from the File Manager once its print starts.
+  queue_remove_after_print: boolean;
   auto_dispatch: boolean;
   queue_force_color_match: boolean;
   save_ams_mapping: boolean;
@@ -9317,6 +9325,9 @@ export const multiVirtualPrinterApi = {
     access_code?: string;
     // null clears the target (a queue VP then sends to any printer of its model).
     target_printer_id?: number | null;
+    // null: back to the shared "Virtual Printer" folder.
+    library_folder_id?: number | null;
+    queue_remove_after_print?: boolean;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
     save_ams_mapping?: boolean;
@@ -9400,8 +9411,9 @@ export const pendingUploadsApi = {
 
   get: (id: number) => request<PendingUpload>(`/pending-uploads/${id}`),
 
+  // Approve: the file goes to its virtual printer's File Manager folder.
   archive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) =>
-    request<{ id: number; print_name: string; filename: string }>(`/pending-uploads/${id}/archive`, {
+    request<{ id: number; filename: string; folder_id: number | null }>(`/pending-uploads/${id}/archive`, {
       method: 'POST',
       body: JSON.stringify(data || {}),
     }),
@@ -9410,7 +9422,7 @@ export const pendingUploadsApi = {
     request<{ success: boolean }>(`/pending-uploads/${id}`, { method: 'DELETE' }),
 
   archiveAll: () =>
-    request<{ archived: number; failed: number }>('/pending-uploads/archive-all', { method: 'POST' }),
+    request<{ stored: number; failed: number }>('/pending-uploads/archive-all', { method: 'POST' }),
 
   discardAll: () =>
     request<{ discarded: number }>('/pending-uploads/discard-all', { method: 'DELETE' }),

+ 5 - 4
frontend/src/components/Layout.tsx

@@ -747,9 +747,10 @@ export function Layout() {
 
                 const { to, icon: Icon, labelKey } = navItem;
                 const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
-                const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
-                const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
-                const showBadge = showQueueBadge || showArchiveBadge;
+                // Review-mode uploads wait on the File Manager page.
+                const showUploadsBadge = id === 'files' && pendingUploadsCount > 0;
+                const badgeCount = showQueueBadge ? pendingQueueCount : showUploadsBadge ? pendingUploadsCount : 0;
+                const showBadge = showQueueBadge || showUploadsBadge;
                 const showClearPlateDot = id === 'printers' && needsClearPlate;
 
                 const expanded = isSidebarCompact || sidebarExpanded;
@@ -795,7 +796,7 @@ export function Layout() {
                         )}
                         {showBadge && (
                           <span className={`absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full ${
-                            showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
+                            showUploadsBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
                           }`}>
                             {badgeCount > 99 ? '99+' : badgeCount}
                           </span>

+ 50 - 51
frontend/src/components/PendingUploadsPanel.tsx

@@ -1,6 +1,7 @@
 import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Archive, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react';
+import { Loader2, FolderInput, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react';
 import { pendingUploadsApi } from '../api/client';
 import type { PendingUpload, ProjectListItem } from '../api/client';
 import { api } from '../api/client';
@@ -10,20 +11,7 @@ import { useToast } from '../contexts/ToastContext';
 import { ConfirmModal } from './ConfirmModal';
 import { formatFileSize } from '../utils/file';
 import { assignableProjects } from '../utils/projectTree';
-
-function formatTimeAgo(dateStr: string): string {
-  const date = new Date(dateStr);
-  const now = new Date();
-  const diffMs = now.getTime() - date.getTime();
-  const diffMins = Math.floor(diffMs / 60000);
-
-  if (diffMins < 1) return 'Just now';
-  if (diffMins < 60) return `${diffMins}m ago`;
-  const diffHours = Math.floor(diffMins / 60);
-  if (diffHours < 24) return `${diffHours}h ago`;
-  const diffDays = Math.floor(diffHours / 24);
-  return `${diffDays}d ago`;
-}
+import { formatRelativeTime } from '../utils/date';
 
 interface PendingUploadItemProps {
   upload: PendingUpload;
@@ -42,6 +30,7 @@ function PendingUploadItem({
   isArchiving,
   isDiscarding,
 }: PendingUploadItemProps) {
+  const { t } = useTranslation();
   const [expanded, setExpanded] = useState(false);
   const [tags, setTags] = useState(upload.tags || '');
   const [notes, setNotes] = useState(upload.notes || '');
@@ -61,12 +50,12 @@ function PendingUploadItem({
                 <span>·</span>
                 <span className="flex items-center gap-1">
                   <Clock className="w-3 h-3" />
-                  {formatTimeAgo(upload.uploaded_at)}
+                  {formatRelativeTime(upload.uploaded_at, 'system', t)}
                 </span>
                 {upload.source_ip && (
                   <>
                     <span>·</span>
-                    <span>from {upload.source_ip}</span>
+                    <span>{t('pendingUploads.from', { ip: upload.source_ip })}</span>
                   </>
                 )}
               </div>
@@ -75,6 +64,7 @@ function PendingUploadItem({
           <div className="flex items-center gap-2">
             <button
               onClick={() => setExpanded(!expanded)}
+              aria-label={expanded ? t('common.collapse') : t('common.expand')}
               className="p-1 text-bambu-gray hover:text-white transition-colors"
             >
               {expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
@@ -89,8 +79,8 @@ function PendingUploadItem({
                 <Loader2 className="w-4 h-4 animate-spin" />
               ) : (
                 <>
-                  <Archive className="w-4 h-4" />
-                  Archive
+                  <FolderInput className="w-4 h-4" />
+                  {t('pendingUploads.addToFileManager')}
                 </>
               )}
             </Button>
@@ -99,6 +89,7 @@ function PendingUploadItem({
               size="sm"
               onClick={() => setShowDiscardConfirm(true)}
               disabled={isDiscarding}
+              aria-label={t('pendingUploads.discard')}
             >
               {isDiscarding ? (
                 <Loader2 className="w-4 h-4 animate-spin" />
@@ -112,9 +103,9 @@ function PendingUploadItem({
         {/* Discard Confirmation Modal */}
         {showDiscardConfirm && (
           <ConfirmModal
-            title="Discard Upload"
-            message={`Are you sure you want to discard "${upload.filename}"? This cannot be undone.`}
-            confirmText="Discard"
+            title={t('pendingUploads.discardUpload')}
+            message={t('pendingUploads.discardConfirm', { name: upload.filename })}
+            confirmText={t('pendingUploads.discard')}
             variant="danger"
             onConfirm={() => {
               onDiscard(upload.id);
@@ -128,33 +119,33 @@ function PendingUploadItem({
         {expanded && (
           <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3">
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Tags</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('pendingUploads.tags')}</label>
               <input
                 type="text"
                 value={tags}
                 onChange={(e) => setTags(e.target.value)}
-                placeholder="e.g., functional, prototype, gift"
+                placeholder={t('pendingUploads.placeholders.tags')}
                 className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm"
               />
             </div>
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Notes</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('pendingUploads.notes')}</label>
               <textarea
                 value={notes}
                 onChange={(e) => setNotes(e.target.value)}
-                placeholder="Add notes about this print..."
+                placeholder={t('pendingUploads.placeholders.notes')}
                 rows={2}
                 className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm resize-none"
               />
             </div>
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Project</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('pendingUploads.project')}</label>
               <select
                 value={projectId || ''}
                 onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
                 className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm"
               >
-                <option value="">No project</option>
+                <option value="">{t('pendingUploads.noProject')}</option>
                 {projects.map((project) => (
                   <option key={project.id} value={project.id}>
                     {project.name}
@@ -170,6 +161,7 @@ function PendingUploadItem({
 }
 
 export function PendingUploadsPanel() {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [showArchiveAllConfirm, setShowArchiveAllConfirm] = useState(false);
@@ -192,7 +184,7 @@ export function PendingUploadsPanel() {
     select: (rows) => assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name))),
   });
 
-  // Archive mutation
+  // Approve: the file goes to its virtual printer's File Manager folder.
   const archiveMutation = useMutation({
     mutationFn: ({ id, data }: { id: number; data?: { tags?: string; notes?: string; project_id?: number } }) =>
       pendingUploadsApi.archive(id, data),
@@ -208,11 +200,12 @@ export function PendingUploadsPanel() {
     },
     onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
-      queryClient.invalidateQueries({ queryKey: ['archives'] });
-      showToast(`Archived: ${data.print_name}`);
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      showToast(t('pendingUploads.toast.added', { name: data.filename }));
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Failed to archive', 'error');
+      showToast(error.message || t('pendingUploads.toast.addFailed'), 'error');
     },
   });
 
@@ -231,23 +224,29 @@ export function PendingUploadsPanel() {
     },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
-      showToast('Upload discarded');
+      showToast(t('pendingUploads.toast.discarded'));
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Failed to discard', 'error');
+      showToast(error.message || t('pendingUploads.toast.discardFailed'), 'error');
     },
   });
 
-  // Archive all mutation
+  // Approve all
   const archiveAllMutation = useMutation({
     mutationFn: pendingUploadsApi.archiveAll,
     onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
-      queryClient.invalidateQueries({ queryKey: ['archives'] });
-      showToast(`Archived ${data.archived} files${data.failed > 0 ? `, ${data.failed} failed` : ''}`);
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      showToast(
+        data.failed > 0
+          ? t('pendingUploads.toast.addedAllSomeFailed', { count: data.stored, failed: data.failed })
+          : t('pendingUploads.toast.addedAll', { count: data.stored }),
+        data.failed > 0 ? 'error' : 'success',
+      );
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Failed to archive all', 'error');
+      showToast(error.message || t('pendingUploads.toast.addFailed'), 'error');
     },
   });
 
@@ -256,10 +255,10 @@ export function PendingUploadsPanel() {
     mutationFn: pendingUploadsApi.discardAll,
     onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
-      showToast(`Discarded ${data.discarded} files`);
+      showToast(t('pendingUploads.toast.discardedAll', { count: data.discarded }));
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Failed to discard all', 'error');
+      showToast(error.message || t('pendingUploads.toast.discardFailed'), 'error');
     },
   });
 
@@ -285,7 +284,7 @@ export function PendingUploadsPanel() {
             <div className="flex items-center gap-2">
               <Upload className="w-5 h-5 text-yellow-500" />
               <h2 className="text-lg font-semibold text-white">
-                Pending Uploads ({uploads.length})
+                {t('pendingUploads.title', { count: uploads.length })}
               </h2>
             </div>
             <div className="flex items-center gap-2">
@@ -299,8 +298,8 @@ export function PendingUploadsPanel() {
                   <Loader2 className="w-4 h-4 animate-spin" />
                 ) : (
                   <>
-                    <Archive className="w-4 h-4" />
-                    Archive All
+                    <FolderInput className="w-4 h-4" />
+                    {t('pendingUploads.addAll')}
                   </>
                 )}
               </Button>
@@ -315,7 +314,7 @@ export function PendingUploadsPanel() {
                 ) : (
                   <>
                     <Trash2 className="w-4 h-4" />
-                    Discard All
+                    {t('pendingUploads.discardAll')}
                   </>
                 )}
               </Button>
@@ -324,7 +323,7 @@ export function PendingUploadsPanel() {
         </CardHeader>
         <CardContent>
           <p className="text-sm text-bambu-gray mb-4">
-            These files were uploaded via the virtual printer. Review and archive them to add to your collection.
+            {t('pendingUploads.description')}
           </p>
           <div className="space-y-3">
             {uploads.map((upload) => (
@@ -345,9 +344,9 @@ export function PendingUploadsPanel() {
       {/* Archive All Confirmation */}
       {showArchiveAllConfirm && (
         <ConfirmModal
-          title="Archive All Uploads"
-          message={`Are you sure you want to archive all ${uploads.length} pending uploads?`}
-          confirmText="Archive All"
+          title={t('pendingUploads.addAllUploads')}
+          message={t('pendingUploads.addAllConfirm', { count: uploads.length })}
+          confirmText={t('pendingUploads.addAll')}
           onConfirm={() => {
             archiveAllMutation.mutate();
             setShowArchiveAllConfirm(false);
@@ -359,9 +358,9 @@ export function PendingUploadsPanel() {
       {/* Discard All Confirmation */}
       {showDiscardAllConfirm && (
         <ConfirmModal
-          title="Discard All Uploads"
-          message={`Are you sure you want to discard all ${uploads.length} pending uploads? This cannot be undone.`}
-          confirmText="Discard All"
+          title={t('pendingUploads.discardAllUploads')}
+          message={t('pendingUploads.discardAllConfirm', { count: uploads.length })}
+          confirmText={t('pendingUploads.discardAll')}
           variant="danger"
           onConfirm={() => {
             discardAllMutation.mutate();

+ 25 - 0
frontend/src/components/PrintModal/archiveAmsMapping.ts

@@ -44,3 +44,28 @@ export function resolveArchiveSlicerAmsMapping(
 
   return saved.mapping;
 }
+
+/**
+ * The slicer's saved pick for the print being set up, if it applies to `printerId`.
+ *
+ * A virtual printer's queue job prints a File Manager file and has no archive
+ * until it prints, so while editing one its own kept pick (`slicer_ams_mapping`,
+ * for the job's printer) is the one. Otherwise the archive's, as
+ * `resolveArchiveSlicerAmsMapping`; a library file without a job has none.
+ */
+export function resolveSlicerAmsMapping({
+  queueItem,
+  isLibraryFile,
+  archiveExtraData,
+  printerId,
+}: {
+  queueItem?: { printer_id: number | null; slicer_ams_mapping?: number[] | null } | null;
+  isLibraryFile: boolean;
+  archiveExtraData: Record<string, unknown> | null | undefined;
+  printerId: number | null | undefined;
+}): number[] | undefined {
+  if (queueItem?.slicer_ams_mapping?.length && printerId != null && queueItem.printer_id === printerId) {
+    return queueItem.slicer_ams_mapping;
+  }
+  return isLibraryFile ? undefined : resolveArchiveSlicerAmsMapping(archiveExtraData, printerId);
+}

+ 11 - 5
frontend/src/components/PrintModal/index.tsx

@@ -23,7 +23,7 @@ import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { isPlaceholderDate, effectivePreferLowest, filamentTypesCompatible } from '../../utils/amsHelpers';
-import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
+import { resolveSlicerAmsMapping } from './archiveAmsMapping';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentOverride } from './FilamentOverride';
 import { PlateSelector } from './PlateSelector';
@@ -468,12 +468,18 @@ export function PrintModal({
   // archives that predate the feature / had it off at print time, and
   // deliberately undefined unless the selected printer is the one the mapping
   // was resolved against. See `resolveArchiveSlicerAmsMapping`.
+  // While editing a virtual printer's queue job, the pick the job keeps (it
+  // has no archive until it prints). See `resolveSlicerAmsMapping`.
+  const editedQueueItem = isEditing ? queueItem : undefined;
   const archiveSlicerAmsMapping = useMemo(
     () =>
-      isLibraryFile
-        ? undefined
-        : resolveArchiveSlicerAmsMapping(archiveDetails?.extra_data, effectivePrinterId),
-    [isLibraryFile, archiveDetails?.extra_data, effectivePrinterId],
+      resolveSlicerAmsMapping({
+        queueItem: editedQueueItem,
+        isLibraryFile,
+        archiveExtraData: archiveDetails?.extra_data,
+        printerId: effectivePrinterId,
+      }),
+    [editedQueueItem, isLibraryFile, archiveDetails?.extra_data, effectivePrinterId],
   );
 
   // Fetch plates for archives

+ 4 - 4
frontend/src/components/VirtualPrinterAddDialog.tsx

@@ -7,10 +7,10 @@ import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 
-type Mode = 'archive' | 'review' | 'queue' | 'proxy';
+type Mode = 'library' | 'review' | 'queue' | 'proxy';
 
 const MODE_LABELS: Record<string, string> = {
-  archive: 'archive',
+  library: 'library',
   review: 'review',
   queue: 'queue',
   proxy: 'proxy',
@@ -26,7 +26,7 @@ export function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProp
   const { showToast } = useToast();
 
   const [name, setName] = useState('');
-  const [mode, setMode] = useState<Mode>('archive');
+  const [mode, setMode] = useState<Mode>('library');
   const [targetPrinterId, setTargetPrinterId] = useState<number | null>(null);
 
   const { data: printers } = useQuery({
@@ -80,7 +80,7 @@ export function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProp
           <div>
             <label className="text-sm text-white font-medium block mb-1">{t('virtualPrinter.mode.title')}</label>
             <div className="grid grid-cols-2 gap-2">
-              {(['archive', 'review', 'queue', 'proxy'] as const).map((m) => (
+              {(['library', 'review', 'queue', 'proxy'] as const).map((m) => (
                 <button
                   key={m}
                   onClick={() => setMode(m)}

+ 101 - 12
frontend/src/components/VirtualPrinterCard.tsx

@@ -6,7 +6,7 @@ import {
   ChevronDown, ChevronRight, ArrowRightLeft, Trash2, ShieldCheck, Copy, Stethoscope,
 } from 'lucide-react';
 import { api, multiVirtualPrinterApi } from '../api/client';
-import type { VirtualPrinterConfig } from '../api/client';
+import type { LibraryFolderTree, VirtualPrinterConfig } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
@@ -14,24 +14,35 @@ import { VirtualPrinterDiagnosticModal } from './VirtualPrinterDiagnosticModal';
 import { useToast } from '../contexts/ToastContext';
 import { copyTextToClipboard } from '../utils/clipboard';
 
-type LocalMode = 'archive' | 'review' | 'queue' | 'proxy';
+type LocalMode = 'library' | 'review' | 'queue' | 'proxy';
 
 const MODE_LABELS: Record<string, string> = {
-  archive: 'archive',
+  library: 'library',
   review: 'review',
   queue: 'queue',
   proxy: 'proxy',
 };
 
-// Legacy wire values (`immediate` → `archive`, `print_queue` → `queue`) shipped
-// before the UI labels were aligned with the wire format. Backend migration
-// flips existing rows but the function tolerates either form so a stale fetch
-// doesn't show an unselected mode (#1429 follow-up).
+// Legacy wire values (`immediate` and `archive` → `library`, `print_queue` →
+// `queue`). Backend migration flips existing rows but the function tolerates
+// either form so a stale fetch doesn't show an unselected mode (#1429 follow-up).
 function normalizeMode(value: string | undefined): LocalMode {
-  if (value === 'immediate') return 'archive';
+  if (value === 'immediate' || value === 'archive') return 'library';
   if (value === 'print_queue' || value === 'queue') return 'queue';
-  if (value === 'archive' || value === 'review' || value === 'proxy') return value;
-  return 'archive';
+  if (value === 'library' || value === 'review' || value === 'proxy') return value;
+  return 'library';
+}
+
+// The folders a virtual printer can store uploads in, as a flat list with
+// their depth: internal ones the user may add files to (an external mount
+// refuses a second file of the same name, and slicer names repeat).
+function uploadFolderOptions(tree: LibraryFolderTree[] | undefined, depth = 0): { id: number; label: string }[] {
+  return (tree ?? [])
+    .filter((folder) => !folder.is_external)
+    .flatMap((folder) => [
+      ...(folder.can_write ? [{ id: folder.id, label: `${'\u00a0\u00a0'.repeat(depth)}${folder.name}` }] : []),
+      ...uploadFolderOptions(folder.children, depth + 1),
+    ]);
 }
 
 interface VirtualPrinterCardProps {
@@ -57,6 +68,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
   const [localSaveAmsMapping, setLocalSaveAmsMapping] = useState(printer.save_ams_mapping ?? false);
   const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
+  const [localFolderId, setLocalFolderId] = useState<number | null>(printer.library_folder_id ?? null);
+  const [localRemoveAfterPrint, setLocalRemoveAfterPrint] = useState(printer.queue_remove_after_print ?? false);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
@@ -104,6 +117,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
       setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
       setLocalGcodeInjection(printer.gcode_injection ?? false);
+      setLocalFolderId(printer.library_folder_id ?? null);
+      setLocalRemoveAfterPrint(printer.queue_remove_after_print ?? false);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
     }
   }, [printer, pendingAction]);
@@ -120,6 +135,13 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
     queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
   });
 
+  const { data: libraryFolders } = useQuery({
+    queryKey: ['library-folders'],
+    queryFn: api.getLibraryFolders,
+    enabled: localMode !== 'proxy',
+  });
+  const folderOptions = uploadFolderOptions(libraryFolders);
+
   const updateMutation = useMutation({
     mutationFn: (data: Parameters<typeof multiVirtualPrinterApi.update>[1]) =>
       multiVirtualPrinterApi.update(printer.id, data),
@@ -141,6 +163,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
       setLocalSaveAmsMapping(printer.save_ams_mapping ?? false);
       setLocalGcodeInjection(printer.gcode_injection ?? false);
+      setLocalFolderId(printer.library_folder_id ?? null);
+      setLocalRemoveAfterPrint(printer.queue_remove_after_print ?? false);
       setPendingAction(null);
     },
   });
@@ -242,7 +266,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   };
 
   const isRunning = printer.status?.running || false;
-  const modeLabel = t(`virtualPrinter.mode.${MODE_LABELS[localMode] || 'archive'}`);
+  const modeLabel = t(`virtualPrinter.mode.${MODE_LABELS[localMode] || 'library'}`);
   const targetPrinter = printers?.find(p => p.id === localTargetPrinterId);
   const targetPrinterName = targetPrinter?.name;
   // The bridge in non-proxy modes (and the transparent relay in proxy mode)
@@ -390,7 +414,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
             <div>
               <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
               <div className="grid grid-cols-2 gap-2">
-                {(['archive', 'review', 'queue', 'proxy'] as const).map((mode) => (
+                {(['library', 'review', 'queue', 'proxy'] as const).map((mode) => (
                   <button
                     key={mode}
                     onClick={() => handleModeChange(mode)}
@@ -415,6 +439,71 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
             </div>
 
+            {/* File Manager folder - every mode that receives files */}
+            {localMode !== 'proxy' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="text-white text-sm font-medium mb-1">{t('virtualPrinter.uploadFolder.title')}</div>
+                <div className="relative">
+                  <select
+                    aria-label={t('virtualPrinter.uploadFolder.title')}
+                    value={localFolderId ?? ''}
+                    onChange={(e) => {
+                      const id = e.target.value === '' ? null : parseInt(e.target.value, 10);
+                      setLocalFolderId(id);
+                      setPendingAction('uploadFolder');
+                      updateMutation.mutate({ library_folder_id: id });
+                    }}
+                    disabled={pendingAction === 'uploadFolder'}
+                    className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
+                  >
+                    <option value="">{t('virtualPrinter.uploadFolder.default')}</option>
+                    {folderOptions.map((folder) => (
+                      <option key={folder.id} value={folder.id}>{folder.label}</option>
+                    ))}
+                    {/* A folder the user can't add to still shows as what it is. */}
+                    {localFolderId !== null && !folderOptions.some((f) => f.id === localFolderId) && (
+                      <option value={localFolderId}>{t('virtualPrinter.uploadFolder.other')}</option>
+                    )}
+                  </select>
+                  <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">{t(`virtualPrinter.uploadFolder.hint.${localMode}`)}</p>
+              </div>
+            )}
+
+            {/* Remove-after-print toggle - only for queue mode */}
+            {localMode === 'queue' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center justify-between gap-3">
+                  <div className="min-w-0">
+                    <div className="text-white text-sm font-medium">{t('virtualPrinter.removeAfterPrint.title')}</div>
+                    <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.removeAfterPrint.description')}</div>
+                  </div>
+                  <button
+                    role="switch"
+                    aria-checked={localRemoveAfterPrint}
+                    aria-label={t('virtualPrinter.removeAfterPrint.title')}
+                    onClick={() => {
+                      const newVal = !localRemoveAfterPrint;
+                      setLocalRemoveAfterPrint(newVal);
+                      setPendingAction('removeAfterPrint');
+                      updateMutation.mutate({ queue_remove_after_print: newVal });
+                    }}
+                    disabled={pendingAction === 'removeAfterPrint'}
+                    className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
+                      localRemoveAfterPrint ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                    } ${pendingAction === 'removeAfterPrint' ? 'opacity-50' : ''}`}
+                  >
+                    <span
+                      className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
+                        localRemoveAfterPrint ? 'translate-x-5' : ''
+                      }`}
+                    />
+                  </button>
+                </div>
+              </div>
+            )}
+
             {/* Auto-dispatch toggle - only for queue mode */}
             {localMode === 'queue' && (
               <div className="pt-2 border-t border-bambu-dark-tertiary">

+ 7 - 0
frontend/src/hooks/useWebSocket.ts

@@ -437,6 +437,13 @@ export function useWebSocket() {
         debouncedInvalidate('archiveStats');
         break;
 
+      case 'library_files_changed':
+        // A virtual printer stored an upload in the File Manager.
+        debouncedInvalidate('library-files');
+        debouncedInvalidate('library-folders');
+        debouncedInvalidate('library-stats');
+        break;
+
       case 'archive_updated':
         debouncedInvalidate('archives');
         debouncedInvalidate('archive');

+ 44 - 13
frontend/src/i18n/locales/de.ts

@@ -6370,15 +6370,29 @@ export default {
     },
     mode: {
       title: 'Modus',
-      archive: 'Archivieren',
-      archiveDesc: 'Dateien sofort archivieren',
+      library: 'Dateimanager',
+      libraryDesc: 'Dateien im Dateimanager ablegen',
       review: 'Überprüfen',
-      reviewDesc: 'Vor dem Archivieren überprüfen',
+      reviewDesc: 'Vor dem Ablegen im Dateimanager prüfen',
       queue: 'Warteschlange',
-      queueDesc: 'Archivieren und zur Warteschlange hinzufügen',
+      queueDesc: 'Dateimanager und Druckwarteschlange',
       proxy: 'Proxy',
       proxyDesc: 'An echten Drucker weiterleiten',
     },
+    uploadFolder: {
+      title: 'Ordner im Dateimanager',
+      default: 'Virtual Printer (gemeinsam)',
+      other: 'Ein Ordner, in den du nichts ablegen kannst',
+      hint: {
+        library: 'An diesen Drucker gesendete Dateien landen in diesem Ordner.',
+        review: 'Freigegebene Dateien landen in diesem Ordner.',
+        queue: 'Dateien landen in diesem Ordner und werden von dort in die Warteschlange gestellt. Das Archiv bekommt die Datei, sobald sie gedruckt wird.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Nach dem Druck aus dem Dateimanager entfernen',
+      description: 'Die Datei verlässt den Dateimanager, wenn ihr Druck startet. Das Archiv behält eine eigene Kopie.',
+    },
     autoDispatch: {
       title: 'Automatisch starten',
       description: 'Drucke automatisch starten, wenn sie zur Warteschlange hinzugefügt werden. Wenn deaktiviert, warten Drucke auf manuellen Start.',
@@ -7309,18 +7323,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: 'Ausstehende Uploads ({{count}})',
+    description: 'Diese Dateien kamen über einen virtuellen Drucker im Prüfmodus. Lege sie im Dateimanager ab oder verwirf sie.',
+    from: 'von {{ip}}',
+    addToFileManager: 'In den Dateimanager',
+    addAll: 'Alle ablegen',
+    addAllUploads: 'Alle Uploads ablegen',
+    addAllConfirm: 'Alle {{count}} ausstehenden Uploads im Dateimanager ablegen?',
+    discard: 'Verwerfen',
+    discardUpload: 'Upload verwerfen',
+    discardConfirm: '„{{name}}" verwerfen? Das lässt sich nicht rückgängig machen.',
+    discardAll: 'Alle verwerfen',
+    discardAllUploads: 'Alle Uploads verwerfen',
+    discardAllConfirm: 'Alle {{count}} ausstehenden Uploads verwerfen? Das lässt sich nicht rückgängig machen.',
+    tags: 'Tags',
+    notes: 'Notizen',
+    project: 'Projekt',
+    noProject: 'Kein Projekt',
     placeholders: {
+      tags: 'z. B. funktional, Prototyp, Geschenk',
       notes: 'Notizen zu diesem Druck hinzufügen...',
     },
-    discardUpload: 'Upload verwerfen',
-    archiveAllUploads: 'Alle Uploads archivieren',
-    discardAllUploads: 'Alle Uploads verwerfen',
-    archive: 'Archivieren',
-    timeAgo: {
-      justNow: 'Gerade eben',
-      minutesAgo: 'vor {{minutes}}m',
-      hoursAgo: 'vor {{hours}}h',
-      daysAgo: 'vor {{days}}d',
+    toast: {
+      added: 'Im Dateimanager abgelegt: {{name}}',
+      addFailed: 'Konnte nicht im Dateimanager abgelegt werden',
+      addedAll: '{{count}} Dateien im Dateimanager abgelegt',
+      addedAllSomeFailed: '{{count}} Dateien im Dateimanager abgelegt, {{failed}} fehlgeschlagen',
+      discarded: 'Upload verworfen',
+      discardedAll: '{{count}} Dateien verworfen',
+      discardFailed: 'Konnte nicht verworfen werden',
     },
   },
 

+ 44 - 13
frontend/src/i18n/locales/en.ts

@@ -6423,15 +6423,29 @@ export default {
     },
     mode: {
       title: 'Mode',
-      archive: 'Archive',
-      archiveDesc: 'Archive files immediately',
+      library: 'File Manager',
+      libraryDesc: 'Store files in the File Manager',
       review: 'Review',
-      reviewDesc: 'Review before archiving',
+      reviewDesc: 'Review before adding to the File Manager',
       queue: 'Queue',
-      queueDesc: 'Archive and add to queue',
+      queueDesc: 'File Manager and print queue',
       proxy: 'Proxy',
       proxyDesc: 'Relay to real printer',
     },
+    uploadFolder: {
+      title: 'File Manager folder',
+      default: 'Virtual Printer (shared)',
+      other: 'A folder you can\'t add to',
+      hint: {
+        library: 'Files sent to this printer are stored in this folder.',
+        review: 'Approved files are stored in this folder.',
+        queue: 'Files are stored in this folder and queued from there. The archive gets the file once it prints.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Remove from File Manager once it prints',
+      description: 'The file leaves the File Manager when its print starts. The archive keeps its own copy.',
+    },
     autoDispatch: {
       title: 'Auto-dispatch',
       description: 'Automatically start prints when added to queue. When off, prints wait for manual dispatch.',
@@ -7362,18 +7376,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: 'Pending uploads ({{count}})',
+    description: 'These files came in on a virtual printer in review mode. Add them to the File Manager or discard them.',
+    from: 'from {{ip}}',
+    addToFileManager: 'Add to File Manager',
+    addAll: 'Add all',
+    addAllUploads: 'Add all uploads',
+    addAllConfirm: 'Add all {{count}} pending uploads to the File Manager?',
+    discard: 'Discard',
+    discardUpload: 'Discard upload',
+    discardConfirm: 'Discard "{{name}}"? This cannot be undone.',
+    discardAll: 'Discard all',
+    discardAllUploads: 'Discard all uploads',
+    discardAllConfirm: 'Discard all {{count}} pending uploads? This cannot be undone.',
+    tags: 'Tags',
+    notes: 'Notes',
+    project: 'Project',
+    noProject: 'No project',
     placeholders: {
+      tags: 'e.g. functional, prototype, gift',
       notes: 'Add notes about this print...',
     },
-    discardUpload: 'Discard Upload',
-    archiveAllUploads: 'Archive All Uploads',
-    discardAllUploads: 'Discard All Uploads',
-    archive: 'Archive',
-    timeAgo: {
-      justNow: 'Just now',
-      minutesAgo: '{{minutes}}m ago',
-      hoursAgo: '{{hours}}h ago',
-      daysAgo: '{{days}}d ago',
+    toast: {
+      added: 'Added to the File Manager: {{name}}',
+      addFailed: 'Could not add to the File Manager',
+      addedAll: 'Added {{count}} files to the File Manager',
+      addedAllSomeFailed: 'Added {{count}} files to the File Manager, {{failed}} failed',
+      discarded: 'Upload discarded',
+      discardedAll: 'Discarded {{count}} files',
+      discardFailed: 'Could not discard',
     },
   },
 

+ 45 - 14
frontend/src/i18n/locales/es.ts

@@ -6378,15 +6378,29 @@ export default {
     },
     mode: {
       title: 'Modo',
-      archive: 'Archivar',
-      archiveDesc: 'Archivar los archivos inmediatamente',
+      library: 'Gestor de archivos',
+      libraryDesc: 'Guardar archivos en el gestor de archivos',
       review: 'Revisar',
-      reviewDesc: 'Revisar antes de archivar',
+      reviewDesc: 'Revisar antes de añadir al gestor de archivos',
       queue: 'Encolar',
-      queueDesc: 'Archivar y añadir a la cola',
+      queueDesc: 'Gestor de archivos y cola de impresión',
       proxy: 'Proxy',
       proxyDesc: 'Retransmitir a una impresora real',
     },
+    uploadFolder: {
+      title: 'Carpeta del gestor de archivos',
+      default: 'Virtual Printer (compartida)',
+      other: 'Una carpeta en la que no puedes añadir',
+      hint: {
+        library: 'Los archivos enviados a esta impresora se guardan en esta carpeta.',
+        review: 'Los archivos aprobados se guardan en esta carpeta.',
+        queue: 'Los archivos se guardan en esta carpeta y se ponen en cola desde ella. El archivo pasa al archivo histórico cuando se imprime.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Quitar del gestor de archivos al imprimir',
+      description: 'El archivo sale del gestor de archivos cuando empieza su impresión. El archivo histórico guarda su propia copia.',
+    },
     autoDispatch: {
       title: 'Envío automático',
       description: 'Iniciar automáticamente las impresiones al añadirlas a la cola. Cuando está desactivado, las impresiones esperan al envío manual.',
@@ -7317,18 +7331,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
-    placeholders: {
-      notes: 'Añada notas sobre esta impresión...',
-    },
+    title: 'Subidas pendientes ({{count}})',
+    description: 'Estos archivos llegaron por una impresora virtual en modo revisión. Añádelos al gestor de archivos o descártalos.',
+    from: 'desde {{ip}}',
+    addToFileManager: 'Añadir al gestor de archivos',
+    addAll: 'Añadir todas',
+    addAllUploads: 'Añadir todas las subidas',
+    addAllConfirm: '¿Añadir las {{count}} subidas pendientes al gestor de archivos?',
+    discard: 'Descartar',
     discardUpload: 'Descartar subida',
-    archiveAllUploads: 'Archivar todas las subidas',
+    discardConfirm: '¿Descartar "{{name}}"? No se puede deshacer.',
+    discardAll: 'Descartar todas',
     discardAllUploads: 'Descartar todas las subidas',
-    archive: 'Archivar',
-    timeAgo: {
-      justNow: 'Ahora mismo',
-      minutesAgo: 'hace {{minutes}} min',
-      hoursAgo: 'hace {{hours}} h',
-      daysAgo: 'hace {{days}} d',
+    discardAllConfirm: '¿Descartar las {{count}} subidas pendientes? No se puede deshacer.',
+    tags: 'Etiquetas',
+    notes: 'Notas',
+    project: 'Proyecto',
+    noProject: 'Sin proyecto',
+    placeholders: {
+      tags: 'p. ej. funcional, prototipo, regalo',
+      notes: 'Añade notas sobre esta impresión...',
+    },
+    toast: {
+      added: 'Añadido al gestor de archivos: {{name}}',
+      addFailed: 'No se pudo añadir al gestor de archivos',
+      addedAll: '{{count}} archivos añadidos al gestor de archivos',
+      addedAllSomeFailed: '{{count}} archivos añadidos al gestor de archivos, {{failed}} fallaron',
+      discarded: 'Subida descartada',
+      discardedAll: '{{count}} archivos descartados',
+      discardFailed: 'No se pudo descartar',
     },
   },
 

+ 45 - 14
frontend/src/i18n/locales/fr.ts

@@ -6358,15 +6358,29 @@ export default {
     },
     mode: {
       title: 'Mode',
-      archive: 'Archiver',
-      archiveDesc: 'Archive immédiatement',
+      library: 'Gestionnaire de fichiers',
+      libraryDesc: 'Ranger les fichiers dans le gestionnaire',
       review: 'Revue',
-      reviewDesc: 'Attendre revue avant archive',
+      reviewDesc: 'Vérifier avant de ranger dans le gestionnaire',
       queue: 'File',
-      queueDesc: 'Archiver et ajouter à la file',
+      queueDesc: 'Gestionnaire de fichiers et file d\'impression',
       proxy: 'Proxy',
       proxyDesc: 'Relais vers imprimante réelle',
     },
+    uploadFolder: {
+      title: 'Dossier du gestionnaire de fichiers',
+      default: 'Virtual Printer (partagé)',
+      other: 'Un dossier où vous ne pouvez pas ajouter',
+      hint: {
+        library: 'Les fichiers envoyés à cette imprimante sont rangés dans ce dossier.',
+        review: 'Les fichiers approuvés sont rangés dans ce dossier.',
+        queue: 'Les fichiers sont rangés dans ce dossier et mis en file d\'attente depuis celui-ci. L\'archive reçoit le fichier une fois imprimé.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Retirer du gestionnaire une fois imprimé',
+      description: 'Le fichier quitte le gestionnaire de fichiers au début de son impression. L\'archive garde sa propre copie.',
+    },
     autoDispatch: {
       title: 'Lancement automatique',
       description: 'Lancer automatiquement les impressions ajoutées à la file. Désactivé, les impressions attendent un lancement manuel.',
@@ -7297,18 +7311,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: 'Envois en attente ({{count}})',
+    description: 'Ces fichiers sont arrivés par une imprimante virtuelle en mode vérification. Ajoutez-les au gestionnaire de fichiers ou supprimez-les.',
+    from: 'depuis {{ip}}',
+    addToFileManager: 'Ajouter au gestionnaire',
+    addAll: 'Tout ajouter',
+    addAllUploads: 'Ajouter tous les envois',
+    addAllConfirm: 'Ajouter les {{count}} envois en attente au gestionnaire de fichiers ?',
+    discard: 'Supprimer',
+    discardUpload: 'Supprimer l\'envoi',
+    discardConfirm: 'Supprimer « {{name}} » ? Action irréversible.',
+    discardAll: 'Tout supprimer',
+    discardAllUploads: 'Supprimer tous les envois',
+    discardAllConfirm: 'Supprimer les {{count}} envois en attente ? Action irréversible.',
+    tags: 'Étiquettes',
+    notes: 'Notes',
+    project: 'Projet',
+    noProject: 'Aucun projet',
     placeholders: {
-      notes: 'Notes sur l\'impression...',
+      tags: 'ex. fonctionnel, prototype, cadeau',
+      notes: 'Ajouter des notes sur cette impression...',
     },
-    discardUpload: 'Rejeter',
-    archiveAllUploads: 'Tout archiver',
-    discardAllUploads: 'Tout rejeter',
-    archive: 'Archiver',
-    timeAgo: {
-      justNow: 'À l\'instant',
-      minutesAgo: 'Il y a {{minutes}}m',
-      hoursAgo: 'Il y a {{hours}}h',
-      daysAgo: 'Il y a {{days}}j',
+    toast: {
+      added: 'Ajouté au gestionnaire de fichiers : {{name}}',
+      addFailed: 'Impossible d\'ajouter au gestionnaire de fichiers',
+      addedAll: '{{count}} fichiers ajoutés au gestionnaire de fichiers',
+      addedAllSomeFailed: '{{count}} fichiers ajoutés au gestionnaire de fichiers, {{failed}} en échec',
+      discarded: 'Envoi supprimé',
+      discardedAll: '{{count}} fichiers supprimés',
+      discardFailed: 'Impossible de supprimer',
     },
   },
 

+ 44 - 13
frontend/src/i18n/locales/it.ts

@@ -6357,15 +6357,29 @@ export default {
     },
     mode: {
       title: 'Modalita',
-      archive: 'Archivio',
-      archiveDesc: 'Archivia subito i file',
+      library: 'Gestore file',
+      libraryDesc: 'Salva i file nel gestore file',
       review: 'Revisione',
-      reviewDesc: 'Rivedi prima di archiviare',
+      reviewDesc: 'Controlla prima di aggiungere al gestore file',
       queue: 'Coda',
-      queueDesc: 'Archivia e aggiungi alla coda',
+      queueDesc: 'Gestore file e coda di stampa',
       proxy: 'Proxy',
       proxyDesc: 'Inoltra a stampante reale',
     },
+    uploadFolder: {
+      title: 'Cartella del gestore file',
+      default: 'Virtual Printer (condivisa)',
+      other: 'Una cartella in cui non puoi aggiungere',
+      hint: {
+        library: 'I file inviati a questa stampante vengono salvati in questa cartella.',
+        review: 'I file approvati vengono salvati in questa cartella.',
+        queue: 'I file vengono salvati in questa cartella e messi in coda da lì. L\'archivio riceve il file quando viene stampato.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Rimuovi dal gestore file dopo la stampa',
+      description: 'Il file lascia il gestore file quando inizia la stampa. L\'archivio conserva una propria copia.',
+    },
     autoDispatch: {
       title: 'Avvio automatico',
       description: 'Avvia automaticamente le stampe aggiunte alla coda. Se disattivato, le stampe attendono l\'avvio manuale.',
@@ -7296,18 +7310,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: 'Caricamenti in attesa ({{count}})',
+    description: 'Questi file sono arrivati da una stampante virtuale in modalità revisione. Aggiungili al gestore file o scartali.',
+    from: 'da {{ip}}',
+    addToFileManager: 'Aggiungi al gestore file',
+    addAll: 'Aggiungi tutti',
+    addAllUploads: 'Aggiungi tutti i caricamenti',
+    addAllConfirm: 'Aggiungere tutti i {{count}} caricamenti in attesa al gestore file?',
+    discard: 'Scarta',
+    discardUpload: 'Scarta caricamento',
+    discardConfirm: 'Scartare "{{name}}"? Non si può annullare.',
+    discardAll: 'Scarta tutti',
+    discardAllUploads: 'Scarta tutti i caricamenti',
+    discardAllConfirm: 'Scartare tutti i {{count}} caricamenti in attesa? Non si può annullare.',
+    tags: 'Tag',
+    notes: 'Note',
+    project: 'Progetto',
+    noProject: 'Nessun progetto',
     placeholders: {
+      tags: 'es. funzionale, prototipo, regalo',
       notes: 'Aggiungi note su questa stampa...',
     },
-    discardUpload: 'Scarta caricamento',
-    archiveAllUploads: 'Archivia tutti i caricamenti',
-    discardAllUploads: 'Scarta tutti i caricamenti',
-    archive: 'Archivia',
-    timeAgo: {
-      justNow: 'Adesso',
-      minutesAgo: '{{minutes}} min fa',
-      hoursAgo: '{{hours}} ore fa',
-      daysAgo: '{{days}} giorni fa',
+    toast: {
+      added: 'Aggiunto al gestore file: {{name}}',
+      addFailed: 'Impossibile aggiungere al gestore file',
+      addedAll: '{{count}} file aggiunti al gestore file',
+      addedAllSomeFailed: '{{count}} file aggiunti al gestore file, {{failed}} non riusciti',
+      discarded: 'Caricamento scartato',
+      discardedAll: '{{count}} file scartati',
+      discardFailed: 'Impossibile scartare',
     },
   },
 

+ 44 - 13
frontend/src/i18n/locales/ja.ts

@@ -6371,15 +6371,29 @@ export default {
     },
     mode: {
       title: 'モード',
-      archive: 'アーカイブ',
-      archiveDesc: 'ファイルを即座にアーカイブ',
+      library: 'ファイル管理',
+      libraryDesc: 'ファイル管理に保存',
       review: 'レビュー',
-      reviewDesc: 'アーカイブ前にレビュー',
+      reviewDesc: 'ファイル管理に追加する前に確認',
       queue: 'キュー',
-      queueDesc: 'アーカイブしてキューに追加',
+      queueDesc: 'ファイル管理と印刷キュー',
       proxy: 'プロキシ',
       proxyDesc: '実際のプリンターに転送',
     },
+    uploadFolder: {
+      title: 'ファイル管理のフォルダー',
+      default: 'Virtual Printer(共有)',
+      other: '追加できないフォルダー',
+      hint: {
+        library: 'このプリンターに送られたファイルはこのフォルダーに保存されます。',
+        review: '承認されたファイルはこのフォルダーに保存されます。',
+        queue: 'ファイルはこのフォルダーに保存され、そこからキューに入ります。印刷されるとアーカイブにファイルが入ります。',
+      },
+    },
+    removeAfterPrint: {
+      title: '印刷したらファイル管理から削除',
+      description: '印刷が始まるとファイルはファイル管理から消えます。アーカイブには独自のコピーが残ります。',
+    },
     autoDispatch: {
       title: '自動ディスパッチ',
       description: 'キューに追加されたときに自動的に印刷を開始します。オフの場合、手動ディスパッチを待ちます。',
@@ -7310,18 +7324,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: '保留中のアップロード ({{count}})',
+    description: 'これらのファイルはレビューモードの仮想プリンターから届きました。ファイル管理に追加するか破棄してください。',
+    from: '{{ip}} から',
+    addToFileManager: 'ファイル管理に追加',
+    addAll: 'すべて追加',
+    addAllUploads: 'すべてのアップロードを追加',
+    addAllConfirm: '保留中の {{count}} 件のアップロードをすべてファイル管理に追加しますか?',
+    discard: '破棄',
+    discardUpload: 'アップロードを破棄',
+    discardConfirm: '「{{name}}」を破棄しますか?元に戻せません。',
+    discardAll: 'すべて破棄',
+    discardAllUploads: 'すべてのアップロードを破棄',
+    discardAllConfirm: '保留中の {{count}} 件のアップロードをすべて破棄しますか?元に戻せません。',
+    tags: 'タグ',
+    notes: 'メモ',
+    project: 'プロジェクト',
+    noProject: 'プロジェクトなし',
     placeholders: {
+      tags: '例: 機能部品, 試作, ギフト',
       notes: 'この印刷に関するメモを追加...',
     },
-    discardUpload: 'アップロードを破棄',
-    archiveAllUploads: 'すべてのアップロードをアーカイブ',
-    discardAllUploads: 'すべてのアップロードを破棄',
-    archive: 'アーカイブ',
-    timeAgo: {
-      justNow: 'たった今',
-      minutesAgo: '{{minutes}}分前',
-      hoursAgo: '{{hours}}時間前',
-      daysAgo: '{{days}}日前',
+    toast: {
+      added: 'ファイル管理に追加しました: {{name}}',
+      addFailed: 'ファイル管理に追加できませんでした',
+      addedAll: '{{count}} 件のファイルをファイル管理に追加しました',
+      addedAllSomeFailed: '{{count}} 件のファイルをファイル管理に追加しました。{{failed}} 件は失敗しました',
+      discarded: 'アップロードを破棄しました',
+      discardedAll: '{{count}} 件のファイルを破棄しました',
+      discardFailed: '破棄できませんでした',
     },
   },
 

+ 46 - 15
frontend/src/i18n/locales/ko.ts

@@ -6107,15 +6107,29 @@ export default {
     },
     mode: {
       title: '모드',
-      archive: '아카이브',
-      archiveDesc: '파일 즉시 아카이브',
+      library: '파일 관리자',
+      libraryDesc: '파일 관리자에 저장',
       review: '검토',
-      reviewDesc: '아카이브 전 검토',
+      reviewDesc: '파일 관리자에 추가하기 전 검토',
       queue: '대기열',
-      queueDesc: '아카이브 후 대기열 추가',
+      queueDesc: '파일 관리자 및 인쇄 대기열',
       proxy: '프록시',
       proxyDesc: '실제 프린터로 릴레이'
     },
+    uploadFolder: {
+      title: '파일 관리자 폴더',
+      default: 'Virtual Printer (공유)',
+      other: '추가할 수 없는 폴더',
+      hint: {
+        library: '이 프린터로 보낸 파일은 이 폴더에 저장됩니다.',
+        review: '승인된 파일은 이 폴더에 저장됩니다.',
+        queue: '파일은 이 폴더에 저장되고 여기서 대기열에 추가됩니다. 인쇄되면 아카이브에 파일이 들어갑니다.',
+      },
+    },
+    removeAfterPrint: {
+      title: '인쇄되면 파일 관리자에서 제거',
+      description: '인쇄가 시작되면 파일이 파일 관리자에서 빠집니다. 아카이브는 자체 사본을 보관합니다.',
+    },
     autoDispatch: {
       title: '자동 발송',
       description: '대기열에 추가될 때 자동으로 인쇄를 시작합니다. 꺼져 있으면 수동 발송을 기다립니다.'
@@ -7003,19 +7017,36 @@ export default {
     categoriesSelected: '{{selectedCount}}개 카테고리 선택됨'
   },
   pendingUploads: {
+    title: '대기 중인 업로드 ({{count}})',
+    description: '이 파일들은 검토 모드의 가상 프린터로 들어왔습니다. 파일 관리자에 추가하거나 버리세요.',
+    from: '{{ip}}에서',
+    addToFileManager: '파일 관리자에 추가',
+    addAll: '모두 추가',
+    addAllUploads: '모든 업로드 추가',
+    addAllConfirm: '대기 중인 업로드 {{count}}개를 모두 파일 관리자에 추가할까요?',
+    discard: '버리기',
+    discardUpload: '업로드 버리기',
+    discardConfirm: '"{{name}}"을(를) 버릴까요? 되돌릴 수 없습니다.',
+    discardAll: '모두 버리기',
+    discardAllUploads: '모든 업로드 버리기',
+    discardAllConfirm: '대기 중인 업로드 {{count}}개를 모두 버릴까요? 되돌릴 수 없습니다.',
+    tags: '태그',
+    notes: '메모',
+    project: '프로젝트',
+    noProject: '프로젝트 없음',
     placeholders: {
-      notes: '이 인쇄에 대한 메모 추가...'
+      tags: '예: 기능성, 시제품, 선물',
+      notes: '이 인쇄에 대한 메모 추가...',
+    },
+    toast: {
+      added: '파일 관리자에 추가됨: {{name}}',
+      addFailed: '파일 관리자에 추가할 수 없습니다',
+      addedAll: '파일 {{count}}개를 파일 관리자에 추가했습니다',
+      addedAllSomeFailed: '파일 {{count}}개를 파일 관리자에 추가했습니다. {{failed}}개 실패',
+      discarded: '업로드를 버렸습니다',
+      discardedAll: '파일 {{count}}개를 버렸습니다',
+      discardFailed: '버릴 수 없습니다',
     },
-    discardUpload: '업로드 삭제',
-    archiveAllUploads: '모든 업로드 아카이브',
-    discardAllUploads: '모든 업로드 삭제',
-    archive: '아카이브',
-    timeAgo: {
-      justNow: '방금',
-      minutesAgo: '{{minutes}}분 전',
-      hoursAgo: '{{hours}}시간 전',
-      daysAgo: '{{days}}일 전'
-    }
   },
   apiBrowser: {
     placeholders: {

+ 45 - 14
frontend/src/i18n/locales/nl.ts

@@ -6421,15 +6421,29 @@ export default {
     },
     mode: {
       title: 'Modus',
-      archive: 'Archief',
-      archiveDesc: 'Bestanden direct archiveren',
+      library: 'Bestandsbeheer',
+      libraryDesc: 'Bestanden opslaan in bestandsbeheer',
       review: 'Beoordelen',
-      reviewDesc: 'Beoordelen vóór archiveren',
+      reviewDesc: 'Controleren voor toevoegen aan bestandsbeheer',
       queue: 'Wachtrij',
-      queueDesc: 'Archiveren en aan wachtrij toevoegen',
+      queueDesc: 'Bestandsbeheer en printwachtrij',
       proxy: 'Proxy',
       proxyDesc: 'Doorsturen naar echte printer',
     },
+    uploadFolder: {
+      title: 'Map in bestandsbeheer',
+      default: 'Virtual Printer (gedeeld)',
+      other: 'Een map waaraan je niets kunt toevoegen',
+      hint: {
+        library: 'Bestanden die naar deze printer worden gestuurd, komen in deze map.',
+        review: 'Goedgekeurde bestanden komen in deze map.',
+        queue: 'Bestanden komen in deze map en worden van daaruit in de wachtrij gezet. Het archief krijgt het bestand zodra het geprint wordt.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Uit bestandsbeheer verwijderen na printen',
+      description: 'Het bestand verlaat bestandsbeheer zodra de print start. Het archief houdt een eigen kopie.',
+    },
     autoDispatch: {
       title: 'Automatisch verzenden',
       description: 'Start afdrukken automatisch wanneer ze aan de wachtrij worden toegevoegd. Als dit uitstaat, wachten afdrukken op handmatige verzending.',
@@ -7360,18 +7374,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
-    placeholders: {
-      notes: 'Voeg notities over deze afdruk toe...',
-    },
+    title: 'Wachtende uploads ({{count}})',
+    description: 'Deze bestanden kwamen binnen via een virtuele printer in controlemodus. Voeg ze toe aan bestandsbeheer of verwijder ze.',
+    from: 'van {{ip}}',
+    addToFileManager: 'Toevoegen aan bestandsbeheer',
+    addAll: 'Alles toevoegen',
+    addAllUploads: 'Alle uploads toevoegen',
+    addAllConfirm: 'Alle {{count}} wachtende uploads toevoegen aan bestandsbeheer?',
+    discard: 'Verwijderen',
     discardUpload: 'Upload verwijderen',
-    archiveAllUploads: 'Alle uploads archiveren',
+    discardConfirm: '"{{name}}" verwijderen? Dit kan niet ongedaan worden gemaakt.',
+    discardAll: 'Alles verwijderen',
     discardAllUploads: 'Alle uploads verwijderen',
-    archive: 'Archief',
-    timeAgo: {
-      justNow: 'Zojuist',
-      minutesAgo: '{{minutes}}m geleden',
-      hoursAgo: '{{hours}}u geleden',
-      daysAgo: '{{days}}d geleden',
+    discardAllConfirm: 'Alle {{count}} wachtende uploads verwijderen? Dit kan niet ongedaan worden gemaakt.',
+    tags: 'Tags',
+    notes: 'Notities',
+    project: 'Project',
+    noProject: 'Geen project',
+    placeholders: {
+      tags: 'bijv. functioneel, prototype, cadeau',
+      notes: 'Notities over deze print toevoegen...',
+    },
+    toast: {
+      added: 'Toegevoegd aan bestandsbeheer: {{name}}',
+      addFailed: 'Kon niet toevoegen aan bestandsbeheer',
+      addedAll: '{{count}} bestanden toegevoegd aan bestandsbeheer',
+      addedAllSomeFailed: '{{count}} bestanden toegevoegd aan bestandsbeheer, {{failed}} mislukt',
+      discarded: 'Upload verwijderd',
+      discardedAll: '{{count}} bestanden verwijderd',
+      discardFailed: 'Kon niet verwijderen',
     },
   },
 

+ 44 - 13
frontend/src/i18n/locales/pl.ts

@@ -6423,15 +6423,29 @@ export default {
     },
     mode: {
       title: 'Tryb',
-      archive: 'Archiwum',
-      archiveDesc: 'Archiwizuj pliki od razu',
+      library: 'Menedżer plików',
+      libraryDesc: 'Zapisuj pliki w menedżerze plików',
       review: 'Przegląd',
-      reviewDesc: 'Przejrzyj przed archiwizacją',
+      reviewDesc: 'Sprawdź przed dodaniem do menedżera plików',
       queue: 'Kolejka',
-      queueDesc: 'Archiwizuj i dodaj do kolejki',
+      queueDesc: 'Menedżer plików i kolejka druku',
       proxy: 'Proxy',
       proxyDesc: 'Przekazuj do prawdziwej drukarki',
     },
+    uploadFolder: {
+      title: 'Folder w menedżerze plików',
+      default: 'Virtual Printer (wspólny)',
+      other: 'Folder, do którego nie możesz dodawać',
+      hint: {
+        library: 'Pliki wysłane do tej drukarki trafiają do tego folderu.',
+        review: 'Zatwierdzone pliki trafiają do tego folderu.',
+        queue: 'Pliki trafiają do tego folderu i stamtąd do kolejki. Archiwum dostaje plik, gdy zostanie wydrukowany.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Usuń z menedżera plików po wydruku',
+      description: 'Plik znika z menedżera plików, gdy zaczyna się jego druk. Archiwum zachowuje własną kopię.',
+    },
     autoDispatch: {
       title: 'Automatyczne wysyłanie',
       description: 'Automatycznie uruchamiaj wydruki po dodaniu do kolejki. Gdy wyłączone, wydruki czekają na ręczne wysłanie.',
@@ -7362,18 +7376,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: 'Oczekujące przesłania ({{count}})',
+    description: 'Te pliki przyszły przez wirtualną drukarkę w trybie przeglądu. Dodaj je do menedżera plików albo odrzuć.',
+    from: 'z {{ip}}',
+    addToFileManager: 'Dodaj do menedżera plików',
+    addAll: 'Dodaj wszystkie',
+    addAllUploads: 'Dodaj wszystkie przesłania',
+    addAllConfirm: 'Dodać wszystkie oczekujące przesłania ({{count}}) do menedżera plików?',
+    discard: 'Odrzuć',
+    discardUpload: 'Odrzuć przesłanie',
+    discardConfirm: 'Odrzucić „{{name}}"? Tego nie można cofnąć.',
+    discardAll: 'Odrzuć wszystkie',
+    discardAllUploads: 'Odrzuć wszystkie przesłania',
+    discardAllConfirm: 'Odrzucić wszystkie oczekujące przesłania ({{count}})? Tego nie można cofnąć.',
+    tags: 'Tagi',
+    notes: 'Notatki',
+    project: 'Projekt',
+    noProject: 'Bez projektu',
     placeholders: {
+      tags: 'np. funkcjonalne, prototyp, prezent',
       notes: 'Dodaj notatki o tym wydruku...',
     },
-    discardUpload: 'Odrzuć przesłanie',
-    archiveAllUploads: 'Zarchiwizuj wszystkie przesłania',
-    discardAllUploads: 'Odrzuć wszystkie przesłania',
-    archive: 'Zarchiwizuj',
-    timeAgo: {
-      justNow: 'Przed chwilą',
-      minutesAgo: '{{minutes}} min temu',
-      hoursAgo: '{{hours}} godz. temu',
-      daysAgo: '{{days}} d temu',
+    toast: {
+      added: 'Dodano do menedżera plików: {{name}}',
+      addFailed: 'Nie udało się dodać do menedżera plików',
+      addedAll: 'Dodano pliki do menedżera plików: {{count}}',
+      addedAllSomeFailed: 'Dodano pliki do menedżera plików: {{count}}, nieudane: {{failed}}',
+      discarded: 'Przesłanie odrzucone',
+      discardedAll: 'Odrzucone pliki: {{count}}',
+      discardFailed: 'Nie udało się odrzucić',
     },
   },
 

+ 44 - 13
frontend/src/i18n/locales/pt-BR.ts

@@ -6357,15 +6357,29 @@ export default {
     },
     mode: {
       title: 'Modo',
-      archive: 'Arquivar',
-      archiveDesc: 'Arquivar arquivos imediatamente',
+      library: 'Gerenciador de Arquivos',
+      libraryDesc: 'Salvar arquivos no Gerenciador de Arquivos',
       review: 'Revisar',
-      reviewDesc: 'Revisar antes de arquivar',
+      reviewDesc: 'Revisar antes de adicionar ao Gerenciador de Arquivos',
       queue: 'Fila',
-      queueDesc: 'Arquivar e adicionar à fila',
+      queueDesc: 'Gerenciador de Arquivos e fila de impressão',
       proxy: 'Proxy',
       proxyDesc: 'Retransmitir para impressora real',
     },
+    uploadFolder: {
+      title: 'Pasta do Gerenciador de Arquivos',
+      default: 'Virtual Printer (compartilhada)',
+      other: 'Uma pasta em que você não pode adicionar',
+      hint: {
+        library: 'Os arquivos enviados a esta impressora são salvos nesta pasta.',
+        review: 'Os arquivos aprovados são salvos nesta pasta.',
+        queue: 'Os arquivos são salvos nesta pasta e entram na fila a partir dela. O arquivo vai para o histórico quando é impresso.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Remover do Gerenciador de Arquivos ao imprimir',
+      description: 'O arquivo sai do Gerenciador de Arquivos quando a impressão começa. O histórico guarda sua própria cópia.',
+    },
     autoDispatch: {
       title: 'Envio automático',
       description: 'Iniciar impressões automaticamente quando adicionadas à fila. Quando desativado, as impressões aguardam envio manual.',
@@ -7296,18 +7310,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: 'Envios pendentes ({{count}})',
+    description: 'Estes arquivos chegaram por uma impressora virtual no modo revisão. Adicione-os ao Gerenciador de Arquivos ou descarte-os.',
+    from: 'de {{ip}}',
+    addToFileManager: 'Adicionar ao Gerenciador de Arquivos',
+    addAll: 'Adicionar todos',
+    addAllUploads: 'Adicionar todos os envios',
+    addAllConfirm: 'Adicionar os {{count}} envios pendentes ao Gerenciador de Arquivos?',
+    discard: 'Descartar',
+    discardUpload: 'Descartar envio',
+    discardConfirm: 'Descartar "{{name}}"? Não é possível desfazer.',
+    discardAll: 'Descartar todos',
+    discardAllUploads: 'Descartar todos os envios',
+    discardAllConfirm: 'Descartar os {{count}} envios pendentes? Não é possível desfazer.',
+    tags: 'Tags',
+    notes: 'Notas',
+    project: 'Projeto',
+    noProject: 'Sem projeto',
     placeholders: {
+      tags: 'ex.: funcional, protótipo, presente',
       notes: 'Adicione notas sobre esta impressão...',
     },
-    discardUpload: 'Descartar Upload',
-    archiveAllUploads: 'Arquivar Todos os Uploads',
-    discardAllUploads: 'Descartar Todos os Uploads',
-    archive: 'Arquivar',
-    timeAgo: {
-      justNow: 'Agora mesmo',
-      minutesAgo: 'há {{minutes}} minutos',
-      hoursAgo: 'há {{hours}} horas',
-      daysAgo: 'há {{days}} dias',
+    toast: {
+      added: 'Adicionado ao Gerenciador de Arquivos: {{name}}',
+      addFailed: 'Não foi possível adicionar ao Gerenciador de Arquivos',
+      addedAll: '{{count}} arquivos adicionados ao Gerenciador de Arquivos',
+      addedAllSomeFailed: '{{count}} arquivos adicionados ao Gerenciador de Arquivos, {{failed}} falharam',
+      discarded: 'Envio descartado',
+      discardedAll: '{{count}} arquivos descartados',
+      discardFailed: 'Não foi possível descartar',
     },
   },
 

+ 44 - 13
frontend/src/i18n/locales/ru.ts

@@ -6095,15 +6095,29 @@ export default {
     },
     mode: {
       title: "Режим",
-      archive: "Архив",
-      archiveDesc: "Сразу сохранять файлы в архив",
+      library: "Файловый менеджер",
+      libraryDesc: "Сохранять файлы в файловом менеджере",
       review: "Проверка",
-      reviewDesc: "Проверять перед добавлением в архив",
+      reviewDesc: "Проверять перед добавлением в файловый менеджер",
       queue: "Очередь",
-      queueDesc: "Сохранять в архив и добавлять в очередь",
+      queueDesc: "Файловый менеджер и очередь печати",
       proxy: "Прокси",
       proxyDesc: "Перенаправлять на реальный принтер",
     },
+    uploadFolder: {
+      title: "Папка в файловом менеджере",
+      default: "Virtual Printer (общая)",
+      other: "Папка, в которую вы не можете добавлять",
+      hint: {
+        library: "Файлы, отправленные на этот принтер, сохраняются в этой папке.",
+        review: "Одобренные файлы сохраняются в этой папке.",
+        queue: "Файлы сохраняются в этой папке и ставятся в очередь из неё. Файл попадает в архив, когда печатается.",
+      },
+    },
+    removeAfterPrint: {
+      title: "Удалять из файлового менеджера после печати",
+      description: "Файл уходит из файлового менеджера, когда начинается печать. В архиве остаётся своя копия.",
+    },
     autoDispatch: {
       title: "Автоматический запуск",
       description: "Автоматически запускать задания после добавления в очередь. Если выключено, задания ожидают ручного запуска.",
@@ -6990,18 +7004,35 @@ export default {
     categoriesSelected: "Выбрано категорий: {{selectedCount}}",
   },
   pendingUploads: {
+    title: "Ожидающие загрузки ({{count}})",
+    description: "Эти файлы пришли через виртуальный принтер в режиме проверки. Добавьте их в файловый менеджер или отклоните.",
+    from: "с {{ip}}",
+    addToFileManager: "В файловый менеджер",
+    addAll: "Добавить все",
+    addAllUploads: "Добавить все загрузки",
+    addAllConfirm: "Добавить все ожидающие загрузки ({{count}}) в файловый менеджер?",
+    discard: "Отклонить",
+    discardUpload: "Отклонить загрузку",
+    discardConfirm: "Отклонить «{{name}}»? Это нельзя отменить.",
+    discardAll: "Отклонить все",
+    discardAllUploads: "Отклонить все загрузки",
+    discardAllConfirm: "Отклонить все ожидающие загрузки ({{count}})? Это нельзя отменить.",
+    tags: "Теги",
+    notes: "Заметки",
+    project: "Проект",
+    noProject: "Без проекта",
     placeholders: {
+      tags: "например, функциональная, прототип, подарок",
       notes: "Добавьте примечания об этой печати...",
     },
-    discardUpload: "Отклонить загрузку",
-    archiveAllUploads: "Добавить все загрузки в архив",
-    discardAllUploads: "Отклонить все загрузки",
-    archive: "В архив",
-    timeAgo: {
-      justNow: "Только что",
-      minutesAgo: "{{minutes}} мин назад",
-      hoursAgo: "{{hours}} ч назад",
-      daysAgo: "{{days}} дн. назад",
+    toast: {
+      added: "Добавлено в файловый менеджер: {{name}}",
+      addFailed: "Не удалось добавить в файловый менеджер",
+      addedAll: "Файлов добавлено в файловый менеджер: {{count}}",
+      addedAllSomeFailed: "Файлов добавлено в файловый менеджер: {{count}}, с ошибкой: {{failed}}",
+      discarded: "Загрузка отклонена",
+      discardedAll: "Отклонено файлов: {{count}}",
+      discardFailed: "Не удалось отклонить",
     },
   },
   apiBrowser: {

+ 45 - 14
frontend/src/i18n/locales/sv.ts

@@ -6452,15 +6452,29 @@ errors: {
     },
     mode: {
       title: 'Läge',
-      archive: 'Arkiv',
-      archiveDesc: 'Arkivera filer omedelbart',
+      library: 'Filhanterare',
+      libraryDesc: 'Spara filer i filhanteraren',
       review: 'Granskning',
-      reviewDesc: 'Granska innan arkivering',
+      reviewDesc: 'Granska innan de läggs i filhanteraren',
       queue: 'Kö',
-      queueDesc: 'Arkivera och lägg till i kö',
+      queueDesc: 'Filhanterare och utskriftskö',
       proxy: 'Proxy',
       proxyDesc: 'Vidarebefordra till riktig skrivare',
     },
+    uploadFolder: {
+      title: 'Mapp i filhanteraren',
+      default: 'Virtual Printer (delad)',
+      other: 'En mapp du inte kan lägga till i',
+      hint: {
+        library: 'Filer som skickas till den här skrivaren sparas i den här mappen.',
+        review: 'Godkända filer sparas i den här mappen.',
+        queue: 'Filer sparas i den här mappen och köas därifrån. Arkivet får filen när den skrivs ut.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Ta bort från filhanteraren när den skrivs ut',
+      description: 'Filen lämnar filhanteraren när utskriften startar. Arkivet behåller en egen kopia.',
+    },
     autoDispatch: {
       title: 'Auto-utskick',
       description: 'Starta utskrifter automatiskt när de läggs till i kön. När avstängd väntar utskrifter på manuell utskickning.',
@@ -7391,18 +7405,35 @@ errors: {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: 'Väntande uppladdningar ({{count}})',
+    description: 'De här filerna kom in via en virtuell skrivare i granskningsläge. Lägg dem i filhanteraren eller släng dem.',
+    from: 'från {{ip}}',
+    addToFileManager: 'Lägg i filhanteraren',
+    addAll: 'Lägg till alla',
+    addAllUploads: 'Lägg till alla uppladdningar',
+    addAllConfirm: 'Lägga alla {{count}} väntande uppladdningar i filhanteraren?',
+    discard: 'Släng',
+    discardUpload: 'Släng uppladdning',
+    discardConfirm: 'Släng "{{name}}"? Det går inte att ångra.',
+    discardAll: 'Släng alla',
+    discardAllUploads: 'Släng alla uppladdningar',
+    discardAllConfirm: 'Släng alla {{count}} väntande uppladdningar? Det går inte att ångra.',
+    tags: 'Taggar',
+    notes: 'Anteckningar',
+    project: 'Projekt',
+    noProject: 'Inget projekt',
     placeholders: {
-      notes: 'Lägg till anteckningar om denna utskrift...',
+      tags: 't.ex. funktionell, prototyp, present',
+      notes: 'Lägg till anteckningar om utskriften...',
     },
-    discardUpload: 'Kassera uppladdning',
-    archiveAllUploads: 'Arkivera alla uppladdningar',
-    discardAllUploads: 'Kassera alla uppladdningar',
-    archive: 'Arkivera',
-    timeAgo: {
-      justNow: 'Precis nu',
-      minutesAgo: '{{minutes}}m sedan',
-      hoursAgo: '{{hours}}t sedan',
-      daysAgo: '{{days}}d sedan',
+    toast: {
+      added: 'Lagd i filhanteraren: {{name}}',
+      addFailed: 'Kunde inte läggas i filhanteraren',
+      addedAll: '{{count}} filer lagda i filhanteraren',
+      addedAllSomeFailed: '{{count}} filer lagda i filhanteraren, {{failed}} misslyckades',
+      discarded: 'Uppladdning slängd',
+      discardedAll: '{{count}} filer slängda',
+      discardFailed: 'Kunde inte slängas',
     },
   },
 

+ 45 - 14
frontend/src/i18n/locales/tr.ts

@@ -6334,15 +6334,29 @@ export default {
     },
     mode: {
       title: 'Mod',
-      archive: 'Arşiv',
-      archiveDesc: 'Dosyaları hemen arşivle',
+      library: 'Dosya Yöneticisi',
+      libraryDesc: 'Dosyaları Dosya Yöneticisine kaydet',
       review: 'İnceleme',
-      reviewDesc: 'Arşivlemeden önce incele',
+      reviewDesc: 'Dosya Yöneticisine eklemeden önce incele',
       queue: 'Kuyruk',
-      queueDesc: 'Arşivle ve kuyruğa ekle',
+      queueDesc: 'Dosya Yöneticisi ve baskı kuyruğu',
       proxy: 'Proxy',
       proxyDesc: 'Gerçek yazıcıya ilet',
     },
+    uploadFolder: {
+      title: 'Dosya Yöneticisi klasörü',
+      default: 'Virtual Printer (paylaşılan)',
+      other: 'Ekleme yapamadığınız bir klasör',
+      hint: {
+        library: 'Bu yazıcıya gönderilen dosyalar bu klasöre kaydedilir.',
+        review: 'Onaylanan dosyalar bu klasöre kaydedilir.',
+        queue: 'Dosyalar bu klasöre kaydedilir ve oradan kuyruğa alınır. Dosya basıldığında arşive girer.',
+      },
+    },
+    removeAfterPrint: {
+      title: 'Basılınca Dosya Yöneticisinden kaldır',
+      description: 'Baskı başladığında dosya Dosya Yöneticisinden çıkar. Arşiv kendi kopyasını tutar.',
+    },
     autoDispatch: {
       title: 'Otomatik sevk',
       description: 'Kuyruğa eklendiğinde baskıları otomatik olarak başlat. Kapalıyken, baskılar manuel sevki bekler.',
@@ -7253,18 +7267,35 @@ export default {
 
   // Bekleyen Yüklemeler Paneli
   pendingUploads: {
+    title: 'Bekleyen yüklemeler ({{count}})',
+    description: 'Bu dosyalar inceleme modundaki bir sanal yazıcıdan geldi. Onları Dosya Yöneticisine ekleyin ya da atın.',
+    from: '{{ip}} adresinden',
+    addToFileManager: 'Dosya Yöneticisine ekle',
+    addAll: 'Tümünü ekle',
+    addAllUploads: 'Tüm yüklemeleri ekle',
+    addAllConfirm: 'Bekleyen {{count}} yüklemenin tümü Dosya Yöneticisine eklensin mi?',
+    discard: 'At',
+    discardUpload: 'Yüklemeyi at',
+    discardConfirm: '"{{name}}" atılsın mı? Bu geri alınamaz.',
+    discardAll: 'Tümünü at',
+    discardAllUploads: 'Tüm yüklemeleri at',
+    discardAllConfirm: 'Bekleyen {{count}} yüklemenin tümü atılsın mı? Bu geri alınamaz.',
+    tags: 'Etiketler',
+    notes: 'Notlar',
+    project: 'Proje',
+    noProject: 'Proje yok',
     placeholders: {
-      notes: 'Bu baskı hakkında notlar ekleyin...',
+      tags: 'örn. işlevsel, prototip, hediye',
+      notes: 'Bu baskı hakkında not ekleyin...',
     },
-    discardUpload: 'Yüklemeyi At',
-    archiveAllUploads: 'Tüm Yüklemeleri Arşivle',
-    discardAllUploads: 'Tüm Yüklemeleri At',
-    archive: 'Arşivle',
-    timeAgo: {
-      justNow: 'Az önce',
-      minutesAgo: '{{minutes}}dk önce',
-      hoursAgo: '{{hours}}sa önce',
-      daysAgo: '{{days}}g önce',
+    toast: {
+      added: 'Dosya Yöneticisine eklendi: {{name}}',
+      addFailed: 'Dosya Yöneticisine eklenemedi',
+      addedAll: '{{count}} dosya Dosya Yöneticisine eklendi',
+      addedAllSomeFailed: '{{count}} dosya Dosya Yöneticisine eklendi, {{failed}} başarısız',
+      discarded: 'Yükleme atıldı',
+      discardedAll: '{{count}} dosya atıldı',
+      discardFailed: 'Atılamadı',
     },
   },
 

+ 45 - 14
frontend/src/i18n/locales/uk.ts

@@ -6411,15 +6411,29 @@ export default {
     },
     mode: {
       title: "Режим",
-      archive: "Архів",
-      archiveDesc: "Одразу архівувати файли",
+      library: "Менеджер файлів",
+      libraryDesc: "Зберігати файли в менеджері файлів",
       review: "Перевірка",
-      reviewDesc: "Перевіряти перед архівуванням",
+      reviewDesc: "Перевіряти перед додаванням до менеджера файлів",
       queue: "Черга",
-      queueDesc: "Архівувати та додати в чергу",
+      queueDesc: "Менеджер файлів і черга друку",
       proxy: "Проксі",
       proxyDesc: "Пересилати на фізичний принтер",
     },
+    uploadFolder: {
+      title: "Папка в менеджері файлів",
+      default: "Virtual Printer (спільна)",
+      other: "Папка, до якої ви не можете додавати",
+      hint: {
+        library: "Файли, надіслані на цей принтер, зберігаються в цій папці.",
+        review: "Схвалені файли зберігаються в цій папці.",
+        queue: "Файли зберігаються в цій папці й стають у чергу звідти. Файл потрапляє до архіву, коли друкується.",
+      },
+    },
+    removeAfterPrint: {
+      title: "Видаляти з менеджера файлів після друку",
+      description: "Файл зникає з менеджера файлів, коли починається друк. В архіві лишається власна копія.",
+    },
     autoDispatch: {
       title: "Автоматичний запуск",
       description: "Автоматично запускати завдання після додавання до черги. Якщо вимкнено, завдання чекатимуть ручного запуску.",
@@ -7350,18 +7364,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: "Очікувані завантаження ({{count}})",
+    description: "Ці файли надійшли через віртуальний принтер у режимі перевірки. Додайте їх до менеджера файлів або відхиліть.",
+    from: "з {{ip}}",
+    addToFileManager: "До менеджера файлів",
+    addAll: "Додати всі",
+    addAllUploads: "Додати всі завантаження",
+    addAllConfirm: "Додати всі очікувані завантаження ({{count}}) до менеджера файлів?",
+    discard: "Відхилити",
+    discardUpload: "Відхилити завантаження",
+    discardConfirm: "Відхилити «{{name}}»? Це не можна скасувати.",
+    discardAll: "Відхилити всі",
+    discardAllUploads: "Відхилити всі завантаження",
+    discardAllConfirm: "Відхилити всі очікувані завантаження ({{count}})? Це не можна скасувати.",
+    tags: "Теги",
+    notes: "Нотатки",
+    project: "Проєкт",
+    noProject: "Без проєкту",
     placeholders: {
-      notes: "Додайте примітки щодо цього друку...",
+      tags: "наприклад, функціональна, прототип, подарунок",
+      notes: "Додайте нотатки про цей друк...",
     },
-    discardUpload: "Відхилити вивантаження",
-    archiveAllUploads: "Архівувати всі вивантаження",
-    discardAllUploads: "Відхилити всі вивантаження",
-    archive: "Архів",
-    timeAgo: {
-      justNow: "Просто зараз",
-      minutesAgo: "{{minutes}}хв тому",
-      hoursAgo: "{{hours}} год тому",
-      daysAgo: "{{days}} дн. тому",
+    toast: {
+      added: "Додано до менеджера файлів: {{name}}",
+      addFailed: "Не вдалося додати до менеджера файлів",
+      addedAll: "Файлів додано до менеджера файлів: {{count}}",
+      addedAllSomeFailed: "Файлів додано до менеджера файлів: {{count}}, з помилкою: {{failed}}",
+      discarded: "Завантаження відхилено",
+      discardedAll: "Відхилено файлів: {{count}}",
+      discardFailed: "Не вдалося відхилити",
     },
   },
 

+ 45 - 14
frontend/src/i18n/locales/zh-CN.ts

@@ -6357,15 +6357,29 @@ export default {
     },
     mode: {
       title: '模式',
-      archive: '归档',
-      archiveDesc: '立即归档文件',
+      library: '文件管理器',
+      libraryDesc: '将文件存入文件管理器',
       review: '审核',
-      reviewDesc: '归档前审核',
+      reviewDesc: '审核后再加入文件管理器',
       queue: '队列',
-      queueDesc: '归档并添加到队列',
+      queueDesc: '文件管理器和打印队列',
       proxy: '代理',
       proxyDesc: '中继到真实打印机',
     },
+    uploadFolder: {
+      title: '文件管理器文件夹',
+      default: 'Virtual Printer(共享)',
+      other: '你无法添加内容的文件夹',
+      hint: {
+        library: '发送到这台打印机的文件会存入此文件夹。',
+        review: '已批准的文件会存入此文件夹。',
+        queue: '文件存入此文件夹,并从这里加入队列。打印时文件才进入归档。',
+      },
+    },
+    removeAfterPrint: {
+      title: '打印后从文件管理器移除',
+      description: '打印开始时,文件会离开文件管理器。归档保留自己的副本。',
+    },
     autoDispatch: {
       title: '自动派发',
       description: '添加到队列时自动开始打印。关闭后,打印任务等待手动派发。',
@@ -7294,18 +7308,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
-    placeholders: {
-      notes: '添加关于此打印的备注...',
-    },
+    title: '待处理上传({{count}})',
+    description: '这些文件来自审核模式下的虚拟打印机。将它们加入文件管理器或丢弃。',
+    from: '来自 {{ip}}',
+    addToFileManager: '加入文件管理器',
+    addAll: '全部加入',
+    addAllUploads: '加入所有上传',
+    addAllConfirm: '将全部 {{count}} 个待处理上传加入文件管理器?',
+    discard: '丢弃',
     discardUpload: '丢弃上传',
-    archiveAllUploads: '归档所有上传',
+    discardConfirm: '丢弃"{{name}}"?此操作无法撤销。',
+    discardAll: '全部丢弃',
     discardAllUploads: '丢弃所有上传',
-    archive: '归档',
-    timeAgo: {
-      justNow: '刚刚',
-      minutesAgo: '{{minutes}} 分钟前',
-      hoursAgo: '{{hours}} 小时前',
-      daysAgo: '{{days}} 天前',
+    discardAllConfirm: '丢弃全部 {{count}} 个待处理上传?此操作无法撤销。',
+    tags: '标签',
+    notes: '备注',
+    project: '项目',
+    noProject: '无项目',
+    placeholders: {
+      tags: '例如:功能件, 原型, 礼物',
+      notes: '为此打印添加备注...',
+    },
+    toast: {
+      added: '已加入文件管理器:{{name}}',
+      addFailed: '无法加入文件管理器',
+      addedAll: '已将 {{count}} 个文件加入文件管理器',
+      addedAllSomeFailed: '已将 {{count}} 个文件加入文件管理器,{{failed}} 个失败',
+      discarded: '已丢弃上传',
+      discardedAll: '已丢弃 {{count}} 个文件',
+      discardFailed: '无法丢弃',
     },
   },
 

+ 45 - 14
frontend/src/i18n/locales/zh-TW.ts

@@ -6357,15 +6357,29 @@ export default {
     },
     mode: {
       title: '模式',
-      archive: '歸檔',
-      archiveDesc: '立即歸檔檔案',
+      library: '檔案管理器',
+      libraryDesc: '將檔案存入檔案管理器',
       review: '審核',
-      reviewDesc: '歸檔前審核',
+      reviewDesc: '審核後再加入檔案管理器',
       queue: '佇列',
-      queueDesc: '歸檔並新增到佇列',
+      queueDesc: '檔案管理器與列印佇列',
       proxy: '代理',
       proxyDesc: '中繼到真實印表機',
     },
+    uploadFolder: {
+      title: '檔案管理器資料夾',
+      default: 'Virtual Printer(共用)',
+      other: '你無法加入內容的資料夾',
+      hint: {
+        library: '傳送到這台印表機的檔案會存入此資料夾。',
+        review: '已核准的檔案會存入此資料夾。',
+        queue: '檔案存入此資料夾,並從這裡加入佇列。列印時檔案才進入封存。',
+      },
+    },
+    removeAfterPrint: {
+      title: '列印後從檔案管理器移除',
+      description: '列印開始時,檔案會離開檔案管理器。封存保留自己的副本。',
+    },
     autoDispatch: {
       title: '自動派發',
       description: '新增到佇列時自動開始列印。關閉後,列印任務等待手動派發。',
@@ -7294,18 +7308,35 @@ export default {
 
   // Pending Uploads Panel
   pendingUploads: {
+    title: '待處理上傳({{count}})',
+    description: '這些檔案來自審核模式下的虛擬印表機。將它們加入檔案管理器或捨棄。',
+    from: '來自 {{ip}}',
+    addToFileManager: '加入檔案管理器',
+    addAll: '全部加入',
+    addAllUploads: '加入所有上傳',
+    addAllConfirm: '將全部 {{count}} 個待處理上傳加入檔案管理器?',
+    discard: '捨棄',
+    discardUpload: '捨棄上傳',
+    discardConfirm: '捨棄「{{name}}」?此動作無法復原。',
+    discardAll: '全部捨棄',
+    discardAllUploads: '捨棄所有上傳',
+    discardAllConfirm: '捨棄全部 {{count}} 個待處理上傳?此動作無法復原。',
+    tags: '標籤',
+    notes: '備註',
+    project: '專案',
+    noProject: '無專案',
     placeholders: {
-      notes: '新增關於此列印的備註...',
+      tags: '例如:功能件, 原型, 禮物',
+      notes: '為此列印新增備註...',
     },
-    discardUpload: '丟棄上傳',
-    archiveAllUploads: '歸檔所有上傳',
-    discardAllUploads: '丟棄所有上傳',
-    archive: '歸檔',
-    timeAgo: {
-      justNow: '剛剛',
-      minutesAgo: '{{minutes}} 分鐘前',
-      hoursAgo: '{{hours}} 小時前',
-      daysAgo: '{{days}} 天前',
+    toast: {
+      added: '已加入檔案管理器:{{name}}',
+      addFailed: '無法加入檔案管理器',
+      addedAll: '已將 {{count}} 個檔案加入檔案管理器',
+      addedAllSomeFailed: '已將 {{count}} 個檔案加入檔案管理器,{{failed}} 個失敗',
+      discarded: '已捨棄上傳',
+      discardedAll: '已捨棄 {{count}} 個檔案',
+      discardFailed: '無法捨棄',
     },
   },
 

+ 0 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -101,7 +101,6 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { ArchiveMediaDownloadModal } from '../components/ArchiveMediaDownloadModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
-import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { TagManagementModal } from '../components/TagManagementModal';
 import { PlatePickerModal } from '../components/PlatePickerModal';
 import type { PlateMetadata } from '../types/plates';
@@ -4536,9 +4535,6 @@ export function ArchivesPage() {
         </CardContent>
       </Card>}
 
-      {/* Pending Uploads Panel (visible when in queue mode with pending files) */}
-      <PendingUploadsPanel />
-
       {/* Archives */}
       {isLoading ? (
         <div className="text-center py-12 text-bambu-gray">{t('archives.loadingArchives')}</div>

+ 5 - 0
frontend/src/pages/FileManagerPage.tsx

@@ -93,6 +93,7 @@ import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/sl
 import { isSlicedLibraryFile, isSliceableLibraryFile } from '../utils/libraryFiles';
 import { useUiSettings } from '../hooks/useUiSettings';
 import { FOLDER_DRAG_TYPE, isValidFolderMove, isWritableFolder, moveDisallowedFolderIds } from '../utils/libraryFolders';
+import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -3194,6 +3195,10 @@ export function FileManagerPage() {
         </div>
       )}
 
+      {/* Uploads from a review-mode virtual printer, waiting for approval;
+          approving stores them in the File Manager. Renders nothing when none. */}
+      <PendingUploadsPanel />
+
       {/* Main content */}
       <div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0">
         {/* Mobile folder selector */}

File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-BT2XUk6S.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-ByPKTG9t.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-DRDGoScq.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-DYByvitA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfirmOutcomeDialog-CBgZux_i.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-CGQxvG5e.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/FileManagerPage-DEXA171M.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-CrONTfif.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/Layout-heUoL0ca.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-C9o7oXPY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintModal-CkCV3RPN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintModal-oBcvLlYd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-BLQFYEZx.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-FlECLtvI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-Db-o5Z__.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-BkJXXimU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-C4uiC0kE.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-CZlRSTOS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-B3Mgfq_e.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-DPeBuglW.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BWe0L8oA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/de-DU-G_Teg.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/es-C2Ko7tyR.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/fr-DMHDjQWT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BfS_79_Q.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/it-C-FKwP1C.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ja-wAtN4Slu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ko-BCDNJZ63.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-DVHuVP2r.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/nl-Dx7NH6eG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pl-BRfMHxLJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pt-BR-BbAdMvr-.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ru-Cp0AuFM6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/spoolCost-BFPKTGkS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/sv-oWe1sIWP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/tr-BHcKh5-R.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/uk-DXBSI5eO.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/usePageFileDrop-Dzw3tEhV.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-CN-UR_WqJm9.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-TW-MUDa8Ggq.js


Some files were not shown because too many files changed in this diff