Ver código fonte

fix(archives): bulk and auto purge now honour the soft / hard delete choice from #1343 (#1390 follow-up)

  Reporter IndividualGhost1905 followed up after the #1378 / #1343
  backfill landed and pointed at the next inconsistency: the per-
  archive delete dialog has had a "Also remove this print from Quick
  Stats" checkbox since #1343, but the "Purge Old" button and the
  scheduled daily auto-purge sweeper both ignored that choice and
  hard-deleted unconditionally. From the user side this looked like
  "automatically deleted from statistics without any warning" — half-
  true, and the inconsistency was real either way.

  The actual current shape (before this fix):

    - POST /archives/purge -> archive_purge_service.purge_older_than
      -> ArchiveService.delete_archive (hard). Archive row dropped.
      Linked PrintLogEntry rows have ON DELETE SET NULL so they
      survive as orphans with archive_id=NULL. Quick Stats keeps the
      filament / cost / energy contribution because the log rows are
      still there, but the archive-list-iterating widgets (Filament
      Trends, By Material, Color Distribution, Printer Stats) lose
      the row, and Time Accuracy loses its join target. Visibly
      inconsistent.
    - Scheduled _maybe_run_auto_purge -> same code path, same effect.
    - Single-archive DELETE /archives/{id} -> already takes
      purge_stats=true|false (default false=soft) and routes either
      soft_delete_archive (keeps everything, flips deleted_at) or
      deletes PrintLogEntry rows first + hard-deletes archive.

  The fix threads the same purge_stats flag through every bulk surface
  with soft as the default, matching the single-archive default:

  Backend:
    - archive_purge_service.purge_older_than(..., purge_stats=False)
      -> per-row soft_delete_archive when False, per-row
      PrintLogEntry deletion + delete_archive when True. Each runs in
      its own session (same pattern the sweeper already used).
    - preview_purge gains the same kwarg so the eligible-count
      matches what an actual run would touch: soft mode excludes
      already-soft-deleted rows, hard mode counts them as eligible
      for promotion.
    - get_settings / set_settings now persist archive_auto_purge_stats
      (default False). _maybe_run_auto_purge reads it on every tick.
    - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings
      schemas extended.
    - Route /archives/purge accepts the body flag, /purge/preview
      accepts the query param, /purge/settings GET + PUT echo the
      setting.

  Frontend:
    - "Purge old archives" modal: new "Also remove from statistics"
      checkbox under the preview, unchecked by default. Plumbed into
      the preview query key + the execute mutation.
    - Settings -> Archives auto-purge card: matching toggle next to
      the days slider, disabled when auto-purge itself is off.
    - api.previewArchivePurge / api.executeArchivePurge accept the
      flag; ArchivePurgeSettings type gains purge_stats.
    - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get
      new purgeStatsLabel / purgeStatsHint / purgeStatsDescription
      keys, plus rewritten effect / warning copy in archivePurge and
      archiveAutoPurge to reflect that the default no longer
      "permanently removes from the database" but instead hides the
      row + removes files while keeping Quick Stats intact. i18n
      parity check clean: 4814 keys across all 8 locales, no fallback.

  Behaviour change for existing users on auto-purge: the sweeper used
  to hard-delete by default and now soft-deletes by default. After
  the upgrade those installs start *preserving* more data in Quick
  Stats rather than losing it — safer direction of the two, but worth
  the explicit call-out. Anyone who wants the old behaviour ticks the
  new toggle once and it persists.
maziggy 1 semana atrás
pai
commit
e7045597bc

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
CHANGELOG.md


+ 28 - 7
backend/app/api/routes/archive_purge.py

@@ -38,11 +38,21 @@ router = APIRouter(prefix="/archives", tags=["archives-purge"])
 @router.get("/purge/preview", response_model=ArchivePurgePreviewResponse)
 async def preview_archive_purge(
     older_than_days: int = Query(ge=1, le=3650),
+    purge_stats: bool = Query(
+        False,
+        description=(
+            "When False (default) the count reflects soft-delete mode — "
+            "already-soft-deleted rows are excluded so the number matches "
+            "what a fresh purge would actually touch. When True the count "
+            "includes already-soft-deleted rows (eligible for promotion to "
+            "hard-delete). #1390."
+        ),
+    ),
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
     """Count + size of archives eligible for purge. Read-only."""
-    result = await archive_purge_service.preview_purge(db, older_than_days=older_than_days)
+    result = await archive_purge_service.preview_purge(db, older_than_days=older_than_days, purge_stats=purge_stats)
     return ArchivePurgePreviewResponse(**result)
 
 
@@ -52,9 +62,18 @@ async def execute_archive_purge(
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
-    """Hard-delete archives older than the threshold. Irreversible."""
-    deleted = await archive_purge_service.purge_older_than(db, older_than_days=body.older_than_days)
-    return ArchivePurgeResponse(deleted=deleted)
+    """Bulk-delete archives older than the threshold.
+
+    Soft-delete by default (Quick Stats preserved). Set ``purge_stats=true``
+    in the body to also drop the contribution from /stats — irreversible
+    in that mode, same as the single-archive route's ``?purge_stats=true``.
+    """
+    deleted = await archive_purge_service.purge_older_than(
+        db,
+        older_than_days=body.older_than_days,
+        purge_stats=body.purge_stats,
+    )
+    return ArchivePurgeResponse(deleted=deleted, purge_stats=body.purge_stats)
 
 
 @router.get("/purge/settings", response_model=ArchivePurgeSettings)
@@ -63,7 +82,7 @@ async def get_archive_purge_settings(
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
     cfg = await archive_purge_service.get_settings(db)
-    return ArchivePurgeSettings(enabled=cfg["enabled"], days=cfg["days"])
+    return ArchivePurgeSettings(enabled=cfg["enabled"], days=cfg["days"], purge_stats=cfg["purge_stats"])
 
 
 @router.put("/purge/settings", response_model=ArchivePurgeSettings)
@@ -77,5 +96,7 @@ async def update_archive_purge_settings(
             status_code=400,
             detail=f"days must be between {MIN_AUTO_PURGE_DAYS} and {MAX_AUTO_PURGE_DAYS}",
         )
-    saved = await archive_purge_service.set_settings(db, enabled=body.enabled, days=body.days)
-    return ArchivePurgeSettings(enabled=saved["enabled"], days=saved["days"])
+    saved = await archive_purge_service.set_settings(
+        db, enabled=body.enabled, days=body.days, purge_stats=body.purge_stats
+    )
+    return ArchivePurgeSettings(enabled=saved["enabled"], days=saved["days"], purge_stats=saved["purge_stats"])

+ 9 - 0
backend/app/schemas/archive_purge.py

@@ -14,12 +14,21 @@ class ArchivePurgePreviewResponse(BaseModel):
 
 class ArchivePurgeRequest(BaseModel):
     older_than_days: int = Field(ge=1, le=3650)
+    # #1390: parity with single-archive delete. False (default) soft-deletes
+    # — files off disk, archive row hidden, Quick Stats preserved. True
+    # also drops PrintLogEntry rows so the contribution leaves /stats.
+    purge_stats: bool = False
 
 
 class ArchivePurgeResponse(BaseModel):
     deleted: int
+    purge_stats: bool = False
 
 
 class ArchivePurgeSettings(BaseModel):
     enabled: bool = False
     days: int = Field(default=365, ge=7, le=3650)
+    # #1390: scheduled-purge equivalent of the single-delete checkbox.
+    # Default False — preserves Quick Stats; flip to True to also drop
+    # the contribution from /stats every time the sweeper runs.
+    purge_stats: bool = False

+ 96 - 24
backend/app/services/archive_purge.py

@@ -29,6 +29,14 @@ logger = logging.getLogger(__name__)
 AUTO_PURGE_ENABLED_KEY = "archive_auto_purge_enabled"
 AUTO_PURGE_DAYS_KEY = "archive_auto_purge_days"
 AUTO_PURGE_LAST_RUN_KEY = "archive_auto_purge_last_run"
+# #1390 follow-up: bulk and scheduled purge inherit the same "soft vs hard"
+# choice the single-archive delete already exposes (#1343). When False
+# (default), each purged archive goes through soft_delete_archive — files
+# removed from disk, row hidden via `deleted_at`, PrintLogEntry rows
+# untouched so Quick Stats keeps every contribution. When True, the linked
+# log rows are deleted up front and the archive row is hard-removed,
+# matching the route's `?purge_stats=true` semantics.
+AUTO_PURGE_STATS_KEY = "archive_auto_purge_stats"
 
 DEFAULT_AUTO_PURGE_DAYS = 365
 # 7-day floor mirrors the library auto-purge; anything shorter treats archives
@@ -104,9 +112,11 @@ class ArchivePurgeService:
             row.value = value
 
     async def get_settings(self, db: AsyncSession) -> dict:
-        """Return ``{enabled, days}``. Missing keys default to disabled / 365d."""
+        """Return ``{enabled, days, purge_stats}``. Missing keys default to
+        disabled / 365d / soft-delete (Quick Stats preserved)."""
         enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
         days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
+        stats_raw = await self._read_setting(db, AUTO_PURGE_STATS_KEY)
 
         enabled = (enabled_raw or "false").lower() == "true"
         try:
@@ -114,14 +124,16 @@ class ArchivePurgeService:
         except (TypeError, ValueError):
             days = DEFAULT_AUTO_PURGE_DAYS
         days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, days))
-        return {"enabled": enabled, "days": days}
+        purge_stats = (stats_raw or "false").lower() == "true"
+        return {"enabled": enabled, "days": days, "purge_stats": purge_stats}
 
-    async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int) -> dict:
+    async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int, purge_stats: bool = False) -> dict:
         clamped_days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, int(days)))
         await self._write_setting(db, AUTO_PURGE_ENABLED_KEY, "true" if enabled else "false")
         await self._write_setting(db, AUTO_PURGE_DAYS_KEY, str(clamped_days))
+        await self._write_setting(db, AUTO_PURGE_STATS_KEY, "true" if purge_stats else "false")
         await db.commit()
-        return {"enabled": enabled, "days": clamped_days}
+        return {"enabled": enabled, "days": clamped_days, "purge_stats": purge_stats}
 
     async def _get_last_run(self, db: AsyncSession) -> datetime | None:
         raw = await self._read_setting(db, AUTO_PURGE_LAST_RUN_KEY)
@@ -147,13 +159,19 @@ class ArchivePurgeService:
         if last is not None and (now - last) < timedelta(hours=24):
             return 0
 
-        deleted = await self.purge_older_than(db, older_than_days=cfg["days"])
+        deleted = await self.purge_older_than(
+            db,
+            older_than_days=cfg["days"],
+            purge_stats=cfg["purge_stats"],
+        )
         await self._stamp_last_run(db, now)
         if deleted:
             logger.info(
-                "Archive auto-purge: hard-deleted %d archive(s) (threshold=%d days)",
+                "Archive auto-purge: %s %d archive(s) (threshold=%d days, purge_stats=%s)",
+                "hard-deleted" if cfg["purge_stats"] else "soft-deleted",
                 deleted,
                 cfg["days"],
+                cfg["purge_stats"],
             )
         return deleted
 
@@ -164,8 +182,16 @@ class ArchivePurgeService:
         db: AsyncSession,
         older_than_days: int,
         sample_limit: int = 5,
+        *,
+        purge_stats: bool = False,
     ) -> dict:
-        """Count + size of archives eligible for purge. Read-only."""
+        """Count + size of archives eligible for purge. Read-only.
+
+        Soft-delete mode (default) excludes already-soft-deleted rows so the
+        admin slider's "eligible" count matches what a fresh purge would
+        actually touch. Hard-delete mode counts every row past the cutoff —
+        already-soft-deleted rows are eligible for promotion to hard-delete.
+        """
         if older_than_days < 1:
             return {
                 "count": 0,
@@ -178,15 +204,21 @@ class ArchivePurgeService:
         last_activity = _last_activity_expr()
         clause = last_activity < cutoff
 
-        count_result = await db.execute(select(func.count(PrintArchive.id)).where(clause))
+        count_stmt = select(func.count(PrintArchive.id)).where(clause)
+        size_stmt = select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause)
+        sample_stmt = select(PrintArchive.filename).where(clause).order_by(last_activity).limit(sample_limit)
+        if not purge_stats:
+            count_stmt = count_stmt.where(PrintArchive.deleted_at.is_(None))
+            size_stmt = size_stmt.where(PrintArchive.deleted_at.is_(None))
+            sample_stmt = sample_stmt.where(PrintArchive.deleted_at.is_(None))
+
+        count_result = await db.execute(count_stmt)
         count = int(count_result.scalar() or 0)
 
-        size_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause))
+        size_result = await db.execute(size_stmt)
         total_bytes = int(size_result.scalar() or 0)
 
-        sample_result = await db.execute(
-            select(PrintArchive.filename).where(clause).order_by(last_activity).limit(sample_limit)
-        )
+        sample_result = await db.execute(sample_stmt)
         samples = [row[0] for row in sample_result.all()]
 
         return {
@@ -196,21 +228,44 @@ class ArchivePurgeService:
             "older_than_days": older_than_days,
         }
 
-    async def purge_older_than(self, db: AsyncSession, older_than_days: int) -> int:
-        """Hard-delete archives older than ``older_than_days``. Returns count.
-
-        Delegates to :meth:`ArchiveService.delete_archive` for every row so the
-        on-disk cleanup (3MF, thumbnail, timelapse, photos) goes through the
-        same safety-checked path as manual deletion. Each delete runs in its
-        own session so a commit-per-row doesn't churn the caller's session
-        (and matches how the sweeper uses :func:`_database.async_session` in production).
+    async def purge_older_than(
+        self,
+        db: AsyncSession,
+        older_than_days: int,
+        *,
+        purge_stats: bool = False,
+    ) -> int:
+        """Bulk-delete archives older than ``older_than_days``. Returns count.
+
+        Two modes, parameter-controlled (#1390):
+
+        * ``purge_stats=False`` (default): each archive goes through
+          :meth:`ArchiveService.soft_delete_archive` — files removed from disk
+          and the row hidden via ``deleted_at``, but the linked
+          ``PrintLogEntry`` rows are untouched so Quick Stats keeps every
+          contribution (filament, cost, energy, time accuracy).
+        * ``purge_stats=True``: linked log rows are hard-deleted up front and
+          the archive row is hard-removed via
+          :meth:`ArchiveService.delete_archive`. Matches the single-archive
+          ``DELETE /archives/{id}?purge_stats=true`` semantics from #1343.
+
+        Each delete runs in its own session so a commit-per-row doesn't churn
+        the caller's session (matches how the sweeper uses
+        :func:`_database.async_session` in production).
         """
         if older_than_days < 1:
             return 0
         now = datetime.now(timezone.utc)
         cutoff = _age_cutoff(now, older_than_days)
 
-        id_result = await db.execute(select(PrintArchive.id).where(_last_activity_expr() < cutoff))
+        # Soft-delete mode must also skip rows already soft-deleted, otherwise
+        # a repeat sweeper run keeps re-touching the same rows. Hard-delete
+        # mode doesn't filter — already-soft-deleted rows are eligible for
+        # promotion to hard-delete when the user opts in.
+        select_stmt = select(PrintArchive.id).where(_last_activity_expr() < cutoff)
+        if not purge_stats:
+            select_stmt = select_stmt.where(PrintArchive.deleted_at.is_(None))
+        id_result = await db.execute(select_stmt)
         ids = [row[0] for row in id_result.all()]
         if not ids:
             return 0
@@ -219,13 +274,30 @@ class ArchivePurgeService:
         for archive_id in ids:
             async with _database.async_session() as delete_db:
                 service = ArchiveService(delete_db)
-                if await service.delete_archive(archive_id):
-                    deleted += 1
+                if purge_stats:
+                    # Hard-delete linked PrintLogEntry rows first so their
+                    # filament / cost contributions stop counting in /stats.
+                    # FK is ON DELETE SET NULL, so without this they'd
+                    # survive the archive row and keep showing up in totals
+                    # (#1343 / #1378 / #1390).
+                    from sqlalchemy import delete as sa_delete
+
+                    from backend.app.models.print_log import PrintLogEntry
+
+                    await delete_db.execute(sa_delete(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))
+                    await delete_db.commit()
+                    if await service.delete_archive(archive_id):
+                        deleted += 1
+                else:
+                    if await service.soft_delete_archive(archive_id):
+                        deleted += 1
         if deleted:
             logger.info(
-                "Archive purge: hard-deleted %d archive(s) (older_than_days=%d)",
+                "Archive purge: %s %d archive(s) (older_than_days=%d, purge_stats=%s)",
+                "hard-deleted" if purge_stats else "soft-deleted",
                 deleted,
                 older_than_days,
+                purge_stats,
             )
         return deleted
 

+ 82 - 10
backend/tests/integration/test_archive_purge_api.py

@@ -15,6 +15,8 @@ async def test_settings_defaults_when_unset(async_client: AsyncClient):
     body = resp.json()
     assert body["enabled"] is False
     assert body["days"] == 365
+    # #1390: default soft-delete — preserves Quick Stats contribution.
+    assert body["purge_stats"] is False
 
 
 @pytest.mark.asyncio
@@ -23,13 +25,13 @@ async def test_settings_roundtrip(async_client: AsyncClient):
     """PUT persists, GET returns the saved values, days is clamped."""
     resp = await async_client.put(
         "/api/v1/archives/purge/settings",
-        json={"enabled": True, "days": 180},
+        json={"enabled": True, "days": 180, "purge_stats": True},
     )
     assert resp.status_code == 200
-    assert resp.json() == {"enabled": True, "days": 180}
+    assert resp.json() == {"enabled": True, "days": 180, "purge_stats": True}
 
     resp = await async_client.get("/api/v1/archives/purge/settings")
-    assert resp.json() == {"enabled": True, "days": 180}
+    assert resp.json() == {"enabled": True, "days": 180, "purge_stats": True}
 
 
 @pytest.mark.asyncio
@@ -87,10 +89,12 @@ async def test_preview_ignores_recently_reprinted_archives(
 
 @pytest.mark.asyncio
 @pytest.mark.integration
-async def test_manual_purge_deletes_old_archives(
+async def test_manual_purge_soft_deletes_by_default(
     async_client: AsyncClient, archive_factory, printer_factory, db_session
 ):
-    """POST /archives/purge hard-deletes archives older than the threshold."""
+    """#1390: POST /archives/purge with no body flag soft-deletes — files
+    off disk, ``deleted_at`` set, archive row survives so Quick Stats keeps
+    every contribution. Matches the single-archive delete default from #1343."""
     from backend.app.models.archive import PrintArchive
 
     printer = await printer_factory()
@@ -108,18 +112,58 @@ async def test_manual_purge_deletes_old_archives(
         json={"older_than_days": 365},
     )
     assert resp.status_code == 200
-    assert resp.json()["deleted"] == 1
+    body = resp.json()
+    assert body["deleted"] == 1
+    assert body["purge_stats"] is False
+
+    db_session.expire_all()
+    # Old row still exists in DB but is soft-deleted.
+    old_row = await db_session.get(PrintArchive, old_id)
+    assert old_row is not None
+    assert old_row.deleted_at is not None
+    fresh_row = await db_session.get(PrintArchive, fresh_id)
+    assert fresh_row is not None
+    assert fresh_row.deleted_at is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_manual_purge_hard_deletes_when_purge_stats_set(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """#1390: when ``purge_stats=true`` is sent in the body, the bulk purge
+    hard-deletes the archive AND the linked PrintLogEntry rows so the
+    contribution drops from /stats — matches the single-archive route's
+    ``?purge_stats=true`` semantics."""
+    from backend.app.models.archive import PrintArchive
+
+    printer = await printer_factory()
+    old = await archive_factory(printer.id, print_name="Old")
+    old_id = old.id
+    old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    await db_session.commit()
+
+    resp = await async_client.post(
+        "/api/v1/archives/purge",
+        json={"older_than_days": 365, "purge_stats": True},
+    )
+    assert resp.status_code == 200
+    body = resp.json()
+    assert body["deleted"] == 1
+    assert body["purge_stats"] is True
 
-    # Old is gone, fresh remains.
     db_session.expire_all()
     assert await db_session.get(PrintArchive, old_id) is None
-    assert await db_session.get(PrintArchive, fresh_id) is not None
 
 
 @pytest.mark.asyncio
 @pytest.mark.integration
-async def test_auto_purge_runs_when_enabled(async_client: AsyncClient, archive_factory, printer_factory, db_session):
-    """With the toggle on, a stale archive is hard-deleted by the sweeper.
+async def test_auto_purge_soft_deletes_by_default(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """#1390: scheduled auto-purge defaults to soft-delete — Quick Stats
+    preserved unless the admin explicitly opts into hard-delete via the
+    settings toggle.
 
     ``async_client`` is included solely so its fixture activates the module-level
     ``async_session`` patches that let :meth:`purge_older_than`'s per-row
@@ -139,6 +183,34 @@ async def test_auto_purge_runs_when_enabled(async_client: AsyncClient, archive_f
     deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
     assert deleted >= 1
 
+    db_session.expire_all()
+    stale_row = await db_session.get(PrintArchive, stale_id)
+    assert stale_row is not None
+    assert stale_row.deleted_at is not None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_auto_purge_hard_deletes_when_settings_opts_in(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """#1390: scheduled auto-purge honours the ``purge_stats`` setting —
+    when True the sweeper hard-deletes archive rows AND linked PrintLogEntry
+    rows, dropping every contribution from /stats."""
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.archive_purge import archive_purge_service
+
+    printer = await printer_factory()
+    stale = await archive_factory(printer.id, print_name="Stale")
+    stale_id = stale.id
+    stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    await db_session.commit()
+
+    await archive_purge_service.set_settings(db_session, enabled=True, days=365, purge_stats=True)
+
+    deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
+    assert deleted >= 1
+
     db_session.expire_all()
     assert await db_session.get(PrintArchive, stale_id) is None
 

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

@@ -3506,12 +3506,18 @@ export const api = {
     request<void>(`/archives/${id}${purgeStats ? '?purge_stats=true' : ''}`, { method: 'DELETE' }),
 
   // ========== Archive auto-purge (#1008 follow-up) ==========
-  previewArchivePurge: (olderThanDays: number) =>
-    request<ArchivePurgePreview>(`/archives/purge/preview?older_than_days=${olderThanDays}`),
-  executeArchivePurge: (olderThanDays: number) =>
-    request<{ deleted: number }>('/archives/purge', {
+  previewArchivePurge: (olderThanDays: number, purgeStats: boolean = false) =>
+    request<ArchivePurgePreview>(
+      `/archives/purge/preview?older_than_days=${olderThanDays}&purge_stats=${purgeStats}`,
+    ),
+  // #1390: purgeStats=false (default) soft-deletes each old archive — Quick Stats
+  // preserved, files removed from disk, row hidden via deleted_at. true matches
+  // the single-archive delete's `?purge_stats=true` semantics (hard-deletes the
+  // linked PrintLogEntry rows so the contribution drops from /stats too).
+  executeArchivePurge: (olderThanDays: number, purgeStats: boolean = false) =>
+    request<{ deleted: number; purge_stats: boolean }>('/archives/purge', {
       method: 'POST',
-      body: JSON.stringify({ older_than_days: olderThanDays }),
+      body: JSON.stringify({ older_than_days: olderThanDays, purge_stats: purgeStats }),
     }),
   getArchivePurgeSettings: () =>
     request<ArchivePurgeSettings>('/archives/purge/settings'),
@@ -5950,6 +5956,10 @@ export interface ArchivePurgePreview {
 export interface ArchivePurgeSettings {
   enabled: boolean;
   days: number;
+  // #1390: when true, bulk-deletes the linked PrintLogEntry rows so the
+  // contribution drops from Quick Stats too. Default false — soft-delete,
+  // Quick Stats preserved.
+  purge_stats: boolean;
 }
 
 export interface LibraryFileUploadResponse {

+ 20 - 3
frontend/src/components/PurgeArchivesModal.tsx

@@ -21,6 +21,9 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
   const { showToast } = useToast();
 
   const [days, setDays] = useState(initialDays ?? DEFAULT_DAYS);
+  // #1390: matches the single-archive delete dialog's "Also remove from
+  // statistics" checkbox. Default off — soft-delete, Quick Stats preserved.
+  const [purgeStats, setPurgeStats] = useState(false);
 
   const [debouncedDays, setDebouncedDays] = useState(days);
   useEffect(() => {
@@ -29,13 +32,13 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
   }, [days]);
 
   const previewQuery = useQuery({
-    queryKey: ['archive-purge-preview', debouncedDays],
-    queryFn: () => api.previewArchivePurge(debouncedDays),
+    queryKey: ['archive-purge-preview', debouncedDays, purgeStats],
+    queryFn: () => api.previewArchivePurge(debouncedDays, purgeStats),
     enabled: debouncedDays >= 1,
   });
 
   const purgeMutation = useMutation({
-    mutationFn: () => api.executeArchivePurge(days),
+    mutationFn: () => api.executeArchivePurge(days, purgeStats),
     onSuccess: (res) => {
       showToast(t('archivePurge.toast.success', { count: res.deleted }), 'success');
       queryClient.invalidateQueries({ queryKey: ['archives'] });
@@ -141,6 +144,20 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
             )}
           </div>
 
+          <label className="flex gap-2 items-start rounded border border-gray-200 dark:border-gray-700 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50">
+            <input
+              type="checkbox"
+              checked={purgeStats}
+              onChange={(e) => setPurgeStats(e.target.checked)}
+              disabled={purgeMutation.isPending}
+              className="mt-0.5 shrink-0"
+            />
+            <span className="text-xs text-gray-700 dark:text-gray-300">
+              <span className="font-medium block mb-0.5">{t('archivePurge.purgeStatsLabel')}</span>
+              <span className="text-gray-500 dark:text-gray-400">{t('archivePurge.purgeStatsHint')}</span>
+            </span>
+          </label>
+
           <div className="flex gap-2 items-start text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded px-3 py-2">
             <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
             <span>{t('archivePurge.warning')}</span>

+ 14 - 10
frontend/src/i18n/locales/de.ts

@@ -5622,28 +5622,32 @@ export default {
     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.',
+    effect1: 'Jedes passende Archiv wird aus der Listenansicht ausgeblendet und seine Dateien werden von der Festplatte entfernt (3MF, Vorschau, Timelapse, Quell-3MF, F3D, Fotos).',
+    effect2: 'Die Archivzeile bleibt in der Datenbank, damit die Quick Stats den Filament-, Zeit-, Kosten- und Energiebeitrag behalten — wie der Standard beim Einzel-Löschen.',
+    effect3: 'Aktiviere unten „Auch aus Statistiken entfernen", um zusätzlich den Quick-Stats-Beitrag zu verwerfen (entspricht dem Einzel-Löschen-Häkchen). Dieser Pfad ist unwiderruflich.',
     effect4: 'Ein erneuter Druck setzt die Frist zurück, Archive, die du weiterhin nutzt, bleiben sicher.',
+    purgeStatsLabel: 'Auch aus Statistiken entfernen',
+    purgeStatsHint: 'Verwirft die passenden Archive aus den Quick Stats (Filament, Zeit, Kosten, Energie). Ohne diese Option behalten die Quick Stats jeden Beitrag und nur die Dateien werden gelöscht.',
     previewLoading: 'Prüfe passende Archive…',
     previewFailed: 'Vorschau konnte nicht erstellt werden.',
-    previewSummary: '{{count}} Archive · {{size}} werden gelöscht',
+    previewSummary: '{{count}} Archive · {{size}} würden entfernt',
     andMore: '…und {{count}} weitere',
-    warning: 'Dies ist endgültig. Lade wichtige Archive vorher herunter oder markiere sie als Favorit.',
-    confirmCta: '{{count}} Archiv(e) löschen',
-    purging: 'Lösche…',
+    warning: 'Dateien werden von der Festplatte entfernt und können nicht wiederhergestellt werden. Lade wichtige Archive vorher herunter oder markiere sie als Favorit.',
+    confirmCta: '{{count}} Archiv(e) entfernen',
+    purging: 'Entferne…',
     toast: {
-      success: '{{count}} Archiv(e) gelöscht.',
+      success: '{{count}} Archiv(e) entfernt.',
       failed: 'Archive konnten nicht gelöscht werden.',
     },
   },
   archiveAutoPurge: {
     enableLabel: 'Alte Archive automatisch löschen',
-    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.',
+    enableDescription: 'Blendet einmal pro Tag Archive aus der Listenansicht aus und entfernt ihre Dateien von der Festplatte, wenn sie im angegebenen Zeitraum nicht gedruckt wurden. Ein erneuter Druck setzt die Frist zurück.',
     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.',
+    ageDescription: 'Minimum 7 Tage, Maximum 10 Jahre. Basiert auf dem letzten abgeschlossenen Druck — ein erneuter Druck setzt die Frist zurück. Entfernt 3MF, Vorschau, Timelapse, Quell-3MF, F3D und Fotos.',
     days: 'Tage',
+    purgeStatsLabel: 'Auch aus Statistiken entfernen',
+    purgeStatsDescription: 'Wenn aktiviert, verwirft der tägliche Sweeper auch den Quick-Stats-Beitrag (Filament, Zeit, Kosten, Energie) jedes gelöschten Archivs. Standard aus — Quick Stats behalten den Beitrag, nur die Dateien werden gelöscht.',
     runNow: 'Archive jetzt löschen',
     saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
   },

+ 14 - 10
frontend/src/i18n/locales/en.ts

@@ -5631,28 +5631,32 @@ export default {
     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.',
+    effect1: 'Each matching archive is hidden from the listings and its files are removed from disk (3MF, thumbnail, timelapse, source 3MF, F3D design file, photos).',
+    effect2: 'The archive row stays in the database so Quick Stats keeps the filament, time, cost, and energy contribution — same as the single-archive delete default.',
+    effect3: 'Tick "Also remove from statistics" below to drop the Quick Stats contribution too (matches the single-archive delete option). That path is irreversible.',
     effect4: 'Reprinting an archive refreshes its age clock, so archives you still use are safe.',
+    purgeStatsLabel: 'Also remove from statistics',
+    purgeStatsHint: 'Drops the matching archives from Quick Stats (filament, time, cost, energy). Without this, Quick Stats keeps every contribution and only the files leave disk.',
     previewLoading: 'Checking how many archives match…',
     previewFailed: 'Could not preview the purge.',
-    previewSummary: '{{count}} archives · {{size}} would be deleted',
+    previewSummary: '{{count}} archives · {{size}} would be removed',
     andMore: '…and {{count}} more',
-    warning: 'This is permanent. Download or favourite anything you want to keep before continuing.',
-    confirmCta: 'Delete {{count}} archive(s)',
-    purging: 'Deleting…',
+    warning: 'Files are removed from disk and cannot be restored. Download or favourite anything you want to keep before continuing.',
+    confirmCta: 'Remove {{count}} archive(s)',
+    purging: 'Removing…',
     toast: {
-      success: 'Deleted {{count}} archive(s).',
+      success: 'Removed {{count}} archive(s).',
       failed: 'Could not purge archives.',
     },
   },
   archiveAutoPurge: {
     enableLabel: 'Auto-purge old archives',
-    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.',
+    enableDescription: 'Once per day, hides archives from the listings and removes their files from disk when they have not been printed within the threshold. Reprinting an archive resets the clock.',
     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.',
+    ageDescription: 'Minimum 7 days, maximum 10 years. Based on the most recent print completion — reprinting an archive refreshes its age. Removes the 3MF, thumbnail, timelapse, source 3MF, F3D, and photos.',
     days: 'days',
+    purgeStatsLabel: 'Also remove from statistics',
+    purgeStatsDescription: 'When enabled, the daily sweeper also drops each purged archive from Quick Stats (filament, time, cost, energy). Default off — Quick Stats keeps the contribution, only the files leave disk.',
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',
   },

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

@@ -5610,28 +5610,32 @@ export default {
     ageLabel: 'Supprimer les archives non imprimées depuis',
     days: 'jours',
     effectsTitle: 'Ce qui se passe lorsque vous cliquez sur Purger',
-    effect1: 'Chaque archive correspondante est définitivement supprimée de la base de données.',
-    effect2: 'Le 3MF, la miniature, le timelapse, le 3MF source, le fichier de conception F3D et le dossier photos sont tous supprimés du disque.',
-    effect3: 'Il n\'y a pas de corbeille pour les archives — la suppression est immédiate et irréversible.',
+    effect1: 'Chaque archive correspondante est masquée des listes et ses fichiers sont retirés du disque (3MF, miniature, timelapse, 3MF source, F3D, photos).',
+    effect2: 'La ligne d\'archive reste en base pour que les statistiques rapides conservent la contribution en filament, temps, coût et énergie — même comportement que la suppression individuelle par défaut.',
+    effect3: 'Cochez « Aussi retirer des statistiques » ci-dessous pour également supprimer la contribution des statistiques rapides (équivalent à l\'option de la suppression individuelle). Ce chemin est irréversible.',
     effect4: 'Réimprimer une archive remet son compteur à zéro, les archives actives sont donc protégées.',
+    purgeStatsLabel: 'Aussi retirer des statistiques',
+    purgeStatsHint: 'Supprime les archives correspondantes des statistiques rapides (filament, temps, coût, énergie). Sans cette option, les statistiques conservent chaque contribution et seuls les fichiers quittent le disque.',
     previewLoading: 'Vérification du nombre d\'archives correspondantes…',
     previewFailed: 'Impossible de prévisualiser la purge.',
-    previewSummary: '{{count}} archives · {{size}} seraient supprimées',
+    previewSummary: '{{count}} archives · {{size}} seraient retirées',
     andMore: '…et {{count}} de plus',
-    warning: 'Action permanente. Téléchargez ou marquez comme favori avant de continuer.',
-    confirmCta: 'Supprimer {{count}} archive(s)',
-    purging: 'Suppression…',
+    warning: 'Les fichiers sont retirés du disque et ne peuvent pas être restaurés. Téléchargez ou marquez comme favori avant de continuer.',
+    confirmCta: 'Retirer {{count}} archive(s)',
+    purging: 'Retrait en cours…',
     toast: {
-      success: '{{count}} archive(s) supprimée(s).',
+      success: '{{count}} archive(s) retirée(s).',
       failed: 'Impossible de purger les archives.',
     },
   },
   archiveAutoPurge: {
     enableLabel: 'Purger auto. les anciennes archives',
-    enableDescription: 'Une fois par jour, supprime définitivement les archives non imprimées dans le seuil. Réimprimer remet le compteur à zéro. Pas de corbeille — suppression immédiate.',
+    enableDescription: 'Une fois par jour, masque des listes les archives non imprimées dans le seuil et retire leurs fichiers du disque. Réimprimer remet le compteur à zéro.',
     ageLabel: 'Suppression auto. des archives non imprimées depuis',
-    ageDescription: 'Minimum 7 jours, maximum 10 ans. Basé sur la dernière impression terminée — réimprimer remet l\'âge à zéro. Supprime l\'archive, 3MF, miniature, timelapse et photos.',
+    ageDescription: 'Minimum 7 jours, maximum 10 ans. Basé sur la dernière impression terminée — réimprimer remet l\'âge à zéro. Retire 3MF, miniature, timelapse, 3MF source, F3D et photos.',
     days: 'jours',
+    purgeStatsLabel: 'Aussi retirer des statistiques',
+    purgeStatsDescription: 'Lorsqu\'activé, le nettoyage quotidien supprime aussi la contribution de chaque archive purgée des statistiques rapides (filament, temps, coût, énergie). Désactivé par défaut — les statistiques conservent la contribution et seuls les fichiers quittent le disque.',
     runNow: 'Purger les archives maintenant',
     saveFailed: 'Impossible d\'enregistrer les paramètres de purge automatique.',
   },

+ 14 - 10
frontend/src/i18n/locales/it.ts

@@ -5609,28 +5609,32 @@ export default {
     ageLabel: 'Elimina archivi non stampati negli ultimi',
     days: 'giorni',
     effectsTitle: 'Cosa succede quando fai clic su Pulisci',
-    effect1: 'Ogni archivio corrispondente viene rimosso permanentemente dal database.',
-    effect2: 'Il 3MF, la miniatura, il timelapse, il 3MF sorgente, il file di progettazione F3D e la cartella foto vengono tutti eliminati dal disco.',
-    effect3: 'Non c\'è un cestino per gli archivi — l\'eliminazione è immediata e non può essere annullata.',
+    effect1: 'Ogni archivio corrispondente viene nascosto dagli elenchi e i suoi file vengono rimossi dal disco (3MF, miniatura, timelapse, 3MF sorgente, F3D, foto).',
+    effect2: 'La riga dell\'archivio rimane nel database, così le Quick Stats conservano il contributo di filamento, tempo, costo ed energia — come il comportamento predefinito dell\'eliminazione singola.',
+    effect3: 'Spunta «Rimuovi anche dalle statistiche» qui sotto per eliminare anche il contributo dalle Quick Stats (equivalente all\'opzione dell\'eliminazione singola). Questo percorso è irreversibile.',
     effect4: 'Ristampare un archivio azzera il suo conteggio dell\'età, quindi gli archivi attivi sono al sicuro.',
+    purgeStatsLabel: 'Rimuovi anche dalle statistiche',
+    purgeStatsHint: 'Rimuove gli archivi selezionati dalle Quick Stats (filamento, tempo, costo, energia). Senza questa opzione le Quick Stats conservano ogni contributo e solo i file vengono cancellati.',
     previewLoading: 'Verifica del numero di archivi corrispondenti…',
     previewFailed: 'Impossibile visualizzare l\'anteprima della pulizia.',
-    previewSummary: '{{count}} archivi · {{size}} verrebbero eliminati',
+    previewSummary: '{{count}} archivi · {{size}} verrebbero rimossi',
     andMore: '…e altri {{count}}',
-    warning: 'Questo è permanente. Scarica o aggiungi ai preferiti tutto ciò che vuoi conservare prima di continuare.',
-    confirmCta: 'Elimina {{count}} archivio(i)',
-    purging: 'Eliminazione…',
+    warning: 'I file vengono rimossi dal disco e non possono essere ripristinati. Scarica o aggiungi ai preferiti tutto ciò che vuoi conservare prima di continuare.',
+    confirmCta: 'Rimuovi {{count}} archivio(i)',
+    purging: 'Rimozione…',
     toast: {
-      success: '{{count}} archivio(i) eliminato.',
+      success: '{{count}} archivio(i) rimosso.',
       failed: 'Impossibile eliminare gli archivi.',
     },
   },
   archiveAutoPurge: {
     enableLabel: 'Elimina auto. vecchi archivi',
-    enableDescription: 'Una volta al giorno, elimina permanentemente gli archivi non stampati entro la soglia. Ristampare azzera il timer. Nessun cestino — eliminazione immediata.',
+    enableDescription: 'Una volta al giorno nasconde dagli elenchi gli archivi non stampati entro la soglia e rimuove i loro file dal disco. Ristampare azzera il timer.',
     ageLabel: 'Eliminazione auto. di archivi non stampati negli ultimi',
-    ageDescription: 'Minimo 7 giorni, massimo 10 anni. Basato sull\'ultima stampa completata — ristampare azzera l\'età. Elimina archivio, 3MF, miniatura, timelapse e foto.',
+    ageDescription: 'Minimo 7 giorni, massimo 10 anni. Basato sull\'ultima stampa completata — ristampare azzera l\'età. Rimuove 3MF, miniatura, timelapse, 3MF sorgente, F3D e foto.',
     days: 'giorni',
+    purgeStatsLabel: 'Rimuovi anche dalle statistiche',
+    purgeStatsDescription: 'Quando abilitato, lo sweeper giornaliero elimina anche il contributo di ogni archivio purgato dalle Quick Stats (filamento, tempo, costo, energia). Disabilitato per impostazione predefinita — le Quick Stats mantengono il contributo e solo i file vengono cancellati.',
     runNow: 'Elimina archivi ora',
     saveFailed: 'Impossibile salvare le impostazioni di pulizia automatica.',
   },

+ 10 - 6
frontend/src/i18n/locales/ja.ts

@@ -5621,15 +5621,17 @@ export default {
     ageLabel: '印刷されていないアーカイブを削除(過去',
     days: '日',
     effectsTitle: '「削除」をクリックすると何が起こるか',
-    effect1: '一致する各アーカイブはデータベースから完全に削除されます。',
-    effect2: '3MF、サムネイル、タイムラプス、ソース3MF、F3Dデザインファイル、写真フォルダーがすべてディスクから削除されます。',
-    effect3: 'アーカイブにはゴミ箱がありません — 削除は即座に行われ、元に戻せません。',
+    effect1: '一致するアーカイブはリストから非表示になり、ファイル(3MF、サムネイル、タイムラプス、ソース3MF、F3D、写真)はディスクから削除されます。',
+    effect2: 'アーカイブのレコードはデータベースに残るため、Quick Stats のフィラメント/時間/コスト/エネルギーの寄与は保持されます — 個別アーカイブ削除の既定動作と同じです。',
+    effect3: '下の「統計からも削除」を有効にすると、Quick Stats の寄与も破棄されます(個別アーカイブ削除のオプションと同等)。この操作は取り消せません。',
     effect4: 'アーカイブを再印刷するとエイジクロックがリセットされるため、まだ使用中のアーカイブは安全です。',
+    purgeStatsLabel: '統計からも削除',
+    purgeStatsHint: '一致するアーカイブを Quick Stats(フィラメント、時間、コスト、エネルギー)からも削除します。チェックしない場合、Quick Stats は寄与を保持し、ファイルのみがディスクから消えます。',
     previewLoading: '一致するアーカイブの数を確認中…',
     previewFailed: '削除のプレビューを表示できませんでした。',
     previewSummary: '{{count}}件のアーカイブ · {{size}}が削除されます',
     andMore: '…他に{{count}}件',
-    warning: 'この操作は元に戻せません。続行する前に、保持したいものをダウンロードまたはお気に入りに追加してください。',
+    warning: 'ファイルはディスクから削除され、元に戻せません。続行する前に、保持したいものをダウンロードまたはお気に入りに追加してください。',
     confirmCta: '{{count}}件のアーカイブを削除',
     purging: '削除中…',
     toast: {
@@ -5639,10 +5641,12 @@ export default {
   },
   archiveAutoPurge: {
     enableLabel: '古いアーカイブを自動削除',
-    enableDescription: '1日1回、しきい値内に印刷されていないアーカイブを完全に削除します。再印刷するとタイマーがリセットされます。ゴミ箱なし — 削除は即座に行われます。',
+    enableDescription: '1日1回、しきい値内に印刷されていないアーカイブをリストから非表示にし、ファイルをディスクから削除します。再印刷するとタイマーがリセットされます。',
     ageLabel: '印刷されていないアーカイブを自動削除(過去',
-    ageDescription: '最小7日、最大10年。最新の印刷完了に基づきます — 再印刷するとエイジがリセットされます。アーカイブ、3MF、サムネイル、タイムラプス、写真を削除します。',
+    ageDescription: '最小7日、最大10年。最新の印刷完了に基づきます — 再印刷するとエイジがリセットされます。3MF、サムネイル、タイムラプス、ソース3MF、F3D、写真を削除します。',
     days: '日',
+    purgeStatsLabel: '統計からも削除',
+    purgeStatsDescription: '有効にすると、毎日のスイーパーが削除した各アーカイブを Quick Stats(フィラメント、時間、コスト、エネルギー)からも除外します。既定はオフ — Quick Stats は寄与を保持し、ファイルのみがディスクから消えます。',
     runNow: 'アーカイブを今すぐ削除',
     saveFailed: '自動削除設定を保存できませんでした。',
   },

+ 14 - 10
frontend/src/i18n/locales/pt-BR.ts

@@ -5609,28 +5609,32 @@ export default {
     ageLabel: 'Excluir arquivos não impressos nos últimos',
     days: 'dias',
     effectsTitle: 'O que acontece quando você clica em Limpar',
-    effect1: 'Cada arquivo correspondente é removido permanentemente do banco de dados.',
-    effect2: 'O 3MF, miniatura, timelapse, 3MF de origem, arquivo de design F3D e pasta de fotos são todos excluídos do disco.',
-    effect3: 'Não há lixeira para arquivos — a exclusão é imediata e não pode ser desfeita.',
+    effect1: 'Cada arquivo correspondente é ocultado das listagens e seus arquivos são removidos do disco (3MF, miniatura, timelapse, 3MF de origem, F3D, fotos).',
+    effect2: 'A linha do arquivo permanece no banco de dados para que o Quick Stats mantenha a contribuição de filamento, tempo, custo e energia — mesmo comportamento padrão da exclusão individual.',
+    effect3: 'Marque "Remover também das estatísticas" abaixo para descartar também a contribuição do Quick Stats (equivalente à opção da exclusão individual). Esse caminho é irreversível.',
     effect4: 'Reimprimir um arquivo reinicia seu cronômetro de idade, então arquivos ativos estão protegidos.',
+    purgeStatsLabel: 'Remover também das estatísticas',
+    purgeStatsHint: 'Remove os arquivos correspondentes do Quick Stats (filamento, tempo, custo, energia). Sem essa opção, o Quick Stats mantém cada contribuição e apenas os arquivos saem do disco.',
     previewLoading: 'Verificando quantos arquivos correspondem…',
     previewFailed: 'Não foi possível visualizar a limpeza.',
-    previewSummary: '{{count}} arquivos · {{size}} seriam excluídos',
+    previewSummary: '{{count}} arquivos · {{size}} seriam removidos',
     andMore: '…e mais {{count}}',
-    warning: 'Isso é permanente. Baixe ou favorite o que quiser manter antes de continuar.',
-    confirmCta: 'Excluir {{count}} arquivo(s)',
-    purging: 'Excluindo…',
+    warning: 'Os arquivos são removidos do disco e não podem ser restaurados. Baixe ou favorite o que quiser manter antes de continuar.',
+    confirmCta: 'Remover {{count}} arquivo(s)',
+    purging: 'Removendo…',
     toast: {
-      success: '{{count}} arquivo(s) excluído(s).',
+      success: '{{count}} arquivo(s) removido(s).',
       failed: 'Não foi possível limpar os arquivos.',
     },
   },
   archiveAutoPurge: {
     enableLabel: 'Limpar arquivos antigos auto.',
-    enableDescription: 'Uma vez por dia, exclui permanentemente arquivos não impressos dentro do limite. Reimprimir reinicia o cronômetro. Sem lixeira — exclusão imediata.',
+    enableDescription: 'Uma vez por dia oculta das listagens os arquivos não impressos dentro do limite e remove seus arquivos do disco. Reimprimir reinicia o cronômetro.',
     ageLabel: 'Exclusão auto. de arquivos não impressos nos últimos',
-    ageDescription: 'Mínimo 7 dias, máximo 10 anos. Baseado na última conclusão de impressão — reimprimir reinicia a idade. Exclui arquivo, 3MF, miniatura, timelapse e fotos.',
+    ageDescription: 'Mínimo 7 dias, máximo 10 anos. Baseado na última conclusão de impressão — reimprimir reinicia a idade. Remove 3MF, miniatura, timelapse, 3MF de origem, F3D e fotos.',
     days: 'dias',
+    purgeStatsLabel: 'Remover também das estatísticas',
+    purgeStatsDescription: 'Quando ativada, a varredura diária também descarta a contribuição de cada arquivo limpo do Quick Stats (filamento, tempo, custo, energia). Desativada por padrão — o Quick Stats mantém a contribuição e apenas os arquivos saem do disco.',
     runNow: 'Limpar arquivos agora',
     saveFailed: 'Não foi possível salvar as configurações de limpeza automática.',
   },

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

@@ -5608,28 +5608,32 @@ export default {
     ageLabel: '删除最近未打印的归档:',
     days: '天',
     effectsTitle: '点击清除时会发生什么',
-    effect1: '每个匹配的归档将从数据库中永久删除。',
-    effect2: '3MF、缩略图、延时摄影、源 3MF、F3D 设计文件和照片文件夹将全部从磁盘删除。',
-    effect3: '归档没有回收站 — 删除是即时的,无法撤销。',
+    effect1: '每个匹配的归档将从列表中隐藏,其磁盘文件(3MF、缩略图、延时摄影、源 3MF、F3D、照片)也将被删除。',
+    effect2: '数据库中的归档记录将被保留,因此 Quick Stats 仍可计入耗材、时间、成本和能耗的贡献 — 与单条删除的默认行为一致。',
+    effect3: '勾选下方"同时从统计中移除"可一并清除 Quick Stats 中的贡献(与单条删除选项等同)。该操作不可撤销。',
     effect4: '重新打印归档会刷新其使用计时器,因此仍在使用的归档不会被清除。',
+    purgeStatsLabel: '同时从统计中移除',
+    purgeStatsHint: '从 Quick Stats(耗材、时间、成本、能耗)中移除匹配的归档。不勾选时,Quick Stats 保留所有贡献,仅文件从磁盘删除。',
     previewLoading: '检查匹配的归档数量…',
     previewFailed: '无法预览清除。',
-    previewSummary: '将除 {{count}} 个归档 · {{size}}',
+    previewSummary: '将除 {{count}} 个归档 · {{size}}',
     andMore: '…还有 {{count}} 个',
-    warning: '此操作不可撤销。继续前请下载或收藏您想保留的内容。',
-    confirmCta: '除 {{count}} 个归档',
-    purging: '除中…',
+    warning: '文件将从磁盘删除且无法恢复。继续前请下载或收藏您想保留的内容。',
+    confirmCta: '除 {{count}} 个归档',
+    purging: '除中…',
     toast: {
-      success: '已除 {{count}} 个归档。',
+      success: '已除 {{count}} 个归档。',
       failed: '无法清除归档。',
     },
   },
   archiveAutoPurge: {
     enableLabel: '自动清除旧归档',
-    enableDescription: '每天一次,永久删除阈值内未打印的归档。重新打印会重置计时器。无回收站 — 删除即时生效。',
+    enableDescription: '每天一次,将阈值内未打印的归档从列表中隐藏,并从磁盘中删除其文件。重新打印会重置计时器。',
     ageLabel: '自动删除最近未打印的归档:',
-    ageDescription: '最少 7 天,最多 10 年。基于最近一次打印完成 — 重新打印会刷新年龄。删除归档、3MF、缩略图、延时摄影和照片。',
+    ageDescription: '最少 7 天,最多 10 年。基于最近一次打印完成 — 重新打印会刷新年龄。删除 3MF、缩略图、延时摄影、源 3MF、F3D 和照片。',
     days: '天',
+    purgeStatsLabel: '同时从统计中移除',
+    purgeStatsDescription: '启用后,每日清理任务还会将每个被清除的归档从 Quick Stats(耗材、时间、成本、能耗)中移除。默认关闭 — Quick Stats 保留贡献,仅文件从磁盘删除。',
     runNow: '立即清除归档',
     saveFailed: '无法保存自动清除设置。',
   },

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

@@ -5608,28 +5608,32 @@ export default {
     ageLabel: '刪除最近未列印的歸檔:',
     days: '天',
     effectsTitle: '點擊清除時會發生什麼',
-    effect1: '每個符合的歸檔將從資料庫中永久刪除。',
-    effect2: '3MF、縮圖、縮時攝影、原始 3MF、F3D 設計檔案和照片資料夾將全部從磁碟刪除。',
-    effect3: '歸檔沒有回收筒 — 刪除是即時的,無法復原。',
+    effect1: '每個符合的歸檔將從清單中隱藏,其磁碟檔案(3MF、縮圖、縮時攝影、原始 3MF、F3D、照片)也會被刪除。',
+    effect2: '資料庫中的歸檔記錄會保留,因此 Quick Stats 仍可計入耗材、時間、成本與能耗的貢獻 — 與單筆刪除的預設行為一致。',
+    effect3: '勾選下方「同時從統計中移除」,可一併清除 Quick Stats 中的貢獻(等同單筆刪除選項)。此操作無法復原。',
     effect4: '重新列印歸檔會重新整理其使用計時器,因此仍在使用的歸檔不會被清除。',
+    purgeStatsLabel: '同時從統計中移除',
+    purgeStatsHint: '從 Quick Stats(耗材、時間、成本、能耗)中移除符合的歸檔。未勾選時,Quick Stats 會保留所有貢獻,僅檔案從磁碟刪除。',
     previewLoading: '檢查符合的歸檔數量…',
     previewFailed: '無法預覽清除。',
-    previewSummary: '將除 {{count}} 個歸檔 · {{size}}',
+    previewSummary: '將除 {{count}} 個歸檔 · {{size}}',
     andMore: '…還有 {{count}} 個',
-    warning: '此操作無法復原。繼續前請下載或收藏您想保留的內容。',
-    confirmCta: '除 {{count}} 個歸檔',
-    purging: '除中…',
+    warning: '檔案會從磁碟刪除且無法復原。繼續前請下載或收藏您想保留的內容。',
+    confirmCta: '除 {{count}} 個歸檔',
+    purging: '除中…',
     toast: {
-      success: '已除 {{count}} 個歸檔。',
+      success: '已除 {{count}} 個歸檔。',
       failed: '無法清除歸檔。',
     },
   },
   archiveAutoPurge: {
     enableLabel: '自動清除舊歸檔',
-    enableDescription: '每天一次,永久刪除閾值內未列印的歸檔。重新列印會重設計時器。無回收筒 — 刪除即時生效。',
+    enableDescription: '每天一次,將閾值內未列印的歸檔從清單中隱藏,並從磁碟中刪除其檔案。重新列印會重設計時器。',
     ageLabel: '自動刪除最近未列印的歸檔:',
-    ageDescription: '最少 7 天,最多 10 年。基於最近一次列印完成 — 重新列印會重新整理年齡。刪除歸檔、3MF、縮圖、縮時攝影和照片。',
+    ageDescription: '最少 7 天,最多 10 年。基於最近一次列印完成 — 重新列印會重新整理年齡。刪除 3MF、縮圖、縮時攝影、原始 3MF、F3D 與照片。',
     days: '天',
+    purgeStatsLabel: '同時從統計中移除',
+    purgeStatsDescription: '啟用後,每日清理任務還會將每個被清除的歸檔從 Quick Stats(耗材、時間、成本、能耗)中移除。預設關閉 — Quick Stats 會保留貢獻,僅檔案從磁碟刪除。',
     runNow: '立即清除歸檔',
     saveFailed: '無法儲存自動清除設定。',
   },

+ 22 - 2
frontend/src/pages/SettingsPage.tsx

@@ -517,7 +517,8 @@ export function SettingsPage() {
   });
 
   const updateArchivePurgeSettingsMutation = useMutation({
-    mutationFn: (body: { enabled: boolean; days: number }) => api.updateArchivePurgeSettings(body),
+    mutationFn: (body: { enabled: boolean; days: number; purge_stats: boolean }) =>
+      api.updateArchivePurgeSettings(body),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['archive-purge-settings'] });
       showToast(t('settings.toast.settingsSaved'), 'success');
@@ -525,11 +526,14 @@ export function SettingsPage() {
     onError: (e: Error) => showToast(e.message || t('archiveAutoPurge.saveFailed'), 'error'),
   });
 
-  const saveArchivePurgeSettings = (patch: Partial<{ enabled: boolean; days: number }>) => {
+  const saveArchivePurgeSettings = (
+    patch: Partial<{ enabled: boolean; days: number; purge_stats: boolean }>,
+  ) => {
     if (!archivePurgeSettings) return;
     updateArchivePurgeSettingsMutation.mutate({
       enabled: archivePurgeSettings.enabled,
       days: archivePurgeSettings.days,
+      purge_stats: archivePurgeSettings.purge_stats,
       ...patch,
     });
   };
@@ -1855,6 +1859,22 @@ export function SettingsPage() {
                     </p>
                   </div>
 
+                  <label className="flex items-start gap-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      disabled={!archivePurgeSettings.enabled}
+                      checked={archivePurgeSettings.purge_stats}
+                      onChange={(e) => saveArchivePurgeSettings({ purge_stats: e.target.checked })}
+                      className="mt-0.5 shrink-0 disabled:opacity-50"
+                    />
+                    <span className="text-sm">
+                      <span className="text-white block">{t('archiveAutoPurge.purgeStatsLabel')}</span>
+                      <span className="text-xs text-bambu-gray block mt-0.5">
+                        {t('archiveAutoPurge.purgeStatsDescription')}
+                      </span>
+                    </span>
+                  </label>
+
                 </div>
               )}
             </CardContent>

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-ChT_Zli3.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CFzqkJEl.js"></script>
+    <script type="module" crossorigin src="/assets/index-ChT_Zli3.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
   </head>
   <body>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff