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, systemPreferences,
BrowserWindow, BrowserWindow,
app, app,
dialog,
shell shell
} from 'electron' } from 'electron'
import { readFileSync, writeFileSync } from 'node:fs'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { Exercise, GameId, Settings } from '@shared/types' import type { Exercise, GameId, Settings } from '@shared/types'
import { import {
@@ -14,9 +16,11 @@ import {
clearHistory, clearHistory,
deleteChallenge, deleteChallenge,
deleteExercise, deleteExercise,
exportState,
getHistory, getHistory,
getState, getState,
getStateForRenderer, getStateForRenderer,
importState,
markDone, markDone,
setGameEnabled, setGameEnabled,
skip, skip,
@@ -304,4 +308,47 @@ export function registerIpc(): void {
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) =>
clearHistory(beforeTs) 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 } state.gamesEnabled = { ...state.gamesEnabled, [id]: enabled }
scheduleWrite() 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
}
}

View File

@@ -116,6 +116,12 @@ const api = {
clearHistory: (beforeTs?: number): Promise<number> => clearHistory: (beforeTs?: number): Promise<number> =>
ipcRenderer.invoke(IPC.clearHistory, beforeTs), ipcRenderer.invoke(IPC.clearHistory, beforeTs),
// Export / Import — открывают native save/open dialogs из main process.
exportState: (): Promise<{ ok: boolean; path: string | null }> =>
ipcRenderer.invoke(IPC.exportState),
importState: (): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke(IPC.importState),
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h), onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h), onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h), onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),

View File

@@ -143,6 +143,21 @@ export const ru: Dict = {
'settings.section.appearance': 'Внешний вид', 'settings.section.appearance': 'Внешний вид',
'settings.section.language': 'Язык', 'settings.section.language': 'Язык',
'settings.section.updates': 'Обновления', 'settings.section.updates': 'Обновления',
'settings.section.data': 'Данные',
'settings.data.export.label': 'Экспортировать всё',
'settings.data.export.hint':
'Сохрани резервную копию упражнений, истории, челленджей и настроек в JSON-файл.',
'settings.data.export.btn': 'Сохранить',
'settings.data.export.ok': 'Сохранено в {path}',
'settings.data.export.err': 'Не удалось сохранить',
'settings.data.import.label': 'Восстановить из файла',
'settings.data.import.hint':
'Загрузить ранее сохранённую копию. Текущие данные будут перезаписаны.',
'settings.data.import.btn': 'Восстановить',
'settings.data.import.confirm':
'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?',
'settings.data.import.ok': 'Восстановлено',
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
'settings.notification_mode.label': 'Режим уведомления', 'settings.notification_mode.label': 'Режим уведомления',
'settings.notification_mode.hint': 'Как должно выглядеть напоминание', 'settings.notification_mode.hint': 'Как должно выглядеть напоминание',
'settings.notification_mode.modal': 'Окно поверх всех', 'settings.notification_mode.modal': 'Окно поверх всех',
@@ -390,6 +405,21 @@ export const en: Dict = {
'settings.section.appearance': 'Appearance', 'settings.section.appearance': 'Appearance',
'settings.section.language': 'Language', 'settings.section.language': 'Language',
'settings.section.updates': 'Updates', 'settings.section.updates': 'Updates',
'settings.section.data': 'Data',
'settings.data.export.label': 'Export everything',
'settings.data.export.hint':
'Save a backup of exercises, history, challenges and settings to a JSON file.',
'settings.data.export.btn': 'Save',
'settings.data.export.ok': 'Saved to {path}',
'settings.data.export.err': 'Could not save',
'settings.data.import.label': 'Restore from file',
'settings.data.import.hint':
'Load a previously saved backup. Current data will be overwritten.',
'settings.data.import.btn': 'Restore',
'settings.data.import.confirm':
'All current exercises, history and settings will be replaced with the file contents. Continue?',
'settings.data.import.ok': 'Restored',
'settings.data.import.err': "Couldn't read the file — not our backup?",
'settings.notification_mode.label': 'Notification mode', 'settings.notification_mode.label': 'Notification mode',
'settings.notification_mode.hint': 'How a reminder appears', 'settings.notification_mode.hint': 'How a reminder appears',
'settings.notification_mode.modal': 'Window on top', 'settings.notification_mode.modal': 'Window on top',

View File

@@ -158,8 +158,99 @@ export default function SettingsPage(): JSX.Element {
<SectionHeader title={t('settings.section.updates')} /> <SectionHeader title={t('settings.section.updates')} />
<UpdaterCard /> <UpdaterCard />
<div className="mt-6">
<SectionHeader title={t('settings.section.data')} />
<DataCard />
</div> </div>
</div> </div>
</div>
)
}
function DataCard(): JSX.Element {
const { t } = useT()
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
// Простое toast'-сообщение в карточке; через 4 сек чистится.
useEffect(() => {
if (!toast) return
const id = setTimeout(() => setToast(null), 4000)
return () => clearTimeout(id)
}, [toast])
async function onExport(): Promise<void> {
setBusy(true)
try {
const r = await window.api.exportState()
if (r.ok && r.path) {
setToast(t('settings.data.export.ok', { path: r.path }))
} else if (!r.ok) {
setToast(t('settings.data.export.err'))
}
} finally {
setBusy(false)
}
}
async function onImport(): Promise<void> {
// eslint-disable-next-line no-alert -- modal-confirm для destructive action
if (!window.confirm(t('settings.data.import.confirm'))) return
setBusy(true)
try {
const r = await window.api.importState()
if (r.ok) setToast(t('settings.data.import.ok'))
else if ('error' in r && r.error !== undefined) {
setToast(t('settings.data.import.err'))
}
} finally {
setBusy(false)
}
}
return (
<Card>
<Row>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.data.export.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.data.export.hint')}
</div>
</div>
<button
onClick={onExport}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
{t('settings.data.export.btn')}
</button>
</Row>
<Row last>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.data.import.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.data.import.hint')}
</div>
</div>
<button
onClick={onImport}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
{t('settings.data.import.btn')}
</button>
</Row>
{toast && (
<div className="px-4 py-2.5 text-[13px] text-text/75 bg-accent/8 truncate font-medium">
{toast}
</div>
)}
</Card>
) )
} }

View File

@@ -52,6 +52,10 @@ export const IPC = {
getHistory: 'history:get', getHistory: 'history:get',
clearHistory: 'history:clear', clearHistory: 'history:clear',
// Export / Import
exportState: 'state:export',
importState: 'state:import',
// events from main → renderer // events from main → renderer
evtTick: 'evt:tick', evtTick: 'evt:tick',
evtFire: 'evt:fire', evtFire: 'evt:fire',