From 773cf387a3bff792e16f1df06fbcb1af0f4714ee Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 3 Oct 2026 09:37:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(backup):=20=E5=AE=9E=E4=BE=8B=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E7=AC=AC4=E8=BD=AE=E2=80=94=E2=80=94=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=89=8D=E7=AB=AF=20helper=EF=BC=88instance-backup.ts?= =?UTF-8?q?=EF=BC=89=E5=8F=8A=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/helpers/instance-backup.test.ts | 38 +++ .../src/helpers/instance-backup.ts | 237 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 apps/app-frontend/src/helpers/instance-backup.test.ts create mode 100644 apps/app-frontend/src/helpers/instance-backup.ts diff --git a/apps/app-frontend/src/helpers/instance-backup.test.ts b/apps/app-frontend/src/helpers/instance-backup.test.ts new file mode 100644 index 0000000..d09215d --- /dev/null +++ b/apps/app-frontend/src/helpers/instance-backup.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { canonicalizeBackupExclusions, formatBackupExclusionPath } from './instance-backup.ts' + +test('directory exclusions display with a trailing slash', () => { + assert.equal(formatBackupExclusionPath({ path: 'caches', kind: 'directory' }), 'caches/') + assert.equal( + formatBackupExclusionPath({ path: 'config/somemod/bigdb.sqlite', kind: 'file' }), + 'config/somemod/bigdb.sqlite', + ) +}) + +test('directory exclusions cover descendants while file exclusions stay exact', () => { + assert.deepEqual( + canonicalizeBackupExclusions([ + { path: 'saves/world', kind: 'directory' }, + { path: 'config\\bigdb.sqlite', kind: 'file' }, + { path: 'saves', kind: 'directory' }, + { path: 'config', kind: 'file' }, + ]), + [ + { path: 'config', kind: 'file' }, + { path: 'saves', kind: 'directory' }, + { path: 'config/bigdb.sqlite', kind: 'file' }, + ], + ) +}) + +test('directory wins when the same exclusion is selected as both kinds', () => { + assert.deepEqual( + canonicalizeBackupExclusions([ + { path: 'cache', kind: 'file' }, + { path: 'cache', kind: 'directory' }, + ]), + [{ path: 'cache', kind: 'directory' }], + ) +}) diff --git a/apps/app-frontend/src/helpers/instance-backup.ts b/apps/app-frontend/src/helpers/instance-backup.ts new file mode 100644 index 0000000..493648c --- /dev/null +++ b/apps/app-frontend/src/helpers/instance-backup.ts @@ -0,0 +1,237 @@ +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' + +export type InstanceBackupEligibility = + | 'eligible' + | 'not_installed' + | 'symlink_instance' + | 'direct_linked_instance' + | 'external_game_directory' + | 'root_missing' + | 'root_is_link' + +export interface BackupRepositoryStatus { + path: string + initialized: boolean + available: boolean + stored_size: number + logical_size: number + snapshot_count: number + error: string | null +} + +export type BackupExclusionKind = 'file' | 'directory' + +export interface BackupExclusion { + path: string + kind: BackupExclusionKind +} + +export function formatBackupExclusionPath(exclusion: BackupExclusion): string { + return exclusion.kind === 'directory' ? `${exclusion.path}/` : exclusion.path +} + +export interface InstanceBackupConfig { + instance_id: string + enabled: boolean + eligibility: InstanceBackupEligibility + excluded_paths: BackupExclusion[] + snapshot_count: number +} + +export interface BackupSnapshot { + id: string + instance_id: string + instance_name: string + created_at: number + file_count: number + symlink_count: number + logical_size: number + added_size: number +} + +export interface BackupDeleteSummary { + snapshot_count: number + logical_size: number +} + +export interface BackupRestorePreview { + added_files: number + modified_files: number + deleted_files: number + plan_token: string +} + +export type BackupOperationType = + 'create' | 'restore' | 'restore_preview' | 'delete' | 'repository_move' +export type BackupOperationState = + | 'queued' + | 'scanning' + | 'hashing' + | 'saving' + | 'validating' + | 'materializing' + | 'applying' + | 'completed' + | 'cancelled' + | 'failed' + | 'interrupted' + +export interface BackupOperation { + id: string + operation_type: BackupOperationType + instance_id: string + snapshot_id?: string + state: BackupOperationState + processed_bytes: number + total_bytes: number + cancellable: boolean + cancel_requested: boolean + error?: string + created_at: number + updated_at: number + finished_at?: number + restore_preview?: BackupRestorePreview +} + +export type BackupProgressStage = + | 'scanning' + | 'hashing' + | 'saving' + | 'validating' + | 'copying' + | 'restoring' + | 'deleting' + | 'completed' + | 'cancelled' + | 'failed' + +export type BackupOperationFinalState = 'completed' | 'cancelled' | 'failed' + +export interface BackupProgressEvent { + operationId: string + instanceId?: string + operationType: BackupOperationType + stage: BackupProgressStage + processedBytes: number + totalBytes: number + finalState?: BackupOperationFinalState + snapshotId?: string + message?: string +} + +export function canonicalizeBackupExclusions(values: BackupExclusion[]): BackupExclusion[] { + const normalized = values + .map((value) => ({ ...value, path: value.path.replaceAll('\\', '/') })) + .sort( + (left, right) => + left.path.split('/').length - right.path.split('/').length || + left.path.localeCompare(right.path), + ) + const result: BackupExclusion[] = [] + for (const value of normalized) { + const duplicate = result.find((existing) => existing.path === value.path) + if (duplicate) { + if (value.kind === 'directory') duplicate.kind = 'directory' + continue + } + if ( + result.some( + (parent) => parent.kind === 'directory' && value.path.startsWith(`${parent.path}/`), + ) + ) { + continue + } + result.push(value) + } + return result +} + +export function getBackupRepositoryStatus(): Promise { + return invoke('plugin:instance|instance_get_backup_repository_status') +} + +export function moveBackupRepository(destination: string): Promise { + return invoke('plugin:instance|instance_move_backup_repository', { destination }) +} + +export function getBackupConfig(instanceId: string): Promise { + return invoke('plugin:instance|instance_get_backup_config', { instanceId }) +} + +export function normalizeBackupExclusion( + instanceId: string, + selectedPath: string, + kind: BackupExclusionKind, +): Promise { + return invoke('plugin:instance|instance_normalize_backup_exclusion', { + instanceId, + selectedPath, + kind, + }) +} + +export function enableBackups( + instanceId: string, + excludedPaths: BackupExclusion[], +): Promise { + return invoke('plugin:instance|instance_enable_backups', { instanceId, excludedPaths }) +} + +export function updateBackupExclusions( + instanceId: string, + excludedPaths: BackupExclusion[], +): Promise { + return invoke('plugin:instance|instance_update_backup_exclusions', { + instanceId, + excludedPaths, + }) +} + +export function disableBackups(instanceId: string): Promise { + return invoke('plugin:instance|instance_disable_backups', { instanceId }) +} + +export function startBackup(instanceId: string): Promise { + return invoke('plugin:instance|instance_start_backup', { instanceId }) +} + +export function cancelBackup(operationId: string): Promise { + return invoke('plugin:instance|instance_cancel_backup', { operationId }) +} + +export function listBackupOperations( + instanceId?: string, + activeOnly = false, +): Promise { + return invoke('plugin:instance|instance_list_backup_operations', { + instanceId, + activeOnly, + }) +} + +export function listBackups(instanceId: string): Promise { + return invoke('plugin:instance|instance_list_backups', { instanceId }) +} + +export function deleteBackup(snapshotId: string): Promise { + return invoke('plugin:instance|instance_delete_backup', { snapshotId }) +} + +export function startBackupRestorePreview(snapshotId: string): Promise { + return invoke('plugin:instance|instance_get_backup_restore_preview', { snapshotId }) +} + +export function restoreBackup(snapshotId: string, planToken: string): Promise { + return invoke('plugin:instance|instance_restore_backup', { snapshotId, planToken }) +} + +export function getBackupDeleteSummary(instanceId: string): Promise { + return invoke('plugin:instance|instance_get_backup_delete_summary', { instanceId }) +} + +export function listenBackupProgress( + handler: (event: BackupProgressEvent) => void, +): Promise { + return listen('instance_backup_progress', (event) => handler(event.payload)) +}