Переглянути джерело

Archives: Git backups keep each print (archive redesign P3)

- The Git backup writes each print of an archive and which archive was
  merged into which; the restore attaches the prints to their restored
  archives, which then show their latest print's outcome
- An archive printed again since the backup is recognised by its
  prints' starts and when it was archived, not added a second time
- A merged archive, before or after the backup, goes to the archive it
  went into and never overwrites what the merge joined
- A print running at the backup comes back cancelled and never
  overwrites the one here; owners by name, only one that still exists
- Left out: the confirm token, photos, timelapses, wallet links
maziggy 1 день тому
батько
коміт
e91758972a
50 змінених файлів з 934 додано та 15 видалено
  1. 1 0
      CHANGELOG.md
  2. 70 0
      backend/app/services/github_backup.py
  3. 316 12
      backend/app/services/github_restore.py
  4. 469 0
      backend/tests/integration/test_git_backup_runs.py
  5. 5 0
      frontend/src/i18n/locales/de.ts
  6. 5 0
      frontend/src/i18n/locales/en.ts
  7. 5 0
      frontend/src/i18n/locales/es.ts
  8. 5 0
      frontend/src/i18n/locales/fr.ts
  9. 5 0
      frontend/src/i18n/locales/it.ts
  10. 5 0
      frontend/src/i18n/locales/ja.ts
  11. 5 0
      frontend/src/i18n/locales/ko.ts
  12. 5 0
      frontend/src/i18n/locales/nl.ts
  13. 5 0
      frontend/src/i18n/locales/pt-BR.ts
  14. 5 0
      frontend/src/i18n/locales/ru.ts
  15. 5 0
      frontend/src/i18n/locales/sv.ts
  16. 5 0
      frontend/src/i18n/locales/tr.ts
  17. 5 0
      frontend/src/i18n/locales/uk.ts
  18. 5 0
      frontend/src/i18n/locales/zh-CN.ts
  19. 5 0
      frontend/src/i18n/locales/zh-TW.ts
  20. 0 0
      static/assets/ArchivesPage-BfhEWJ1i.js
  21. 0 0
      static/assets/CamWallPage-CbJ9BcgF.js
  22. 0 0
      static/assets/CameraPage-DJm2ElGD.js
  23. 0 0
      static/assets/FileManagerPage-BSoMHJEc.js
  24. 0 0
      static/assets/InventoryPage-CnVha-n8.js
  25. 0 0
      static/assets/Layout-DaXS8Glt.js
  26. 0 0
      static/assets/ModelSourcesPage-CWtA1Sw3.js
  27. 0 0
      static/assets/PhotoGalleryModal-CdHJisSV.js
  28. 0 0
      static/assets/PrintersPage-D2yvw3Pn.js
  29. 0 0
      static/assets/SettingsPage-DMD4NV9W.js
  30. 0 0
      static/assets/SliceModal-CMP5M6E_.js
  31. 0 0
      static/assets/SpoolBuddyInventoryPage-BBJahJ2O.js
  32. 1 1
      static/assets/SpoolBuddySettingsPage-Ba9epOZ4.js
  33. 0 0
      static/assets/de-B1H3-1Tb.js
  34. 0 0
      static/assets/es-2ynGqczX.js
  35. 0 0
      static/assets/fr-SbrG7YkC.js
  36. 0 0
      static/assets/index-akmrX1eO.js
  37. 0 0
      static/assets/it-DESzbcWr.js
  38. 0 0
      static/assets/ja-BGSFXmWe.js
  39. 0 0
      static/assets/ko-bRaPo11M.js
  40. 0 0
      static/assets/locationSensorDefaults-CCmaZS2k.js
  41. 0 0
      static/assets/nl-85HaNq0-.js
  42. 0 0
      static/assets/pt-BR-D7_haw2T.js
  43. 0 0
      static/assets/ru-DYjhcHvT.js
  44. 0 0
      static/assets/spoolCost-DqlDJCVl.js
  45. 0 0
      static/assets/sv-Bv9H9UOT.js
  46. 0 0
      static/assets/tr-kyMaffzV.js
  47. 0 0
      static/assets/uk-CJ2qa1V4.js
  48. 0 0
      static/assets/zh-CN-DuZCGb_F.js
  49. 0 0
      static/assets/zh-TW-DM8ZBDzp.js
  50. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -209,6 +209,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Git backup: prints come back with their archives** — A Git backup held archives only, so restoring onto a rebuilt instance gave archives without their prints: statistics and the Print Log came back empty, and each print's printer, plate, outcome, cost and energy was lost. The backup now also writes each print (`archives/print_runs.json`) and which archive was merged into which (`archives/aliases.json`), and **Print archives** restores both: each print is attached to its restored archive, which then shows its latest print's outcome. An archive printed again since the backup is recognised by when it was archived and its prints' start times instead of being added a second time, and an archive merged into another, before or after the backup, is not restored as one of its own, with its spool usage going to the archive it went into. A print running when the backup was made comes back cancelled, like its archive. The one-tap confirm link, photos, timelapses and wallet links are not in the backup. Older backups restore as before. Wiki updated.
 - **Orca Cloud presets with quoted string lists** - Orca Cloud returns lists like compatible_printers as one INI-quoted string, so its processes were hidden for every printer, and a process made for a user printer preset was refused by the slicer. Bambuddy now reads these lists the way OrcaSlicer does, in the preset list and on the slice path. Reported in #3380, fixed by @pleasantone in #3381.
 - **A camera opened in the iOS home-screen web app can be left again (#3419)** — In Bambuddy added to the home screen ("Add to Home Screen" in Safari), opening a camera in window mode loaded the camera page into the app itself, full screen, with no way back short of closing the app. A web app has no second window to open, so its cameras now always open as the floating overlay: the camera button offers no window mode, and the overlay has no pop-out button. A Camera Wall tile opens the camera page in place. The camera page has a **Back** button whenever it was not opened as a window (web app, bookmark, link), which returns to the previous Bambuddy page or the start page, and its pop-in does the same there instead of trying to close itself. Bambuddy installed as an app on a desktop or Android is unchanged: a camera window opens normally there. Wiki updated.
 - **The connection diagnostic no longer calls a macvlan container bridge networking (#3417, reported by @ddavidebor)** — A container on a Docker macvlan network has its own address on the LAN, so printer discovery and the Virtual Printer work, yet the diagnostic warned that it was on bridge networking and should be recreated with host networking. The interface is moved into the container from the host, like a bridge network's, and the check could not tell the two apart. It now reads the interface's link type: macvlan, and ipvlan in L2 mode, pass, and the subnet check runs as on host networking. ipvlan in L3 mode gets its own warning: it passes no broadcast or multicast, so the slicer can't find the Virtual Printer and printers can only be found with the subnet scan. Where the link type can't be read, the check now says the network mode could not be determined rather than guessing bridge. The support package reads the network mode the same way. Wiki updated.

+ 70 - 0
backend/app/services/github_backup.py

@@ -14,7 +14,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.database import async_session
 from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN, redact_refused_headers
 from backend.app.models.archive import PrintArchive
+from backend.app.models.archive_alias import ArchiveAlias
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
@@ -873,6 +875,12 @@ class GitHubBackupService:
         result = await db.execute(select(PrintArchive))
         archives = result.scalars().all()
 
+        # Its prints and merges, written whenever archives are: a push keeps
+        # the files a backup leaves out, so skipping an empty list would leave
+        # an earlier backup's beside these archives.
+        await self._collect_print_runs(db, files)
+        await self._collect_archive_aliases(db, files)
+
         if not archives:
             return
 
@@ -949,6 +957,68 @@ class GitHubBackupService:
 
         logger.info("Collected %d print archives", len(archive_list))
 
+    async def _collect_print_runs(self, db: AsyncSession, files: dict) -> None:
+        """Each print of an archive (archive redesign P3): statistics and the
+        print log count these, not archives.
+
+        Left out: the one-tap confirm token (a credential), photo, timelapse
+        and thumbnail paths (their files are not in a Git backup), the energy
+        meter reading at the start (only needed while it prints), and the
+        wallet, cost centre and queue links, none of which are backed up.
+        """
+        runs = (await db.execute(select(PrintLogEntry).execution_options(**{INCLUDE_OPEN_RUNS: True}))).scalars().all()
+        user_names = dict((await db.execute(select(User.id, User.username))).all())
+        files["archives/print_runs.json"] = {
+            "version": "1.0",
+            "runs": [
+                {
+                    "id": r.id,
+                    "archive_id": r.archive_id,
+                    "print_name": r.print_name,
+                    "plate_id": r.plate_id,
+                    "printer_id": r.printer_id,
+                    "printer_name": r.printer_name,
+                    "status": r.status,
+                    "started_at": str(r.started_at) if r.started_at else None,
+                    "completed_at": str(r.completed_at) if r.completed_at else None,
+                    "duration_seconds": r.duration_seconds,
+                    "duration_unmeasured": r.duration_unmeasured,
+                    "ended_offline": r.ended_offline,
+                    "filament_type": r.filament_type,
+                    "filament_color": r.filament_color,
+                    "filament_used_grams": r.filament_used_grams,
+                    "cost": r.cost,
+                    "energy_kwh": r.energy_kwh,
+                    "energy_cost": r.energy_cost,
+                    "wear_cost": r.wear_cost,
+                    "failure_reason": r.failure_reason,
+                    "user_verdict": r.user_verdict,
+                    "user_verdict_source": r.user_verdict_source,
+                    "user_verdict_at": str(r.user_verdict_at) if r.user_verdict_at else None,
+                    "quantity": r.quantity,
+                    "notes": r.notes,
+                    # Who started it, as the print log shows it.
+                    "created_by_username": r.created_by_username,
+                    # Its owner, by name as for archives (ids renumber on a
+                    # rebuilt instance), and only one that still exists: a
+                    # deleted user's name may be someone else's on restore.
+                    "owner_username": user_names.get(r.created_by_id) if r.created_by_id is not None else None,
+                    "created_at": str(r.created_at) if r.created_at else None,
+                }
+                for r in runs
+            ],
+        }
+        logger.info("Collected %d prints", len(runs))
+
+    async def _collect_archive_aliases(self, db: AsyncSession, files: dict) -> None:
+        """Which archive went into which (archive redesign P2), so a restore
+        follows a merged archive to the one it went into."""
+        aliases = (await db.execute(select(ArchiveAlias.merged_archive_id, ArchiveAlias.archive_id))).all()
+        files["archives/aliases.json"] = {
+            "version": "1.0",
+            "aliases": [{"merged_archive_id": merged, "archive_id": into} for merged, into in aliases],
+        }
+
     async def _push_to_provider(self, config: GitHubBackupConfig, files: dict) -> dict:
         """Push files to the configured Git provider."""
         backend = get_provider_backend(config.provider)

+ 316 - 12
backend/app/services/github_restore.py

@@ -37,6 +37,7 @@ import logging
 import math
 import os
 import re
+from collections.abc import Iterable
 from contextvars import ContextVar
 from dataclasses import dataclass, field as dataclasses_field
 from datetime import datetime, timezone
@@ -50,7 +51,9 @@ from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
 from backend.app.core.database import async_session
 from backend.app.core.printer_scope import drop_printers_from_obico
 from backend.app.models.archive import PrintArchive
+from backend.app.models.archive_alias import ArchiveAlias
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
@@ -72,6 +75,10 @@ SETTINGS_PATH = "settings/app_settings.json"
 SPOOLS_PATH = "spools/inventory.json"
 SPOOL_USAGE_PATH = "spools/usage_history.json"
 ARCHIVES_PATH = "archives/print_history.json"
+# Each print of an archive and which archive went into which (archive
+# redesign P3). Backups written before carry neither.
+RUNS_PATH = "archives/print_runs.json"
+ALIASES_PATH = "archives/aliases.json"
 
 # kprofiles/{printer_serial}/{nozzle_diameter}.json
 _KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
@@ -520,6 +527,29 @@ def _restored_number(value, *, maximum: float | None = None, integer: bool = Fal
         return None
 
 
+def _runs_of(payload) -> list:
+    """The prints in a backup's print_runs.json, or none."""
+    runs = payload.get("runs") if isinstance(payload, dict) else None
+    return runs if isinstance(runs, list) else []
+
+
+def _aliases_of(payload) -> dict[int, int]:
+    """merged archive id -> the archive it went into, from aliases.json."""
+    aliases = payload.get("aliases") if isinstance(payload, dict) else None
+    found: dict[int, int] = {}
+    for alias in aliases if isinstance(aliases, list) else []:
+        if not isinstance(alias, dict):
+            continue
+        merged, into = alias.get("merged_archive_id"), alias.get("archive_id")
+        if isinstance(merged, int) and isinstance(into, int) and merged != into:
+            found[merged] = into
+    return found
+
+
+# A print's outcome verdict, as the confirm flow writes it.
+_RUN_VERDICTS = ("good", "reject")
+
+
 class _CategoryTally:
     """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
 
@@ -643,7 +673,7 @@ class GitHubRestoreService:
         if category == RestoreCategory.SPOOLS:
             return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
         if category == RestoreCategory.ARCHIVES:
-            return [p for p in (ARCHIVES_PATH,) if p in available]
+            return [p for p in (ARCHIVES_PATH, RUNS_PATH, ALIASES_PATH) if p in available]
         if category == RestoreCategory.KPROFILES:
             return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
         return []
@@ -1022,6 +1052,14 @@ class GitHubRestoreService:
             payload = parsed.get(ARCHIVES_PATH)
             archives = payload.get("archives") if isinstance(payload, dict) else None
             count = len(archives) if isinstance(archives, list) else 0
+            # Prints are tallied with the archives, so counted here too.
+            runs = _runs_of(parsed.get(RUNS_PATH))
+            if runs:
+                return count + len(runs), _Detail(
+                    "archivesRunsCount",
+                    f"including {len(runs)} print(s) - metadata only, 3MF files and thumbnails are not in a Git backup",
+                    {"count": len(runs)},
+                )
             return count, _Detail(
                 "archivesMetadataOnly", "Metadata only — 3MF files and thumbnails are not in a Git backup"
             )
@@ -1267,7 +1305,16 @@ class GitHubRestoreService:
         if RestoreCategory.ARCHIVES in categories:
             self._progress = "Restoring print archives..."
             tally = _CategoryTally()
-            await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map)
+            await self._restore_archives(
+                db,
+                payload.get(ARCHIVES_PATH),
+                overwrite,
+                tally,
+                archive_id_map,
+                runs_payload=payload.get(RUNS_PATH),
+                aliases_payload=payload.get(ALIASES_PATH),
+            )
+            await self._restore_runs(db, payload.get(RUNS_PATH), overwrite, tally, archive_id_map)
             await db.commit()
             results[RestoreCategory.ARCHIVES.value] = tally
 
@@ -1351,12 +1398,30 @@ class GitHubRestoreService:
         overwrite: bool,
         tally: _CategoryTally,
         id_map: dict[int, int],
+        *,
+        runs_payload=None,
+        aliases_payload=None,
     ) -> None:
         archives = payload.get("archives") if isinstance(payload, dict) else None
         if not isinstance(archives, list):
             tally.note("noData", "No data of this kind in this backup")
             return
 
+        # An archive merged into another is not restored as a row of its own:
+        # what pointed at it (its prints, spool usage) goes to the archive it
+        # went into, as the merge moved them.
+        aliases = _aliases_of(aliases_payload)
+        # Local archives an entry of this restore already went to.
+        claimed: set[int] = set()
+        # Its prints' starts recognise an archive printed again since the
+        # backup: the archive's own start is its latest print's.
+        run_starts: dict[int, set[datetime]] = {}
+        for run in _runs_of(runs_payload):
+            if isinstance(run, dict) and isinstance(run.get("archive_id"), int):
+                started = _parse_dt(run.get("started_at"))
+                if started is not None:
+                    run_starts.setdefault(run["archive_id"], set()).add(started)
+
         valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
         valid_projects = set((await db.execute(select(Project.id))).scalars().all())
         # Ownership decides visibility, not just attribution: an archive with a
@@ -1387,8 +1452,40 @@ class GitHubRestoreService:
                 continue
 
             old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
+            if old_id in aliases:
+                tally.skipped += 1
+                tally.note(
+                    "archivesMergedFollowed",
+                    "Archives merged into another are not restored as rows of their own - what pointed at them "
+                    "goes to the archive they were merged into",
+                )
+                continue
             started_at = _parse_dt(entry.get("started_at"))
-            existing = await self._find_archive(db, entry, started_at)
+            existing = await self._find_archive(
+                db, entry, started_at, run_starts.get(old_id, set()) if old_id is not None else ()
+            )
+            # Merged here since the backup: its prints went into the archive
+            # it went into, and so does everything else of this entry. That
+            # archive is not overwritten with this one's fields, nor with a
+            # second entry's: it holds what the merge joined.
+            followed = False
+            if existing is not None:
+                into = await db.scalar(
+                    select(ArchiveAlias.archive_id).where(ArchiveAlias.merged_archive_id == existing.id)
+                )
+                if into is not None:
+                    existing = await db.get(PrintArchive, into)
+                    followed = True
+            if existing is not None and (followed or existing.id in claimed):
+                if old_id is not None:
+                    id_map[old_id] = existing.id
+                tally.skipped += 1
+                tally.note(
+                    "archivesMergedFollowed",
+                    "Archives merged into another are not restored as rows of their own - what pointed at them "
+                    "goes to the archive they were merged into",
+                )
+                continue
 
             numbers = {
                 "print_time_seconds": _restored_number(entry.get("print_time_seconds"), integer=True),
@@ -1531,6 +1628,7 @@ class GitHubRestoreService:
                 fields["deleted_at"] = _parse_dt(entry.get("deleted_at"))
 
             if existing is not None:
+                claimed.add(existing.id)
                 if old_id is not None:
                     id_map[old_id] = existing.id
                 if not overwrite:
@@ -1595,11 +1693,18 @@ class GitHubRestoreService:
                 row.created_at = created_at
             db.add(row)
             await db.flush()
+            claimed.add(row.id)
             if old_id is not None:
                 id_map[old_id] = row.id
             tally.restored += 1
 
-    async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None:
+        for merged, into in aliases.items():
+            if into in id_map:
+                id_map[merged] = id_map[into]
+
+    async def _find_archive(
+        self, db: AsyncSession, entry: dict, started_at: datetime | None, run_starts: Iterable[datetime] = ()
+    ) -> PrintArchive | None:
         """Match a backed-up archive to a local row by natural key.
 
         ``started_at`` is nullable and genuinely NULL for a whole class of rows —
@@ -1620,23 +1725,222 @@ class GitHubRestoreService:
         filter here because the row still exists, and matching it is what stops a
         restore inserting a live duplicate of an archive the user has deleted.
         """
-        started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None)
+        created_at = _parse_dt(entry.get("created_at"))
+        # A print of it starting then, here or in the backup: printed again
+        # since, the archive's own start is a later print's (archive redesign
+        # P3).
+        starts = set(run_starts) | ({started_at} if started_at else set())
+
+        async def best(where, *, own_start: bool) -> PrintArchive | None:
+            rows = (await db.execute(select(PrintArchive).where(where).order_by(PrintArchive.id))).scalars().all()
+            if not rows:
+                return None
+            # Its own row first, by its own start: one merged since is then
+            # followed by the caller rather than taken for its survivor.
+            if own_start:
+                for row in rows:
+                    if row.started_at == started_at:
+                        return row
+            if starts:
+                # Core table, so a print running now counts too.
+                runs = PrintLogEntry.__table__.c
+                printed = set(
+                    (
+                        await db.execute(
+                            select(runs.archive_id).where(
+                                runs.archive_id.in_([row.id for row in rows]), runs.started_at.in_(starts)
+                            )
+                        )
+                    )
+                    .scalars()
+                    .all()
+                )
+                for row in rows:
+                    if row.id in printed:
+                        return row
+            # When it was archived, which no print changes: an archive printed
+            # since, without a start or print in the backup to go by.
+            return next((row for row in rows if _created_at_matches(row, created_at)), None)
 
         content_hash = entry.get("content_hash")
         if content_hash:
-            result = await db.execute(
-                select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate)
-            )
-            row = result.scalars().first()
+            row = await best(PrintArchive.content_hash == content_hash, own_start=True)
             if row is not None:
                 return row
 
         filename = entry.get("filename")
-        if filename and started_at:
-            result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate))
-            return result.scalars().first()
+        if filename:
+            return await best(PrintArchive.filename == filename, own_start=started_at is not None)
         return None
 
+    async def _restore_runs(
+        self,
+        db: AsyncSession,
+        payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        id_map: dict[int, int],
+    ) -> None:
+        """Each print of the restored archives (archive redesign P3).
+
+        After the archives, through their ``old_id -> new_id`` map. A print
+        already here is the same archive's at the same start on the same
+        plate; it is left alone, or made to match the backup with overwrite.
+        Each archive that gained or changed a print then takes its latest
+        print's outcome, as everywhere else. A backup written before prints
+        were backed up has none, and its archives restore as before.
+        """
+        from backend.app.services.print_log import mirror_latest_run
+
+        runs = _runs_of(payload)
+        if not runs:
+            return
+
+        valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
+        users_by_name = {name: user_id for user_id, name in (await db.execute(select(User.id, User.username))).all()}
+        touched: set[int] = set()
+
+        for entry in runs:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+            backup_archive = entry.get("archive_id")
+            archive_id = None
+            if backup_archive is not None:
+                archive_id = id_map.get(backup_archive) if isinstance(backup_archive, int) else None
+                if archive_id is None:
+                    # Its archive is not in this restore (it failed, or the
+                    # backup is damaged): there is nothing to attach it to.
+                    tally.skipped += 1
+                    tally.note("runsArchiveMissing", "Some prints were skipped - their archive is not in this restore")
+                    continue
+
+            started_at = _parse_dt(entry.get("started_at"))
+            created_at = _parse_dt(entry.get("created_at"))
+            plate_id = entry.get("plate_id") if isinstance(entry.get("plate_id"), int) else None
+            existing = await self._find_run(db, archive_id, started_at, plate_id, created_at, entry)
+
+            numbers = {
+                "duration_seconds": _restored_number(entry.get("duration_seconds"), integer=True),
+                "filament_used_grams": _restored_number(entry.get("filament_used_grams"), maximum=_MAX_RESTORED_GRAMS),
+                "cost": _restored_number(entry.get("cost")),
+                "energy_kwh": _restored_number(entry.get("energy_kwh")),
+                "energy_cost": _restored_number(entry.get("energy_cost")),
+                "wear_cost": _restored_number(entry.get("wear_cost")),
+                "quantity": _restored_number(entry.get("quantity"), maximum=10_000, integer=True),
+            }
+            if any(entry.get(key) is not None and value is None for key, value in numbers.items()):
+                tally.note(
+                    "runsNumbersDropped",
+                    "Some prints had grams, costs, energy or durations that are not valid numbers - left empty",
+                )
+            # A print running when the backup was made is long over: closed
+            # as its archive is (see _restore_archives).
+            status = entry.get("status") if isinstance(entry.get("status"), str) else "completed"
+            failure_reason = entry.get("failure_reason")
+            in_flight = status in (RUN_OPEN, "paused")
+            if in_flight:
+                status, failure_reason = "cancelled", "noStatusUpdate"
+            verdict = entry.get("user_verdict") if entry.get("user_verdict") in _RUN_VERDICTS else None
+
+            printer_id = entry.get("printer_id")
+            if printer_id is not None and printer_id not in valid_printers:
+                tally.note(
+                    "runsPrinterMissing",
+                    "Some prints referenced printers that no longer exist - link cleared, their printer name kept",
+                )
+                printer_id = None
+            username = entry.get("created_by_username")
+            owner_name = entry.get("owner_username")
+            fields = {
+                "archive_id": archive_id,
+                "print_name": entry.get("print_name"),
+                "plate_id": plate_id,
+                "printer_id": printer_id,
+                "printer_name": entry.get("printer_name"),
+                "status": status,
+                "started_at": started_at,
+                "completed_at": _parse_dt(entry.get("completed_at")),
+                "duration_unmeasured": entry.get("duration_unmeasured"),
+                "ended_offline": entry.get("ended_offline"),
+                "filament_type": entry.get("filament_type"),
+                "filament_color": entry.get("filament_color"),
+                "failure_reason": failure_reason,
+                "user_verdict": verdict,
+                "user_verdict_source": entry.get("user_verdict_source") if verdict else None,
+                "user_verdict_at": _parse_dt(entry.get("user_verdict_at")) if verdict else None,
+                "notes": entry.get("notes"),
+                "created_by_username": username if isinstance(username, str) else None,
+                **numbers,
+            }
+            # By name, never the backup's id, and only the owner's: the
+            # recorded name stays when its user is deleted, and may be someone
+            # else's by now. A name this instance does not have is left out,
+            # as for archives: a local print keeps its owner, a new one has
+            # none and keeps the name.
+            owner = users_by_name.get(owner_name) if isinstance(owner_name, str) else None
+            if owner is not None or not isinstance(owner_name, str):
+                fields["created_by_id"] = owner
+
+            if existing is not None:
+                # The local print knows more than the backup's snapshot of it
+                # mid-way (its figures came at its end), and one printing now
+                # is still being written by its print.
+                if not overwrite or in_flight or existing.status == RUN_OPEN:
+                    tally.skipped += 1
+                    continue
+                for key, value in fields.items():
+                    setattr(existing, key, value)
+                tally.restored += 1
+            else:
+                row = PrintLogEntry(**fields)
+                if created_at is not None:
+                    # Statistics date a print by it.
+                    row.created_at = created_at
+                db.add(row)
+                await db.flush()
+                tally.restored += 1
+            if archive_id is not None:
+                touched.add(archive_id)
+
+        for archive_id in touched:
+            await mirror_latest_run(db, archive_id)
+
+    async def _find_run(
+        self,
+        db: AsyncSession,
+        archive_id: int | None,
+        started_at: datetime | None,
+        plate_id: int | None,
+        created_at: datetime | None,
+        entry: dict,
+    ) -> PrintLogEntry | None:
+        """The same print already here: its archive's, started then, on that
+        plate. Without a start, by when it was logged (compared in Python, see
+        _created_at_matches); one without an archive by its printer and name
+        too. A print running now counts."""
+        query = (
+            select(PrintLogEntry)
+            .where(
+                PrintLogEntry.archive_id == archive_id
+                if archive_id is not None
+                else PrintLogEntry.archive_id.is_(None),
+                PrintLogEntry.plate_id == plate_id if plate_id is not None else PrintLogEntry.plate_id.is_(None),
+                PrintLogEntry.started_at == started_at if started_at else PrintLogEntry.started_at.is_(None),
+            )
+            .order_by(PrintLogEntry.id)
+            .execution_options(**{INCLUDE_OPEN_RUNS: True})
+        )
+        if archive_id is None:
+            query = query.where(
+                PrintLogEntry.printer_name == entry.get("printer_name"),
+                PrintLogEntry.print_name == entry.get("print_name"),
+            )
+        candidates = (await db.execute(query)).scalars().all()
+        if started_at:
+            return candidates[0] if candidates else None
+        return next((row for row in candidates if _created_at_matches(row, created_at)), None)
+
     async def _restore_spools(
         self,
         db: AsyncSession,

+ 469 - 0
backend/tests/integration/test_git_backup_runs.py

@@ -0,0 +1,469 @@
+"""A Git backup keeps each print of an archive (archive redesign P3).
+
+Statistics and the print log count prints, not archives: a backup with
+archives only restored them empty. The backup now also writes each print and
+which archive went into which, and the restore attaches the prints to their
+restored archives, recognises an archive printed again since the backup, and
+follows a merged archive to the one it went into.
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+
+import pytest
+from sqlalchemy import delete, func, select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.archive_alias import ArchiveAlias
+from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
+from backend.app.models.user import User
+from backend.app.schemas.github_backup import RestoreCategory
+from backend.app.services.github_backup import github_backup_service
+from backend.app.services.github_restore import (
+    ALIASES_PATH,
+    ARCHIVES_PATH,
+    RUNS_PATH,
+    GitHubRestoreService,
+    _CategoryTally,
+)
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+T1 = datetime(2026, 10, 1, 9)
+T2 = datetime(2026, 10, 5, 9)
+T3 = datetime(2026, 10, 9, 9)
+
+
+async def _backup(db) -> dict:
+    files: dict = {}
+    await github_backup_service._collect_archives(db, files)
+    # As read back out of the repository.
+    return json.loads(json.dumps(files))
+
+
+async def _restore(db, files: dict, overwrite: bool = False) -> _CategoryTally:
+    results = await GitHubRestoreService()._apply(db, files, [RestoreCategory.ARCHIVES], overwrite)
+    return results[RestoreCategory.ARCHIVES.value]
+
+
+async def _all_runs(db) -> list[PrintLogEntry]:
+    return list(
+        (
+            await db.execute(
+                select(PrintLogEntry).order_by(PrintLogEntry.started_at).execution_options(**{INCLUDE_OPEN_RUNS: True})
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+
+async def _wipe(db) -> None:
+    """A rebuilt instance: no archives, prints or merges."""
+    for model in (PrintLogEntry, ArchiveAlias, PrintArchive):
+        await db.execute(delete(model))
+    await db.commit()
+
+
+@pytest.fixture
+async def printed(db_session, printer_factory):
+    """A file printed twice on two plates, with a merged duplicate."""
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    planter = PrintArchive(
+        printer_id=b.id,
+        filename="planter.3mf",
+        file_path="archives/t/planter.3mf",
+        file_size=1,
+        content_hash="hash-planter",
+        print_name="Planter",
+        status="failed",
+        started_at=T2,
+        created_at=T1,
+    )
+    merged = PrintArchive(
+        printer_id=a.id,
+        filename="planter.3mf",
+        file_path="",
+        file_size=1,
+        content_hash="hash-planter",
+        print_name="Planter",
+        status="completed",
+        started_at=T1,
+        created_at=T2,
+        deleted_at=T2,
+    )
+    db_session.add_all([planter, merged])
+    await db_session.flush()
+    db_session.add(ArchiveAlias(merged_archive_id=merged.id, archive_id=planter.id))
+    db_session.add_all(
+        [
+            PrintLogEntry(
+                archive_id=planter.id,
+                printer_id=a.id,
+                printer_name="A",
+                plate_id=1,
+                status="completed",
+                started_at=T1,
+                filament_used_grams=12.0,
+                cost=0.5,
+                user_verdict="good",
+                user_verdict_source="dialog",
+                confirm_token="secret-token",
+                created_at=T1,
+            ),
+            PrintLogEntry(
+                archive_id=planter.id,
+                printer_id=b.id,
+                printer_name="B",
+                plate_id=2,
+                status="failed",
+                started_at=T2,
+                failure_reason="spaghetti",
+                created_at=T2,
+            ),
+        ]
+    )
+    await db_session.commit()
+    return {"a": a.id, "b": b.id, "planter": planter.id, "merged": merged.id}
+
+
+async def test_the_backup_keeps_each_print_and_merge(db_session, printed):
+    files = await _backup(db_session)
+
+    runs = files[RUNS_PATH]["runs"]
+    assert sorted((r["archive_id"], r["plate_id"], r["status"]) for r in runs) == [
+        (printed["planter"], 1, "completed"),
+        (printed["planter"], 2, "failed"),
+    ]
+    assert files[ALIASES_PATH]["aliases"] == [
+        {"merged_archive_id": printed["merged"], "archive_id": printed["planter"]}
+    ]
+    # The one-tap confirm token is a credential.
+    assert "secret-token" not in json.dumps(files)
+
+
+async def test_a_rebuilt_instance_gets_its_prints_back(db_session, printed):
+    files = await _backup(db_session)
+    await _wipe(db_session)
+
+    tally = await _restore(db_session, files)
+
+    archives = (await db_session.execute(select(PrintArchive))).scalars().all()
+    # The merged one is not a row of its own.
+    assert [a.print_name for a in archives] == ["Planter"]
+    runs = await _all_runs(db_session)
+    assert [(r.archive_id, r.plate_id, r.printer_name, r.status, r.user_verdict) for r in runs] == [
+        (archives[0].id, 1, "A", "completed", "good"),
+        (archives[0].id, 2, "B", "failed", None),
+    ]
+    assert (runs[0].filament_used_grams, runs[0].cost, runs[0].created_at) == (12.0, 0.5, T1)
+    assert runs[0].confirm_token is None
+    # 2 archives (1 followed) and 2 prints.
+    assert (tally.restored, tally.skipped, tally.failed) == (3, 1, 0)
+
+
+async def test_the_archive_takes_its_latest_prints_outcome(db_session, printed):
+    files = await _backup(db_session)
+    await _wipe(db_session)
+    # The backup's archive claims the older print's outcome.
+    files[ARCHIVES_PATH]["archives"][0].update(status="completed", started_at=str(T1))
+
+    await _restore(db_session, files)
+
+    [archive] = (await db_session.execute(select(PrintArchive))).scalars().all()
+    assert (archive.status, archive.started_at, archive.failure_reason) == ("failed", T2, "spaghetti")
+
+
+async def test_restoring_twice_adds_nothing(db_session, printed):
+    files = await _backup(db_session)
+    await _wipe(db_session)
+    await _restore(db_session, files)
+
+    tally = await _restore(db_session, files)
+
+    assert await db_session.scalar(select(func.count(PrintArchive.id))) == 1
+    assert len(await _all_runs(db_session)) == 2
+    assert (tally.restored, tally.skipped) == (0, 4)
+
+
+async def test_an_archive_printed_again_since_the_backup_is_recognised(db_session, printed):
+    """Its own start is the newer print's now, so it no longer matches it."""
+    files = await _backup(db_session)
+    archive = await db_session.get(PrintArchive, printed["planter"])
+    archive.started_at = T3
+    db_session.add(PrintLogEntry(archive_id=archive.id, printer_id=printed["a"], status="completed", started_at=T3))
+    await db_session.commit()
+
+    await _restore(db_session, files)
+
+    assert await db_session.scalar(select(func.count(PrintArchive.id))) == 2  # the planter and its merged one
+    assert len(await _all_runs(db_session)) == 3
+
+
+async def test_what_pointed_at_a_merged_archive_follows_it(db_session, printed):
+    files = await _backup(db_session)
+    await _wipe(db_session)
+    id_map: dict[int, int] = {}
+
+    await GitHubRestoreService()._restore_archives(
+        db_session,
+        files[ARCHIVES_PATH],
+        False,
+        _CategoryTally(),
+        id_map,
+        runs_payload=files[RUNS_PATH],
+        aliases_payload=files[ALIASES_PATH],
+    )
+
+    assert id_map[printed["merged"]] == id_map[printed["planter"]]
+
+
+async def test_a_print_running_at_the_backup_comes_back_cancelled(db_session, printed):
+    db_session.add(
+        PrintLogEntry(archive_id=printed["planter"], printer_id=printed["a"], status=RUN_OPEN, started_at=T3)
+    )
+    await db_session.commit()
+    files = await _backup(db_session)
+    await _wipe(db_session)
+
+    await _restore(db_session, files)
+
+    last = (await _all_runs(db_session))[-1]
+    assert (last.started_at, last.status, last.failure_reason) == (T3, "cancelled", "noStatusUpdate")
+
+
+async def test_overwrite_makes_a_print_match_the_backup(db_session, printed):
+    files = await _backup(db_session)
+    run = (await _all_runs(db_session))[0]
+    run.cost, run.user_verdict = 9.0, "reject"
+    await db_session.commit()
+
+    await _restore(db_session, files, overwrite=True)
+
+    await db_session.refresh(run)
+    assert (run.cost, run.user_verdict) == (0.5, "good")
+
+
+async def test_overwrite_leaves_a_print_running_now_to_its_print(db_session, printed):
+    run = PrintLogEntry(archive_id=printed["planter"], printer_id=printed["a"], status=RUN_OPEN, started_at=T3)
+    db_session.add(run)
+    await db_session.commit()
+    files = await _backup(db_session)
+    # Even a backup that says it ended (another instance's, say).
+    for entry in files[RUNS_PATH]["runs"]:
+        entry["status"] = "completed"
+
+    await _restore(db_session, files, overwrite=True)
+
+    await db_session.refresh(run)
+    assert run.status == RUN_OPEN
+
+
+async def test_an_owner_this_instance_lacks_keeps_the_local_one(db_session, printed):
+    owner = User(username="alice", role="operator")
+    db_session.add(owner)
+    await db_session.flush()
+    run = (await _all_runs(db_session))[0]
+    run.created_by_id = owner.id
+    await db_session.commit()
+    files = await _backup(db_session)
+    for entry in files[RUNS_PATH]["runs"]:
+        entry["owner_username"] = "bob"
+
+    await _restore(db_session, files, overwrite=True)
+
+    await db_session.refresh(run)
+    assert run.created_by_id == owner.id
+
+
+async def test_the_preview_counts_the_prints(db_session, printed):
+    files = await _backup(db_session)
+
+    count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.ARCHIVES, files)
+
+    assert (count, detail.code, detail.params) == (4, "archivesRunsCount", {"count": 2})
+
+
+async def test_the_restore_reads_the_new_files():
+    paths = GitHubRestoreService()._category_paths(
+        RestoreCategory.ARCHIVES, [ARCHIVES_PATH, RUNS_PATH, ALIASES_PATH, "settings/app_settings.json"]
+    )
+
+    assert paths == [ARCHIVES_PATH, RUNS_PATH, ALIASES_PATH]
+
+
+async def test_a_backup_from_before_a_merge_finds_the_archive_it_went_into(db_session, printer_factory):
+    """Not the merged row, which kept its own start but none of its prints."""
+    printer = await printer_factory(name="A")
+    first, second = (
+        PrintArchive(
+            printer_id=printer.id,
+            filename="hook.3mf",
+            file_path="archives/t/hook.3mf",
+            file_size=1,
+            content_hash="hash-hook",
+            status="completed",
+            started_at=start,
+        )
+        for start in (T1, T2)
+    )
+    db_session.add_all([first, second])
+    await db_session.flush()
+    db_session.add_all(
+        [
+            PrintLogEntry(archive_id=first.id, printer_id=printer.id, status="completed", started_at=T1),
+            PrintLogEntry(archive_id=second.id, printer_id=printer.id, status="completed", started_at=T2),
+        ]
+    )
+    await db_session.commit()
+    files = await _backup(db_session)
+    assert files[ALIASES_PATH]["aliases"] == []
+    # Merged since: the prints went into the second, the first (found
+    # first) is an alias.
+    for run in await _all_runs(db_session):
+        run.archive_id = second.id
+    first.deleted_at = T3
+    db_session.add(ArchiveAlias(merged_archive_id=first.id, archive_id=second.id))
+    await db_session.commit()
+
+    await _restore(db_session, files)
+
+    runs = await _all_runs(db_session)
+    assert [(r.archive_id, r.started_at) for r in runs] == [(second.id, T1), (second.id, T2)]
+    assert await db_session.scalar(select(func.count(PrintArchive.id))) == 2
+
+
+async def _two_of_one_file(db, printer_id, *, second_printed: bool = True):
+    first, second = (
+        PrintArchive(
+            printer_id=printer_id,
+            filename="hook.3mf",
+            file_path="archives/t/hook.3mf",
+            file_size=1,
+            content_hash="hash-hook",
+            status="completed" if start else "archived",
+            started_at=start,
+            notes=notes,
+            created_at=created,
+        )
+        for start, notes, created in ((T1, "first", T1), (T2 if second_printed else None, "second", T2))
+    )
+    db.add_all([first, second])
+    await db.flush()
+    db.add(PrintLogEntry(archive_id=first.id, printer_id=printer_id, status="completed", started_at=T1))
+    if second_printed:
+        db.add(PrintLogEntry(archive_id=second.id, printer_id=printer_id, status="completed", started_at=T2))
+    await db.commit()
+    return first, second
+
+
+async def _merge_into(db, survivor, merged, notes=None) -> None:
+    """As the merge leaves them: prints moved, the merged row an alias."""
+    for run in await _all_runs(db):
+        run.archive_id = survivor.id
+    merged.deleted_at = T3
+    if notes:
+        survivor.notes = notes
+    db.add(ArchiveAlias(merged_archive_id=merged.id, archive_id=survivor.id))
+    await db.commit()
+
+
+async def test_a_never_printed_archive_merged_since_stays_merged(db_session, printer_factory):
+    printer = await printer_factory(name="A")
+    printed, upload = await _two_of_one_file(db_session, printer.id, second_printed=False)
+    files = await _backup(db_session)
+    await _merge_into(db_session, printed, upload)
+
+    await _restore(db_session, files)
+
+    live = (await db_session.execute(select(PrintArchive).where(PrintArchive.deleted_at.is_(None)))).scalars().all()
+    assert [a.id for a in live] == [printed.id]
+
+
+async def test_overwrite_leaves_what_a_merge_joined(db_session, printer_factory):
+    """Both archives of the backup are the one here now."""
+    printer = await printer_factory(name="A")
+    first, second = await _two_of_one_file(db_session, printer.id)
+    files = await _backup(db_session)
+    await _merge_into(db_session, first, second, notes="first\nsecond")
+
+    await _restore(db_session, files, overwrite=True)
+
+    await db_session.refresh(first)
+    # The first's own entry is restored over it; the second's goes to it,
+    # without writing its fields over the first's.
+    assert first.notes == "first"
+    assert len(await _all_runs(db_session)) == 2
+
+
+async def test_an_archive_without_a_start_printed_since_is_recognised(db_session, printer_factory):
+    printer = await printer_factory(name="A")
+    _, upload = await _two_of_one_file(db_session, printer.id, second_printed=False)
+    files = await _backup(db_session)
+    upload.started_at = T3
+    db_session.add(PrintLogEntry(archive_id=upload.id, printer_id=printer.id, status="completed", started_at=T3))
+    await db_session.commit()
+
+    await _restore(db_session, files)
+
+    assert await db_session.scalar(select(func.count(PrintArchive.id))) == 2
+
+
+async def test_a_print_running_at_the_backup_keeps_its_figures_from_its_end(db_session, printed):
+    run = PrintLogEntry(archive_id=printed["planter"], printer_id=printed["a"], status=RUN_OPEN, started_at=T3)
+    db_session.add(run)
+    await db_session.commit()
+    files = await _backup(db_session)
+    run.status, run.cost, run.duration_seconds = "completed", 1.2, 3600
+    await db_session.commit()
+
+    await _restore(db_session, files, overwrite=True)
+
+    await db_session.refresh(run)
+    assert (run.status, run.cost, run.duration_seconds) == ("completed", 1.2, 3600)
+
+
+async def test_a_deleted_users_prints_go_to_no_one_with_their_name(db_session, printed):
+    """The print log keeps the name of who started it; the owner went."""
+    run = (await _all_runs(db_session))[0]
+    run.created_by_id, run.created_by_username = None, "alice"
+    await db_session.commit()
+    files = await _backup(db_session)
+    await _wipe(db_session)
+    db_session.add(User(username="alice", role="operator"))
+    await db_session.commit()
+
+    await _restore(db_session, files)
+
+    restored = (await _all_runs(db_session))[0]
+    assert (restored.created_by_id, restored.created_by_username) == (None, "alice")
+
+
+async def test_a_merged_archive_purged_since_is_found_by_its_prints(db_session, printer_factory):
+    """Its row is gone, its alias and prints are in the one it went into."""
+    printer = await printer_factory(name="A")
+    first, second = await _two_of_one_file(db_session, printer.id)
+    files = await _backup(db_session)
+    await _merge_into(db_session, first, second, notes="first\nsecond")
+    await db_session.delete(second)
+    await db_session.commit()
+
+    await _restore(db_session, files, overwrite=True)
+
+    await db_session.refresh(first)
+    assert await db_session.scalar(select(func.count(PrintArchive.id))) == 1
+    assert first.notes == "first"
+
+
+async def test_a_backup_from_before_a_merge_into_the_newer_overwrites_only_it(db_session, printer_factory):
+    printer = await printer_factory(name="A")
+    first, second = await _two_of_one_file(db_session, printer.id)
+    files = await _backup(db_session)
+    await _merge_into(db_session, second, first, notes="second\nfirst")
+
+    await _restore(db_session, files, overwrite=True)
+
+    await db_session.refresh(second)
+    assert second.notes == "second"

+ 5 - 0
frontend/src/i18n/locales/de.ts

@@ -5812,6 +5812,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} Einstellung(en) werden übersprungen - Werte, die die Einstellungen nicht annehmen würden, oder Schlüssel, die ein Backup nicht wiederherstellt',
         spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
         archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
+        archivesRunsCount: 'einschließlich {{count}} Druck(e) - nur Metadaten, 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
         kprofilesPrinterCount: 'über {{count}} Drucker',
       },
       notes: {
@@ -5825,6 +5826,10 @@ export default {
         archivesOwnerUnknown: 'Einige Archive wurden ohne Eigentümer wiederhergestellt - diese Sicherung enthält keinen, daher sind sie nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
         archivesUndeleted: 'Seit dem Backup gelöschte Archive sind wieder sichtbar - Überschreiben war aktiv',
         archivesMetadataOnly: 'Wiederhergestellte Archive enthalten nur Metadaten - die 3MF- und Vorschaudateien sind nicht im Git-Backup enthalten',
+        archivesMergedFollowed: 'In ein anderes Archiv zusammengeführte Archive werden nicht als eigene Einträge wiederhergestellt - was auf sie verwies, geht an das Archiv, in das sie zusammengeführt wurden',
+        runsArchiveMissing: 'Einige Drucke wurden übersprungen - ihr Archiv ist nicht Teil dieser Wiederherstellung',
+        runsNumbersDropped: 'Einige Drucke hatten Gramm, Kosten, Energie oder Dauern, die keine gültigen Zahlen sind - leer gelassen',
+        runsPrinterMissing: 'Einige Drucke verwiesen auf Drucker, die nicht mehr existieren - Verknüpfung entfernt, Druckername behalten',
         spoolUsageUnresolved: '{{count}} Verbrauchseinträge übersprungen - ihre Spule ist nicht in der Spulenliste dieses Backups, es gibt also nichts, woran sie hängen könnten.',
         spoolUsageUnlinked: '{{count}} Verbrauchseinträge ohne Verknüpfung zum Druckverlauf wiederhergestellt - wählen Sie Druckarchive zusammen mit dem Spulenbestand, um sie zu behalten.',
         spoolTagKept: '{{count}} Spulen-Tags unverändert gelassen - das Backup hätte einen inzwischen gescannten Tag gelöscht oder ihn auf eine zweite Spule verschoben.',

+ 5 - 0
frontend/src/i18n/locales/en.ts

@@ -5861,6 +5861,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} setting(s) will be skipped - values Settings would not accept, or keys a backup does not restore',
         spoolsUsageCount: 'including {{count}} usage record(s)',
         archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
+        archivesRunsCount: 'including {{count}} print(s) - metadata only, 3MF files and thumbnails are not in a Git backup',
         kprofilesPrinterCount: 'across {{count}} printer(s)',
       },
       // Tally notes, same contract. noData is shared by all four categories:
@@ -5877,6 +5878,10 @@ export default {
         archivesOwnerUnknown: 'Some archives were restored without an owner - this backup does not record one, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
         archivesUndeleted: 'Archive(s) deleted since the backup are visible again - overwrite was on',
         archivesMetadataOnly: 'Restored archives carry metadata only - the 3MF and thumbnail files are not in a Git backup',
+        archivesMergedFollowed: 'Archives merged into another are not restored as rows of their own - what pointed at them goes to the archive they were merged into',
+        runsArchiveMissing: 'Some prints were skipped - their archive is not in this restore',
+        runsNumbersDropped: 'Some prints had grams, costs, energy or durations that are not valid numbers - left empty',
+        runsPrinterMissing: 'Some prints referenced printers that no longer exist - link cleared, their printer name kept',
         spoolUsageUnresolved: '{{count}} usage record(s) skipped - their spool is not in this backup\'s spool list, so there is nothing to attach them to.',
         spoolUsageUnlinked: '{{count}} usage record(s) restored without their print-history link - select Print archives alongside Spool inventory to keep it.',
         spoolTagKept: '{{count}} spool tag(s) left as they are - the backup would have cleared a tag that has since been scanned, or moved one onto a second spool.',

+ 5 - 0
frontend/src/i18n/locales/es.ts

@@ -5819,6 +5819,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} ajuste(s) se omitirán - valores que Ajustes no aceptaría o claves que una copia de seguridad no restaura',
         spoolsUsageCount: 'incluidos {{count}} registros de consumo',
         archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git',
+        archivesRunsCount: 'incluidas {{count}} impresiones - solo metadatos, los archivos 3MF y las miniaturas no están en una copia de Git',
         kprofilesPrinterCount: 'en {{count}} impresoras',
       },
       notes: {
@@ -5832,6 +5833,10 @@ export default {
         archivesOwnerUnknown: 'Algunos archivos se restauraron sin propietario - esta copia de seguridad no registra ninguno, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
         archivesUndeleted: 'Los archivos eliminados desde la copia vuelven a estar visibles - la sobrescritura estaba activada',
         archivesMetadataOnly: 'Los archivos restaurados solo contienen metadatos - los ficheros 3MF y las miniaturas no están en una copia de Git',
+        archivesMergedFollowed: 'Los archivos fusionados en otro no se restauran como filas propias - lo que apuntaba a ellos pasa al archivo en el que se fusionaron',
+        runsArchiveMissing: 'Se omitieron algunas impresiones - su archivo no forma parte de esta restauración',
+        runsNumbersDropped: 'Algunas impresiones tenían gramos, costes, energía o duraciones que no son números válidos - se dejaron vacíos',
+        runsPrinterMissing: 'Algunas impresiones hacían referencia a impresoras que ya no existen - enlace eliminado, se conserva el nombre de la impresora',
         spoolUsageUnresolved: '{{count}} registros de consumo omitidos - su bobina no está en la lista de bobinas de esta copia, así que no hay nada a lo que asociarlos.',
         spoolUsageUnlinked: '{{count}} registros de consumo restaurados sin su enlace al historial de impresión - selecciona Archivos de impresión junto con Inventario de bobinas para conservarlo.',
         spoolTagKept: '{{count}} etiquetas de bobina se han dejado como estaban - la copia habría borrado una etiqueta escaneada desde entonces, o la habría movido a una segunda bobina.',

+ 5 - 0
frontend/src/i18n/locales/fr.ts

@@ -5799,6 +5799,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} paramètre(s) seront ignorés - des valeurs que les Paramètres n\'accepteraient pas, ou des clés qu\'une sauvegarde ne restaure pas',
         spoolsUsageCount: 'dont {{count}} enregistrements de consommation',
         archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
+        archivesRunsCount: 'dont {{count}} impression(s) - métadonnées uniquement, les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
         kprofilesPrinterCount: 'sur {{count}} imprimantes',
       },
       notes: {
@@ -5812,6 +5813,10 @@ export default {
         archivesOwnerUnknown: 'Certaines archives ont été restaurées sans propriétaire - cette sauvegarde n\'en enregistre aucun, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
         archivesUndeleted: 'Les archives supprimées depuis la sauvegarde sont de nouveau visibles - l\'écrasement était activé',
         archivesMetadataOnly: 'Les archives restaurées ne contiennent que des métadonnées - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
+        archivesMergedFollowed: 'Les archives fusionnées dans une autre ne sont pas restaurées comme lignes propres - ce qui pointait vers elles va à l\'archive dans laquelle elles ont été fusionnées',
+        runsArchiveMissing: 'Certaines impressions ont été ignorées - leur archive ne fait pas partie de cette restauration',
+        runsNumbersDropped: 'Certaines impressions avaient des grammes, coûts, énergies ou durées qui ne sont pas des nombres valides - laissés vides',
+        runsPrinterMissing: 'Certaines impressions faisaient référence à des imprimantes qui n\'existent plus - lien supprimé, nom de l\'imprimante conservé',
         spoolUsageUnresolved: '{{count}} enregistrements de consommation ignorés - leur bobine ne figure pas dans la liste des bobines de cette sauvegarde, il n\'y a donc rien à quoi les rattacher.',
         spoolUsageUnlinked: '{{count}} enregistrements de consommation restaurés sans leur lien vers l\'historique d\'impression - sélectionnez Archives d\'impression en même temps que l\'Inventaire des bobines pour le conserver.',
         spoolTagKept: '{{count}} étiquettes de bobine laissées telles quelles - la sauvegarde aurait effacé une étiquette scannée depuis, ou l\'aurait déplacée sur une seconde bobine.',

+ 5 - 0
frontend/src/i18n/locales/it.ts

@@ -5798,6 +5798,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} impostazione/i verranno saltate - valori che le Impostazioni non accetterebbero o chiavi che un backup non ripristina',
         spoolsUsageCount: 'inclusi {{count}} record di consumo',
         archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git',
+        archivesRunsCount: 'incluse {{count}} stampe - solo metadati, i file 3MF e le miniature non sono in un backup Git',
         kprofilesPrinterCount: 'su {{count}} stampanti',
       },
       notes: {
@@ -5811,6 +5812,10 @@ export default {
         archivesOwnerUnknown: 'Alcuni archivi sono stati ripristinati senza proprietario - questo backup non ne registra alcuno, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
         archivesUndeleted: 'Gli archivi eliminati dopo il backup sono di nuovo visibili - la sovrascrittura era attiva',
         archivesMetadataOnly: 'Gli archivi ripristinati contengono solo metadati - i file 3MF e le miniature non sono in un backup Git',
+        archivesMergedFollowed: 'Gli archivi uniti a un altro non vengono ripristinati come righe proprie - ciò che puntava a loro passa all\'archivio in cui sono stati uniti',
+        runsArchiveMissing: 'Alcune stampe sono state saltate - il loro archivio non fa parte di questo ripristino',
+        runsNumbersDropped: 'Alcune stampe avevano grammi, costi, energia o durate che non sono numeri validi - lasciati vuoti',
+        runsPrinterMissing: 'Alcune stampe facevano riferimento a stampanti che non esistono più - collegamento rimosso, nome della stampante mantenuto',
         spoolUsageUnresolved: '{{count}} record di consumo saltati - la loro bobina non è nell\'elenco bobine di questo backup, quindi non c\'è nulla a cui collegarli.',
         spoolUsageUnlinked: '{{count}} record di consumo ripristinati senza il collegamento alla cronologia di stampa - seleziona Archivi di stampa insieme a Inventario bobine per mantenerlo.',
         spoolTagKept: '{{count}} tag bobina lasciati invariati - il backup avrebbe cancellato un tag nel frattempo scansionato, oppure lo avrebbe spostato su una seconda bobina.',

+ 5 - 0
frontend/src/i18n/locales/ja.ts

@@ -5812,6 +5812,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} 件の設定はスキップされます - 設定画面が受け付けない値、またはバックアップから復元しないキーです',
         spoolsUsageCount: '使用履歴 {{count}} 件を含む',
         archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません',
+        archivesRunsCount: '{{count}} 件の印刷を含む - メタデータのみ、3MF ファイルとサムネイルは Git バックアップに含まれません',
         kprofilesPrinterCount: 'プリンター {{count}} 台分',
       },
       notes: {
@@ -5825,6 +5826,10 @@ export default {
         archivesOwnerUnknown: '一部のアーカイブは所有者なしで復元されました - このバックアップに所有者が記録されていないため、管理者が割り当てるまで archives:read_all 権限を持つユーザーにしか表示されません',
         archivesUndeleted: 'バックアップ後に削除されたアーカイブが再び表示されます - 上書きが有効でした',
         archivesMetadataOnly: '復元されたアーカイブはメタデータのみです - 3MF ファイルとサムネイルは Git バックアップに含まれません',
+        archivesMergedFollowed: '別のアーカイブに統合されたアーカイブは独立した行として復元されません - それらを参照していたものは統合先のアーカイブに移ります',
+        runsArchiveMissing: '一部の印刷がスキップされました - そのアーカイブはこの復元に含まれていません',
+        runsNumbersDropped: '一部の印刷のグラム数、コスト、電力量、所要時間が有効な数値ではありません - 空のままにしました',
+        runsPrinterMissing: '一部の印刷が存在しなくなったプリンターを参照していました - リンクを解除し、プリンター名は保持しました',
         spoolUsageUnresolved: '使用履歴 {{count}} 件をスキップしました - 対応するスプールがこのバックアップのスプール一覧にないため、紐付ける先がありません。',
         spoolUsageUnlinked: '使用履歴 {{count}} 件を印刷履歴へのリンクなしで復元しました - リンクを保持するにはスプール在庫と一緒に印刷アーカイブも選択してください。',
         spoolTagKept: 'スプールタグ {{count}} 件をそのままにしました - バックアップの内容ではその後スキャンされたタグが消えるか、別のスプールに移ってしまうためです。',

+ 5 - 0
frontend/src/i18n/locales/ko.ts

@@ -5575,6 +5575,7 @@ export default {
         settingsNotRestorableWillSkip: '설정 {{count}}개를 건너뜁니다 - 설정 화면이 받아들이지 않는 값이거나 백업에서 복원하지 않는 키입니다',
         spoolsUsageCount: '사용 기록 {{count}}건 포함',
         archivesMetadataOnly: '메타데이터만 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
+        archivesRunsCount: '인쇄 {{count}}건 포함 - 메타데이터만, 3MF 파일과 썸네일은 Git 백업에 없습니다',
         kprofilesPrinterCount: '프린터 {{count}}대 분량',
       },
       notes: {
@@ -5588,6 +5589,10 @@ export default {
         archivesOwnerUnknown: '일부 아카이브가 소유자 없이 복원되었습니다 - 이 백업에 소유자가 기록되어 있지 않으므로 관리자가 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
         archivesUndeleted: '백업 이후 삭제된 아카이브가 다시 표시됩니다 - 덮어쓰기가 켜져 있었습니다',
         archivesMetadataOnly: '복원된 아카이브에는 메타데이터만 있습니다 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
+        archivesMergedFollowed: '다른 아카이브로 병합된 아카이브는 별도 행으로 복원되지 않습니다 - 이를 가리키던 항목은 병합된 아카이브로 이동합니다',
+        runsArchiveMissing: '일부 인쇄를 건너뛰었습니다 - 해당 아카이브가 이 복원에 포함되어 있지 않습니다',
+        runsNumbersDropped: '일부 인쇄의 그램, 비용, 에너지 또는 소요 시간이 유효한 숫자가 아닙니다 - 비워 두었습니다',
+        runsPrinterMissing: '일부 인쇄가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제하고 프린터 이름은 유지했습니다',
         spoolUsageUnresolved: '사용 기록 {{count}}건을 건너뛰었습니다 - 해당 스풀이 이 백업의 스풀 목록에 없어 연결할 대상이 없습니다.',
         spoolUsageUnlinked: '사용 기록 {{count}}건을 출력 기록 연결 없이 복원했습니다 - 연결을 유지하려면 스풀 재고와 함께 출력 아카이브도 선택하세요.',
         spoolTagKept: '스풀 태그 {{count}}개를 그대로 두었습니다 - 백업대로라면 그 사이 스캔된 태그가 지워지거나 다른 스풀로 옮겨졌을 것입니다.',

+ 5 - 0
frontend/src/i18n/locales/nl.ts

@@ -5859,6 +5859,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} instelling(en) worden overgeslagen - waarden die Instellingen niet zou accepteren, of sleutels die een back-up niet herstelt',
         spoolsUsageCount: 'inclusief {{count}} verbruiksrecord(s)',
         archivesMetadataOnly: 'Alleen metadata - 3MF-bestanden en miniaturen zitten niet in een Git-back-up',
+        archivesRunsCount: 'inclusief {{count}} print(s) - alleen metadata, 3MF-bestanden en miniaturen zitten niet in een Git-back-up',
         kprofilesPrinterCount: 'over {{count}} printer(s)',
       },
       // Tally notes, same contract. noData is shared by all four categories:
@@ -5875,6 +5876,10 @@ export default {
         archivesOwnerUnknown: 'Sommige archieven zijn zonder eigenaar hersteld - deze back-up registreert er geen, waardoor ze alleen zichtbaar zijn voor gebruikers met de machtiging archives:read_all totdat een beheerder ze opnieuw toewijst',
         archivesUndeleted: 'Archief/archieven die sinds de back-up waren verwijderd zijn weer zichtbaar - overschrijven stond aan',
         archivesMetadataOnly: 'Herstelde archieven bevatten alleen metadata - de 3MF- en miniatuurbestanden zitten niet in een Git-back-up',
+        archivesMergedFollowed: 'Archieven die in een ander zijn samengevoegd worden niet als eigen rijen hersteld - wat naar hen verwees gaat naar het archief waarin ze zijn samengevoegd',
+        runsArchiveMissing: 'Sommige prints zijn overgeslagen - hun archief maakt geen deel uit van dit herstel',
+        runsNumbersDropped: 'Sommige prints hadden grammen, kosten, energie of duur die geen geldige getallen zijn - leeg gelaten',
+        runsPrinterMissing: 'Sommige prints verwezen naar printers die niet meer bestaan - koppeling verwijderd, printernaam behouden',
         spoolUsageUnresolved: '{{count}} verbruiksrecord(s) overgeslagen - hun spoel staat niet in de spoellijst van deze back-up, dus er is niets om ze aan te koppelen.',
         spoolUsageUnlinked: '{{count}} verbruiksrecord(s) hersteld zonder koppeling met afdrukgeschiedenis - selecteer Afdrukarchieven samen met Spoelvoorraad om deze te behouden.',
         spoolTagKept: '{{count}} spoeltag(s) gelaten zoals ze zijn - de back-up zou een tag hebben gewist die sindsdien is gescand, of een tag naar een tweede spoel hebben verplaatst.',

+ 5 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5798,6 +5798,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} configuração(ões) serão ignoradas - valores que as Configurações não aceitariam ou chaves que um backup não restaura',
         spoolsUsageCount: 'incluindo {{count}} registros de consumo',
         archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
+        archivesRunsCount: 'incluindo {{count}} impressão(ões) - somente metadados, arquivos 3MF e miniaturas não estão em um backup Git',
         kprofilesPrinterCount: 'em {{count}} impressoras',
       },
       notes: {
@@ -5811,6 +5812,10 @@ export default {
         archivesOwnerUnknown: 'Alguns arquivos foram restaurados sem proprietário - este backup não registra nenhum, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os atribua',
         archivesUndeleted: 'Arquivos excluídos desde o backup voltaram a ficar visíveis - a sobrescrita estava ligada',
         archivesMetadataOnly: 'Os arquivos restaurados contêm apenas metadados - os arquivos 3MF e as miniaturas não ficam em um backup Git',
+        archivesMergedFollowed: 'Arquivos mesclados em outro não são restaurados como linhas próprias - o que apontava para eles vai para o arquivo em que foram mesclados',
+        runsArchiveMissing: 'Algumas impressões foram ignoradas - o arquivo delas não faz parte desta restauração',
+        runsNumbersDropped: 'Algumas impressões tinham gramas, custos, energia ou durações que não são números válidos - deixados vazios',
+        runsPrinterMissing: 'Algumas impressões referenciavam impressoras que não existem mais - vínculo removido, nome da impressora mantido',
         spoolUsageUnresolved: '{{count}} registros de consumo ignorados - o carretel deles não está na lista de carretéis deste backup, então não há a que vinculá-los.',
         spoolUsageUnlinked: '{{count}} registros de consumo restaurados sem o vínculo com o histórico de impressão - selecione Arquivos de impressão junto com Inventário de carretéis para mantê-lo.',
         spoolTagKept: '{{count}} etiquetas de carretel foram mantidas como estavam - o backup teria apagado uma etiqueta lida desde então, ou a teria movido para um segundo carretel.',

+ 5 - 0
frontend/src/i18n/locales/ru.ts

@@ -5567,6 +5567,7 @@ export default {
         settingsNotRestorableWillSkip: 'Настройки ({{count}}) будут пропущены - значения, которые настройки не приняли бы, или ключи, которые резервная копия не восстанавливает',
         spoolsUsageCount: 'включая записей расхода: {{count}}',
         archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
+        archivesRunsCount: 'включая печати: {{count}} - только метаданные, файлы 3MF и миниатюры не входят в резервную копию Git',
         kprofilesPrinterCount: 'по {{count}} принтерам',
       },
       notes: {
@@ -5580,6 +5581,10 @@ export default {
         archivesOwnerUnknown: 'Некоторые архивы восстановлены без владельца - в этой резервной копии он не записан, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца',
         archivesUndeleted: 'Архивы, удалённые после резервного копирования, снова видны - перезапись была включена',
         archivesMetadataOnly: 'Восстановленные архивы содержат только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
+        archivesMergedFollowed: 'Архивы, объединённые с другим, не восстанавливаются отдельными строками - то, что ссылалось на них, переходит к архиву, с которым они объединены',
+        runsArchiveMissing: 'Некоторые печати пропущены - их архив не входит в это восстановление',
+        runsNumbersDropped: 'У некоторых печатей граммы, стоимость, энергия или длительность не являются допустимыми числами - оставлены пустыми',
+        runsPrinterMissing: 'Некоторые печати ссылались на принтеры, которых больше нет - связь удалена, имя принтера сохранено',
         spoolUsageUnresolved: 'Записей расхода пропущено: {{count}} - их катушки нет в списке катушек этой резервной копии, поэтому привязать их не к чему.',
         spoolUsageUnlinked: 'Записей расхода восстановлено без связи с историей печати: {{count}} - выберите «Архивы печати» вместе с «Инвентарём катушек», чтобы сохранить связь.',
         spoolTagKept: 'Меток катушек оставлено без изменений: {{count}} - резервная копия стёрла бы метку, отсканированную позже, или перенесла бы её на другую катушку.',

+ 5 - 0
frontend/src/i18n/locales/sv.ts

@@ -5857,6 +5857,7 @@ errors: {
         settingsNotRestorableWillSkip: '{{count}} inställning(ar) hoppas över - värden som Inställningar inte skulle godta, eller nycklar som en säkerhetskopia inte återställer',
         spoolsUsageCount: 'inklusive {{count}} användningspost(er)',
         archivesMetadataOnly: 'Endast metadata — 3MF-filer och minibilder finns inte i en Git-säkerhetskopia',
+        archivesRunsCount: 'inklusive {{count}} utskrift(er) - endast metadata, 3MF-filer och miniatyrbilder finns inte i en Git-säkerhetskopia',
         kprofilesPrinterCount: 'över {{count}} skrivare',
       },
       // Tally notes, same contract. noData is shared by all four categories:
@@ -5873,6 +5874,10 @@ errors: {
         archivesOwnerUnknown: 'Vissa arkiv återställdes utan en ägare — denna säkerhetskopia registrerar ingen, så de är synliga endast för användare med behörigheten archives:read_all tills en admin tilldelar dem på nytt',
         archivesUndeleted: 'Arkiv som tagits bort sedan säkerhetskopian är synliga igen — skriv över var aktiverad',
         archivesMetadataOnly: 'Återställda arkiv innehåller endast metadata — 3MF- och minibildfiler finns inte i en Git-säkerhetskopia',
+        archivesMergedFollowed: 'Arkiv som slagits ihop med ett annat återställs inte som egna rader - det som pekade på dem går till arkivet de slogs ihop med',
+        runsArchiveMissing: 'Vissa utskrifter hoppades över - deras arkiv ingår inte i den här återställningen',
+        runsNumbersDropped: 'Vissa utskrifter hade gram, kostnader, energi eller varaktigheter som inte är giltiga tal - lämnade tomma',
+        runsPrinterMissing: 'Vissa utskrifter hänvisade till skrivare som inte längre finns - länken togs bort, skrivarnamnet behölls',
         spoolUsageUnresolved: '{{count}} användningspost(er) hoppade över — deras rulle finns inte i denna säkerhetskopias rulllista, så det finns inget att koppla dem till.',
         spoolUsageUnlinked: '{{count}} användningspost(er) återställda utan sin utskriftshistorik-länk — välj Utskriftsarkiv tillsammans med Rullinventering för att behålla den.',
         spoolTagKept: '{{count}} rulletagg(ar) lämnade som de är — säkerhetskopian skulle ha rensat en tagg som sedan dess skannats, eller flyttat en till en andra rulle.',

+ 5 - 0
frontend/src/i18n/locales/tr.ts

@@ -5788,6 +5788,7 @@ export default {
         settingsNotRestorableWillSkip: "{{count}} ayar atlanacak - Ayarlar'ın kabul etmeyeceği değerler veya bir yedeğin geri yüklemediği anahtarlar",
         spoolsUsageCount: '{{count}} kullanım kaydı dahil',
         archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
+        archivesRunsCount: '{{count}} baskı dahil - yalnızca meta veriler, 3MF dosyaları ve küçük resimler Git yedeğinde yok',
         kprofilesPrinterCount: '{{count}} yazıcı genelinde',
       },
       notes: {
@@ -5801,6 +5802,10 @@ export default {
         archivesOwnerUnknown: 'Bazı arşivler sahipsiz geri yüklendi - bu yedek sahip bilgisi içermiyor, bu yüzden bir yönetici atama yapana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
         archivesUndeleted: 'Yedekten sonra silinen arşivler yeniden görünür oldu - üzerine yazma açıktı',
         archivesMetadataOnly: 'Geri yüklenen arşivler yalnızca üst veri içerir - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
+        archivesMergedFollowed: 'Başka bir arşivle birleştirilen arşivler ayrı satır olarak geri yüklenmez - onlara işaret eden her şey birleştirildikleri arşive gider',
+        runsArchiveMissing: 'Bazı baskılar atlandı - arşivleri bu geri yüklemede yok',
+        runsNumbersDropped: 'Bazı baskıların gram, maliyet, enerji veya süre değerleri geçerli sayı değil - boş bırakıldı',
+        runsPrinterMissing: 'Bazı baskılar artık var olmayan yazıcılara başvuruyordu - bağlantı kaldırıldı, yazıcı adı korundu',
         spoolUsageUnresolved: '{{count}} kullanım kaydı atlandı - makaraları bu yedeğin makara listesinde olmadığı için bağlanacak bir şey yok.',
         spoolUsageUnlinked: '{{count}} kullanım kaydı baskı geçmişi bağlantısı olmadan geri yüklendi - bağlantıyı korumak için Baskı arşivlerini Makara envanteriyle birlikte seçin.',
         spoolTagKept: '{{count}} makara etiketi olduğu gibi bırakıldı - yedek, o zamandan beri okutulmuş bir etiketi silecek ya da ikinci bir makaraya taşıyacaktı.',

+ 5 - 0
frontend/src/i18n/locales/uk.ts

@@ -5852,6 +5852,7 @@ export default {
         settingsNotRestorableWillSkip: 'Налаштування ({{count}}) буде пропущено - значення, яких не прийняли б налаштування, або ключі, які резервна копія не відновлює',
         spoolsUsageCount: "включно із записами використання: {{count}}",
         archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
+        archivesRunsCount: 'включно з друками: {{count}} - лише метадані, файли 3MF і мініатюри не входять до резервної копії Git',
         kprofilesPrinterCount: "по {{count}} принтерах",
       },
       notes: {
@@ -5865,6 +5866,10 @@ export default {
         archivesOwnerUnknown: "Деякі архіви відновлено без власника - у цій резервній копії його не записано, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника",
         archivesUndeleted: "Архіви, видалені після резервного копіювання, знову видимі - перезапис був увімкнений",
         archivesMetadataOnly: "Відновлені архіви містять лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
+        archivesMergedFollowed: 'Архіви, об’єднані з іншим, не відновлюються окремими рядками - те, що на них посилалося, переходить до архіву, з яким їх об’єднано',
+        runsArchiveMissing: 'Деякі друки пропущено - їхній архів не входить до цього відновлення',
+        runsNumbersDropped: 'Деякі друки мали грами, вартість, енергію або тривалість, що не є коректними числами - залишено порожніми',
+        runsPrinterMissing: 'Деякі друки посилалися на принтери, яких більше немає - зв’язок видалено, назву принтера збережено',
         spoolUsageUnresolved: "Записів використання пропущено: {{count}} - їхньої котушки немає у списку котушок цієї резервної копії, тож немає до чого їх прив'язати.",
         spoolUsageUnlinked: "Записів використання відновлено без зв'язку з історією друку: {{count}} - виберіть «Архіви друку» разом з «Інвентарем котушок», щоб зберегти зв'язок.",
         spoolTagKept: "Міток котушок залишено без змін: {{count}} - резервна копія стерла б мітку, відскановану пізніше, або перенесла б її на іншу котушку.",

+ 5 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5798,6 +5798,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} 项设置将被跳过 - 设置不会接受的值,或备份不恢复的键',
         spoolsUsageCount: '其中含 {{count}} 条使用记录',
         archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
+        archivesRunsCount: '其中含 {{count}} 次打印 - 仅元数据,3MF 文件和缩略图不在 Git 备份中',
         kprofilesPrinterCount: '涉及 {{count}} 台打印机',
       },
       notes: {
@@ -5811,6 +5812,10 @@ export default {
         archivesOwnerUnknown: '部分归档在恢复时没有归属 - 此备份未记录归属,因此在管理员指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
         archivesUndeleted: '备份之后被删除的归档重新可见 - 当时启用了覆盖',
         archivesMetadataOnly: '恢复的归档仅含元数据 - 3MF 文件和缩略图不在 Git 备份中',
+        archivesMergedFollowed: '已合并到其他归档的归档不会作为单独的行恢复 - 指向它们的内容会转到合并后的归档',
+        runsArchiveMissing: '部分打印已跳过 - 其归档不在本次恢复中',
+        runsNumbersDropped: '部分打印的克数、成本、能耗或时长不是有效数字 - 已留空',
+        runsPrinterMissing: '部分打印引用的打印机已不存在 - 已清除链接,保留打印机名称',
         spoolUsageUnresolved: '已跳过 {{count}} 条使用记录 - 其耗材卷不在此备份的耗材列表中,没有可挂接的对象。',
         spoolUsageUnlinked: '已恢复 {{count}} 条使用记录,但缺少打印历史关联 - 请同时选择“打印归档”和“耗材库存”以保留该关联。',
         spoolTagKept: '{{count}} 个耗材标签保持原样 - 按备份内容会清除此后扫描过的标签,或把它挪到另一卷耗材上。',

+ 5 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5798,6 +5798,7 @@ export default {
         settingsNotRestorableWillSkip: '{{count}} 項設定將被略過 - 設定不會接受的值,或備份不還原的鍵',
         spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
         archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
+        archivesRunsCount: '其中含 {{count}} 次列印 - 僅中繼資料,3MF 檔案和縮圖不在 Git 備份中',
         kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
       },
       notes: {
@@ -5811,6 +5812,10 @@ export default {
         archivesOwnerUnknown: '部分封存還原時沒有擁有者 - 此備份未記錄擁有者,因此在管理員指派之前,只有具備 archives:read_all 權限的使用者才看得到',
         archivesUndeleted: '備份之後刪除的封存重新可見 - 當時啟用了覆寫',
         archivesMetadataOnly: '還原的封存僅含中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
+        archivesMergedFollowed: '已合併到其他封存的封存不會作為單獨的列還原 - 指向它們的內容會轉到合併後的封存',
+        runsArchiveMissing: '部分列印已略過 - 其封存不在本次還原中',
+        runsNumbersDropped: '部分列印的克數、成本、能耗或時長不是有效數字 - 已留空',
+        runsPrinterMissing: '部分列印引用的印表機已不存在 - 已清除連結,保留印表機名稱',
         spoolUsageUnresolved: '已略過 {{count}} 筆使用紀錄 - 其耗材捲不在此備份的耗材清單中,沒有可掛接的對象。',
         spoolUsageUnlinked: '已還原 {{count}} 筆使用紀錄,但缺少列印歷史連結 - 請同時選擇「列印封存」與「耗材庫存」以保留該連結。',
         spoolTagKept: '{{count}} 個耗材標籤維持原樣 - 依備份內容會清除此後掃描過的標籤,或把它移到另一捲耗材上。',

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/ArchivesPage-BfhEWJ1i.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/CamWallPage-CbJ9BcgF.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/CameraPage-DJm2ElGD.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/FileManagerPage-BSoMHJEc.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/InventoryPage-CnVha-n8.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/Layout-DaXS8Glt.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/ModelSourcesPage-CWtA1Sw3.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/PhotoGalleryModal-CdHJisSV.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/PrintersPage-D2yvw3Pn.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/SettingsPage-DMD4NV9W.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/SliceModal-CMP5M6E_.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BBJahJ2O.js


Різницю між файлами не показано, бо вона завелика
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Ba9epOZ4.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/de-B1H3-1Tb.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/es-2ynGqczX.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/fr-SbrG7YkC.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-akmrX1eO.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/it-DESzbcWr.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/ja-BGSFXmWe.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/ko-bRaPo11M.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/locationSensorDefaults-CCmaZS2k.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/nl-85HaNq0-.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/pt-BR-D7_haw2T.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/ru-DYjhcHvT.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/spoolCost-DqlDJCVl.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/sv-Bv9H9UOT.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/tr-kyMaffzV.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/uk-CJ2qa1V4.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/zh-CN-DuZCGb_F.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/zh-TW-DM8ZBDzp.js


+ 2 - 2
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-DXN5_K40.js"></script>
+    <script type="module" crossorigin src="/assets/index-akmrX1eO.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-CWBrdRQ8.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-B3DWHe9Z.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-B0OkFh9b.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-CyXcMNPL.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-Bf84BtBl.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-ruwL5Qle.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-DaXS8Glt.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Bm33gdbH.css">
   </head>
   <body>

Деякі файли не було показано, через те що забагато файлів було змінено