feat(#9): export/import состояния — backup в JSON и восстановление

This commit is contained in:
AnRil
2026-05-22 13:33:38 +07:00
parent fd62177375
commit 72e54c579d
6 changed files with 231 additions and 0 deletions

View File

@@ -4,8 +4,10 @@ import {
systemPreferences,
BrowserWindow,
app,
dialog,
shell
} from 'electron'
import { readFileSync, writeFileSync } from 'node:fs'
import { IPC } from '@shared/ipc'
import type { Exercise, GameId, Settings } from '@shared/types'
import {
@@ -14,9 +16,11 @@ import {
clearHistory,
deleteChallenge,
deleteExercise,
exportState,
getHistory,
getState,
getStateForRenderer,
importState,
markDone,
setGameEnabled,
skip,
@@ -304,4 +308,47 @@ export function registerIpc(): void {
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) =>
clearHistory(beforeTs)
)
// Export / Import. Используем native save/open dialogs Electron'а
// renderer не получает прямого доступа к ФС.
ipcMain.handle(IPC.exportState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const stamp = new Date()
.toISOString()
.replace(/[:T]/g, '-')
.slice(0, 16)
const defaultPath = `laude-backup-${stamp}.json`
const result = await dialog.showSaveDialog(win!, {
title: 'Сохранить резервную копию',
defaultPath,
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || !result.filePath) return { ok: false, path: null }
try {
writeFileSync(result.filePath, exportState(), 'utf-8')
return { ok: true, path: result.filePath }
} catch (e) {
return { ok: false, path: null, error: String(e) }
}
})
ipcMain.handle(IPC.importState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const result = await dialog.showOpenDialog(win!, {
title: 'Восстановить из резервной копии',
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || result.filePaths.length === 0) {
return { ok: false }
}
try {
const raw = readFileSync(result.filePaths[0], 'utf-8')
const ok = importState(raw)
if (ok) broadcastState()
return { ok }
} catch (e) {
return { ok: false, error: String(e) }
}
})
}

View File

@@ -504,3 +504,56 @@ export function setGameEnabled(id: GameId, enabled: boolean): void {
state.gamesEnabled = { ...state.gamesEnabled, [id]: enabled }
scheduleWrite()
}
// ----- Export / Import -----
/**
* Полный snapshot persisted-state (включая историю и schema-version).
* Используется для backup'а или переноса на другую машину.
*/
export function exportState(): string {
const state = getState()
return JSON.stringify(
{
__schemaVersion: CURRENT_SCHEMA_VERSION,
__exportedAt: new Date().toISOString(),
__appVersion: app.getVersion(),
...state
},
null,
2
)
}
/**
* Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при
* успехе. Идёт через тот же coerce + runMigrations что и load() — это
* валидирует тип/диапазоны.
*
* НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты
* settings) — простое replace. Перед импортом UI должен спросить
* подтверждение.
*/
export function importState(raw: string): boolean {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (e) {
log.warn('[store] import: invalid JSON', e)
return false
}
if (!isValidParsed(parsed)) {
log.warn('[store] import: expected object')
return false
}
try {
const migrated = runMigrations(parsed)
const coerced = coerce(migrated)
cache = coerced
flushSync()
return true
} catch (e) {
log.error('[store] import: coerce/migrate failed', e)
return false
}
}