feat(#9): export/import состояния — backup в JSON и восстановление
This commit is contained in:
@@ -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) }
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -158,11 +158,102 @@ 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ToggleRow({
|
function ToggleRow({
|
||||||
label,
|
label,
|
||||||
hint,
|
hint,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user