Browse Source

● feat(#1008): honour reprint dates in archive purge + clarify purge UX

  Fix a silent correctness bug: archive purge used `created_at` which is
  pinned to the first print, so reprinting a two-year-old archive yesterday
  would still make it eligible for a 365-day purge. The preview and purge
  queries now age each archive by `COALESCE(completed_at, started_at,
  created_at)` — reprints refresh the clock.

  Also flesh out both purge modals (File Manager + Archives) with an
  explicit "What happens when you click Purge" effects list so users see
  upfront that library files go to Trash (reversible) while archives are
  hard-deleted (irreversible), plus what disk artefacts get removed.

  Backend:
  - services/archive_purge.py: `_last_activity_expr()` helper used by
    preview, purge, and sample query
  - tests/integration/test_archive_purge_api.py: new test covering the
    reprinted-archive case

  Frontend:
  - PurgeOldFilesModal / PurgeArchivesModal: new effects bullet list
  - i18n: reprint-aware ageLabel/description/warning and effects bullets
    across all 8 locales (en/de fully translated, rest English fallback)

  Docs:
  - wiki/features/archiving.md: "How old is measured" note + effects list
  - wiki/features/file-manager.md: "What happens when you click Purge"
    section + explicit age-rule breakdown
  - CHANGELOG: archive auto-purge entry rewritten to mention reprint
    semantics, `archives:purge` permission backfill, and updated test count
maziggy 1 month ago
parent
commit
689bc04d7b

+ 1 - 1
CHANGELOG.md

@@ -6,7 +6,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### Added
 - **Library Trash Bin + Admin Bulk Purge + Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than *N* days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new **Auto-purge** setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New `library:purge` permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable `deleted_at` column on `library_files` with an index (dialect-aware migration: `DATETIME` on SQLite, `TIMESTAMP` on PostgreSQL, since raw `DATETIME` is SQLite-only syntax); every `LibraryFile` query site now routes through a new `LibraryFile.active()` classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec.
-- **Archive Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008) follow-up) — Settings → Archives now has an auto-purge toggle that hard-deletes print archives older than a configurable age threshold (default 365 days, min 7, max 10 years) plus a **Purge archives now** button with the same live-preview modal as the library purge. Unlike the library trash, archives are hard-deleted — print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checked `ArchiveService.delete_archive` path so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Admin-only (reuses `archives:delete_all`); 8 new backend integration tests; localised across all 8 UI languages.
+- **Archive Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008) follow-up) — Settings → Archives now has an auto-purge toggle plus a **Purge archives now** action on the Archives page header (next to Upload 3MF, mirroring File Manager's placement) that hard-deletes print archives not printed within a configurable window (default 365 days, min 7, max 10 years) with the same live-preview modal as the library purge. Reprinting an archive reuses the row and updates its `completed_at`, so the purge honours the **most recent print completion** — a two-year-old archive you reprinted yesterday is not eligible for deletion. Unlike the library trash, archives are hard-deleted: print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checked `ArchiveService.delete_archive` path so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Gated by a new dedicated `archives:purge` permission (Administrators group by default, backfilled on upgrade); 9 new backend integration tests; localised across all 8 UI languages.
 - **MakerWorld Integration** — Paste any `makerworld.com/models/…` URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click **Save** or **Save & Slice in Bambu Studio / OrcaSlicer** per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. `LibraryFile` now tracks `source_type` + `source_url`, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links).
   **Endpoint route (non-obvious, ~1 day of reverse engineering)** — Pr0zak/YASTL#51 documented that `makerworld.com`-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at `api.bambulab.com`. The working path turned out to be `GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId}` with `Authorization: Bearer <cloud_token>` — a different service (`iot-service`, not `design-service`) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (``s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…``). The `modelId` query param is the alphanumeric identifier (e.g. ``US2bb73b106683e5``) that only appears in the design response body, *not* the integer ``designId`` from the ``/models/{N}`` URL — so the import flow fetches design metadata first, reads `modelId`, then calls iot-service. S3 presigned URLs must be fetched with ``urllib.request`` (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with ``SignatureDoesNotMatch`` 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path.
   **UI and UX features** — per-plate picker with inline **Save** / **Save & Slice in Bambu Studio / OrcaSlicer** buttons, **Import all** to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (**View in File Manager** / **Open in Bambu Studio** / **Open in OrcaSlicer** / **Remove from library**), per-plate delete via the standard Bambuddy confirm modal (no browser `confirm()`), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (`/library/files/{id}/dl/{token}/{filename}`) so the handoff works even with auth enabled. Localised across all eight UI languages.

+ 18 - 3
backend/app/services/archive_purge.py

@@ -41,6 +41,20 @@ def _age_cutoff(now: datetime, older_than_days: int) -> datetime:
     return now - timedelta(days=older_than_days)
 
 
+def _last_activity_expr():
+    """Most-recent timestamp on an archive row.
+
+    Reprints reuse the archive row and update ``completed_at``/``started_at`` but
+    leave ``created_at`` pinned to the first print, so purging on ``created_at``
+    would evict recently-reprinted archives. Use the latest of the three instead.
+    """
+    return func.coalesce(
+        PrintArchive.completed_at,
+        PrintArchive.started_at,
+        PrintArchive.created_at,
+    )
+
+
 class ArchivePurgeService:
     """Manages archive auto-purge sweeper + admin-triggered manual purges."""
 
@@ -161,7 +175,8 @@ class ArchivePurgeService:
             }
         now = datetime.now(timezone.utc)
         cutoff = _age_cutoff(now, older_than_days)
-        clause = PrintArchive.created_at < cutoff
+        last_activity = _last_activity_expr()
+        clause = last_activity < cutoff
 
         count_result = await db.execute(select(func.count(PrintArchive.id)).where(clause))
         count = int(count_result.scalar() or 0)
@@ -170,7 +185,7 @@ class ArchivePurgeService:
         total_bytes = int(size_result.scalar() or 0)
 
         sample_result = await db.execute(
-            select(PrintArchive.filename).where(clause).order_by(PrintArchive.created_at).limit(sample_limit)
+            select(PrintArchive.filename).where(clause).order_by(last_activity).limit(sample_limit)
         )
         samples = [row[0] for row in sample_result.all()]
 
@@ -195,7 +210,7 @@ class ArchivePurgeService:
         now = datetime.now(timezone.utc)
         cutoff = _age_cutoff(now, older_than_days)
 
-        id_result = await db.execute(select(PrintArchive.id).where(PrintArchive.created_at < cutoff))
+        id_result = await db.execute(select(PrintArchive.id).where(_last_activity_expr() < cutoff))
         ids = [row[0] for row in id_result.all()]
         if not ids:
             return 0

+ 21 - 0
backend/tests/integration/test_archive_purge_api.py

@@ -64,6 +64,27 @@ async def test_preview_counts_old_archives(async_client: AsyncClient, archive_fa
     assert "Old" in body["sample_filenames"][0] or old.filename in body["sample_filenames"]
 
 
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_preview_ignores_recently_reprinted_archives(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """Reprints update completed_at but leave created_at pinned; purge must honour that."""
+    printer = await printer_factory()
+    reprinted = await archive_factory(printer.id, print_name="Reprinted", file_size=1000)
+
+    # Originally printed 400 days ago, but a reprint last week refreshed completed_at.
+    reprinted.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    reprinted.started_at = datetime.now(timezone.utc) - timedelta(days=7)
+    reprinted.completed_at = datetime.now(timezone.utc) - timedelta(days=7)
+    await db_session.commit()
+
+    resp = await async_client.get("/api/v1/archives/purge/preview?older_than_days=365")
+    assert resp.status_code == 200
+    body = resp.json()
+    assert body["count"] == 0
+
+
 @pytest.mark.asyncio
 @pytest.mark.integration
 async def test_manual_purge_deletes_old_archives(

+ 12 - 0
frontend/src/components/PurgeArchivesModal.tsx

@@ -99,6 +99,18 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
             </div>
           </div>
 
+          <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30 p-3">
+            <div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
+              {t('archivePurge.effectsTitle')}
+            </div>
+            <ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1 list-disc pl-4">
+              <li>{t('archivePurge.effect1')}</li>
+              <li>{t('archivePurge.effect2')}</li>
+              <li>{t('archivePurge.effect3')}</li>
+              <li>{t('archivePurge.effect4')}</li>
+            </ul>
+          </div>
+
           <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-3">
             {previewQuery.isLoading || previewQuery.isFetching ? (
               <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">

+ 12 - 0
frontend/src/components/PurgeOldFilesModal.tsx

@@ -112,6 +112,18 @@ export function PurgeOldFilesModal({ onClose }: PurgeOldFilesModalProps) {
             {t('libraryPurge.includeNeverPrinted')}
           </label>
 
+          <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30 p-3">
+            <div className="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 mb-2">
+              {t('libraryPurge.effectsTitle')}
+            </div>
+            <ul className="text-xs text-gray-700 dark:text-gray-300 space-y-1 list-disc pl-4">
+              <li>{t('libraryPurge.effect1')}</li>
+              <li>{t('libraryPurge.effect2')}</li>
+              <li>{t('libraryPurge.effect3')}</li>
+              <li>{t('libraryPurge.effect4')}</li>
+            </ul>
+          </div>
+
           <div className="rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-3">
             {previewQuery.isLoading || previewQuery.isFetching ? (
               <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">

+ 18 - 8
frontend/src/i18n/locales/de.ts

@@ -5196,15 +5196,20 @@ export default {
     title: 'Alte Dateien entfernen',
     headerButton: 'Alte entfernen',
     headerTooltip: 'Alte Dateien im Block in den Papierkorb verschieben',
-    description: 'Dateien, die älter als die gewählte Schwelle sind, werden in den Papierkorb verschoben. Externe Ordner werden übersprungen. Vor dem automatischen Löschen können Dateien aus dem Papierkorb wiederhergestellt werden.',
+    description: 'Räume alte Dateien in einem Rutsch aus deiner Bibliothek. Dateien mit Druckverlauf werden nach dem letzten Druckdatum bewertet; nie gedruckte Dateien nach dem Upload-Datum.',
     ageLabel: 'Dateien älter als',
     days: 'Tage',
     includeNeverPrinted: 'Dateien einbeziehen, die nie gedruckt wurden',
+    effectsTitle: 'Was passiert, wenn du auf „Entfernen" klickst',
+    effect1: 'Passende Dateien werden in den Papierkorb verschoben — noch nicht von der Festplatte gelöscht.',
+    effect2: 'Du kannst sie bis zum Ablauf der Aufbewahrungsfrist jederzeit wiederherstellen.',
+    effect3: 'Nach Ablauf der Frist löscht der Papierkorb-Sweeper sie endgültig von der Festplatte.',
+    effect4: 'Dateien in externen (verknüpften) Ordnern werden übersprungen — Bambuddy löscht keine Bytes, die ihm nicht gehören.',
     previewLoading: 'Prüfe, wie viele Dateien passen…',
     previewFailed: 'Vorschau konnte nicht geladen werden.',
     previewSummary: '{{count}} Dateien · {{size}} würden in den Papierkorb verschoben',
     andMore: '…und {{count}} weitere',
-    warning: 'Dateien werden nur in den Papierkorb verschoben — bis zum Ablauf der Aufbewahrungsfrist kannst du sie wiederherstellen.',
+    warning: 'Dateien im Papierkorb zählen weiterhin zum Speicher, bis die Aufbewahrungsfrist abgelaufen ist. Leere den Papierkorb danach, um sofort Speicher freizugeben.',
     confirmCta: '{{count}} in den Papierkorb verschieben',
     purging: 'Wird verschoben…',
     toast: {
@@ -5225,14 +5230,19 @@ export default {
     headerButton: 'Alte löschen',
     headerTooltip: 'Alte Archive in einem Rutsch löschen',
     title: 'Alte Archive löschen',
-    description: 'Archive, die älter als der Schwellenwert sind, werden dauerhaft gelöscht — inklusive Dateien, Vorschaubildern und Timelapses. Kann nicht rückgängig gemacht werden.',
-    ageLabel: 'Archive löschen, die älter sind als',
+    description: 'Räume alte Druckhistorie auf. Jedes Archiv wird nach seinem letzten abgeschlossenen Druck bewertet — ein erneuter Druck setzt die Frist zurück, aktive Arbeit bleibt sicher.',
+    ageLabel: 'Archive löschen, die zuletzt gedruckt wurden vor',
     days: 'Tage',
+    effectsTitle: 'Was passiert, wenn du auf „Löschen" klickst',
+    effect1: 'Jedes passende Archiv wird endgültig aus der Datenbank entfernt.',
+    effect2: 'Die 3MF-Datei, Vorschau, Timelapse, Quell-3MF, F3D-Designdatei und der Foto-Ordner werden von der Festplatte gelöscht.',
+    effect3: 'Es gibt keinen Papierkorb für Archive — die Löschung ist sofort und unwiderruflich.',
+    effect4: 'Ein erneuter Druck setzt die Frist zurück, Archive, die du weiterhin nutzt, bleiben sicher.',
     previewLoading: 'Prüfe passende Archive…',
     previewFailed: 'Vorschau konnte nicht erstellt werden.',
     previewSummary: '{{count}} Archive · {{size}} werden gelöscht',
     andMore: '…und {{count}} weitere',
-    warning: 'Archive werden endgültig gelöscht — es gibt keinen Papierkorb für Archive. Lade wichtige Archive vorher herunter oder markiere sie als Favorit.',
+    warning: 'Dies ist endgültig. Lade wichtige Archive vorher herunter oder markiere sie als Favorit.',
     confirmCta: '{{count}} Archiv(e) löschen',
     purging: 'Lösche…',
     toast: {
@@ -5242,9 +5252,9 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: 'Alte Archive automatisch löschen',
-    enableDescription: 'Löscht einmal pro Tag Archive, die älter als der Schwellenwert sind, endgültig. Kein Papierkorb — die Löschung erfolgt sofort.',
-    ageLabel: 'Archive automatisch löschen, die älter sind als',
-    ageDescription: 'Minimum 7 Tage, Maximum 10 Jahre. Löscht Archiv, 3MF, Vorschaubild, Timelapse und Fotos.',
+    enableDescription: 'Löscht einmal pro Tag Archive endgültig, die im angegebenen Zeitraum nicht gedruckt wurden. Ein erneuter Druck setzt die Frist zurück. Kein Papierkorb — die Löschung erfolgt sofort.',
+    ageLabel: 'Archive automatisch löschen, die zuletzt gedruckt wurden vor',
+    ageDescription: 'Minimum 7 Tage, Maximum 10 Jahre. Basiert auf dem letzten abgeschlossenen Druck — ein erneuter Druck setzt die Frist zurück. Löscht Archiv, 3MF, Vorschaubild, Timelapse und Fotos.',
     days: 'Tage',
     runNow: 'Archive jetzt löschen',
     saveFailed: 'Einstellungen konnten nicht gespeichert werden.',

+ 18 - 8
frontend/src/i18n/locales/en.ts

@@ -5204,15 +5204,20 @@ export default {
     title: 'Purge old files',
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-move old files to trash',
-    description: 'Files older than the threshold will be moved to the trash. External folders are skipped. You can restore from trash before auto-deletion.',
+    description: 'Sweep old files out of your library in one shot. Files with a print history are aged by their last-printed date; files that were never printed are aged by their upload date.',
     ageLabel: 'Move files older than',
     days: 'days',
     includeNeverPrinted: 'Include files that have never been printed',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
+    effect2: 'You can restore them from Trash at any time until the retention window expires.',
+    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
+    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',
     previewLoading: 'Checking how many files match…',
     previewFailed: 'Could not preview the purge.',
     previewSummary: '{{count}} files · {{size}} would move to trash',
     andMore: '…and {{count}} more',
-    warning: 'Files are soft-deleted — you can restore them from Trash until the retention window expires.',
+    warning: 'Trashed files still count against storage until the retention window expires. Empty the Trash afterwards to free disk immediately.',
     confirmCta: 'Move {{count}} to trash',
     purging: 'Moving to trash…',
     toast: {
@@ -5233,14 +5238,19 @@ export default {
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-delete old archives',
     title: 'Purge old archives',
-    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
-    ageLabel: 'Delete archives older than',
+    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
+    ageLabel: 'Delete archives not printed in the last',
     days: 'days',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Each matching archive is permanently removed from the database.',
+    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
+    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
+    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
     previewLoading: 'Checking how many archives match…',
     previewFailed: 'Could not preview the purge.',
     previewSummary: '{{count}} archives · {{size}} would be deleted',
     andMore: '…and {{count}} more',
-    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
     confirmCta: 'Delete {{count}} archive(s)',
     purging: 'Deleting…',
     toast: {
@@ -5250,9 +5260,9 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives older than',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives not printed in the last',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
     days: 'days',
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',

+ 16 - 7
frontend/src/i18n/locales/fr.ts

@@ -5113,7 +5113,11 @@ export default {
     ageLabel: 'Déplacer les fichiers plus anciens que',
     days: 'jours',
     includeNeverPrinted: 'Inclure les fichiers jamais imprimés',
-    previewLoading: 'Vérification du nombre de fichiers concernés…',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
+    effect2: 'You can restore them from Trash at any time until the retention window expires.',
+    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
+    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: 'Vérification du nombre de fichiers concernés…',
     previewFailed: 'Impossible de prévisualiser la purge.',
     previewSummary: '{{count}} fichiers · {{size}} seraient déplacés vers la corbeille',
     andMore: '…et {{count}} de plus',
@@ -5138,14 +5142,19 @@ export default {
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-delete old archives',
     title: 'Purge old archives',
-    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
-    ageLabel: 'Delete archives older than',
+    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
+    ageLabel: 'Delete archives not printed in the last',
     days: 'days',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Each matching archive is permanently removed from the database.',
+    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
+    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
+    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
     previewLoading: 'Checking how many archives match…',
     previewFailed: 'Could not preview the purge.',
     previewSummary: '{{count}} archives · {{size}} would be deleted',
     andMore: '…and {{count}} more',
-    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
     confirmCta: 'Delete {{count}} archive(s)',
     purging: 'Deleting…',
     toast: {
@@ -5155,9 +5164,9 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives older than',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives not printed in the last',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
     days: 'days',
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',

+ 16 - 7
frontend/src/i18n/locales/it.ts

@@ -5112,7 +5112,11 @@ export default {
     ageLabel: 'Sposta i file più vecchi di',
     days: 'giorni',
     includeNeverPrinted: 'Includi i file mai stampati',
-    previewLoading: 'Verifica quanti file corrispondono…',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
+    effect2: 'You can restore them from Trash at any time until the retention window expires.',
+    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
+    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: 'Verifica quanti file corrispondono…',
     previewFailed: 'Impossibile mostrare l\'anteprima.',
     previewSummary: '{{count}} file · {{size}} verrebbero spostati nel cestino',
     andMore: '…e altri {{count}}',
@@ -5137,14 +5141,19 @@ export default {
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-delete old archives',
     title: 'Purge old archives',
-    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
-    ageLabel: 'Delete archives older than',
+    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
+    ageLabel: 'Delete archives not printed in the last',
     days: 'days',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Each matching archive is permanently removed from the database.',
+    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
+    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
+    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
     previewLoading: 'Checking how many archives match…',
     previewFailed: 'Could not preview the purge.',
     previewSummary: '{{count}} archives · {{size}} would be deleted',
     andMore: '…and {{count}} more',
-    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
     confirmCta: 'Delete {{count}} archive(s)',
     purging: 'Deleting…',
     toast: {
@@ -5154,9 +5163,9 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives older than',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives not printed in the last',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
     days: 'days',
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',

+ 16 - 7
frontend/src/i18n/locales/ja.ts

@@ -5151,7 +5151,11 @@ export default {
     ageLabel: '次より古いファイルを移動',
     days: '日',
     includeNeverPrinted: '一度も印刷していないファイルも含める',
-    previewLoading: '対象ファイル数を確認中…',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
+    effect2: 'You can restore them from Trash at any time until the retention window expires.',
+    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
+    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: '対象ファイル数を確認中…',
     previewFailed: 'プレビューを取得できませんでした。',
     previewSummary: '{{count}} 件 · {{size}} がゴミ箱に移動されます',
     andMore: '…ほか {{count}} 件',
@@ -5176,14 +5180,19 @@ export default {
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-delete old archives',
     title: 'Purge old archives',
-    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
-    ageLabel: 'Delete archives older than',
+    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
+    ageLabel: 'Delete archives not printed in the last',
     days: 'days',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Each matching archive is permanently removed from the database.',
+    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
+    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
+    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
     previewLoading: 'Checking how many archives match…',
     previewFailed: 'Could not preview the purge.',
     previewSummary: '{{count}} archives · {{size}} would be deleted',
     andMore: '…and {{count}} more',
-    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
     confirmCta: 'Delete {{count}} archive(s)',
     purging: 'Deleting…',
     toast: {
@@ -5193,9 +5202,9 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives older than',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives not printed in the last',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
     days: 'days',
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',

+ 16 - 7
frontend/src/i18n/locales/pt-BR.ts

@@ -5126,7 +5126,11 @@ export default {
     ageLabel: 'Mover arquivos mais antigos que',
     days: 'dias',
     includeNeverPrinted: 'Incluir arquivos que nunca foram impressos',
-    previewLoading: 'Verificando quantos arquivos correspondem…',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
+    effect2: 'You can restore them from Trash at any time until the retention window expires.',
+    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
+    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: 'Verificando quantos arquivos correspondem…',
     previewFailed: 'Não foi possível pré-visualizar a limpeza.',
     previewSummary: '{{count}} arquivos · {{size}} seriam movidos para a lixeira',
     andMore: '…e mais {{count}}',
@@ -5151,14 +5155,19 @@ export default {
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-delete old archives',
     title: 'Purge old archives',
-    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
-    ageLabel: 'Delete archives older than',
+    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
+    ageLabel: 'Delete archives not printed in the last',
     days: 'days',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Each matching archive is permanently removed from the database.',
+    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
+    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
+    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
     previewLoading: 'Checking how many archives match…',
     previewFailed: 'Could not preview the purge.',
     previewSummary: '{{count}} archives · {{size}} would be deleted',
     andMore: '…and {{count}} more',
-    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
     confirmCta: 'Delete {{count}} archive(s)',
     purging: 'Deleting…',
     toast: {
@@ -5168,9 +5177,9 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives older than',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives not printed in the last',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
     days: 'days',
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',

+ 16 - 7
frontend/src/i18n/locales/zh-CN.ts

@@ -5190,7 +5190,11 @@ export default {
     ageLabel: '移动早于以下天数的文件',
     days: '天',
     includeNeverPrinted: '包括从未打印过的文件',
-    previewLoading: '正在检查匹配的文件数量…',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
+    effect2: 'You can restore them from Trash at any time until the retention window expires.',
+    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
+    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: '正在检查匹配的文件数量…',
     previewFailed: '无法预览清理结果。',
     previewSummary: '{{count}} 个文件 · {{size}} 将被移至回收站',
     andMore: '…还有 {{count}} 个',
@@ -5215,14 +5219,19 @@ export default {
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-delete old archives',
     title: 'Purge old archives',
-    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
-    ageLabel: 'Delete archives older than',
+    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
+    ageLabel: 'Delete archives not printed in the last',
     days: 'days',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Each matching archive is permanently removed from the database.',
+    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
+    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
+    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
     previewLoading: 'Checking how many archives match…',
     previewFailed: 'Could not preview the purge.',
     previewSummary: '{{count}} archives · {{size}} would be deleted',
     andMore: '…and {{count}} more',
-    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
     confirmCta: 'Delete {{count}} archive(s)',
     purging: 'Deleting…',
     toast: {
@@ -5232,9 +5241,9 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives older than',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives not printed in the last',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
     days: 'days',
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',

+ 16 - 7
frontend/src/i18n/locales/zh-TW.ts

@@ -5190,7 +5190,11 @@ export default {
     ageLabel: '移動早於以下天數的檔案',
     days: '天',
     includeNeverPrinted: '包括從未列印過的檔案',
-    previewLoading: '正在檢查符合的檔案數量…',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Matching files are moved to Trash — they are not deleted from disk yet.',
+    effect2: 'You can restore them from Trash at any time until the retention window expires.',
+    effect3: 'After retention, the trash sweeper permanently removes them from disk.',
+    effect4: 'Files in external (linked) folders are skipped — Bambuddy never deletes bytes it does not own.',    previewLoading: '正在檢查符合的檔案數量…',
     previewFailed: '無法預覽清理結果。',
     previewSummary: '{{count}} 個檔案 · {{size}} 將被移至資源回收筒',
     andMore: '…還有 {{count}} 個',
@@ -5215,14 +5219,19 @@ export default {
     headerButton: 'Purge old',
     headerTooltip: 'Bulk-delete old archives',
     title: 'Purge old archives',
-    description: 'Archives older than the threshold will be permanently deleted along with their files, thumbnails, and timelapses. This cannot be undone.',
-    ageLabel: 'Delete archives older than',
+    description: 'Clear out old print history. Each archive is aged by its most recent print completion — reprinting an archive refreshes its age, so active work is never purged.',
+    ageLabel: 'Delete archives not printed in the last',
     days: 'days',
+    effectsTitle: 'What happens when you click Purge',
+    effect1: 'Each matching archive is permanently removed from the database.',
+    effect2: 'The 3MF, thumbnail, timelapse, source 3MF, F3D design file, and photo folder are all deleted from disk.',
+    effect3: 'There is no trash bin for archives — deletion is immediate and cannot be undone.',
+    effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
     previewLoading: 'Checking how many archives match…',
     previewFailed: 'Could not preview the purge.',
     previewSummary: '{{count}} archives · {{size}} would be deleted',
     andMore: '…and {{count}} more',
-    warning: 'Archives are hard-deleted — there is no trash bin for archives. Download or favourite anything you want to keep first.',
+    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
     confirmCta: 'Delete {{count}} archive(s)',
     purging: 'Deleting…',
     toast: {
@@ -5232,9 +5241,9 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: 'Auto-purge old archives',
-    enableDescription: 'Once per day, permanently deletes archives older than the threshold. No trash bin — deletion is immediate.',
-    ageLabel: 'Auto-delete archives older than',
-    ageDescription: 'Minimum 7 days, maximum 10 years. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
+    enableDescription: 'Once per day, permanently deletes archives that have not been printed within the threshold. Reprinting an archive resets the clock. No trash bin — deletion is immediate.',
+    ageLabel: 'Auto-delete archives not printed in the last',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Deletes the archive, 3MF, thumbnail, timelapse, and photos.',
     days: 'days',
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',

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


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


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CHfSxZ0B.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-ByQZwYKW.css">
+    <script type="module" crossorigin src="/assets/index-D6O7X0cZ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DSTnupH9.css">
   </head>
   <body>
     <div id="root"></div>

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