From 72e54c579d4895c6676053b878023af2737c5550 Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 22 May 2026 13:33:38 +0700 Subject: [PATCH] =?UTF-8?q?feat(#9):=20export/import=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20=E2=80=94=20backup=20?= =?UTF-8?q?=D0=B2=20JSON=20=D0=B8=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc.ts | 47 +++++++++++++++ src/main/store.ts | 53 +++++++++++++++++ src/preload/index.ts | 6 ++ src/renderer/src/i18n/dict.ts | 30 ++++++++++ src/renderer/src/pages/Settings.tsx | 91 +++++++++++++++++++++++++++++ src/shared/ipc.ts | 4 ++ 6 files changed, 231 insertions(+) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fe8569a..d9f4626 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) } + } + }) } diff --git a/src/main/store.ts b/src/main/store.ts index 3f37ff4..35cdd94 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -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 + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1c4f95d..8d548c8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -116,6 +116,12 @@ const api = { clearHistory: (beforeTs?: number): Promise => 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): Unsub => on(IPC.evtTick, h), onFire: (h: Handler): Unsub => on(IPC.evtFire, h), onMatchEnd: (h: Handler): Unsub => on(IPC.evtMatchEnd, h), diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 062aeb5..4f0d812 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -143,6 +143,21 @@ export const ru: Dict = { 'settings.section.appearance': 'Внешний вид', 'settings.section.language': 'Язык', '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.hint': 'Как должно выглядеть напоминание', 'settings.notification_mode.modal': 'Окно поверх всех', @@ -390,6 +405,21 @@ export const en: Dict = { 'settings.section.appearance': 'Appearance', 'settings.section.language': 'Language', '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.hint': 'How a reminder appears', 'settings.notification_mode.modal': 'Window on top', diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index ceb35c7..0c12571 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -158,11 +158,102 @@ export default function SettingsPage(): JSX.Element { + +
+ + +
) } +function DataCard(): JSX.Element { + const { t } = useT() + const [busy, setBusy] = useState(false) + const [toast, setToast] = useState(null) + + // Простое toast'-сообщение в карточке; через 4 сек чистится. + useEffect(() => { + if (!toast) return + const id = setTimeout(() => setToast(null), 4000) + return () => clearTimeout(id) + }, [toast]) + + async function onExport(): Promise { + 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 { + // 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 ( + + +
+
+ {t('settings.data.export.label')} +
+
+ {t('settings.data.export.hint')} +
+
+ +
+ +
+
+ {t('settings.data.import.label')} +
+
+ {t('settings.data.import.hint')} +
+
+ +
+ {toast && ( +
+ {toast} +
+ )} +
+ ) +} + function ToggleRow({ label, hint, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 434bf2e..e03d9c0 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -52,6 +52,10 @@ export const IPC = { getHistory: 'history:get', clearHistory: 'history:clear', + // Export / Import + exportState: 'state:export', + importState: 'state:import', + // events from main → renderer evtTick: 'evt:tick', evtFire: 'evt:fire',