diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py index 1319471e8..5fbd04245 100644 --- a/backend/app/services/github_backup.py +++ b/backend/app/services/github_backup.py @@ -828,6 +828,14 @@ class GitHubBackupService: if not archives: return + # The natural key for an owner. created_by_id alone is only meaningful on + # the instance that wrote it: restoring onto a rebuilt instance — this + # feature's main use case — renumbers the users table, so a live id can + # land on a different person. username is unique on users, so the restore + # can resolve on it and treat a rename as unknown rather than guess. + # One query for the map; archives outnumber users by orders of magnitude. + user_names = dict((await db.execute(select(User.id, User.username))).all()) + archive_list = [] for a in archives: archive_data = { @@ -876,6 +884,12 @@ class GitHubBackupService: # restored row without it is invisible to everyone but an admin — # while the restore reports it restored. "created_by_id": a.created_by_id, + # Preferred over the id on restore; the id stays as the fallback + # for an owner whose row has since gone. Null when the archive + # has no owner, or when it points at a user row that no longer + # exists locally — the same "absent is not null" rule the restore + # applies, so a backup can't claim an owner it cannot name. + "created_by_username": user_names.get(a.created_by_id), } archive_list.append(archive_data) diff --git a/backend/app/services/github_restore.py b/backend/app/services/github_restore.py index cafed9a97..881714b17 100644 --- a/backend/app/services/github_restore.py +++ b/backend/app/services/github_restore.py @@ -942,14 +942,17 @@ class GitHubRestoreService: # (_ensure_archive_visible fails closed on it) and never appears in the # ownership-scoped list queries. Hoisted like the two above. # - # Note this is the one place a raw backup id is reused, against the - # module's own rule at the top of the file. Users have no natural key the - # backup carries today, and the id is validated rather than trusted, so a - # *stale* id clears instead of pointing somewhere wrong. What it cannot - # catch is a live id belonging to a different person on a different - # instance. Collecting username and resolving on that would close it; - # raised with the maintainer rather than decided here. - valid_users = set((await db.execute(select(User.id))).scalars().all()) + # username is the natural key and wins, per the module's rule at the top + # of the file; created_by_id is the fallback for a pre-#2656 commit that + # carries no username. That ordering is what makes restoring onto a + # rebuilt instance safe: the users table renumbers there, so a live id + # can land on a different person, and the id path alone cannot tell that + # from a correct match. Resolving on the name instead means the one case + # it cannot resolve — a user renamed since the backup — falls through to + # ownerless-with-a-note below rather than misattributing in silence. + users = (await db.execute(select(User.id, User.username))).all() + valid_users = {user_id for user_id, _ in users} + users_by_name = {username: user_id for user_id, username in users} # Only metadata is backed up, never the 3MF/thumbnail bytes, and # print_archives.file_path is NOT NULL — so inserted rows get an empty @@ -1008,7 +1011,7 @@ class GitHubRestoreService: fields["printer_id"] = printer_id fields["project_id"] = project_id - # created_by_id and deleted_at are the two late arrivals — a backup + # The ownership pair and deleted_at are the late arrivals — a backup # commit taken before the collector wrote them carries neither key. # Absent is NOT the same as null here, because the overwrite branch # below is a blanket setattr: treating a missing key as None would @@ -1018,13 +1021,33 @@ class GitHubRestoreService: # deleted. So only carry a column the backup actually knows about; # on insert, an absent key just takes the model default. owner_cleared = False - if "created_by_id" in entry: + backup_username = entry.get("created_by_username") + if isinstance(backup_username, str) and backup_username: + # The natural-key path. A miss here is a user renamed or deleted + # since the backup, and there is nothing else to resolve on: the + # id alongside it is from the source instance's numbering, so + # trusting it is exactly the misattribution the name is here to + # prevent. Cleared rather than failing the row — the archive is + # still worth having, and an admin can reassign it — but said out + # loud, because a cleared owner is not silent-safe. + created_by_id = users_by_name.get(backup_username) + if created_by_id is None: + tally.note( + "archivesOwnerUnmatched", + "Some archives name an owner this instance does not have — owner cleared rather than " + "guessed from the backup's user id, so they are visible only to users with the " + "archives:read_all permission until an admin reassigns them", + ) + owner_cleared = True + fields["created_by_id"] = created_by_id + elif "created_by_id" in entry: + # Fallback for a commit taken before the collector recorded the + # username. Validated rather than trusted, so a *stale* id clears + # instead of pointing somewhere wrong; a live id belonging to a + # different person on a rebuilt instance is the case this path + # cannot see, and is why the branch above exists. created_by_id = entry.get("created_by_id") if created_by_id is not None and created_by_id not in valid_users: - # Coerced rather than failing the row: the archive is still - # worth having, and an admin can reassign it. Said out loud - # because a cleared owner is not silent-safe — the archive - # becomes visible only to archives:read_all until someone does. tally.note( "archivesOwnerCleared", "Some archives referenced users that no longer exist — owner cleared, so they are " diff --git a/backend/tests/unit/test_github_restore.py b/backend/tests/unit/test_github_restore.py index 9c5b1737b..1cb96ee39 100644 --- a/backend/tests/unit/test_github_restore.py +++ b/backend/tests/unit/test_github_restore.py @@ -2390,6 +2390,239 @@ class TestRestoredArchiveOwnership: assert row.created_by_id == user.id, "a restored archive its owner cannot see is not restored" +class TestArchiveOwnerNaturalKey: + """``created_by_username`` decides the owner; the id is only the fallback. + + Restoring onto a rebuilt instance is this feature's main use case, and the + users table renumbers there. A raw ``created_by_id`` cannot tell a correct + match from a live id that now belongs to somebody else, so the id path hands + one person's print history to another under ``ARCHIVES_READ_OWN`` — silently, + because ``archivesOwnerCleared`` only fires for an id that is *absent*. + ``username`` is unique on ``users``, so resolving on it turns that silent + misattribution into an ownerless row with a note. + """ + + def _entry(self, **overrides): + entry = { + "id": 77, + "filename": "benchy.3mf", + "file_size": 2048, + "content_hash": "abc123", + "started_at": "2026-03-01 10:00:00", + "created_at": "2026-03-01 10:00:00", + } + entry.update(overrides) + return entry + + async def _user(self, db, username): + user = User(username=username, role="operator") + db.add(user) + await db.flush() + return user + + @pytest.mark.asyncio + async def test_the_name_resolves_across_a_renumbered_users_table(self, db_session): + """The whole point: same person, different id, restore still finds them.""" + alice = await self._user(db_session, "alice") + tally = _CategoryTally() + + await _service()._restore_archives( + db_session, + {"archives": [self._entry(created_by_id=alice.id + 500, created_by_username="alice")]}, + False, + tally, + {}, + ) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.created_by_id == alice.id + assert not any("owner cleared" in note for note in _messages(tally)) + + @pytest.mark.asyncio + async def test_the_name_beats_a_live_id_belonging_to_someone_else(self, db_session): + """The misattribution case, and the one the id path cannot even detect. + + Both ids exist locally, so the id path would write bob's — a valid row, + no note, alice's print history readable by bob. + """ + alice = await self._user(db_session, "alice") + bob = await self._user(db_session, "bob") + + await _service()._restore_archives( + db_session, + {"archives": [self._entry(created_by_id=bob.id, created_by_username="alice")]}, + False, + _CategoryTally(), + {}, + ) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.created_by_id == alice.id, "the name is the natural key; the id is from another instance" + + @pytest.mark.asyncio + async def test_a_renamed_owner_lands_ownerless_with_a_note(self, db_session): + """No local match, so nothing to resolve — and the id is not a fallback here. + + Falling back to it is exactly the guess the name exists to prevent, so + the row is cleared and said out loud instead. + """ + bob = await self._user(db_session, "bob") + tally = _CategoryTally() + + await _service()._restore_archives( + db_session, + {"archives": [self._entry(created_by_id=bob.id, created_by_username="alice")]}, + False, + tally, + {}, + ) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.created_by_id is None + assert tally.restored == 1 and tally.failed == 0 + assert any("does not have" in note and "archives:read_all" in note for note in _messages(tally)) + + @pytest.mark.asyncio + async def test_the_unmatched_note_is_emitted_once_for_many_rows(self, db_session): + tally = _CategoryTally() + archives = [ + self._entry(id=1, content_hash="h1", filename="a.3mf", created_by_username="alice"), + self._entry(id=2, content_hash="h2", filename="b.3mf", created_by_username="carol"), + ] + + await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {}) + await db_session.commit() + + assert sum(1 for note in _messages(tally) if "does not have" in note) == 1 + + @pytest.mark.asyncio + async def test_an_unmatched_name_does_not_also_claim_no_owner_was_recorded(self, db_session): + """One row, one cause, one note — as with the stale-id branch.""" + tally = _CategoryTally() + + await _service()._restore_archives( + db_session, {"archives": [self._entry(created_by_username="alice")]}, False, tally, {} + ) + await db_session.commit() + + assert any("does not have" in note for note in _messages(tally)) + assert not any("without an owner" in note for note in _messages(tally)) + + @pytest.mark.asyncio + async def test_a_pre_username_backup_still_resolves_on_the_id(self, db_session): + """The fallback has to keep working — every backup taken before this change.""" + alice = await self._user(db_session, "alice") + + await _service()._restore_archives( + db_session, {"archives": [self._entry(created_by_id=alice.id)]}, False, _CategoryTally(), {} + ) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.created_by_id == alice.id + + @pytest.mark.asyncio + async def test_an_explicitly_ownerless_archive_reads_as_no_owner_not_as_unmatched(self, db_session): + """A current-format backup of an unowned archive writes both keys null.""" + tally = _CategoryTally() + + await _service()._restore_archives( + db_session, + {"archives": [self._entry(created_by_id=None, created_by_username=None)]}, + False, + tally, + {}, + ) + await db_session.commit() + + assert any("without an owner" in note for note in _messages(tally)) + assert not any("does not have" in note for note in _messages(tally)) + + @pytest.mark.asyncio + async def test_overwrite_leaves_the_owner_alone_when_neither_key_is_present(self, db_session): + """The absent-is-not-null rule still holds now that there are two keys.""" + bob = await self._user(db_session, "bob") + db_session.add( + PrintArchive( + filename="benchy.3mf", + file_path="/data/benchy.3mf", + file_size=2048, + content_hash="abc123", + started_at=datetime(2026, 3, 1, 10, 0, 0), + created_by_id=bob.id, + ) + ) + await db_session.commit() + + await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, _CategoryTally(), {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.created_by_id == bob.id + + @pytest.mark.asyncio + async def test_the_name_survives_collect_then_restore(self, db_session): + """Both halves, because the collector writing nothing looks harmless alone.""" + from backend.app.services.github_backup import github_backup_service + + alice = await self._user(db_session, "alice") + db_session.add( + PrintArchive( + filename="owned.3mf", + file_path="", + file_size=1024, + content_hash="hash-owned", + started_at=datetime(2026, 3, 1, 10, 0, 0), + created_by_id=alice.id, + ) + ) + await db_session.commit() + + files: dict = {} + await github_backup_service._collect_archives(db_session, files) + payload = files[ARCHIVES_PATH] + assert payload["archives"][0]["created_by_username"] == "alice" + + # Rebuilt instance: same person, and nothing else holds their old id. + await db_session.execute(PrintArchive.__table__.delete()) + await db_session.execute(User.__table__.delete()) + await db_session.commit() + rebuilt = await self._user(db_session, "alice") + await db_session.commit() + + await _service()._restore_archives(db_session, payload, False, _CategoryTally(), {}) + await db_session.commit() + + row = (await db_session.execute(select(PrintArchive))).scalar_one() + assert row.created_by_id == rebuilt.id + + @pytest.mark.asyncio + async def test_the_collector_names_no_owner_for_an_unowned_archive(self, db_session): + """Null rather than absent, so a restore can tell "none" from "not recorded".""" + from backend.app.services.github_backup import github_backup_service + + db_session.add( + PrintArchive( + filename="unowned.3mf", + file_path="", + file_size=1024, + content_hash="hash-unowned", + started_at=datetime(2026, 3, 1, 10, 0, 0), + ) + ) + await db_session.commit() + + files: dict = {} + await github_backup_service._collect_archives(db_session, files) + + entry = files[ARCHIVES_PATH]["archives"][0] + assert entry["created_by_username"] is None + assert "created_by_username" in entry + + class TestCategoryPathMapping: def setup_method(self): self.service = _service() diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 72beebe43..29575f2e1 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -4912,6 +4912,7 @@ export default { archivesPrinterMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Drucker - Verknüpfung entfernt', archivesProjectMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Projekte - Verknüpfung entfernt', archivesOwnerCleared: 'Einige Archive verwiesen auf nicht mehr vorhandene Benutzer - Eigentümer entfernt. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist', + archivesOwnerUnmatched: 'Einige Archive nennen einen Eigentümer, den es auf dieser Instanz nicht gibt - der Eigentümer wurde entfernt statt aus der Benutzer-ID der Sicherung geraten. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist', 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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 7850440f5..249080d75 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4960,6 +4960,7 @@ export default { archivesPrinterMissing: 'Some archives referenced printers that no longer exist - link cleared', archivesProjectMissing: 'Some archives referenced projects that no longer exist - link cleared', archivesOwnerCleared: 'Some archives referenced users that no longer exist - owner cleared, so they are visible only to users with the archives:read_all permission until an admin reassigns them', + archivesOwnerUnmatched: 'Some archives name an owner this instance does not have - owner cleared rather than guessed from the backup\'s user id, so they are visible only to users with the archives:read_all permission until an admin reassigns them', 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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 01039127e..fcefdac86 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -4920,6 +4920,7 @@ export default { archivesPrinterMissing: 'Algunos archivos hacían referencia a impresoras que ya no existen - enlace eliminado', archivesProjectMissing: 'Algunos archivos hacían referencia a proyectos que ya no existen - enlace eliminado', archivesOwnerCleared: 'Algunos archivos hacían referencia a usuarios que ya no existen - se ha borrado el propietario, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne', + archivesOwnerUnmatched: 'Algunos archivos indican un propietario que no existe en esta instancia - se ha borrado el propietario en lugar de deducirlo del id de usuario de la copia de seguridad, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne', 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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index e58d043ce..abc8eff83 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -4901,6 +4901,7 @@ export default { archivesPrinterMissing: 'Certaines archives référençaient des imprimantes qui n\'existent plus - lien effacé', archivesProjectMissing: 'Certaines archives référençaient des projets qui n\'existent plus - lien effacé', archivesOwnerCleared: 'Certaines archives référençaient des utilisateurs qui n\'existent plus - propriétaire effacé, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue', + archivesOwnerUnmatched: 'Certaines archives désignent un propriétaire absent de cette instance - le propriétaire a été effacé plutôt que déduit de l\'identifiant utilisateur de la sauvegarde, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue', 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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 5dd7b06c5..7f9bb36fb 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -4900,6 +4900,7 @@ export default { archivesPrinterMissing: 'Alcuni archivi facevano riferimento a stampanti non più esistenti - collegamento rimosso', archivesProjectMissing: 'Alcuni archivi facevano riferimento a progetti non più esistenti - collegamento rimosso', archivesOwnerCleared: 'Alcuni archivi facevano riferimento a utenti non più esistenti - proprietario rimosso, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna', + archivesOwnerUnmatched: 'Alcuni archivi indicano un proprietario che questa istanza non ha - il proprietario è stato rimosso anziché dedotto dall\'id utente del backup, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna', 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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 16aa8c212..6514b62ce 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -4912,6 +4912,7 @@ export default { archivesPrinterMissing: '一部のアーカイブが存在しないプリンターを参照していました - リンクを解除しました', archivesProjectMissing: '一部のアーカイブが存在しないプロジェクトを参照していました - リンクを解除しました', archivesOwnerCleared: '一部のアーカイブが存在しないユーザーを参照していました - 所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません', + archivesOwnerUnmatched: '一部のアーカイブはこのインスタンスに存在しない所有者を指しています - バックアップのユーザー ID から推測せずに所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません', archivesOwnerUnknown: '一部のアーカイブは所有者なしで復元されました - このバックアップに所有者が記録されていないため、管理者が割り当てるまで archives:read_all 権限を持つユーザーにしか表示されません', archivesUndeleted: 'バックアップ後に削除されたアーカイブが再び表示されます - 上書きが有効でした', archivesMetadataOnly: '復元されたアーカイブはメタデータのみです - 3MF ファイルとサムネイルは Git バックアップに含まれません', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 2c1aa2a53..2681d1986 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -4677,6 +4677,7 @@ export default { archivesPrinterMissing: '일부 아카이브가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제했습니다', archivesProjectMissing: '일부 아카이브가 더 이상 존재하지 않는 프로젝트를 참조했습니다 - 연결을 해제했습니다', archivesOwnerCleared: '일부 아카이브가 더 이상 존재하지 않는 사용자를 참조했습니다 - 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다', + archivesOwnerUnmatched: '일부 아카이브가 이 인스턴스에 없는 소유자를 가리킵니다 - 백업의 사용자 ID로 추측하지 않고 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다', archivesOwnerUnknown: '일부 아카이브가 소유자 없이 복원되었습니다 - 이 백업에 소유자가 기록되어 있지 않으므로 관리자가 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다', archivesUndeleted: '백업 이후 삭제된 아카이브가 다시 표시됩니다 - 덮어쓰기가 켜져 있었습니다', archivesMetadataOnly: '복원된 아카이브에는 메타데이터만 있습니다 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index fa4e09402..d87f91286 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -4900,6 +4900,7 @@ export default { archivesPrinterMissing: 'Alguns arquivos referenciavam impressoras que não existem mais - vínculo removido', archivesProjectMissing: 'Alguns arquivos referenciavam projetos que não existem mais - vínculo removido', archivesOwnerCleared: 'Alguns arquivos referenciavam usuários que não existem mais - o proprietário foi limpo, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua', + archivesOwnerUnmatched: 'Alguns arquivos apontam para um proprietário que esta instância não tem - o proprietário foi limpo em vez de deduzido do id de usuário da cópia, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua', 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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 5a99be1e0..e9c5dccc8 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -4669,6 +4669,7 @@ export default { archivesPrinterMissing: 'Некоторые архивы ссылались на несуществующие принтеры - связь очищена', archivesProjectMissing: 'Некоторые архивы ссылались на несуществующие проекты - связь очищена', archivesOwnerCleared: 'Некоторые архивы ссылались на несуществующих пользователей - владелец очищен, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново', + archivesOwnerUnmatched: 'Некоторые архивы указывают владельца, которого нет в этом экземпляре - владелец очищен, а не угадан по идентификатору пользователя из резервной копии, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново', archivesOwnerUnknown: 'Некоторые архивы восстановлены без владельца - в этой резервной копии он не записан, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца', archivesUndeleted: 'Архивы, удалённые после резервного копирования, снова видны - перезапись была включена', archivesMetadataOnly: 'Восстановленные архивы содержат только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 5b2b123f1..b24f30125 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -4890,6 +4890,7 @@ export default { archivesPrinterMissing: 'Bazı arşivler artık var olmayan yazıcılara işaret ediyordu - bağlantı temizlendi', archivesProjectMissing: 'Bazı arşivler artık var olmayan projelere işaret ediyordu - bağlantı temizlendi', archivesOwnerCleared: 'Bazı arşivler artık var olmayan kullanıcılara işaret ediyordu - sahip temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler', + archivesOwnerUnmatched: 'Bazı arşivler bu örnekte bulunmayan bir sahibi belirtiyor - sahip, yedekteki kullanıcı kimliğinden tahmin edilmek yerine temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler', 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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index f8d5f8de7..8d7e0654b 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -4955,6 +4955,7 @@ export default { archivesPrinterMissing: "Деякі архіви посилалися на принтери, яких більше немає - зв'язок очищено", archivesProjectMissing: "Деякі архіви посилалися на проєкти, яких більше немає - зв'язок очищено", archivesOwnerCleared: "Деякі архіви посилалися на користувачів, яких більше немає - власника очищено, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову", + archivesOwnerUnmatched: "Деякі архіви вказують власника, якого немає в цьому екземплярі - власника очищено, а не вгадано за ідентифікатором користувача з резервної копії, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову", archivesOwnerUnknown: "Деякі архіви відновлено без власника - у цій резервній копії його не записано, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника", archivesUndeleted: "Архіви, видалені після резервного копіювання, знову видимі - перезапис був увімкнений", archivesMetadataOnly: "Відновлені архіви містять лише метадані - файли 3MF і мініатюри не входять до резервної копії Git", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 7246f6ec0..d7ae822b8 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -4900,6 +4900,7 @@ export default { archivesPrinterMissing: '部分归档引用了已不存在的打印机 - 已清除关联', archivesProjectMissing: '部分归档引用了已不存在的项目 - 已清除关联', archivesOwnerCleared: '部分归档引用了已不存在的用户 - 已清除归属,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们', + archivesOwnerUnmatched: '部分归档指向本实例没有的用户 - 已清除归属,而不是根据备份中的用户 ID 猜测,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们', archivesOwnerUnknown: '部分归档在恢复时没有归属 - 此备份未记录归属,因此在管理员指派之前,只有拥有 archives:read_all 权限的用户才能看到它们', archivesUndeleted: '备份之后被删除的归档重新可见 - 当时启用了覆盖', archivesMetadataOnly: '恢复的归档仅含元数据 - 3MF 文件和缩略图不在 Git 备份中', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 058aa0c50..ef440736e 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -4900,6 +4900,7 @@ export default { archivesPrinterMissing: '部分封存參照了已不存在的印表機 - 已清除連結', archivesProjectMissing: '部分封存參照了已不存在的專案 - 已清除連結', archivesOwnerCleared: '部分封存參照了已不存在的使用者 - 已清除擁有者,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到', + archivesOwnerUnmatched: '部分封存指向本執行個體沒有的使用者 - 已清除擁有者,而非依備份中的使用者 ID 推測,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到', archivesOwnerUnknown: '部分封存還原時沒有擁有者 - 此備份未記錄擁有者,因此在管理員指派之前,只有具備 archives:read_all 權限的使用者才看得到', archivesUndeleted: '備份之後刪除的封存重新可見 - 當時啟用了覆寫', archivesMetadataOnly: '還原的封存僅含中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',