import { app, dialog, shell } from 'electron'; import * as fsp from 'fs/promises'; import * as path from 'path'; import { destroyDatabase, initializeDatabase } from './db/database'; import { createZipArchive, extractZipEntries, readZipArchive, type ZipArchiveEntry } from './data-archive'; export interface ExportUserDataResult { cancelled: boolean; exported: boolean; filePath?: string; } export interface ImportUserDataResult { backupPath?: string; cancelled: boolean; imported: boolean; restartRequired: boolean; } export interface EraseUserDataResult { erased: boolean; restartRequired: boolean; } const ARCHIVE_MANIFEST_PATH = 'metoyou-data-manifest.json'; const ARCHIVE_DATA_PREFIX = 'data/'; const BACKUP_DIRECTORY_NAME = 'metoyou-data-backups'; export async function openCurrentDataFolder(): Promise { const error = await shell.openPath(app.getPath('userData')); return error.length === 0; } export async function exportUserData(): Promise { const dataPath = app.getPath('userData'); const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`; const { canceled, filePath } = await dialog.showSaveDialog({ defaultPath: path.join(app.getPath('documents'), defaultFileName), filters: [ { extensions: ['dat'], name: 'MetoYou data archive' } ], title: 'Export MetoYou data' }); if (canceled || !filePath) { return { cancelled: true, exported: false }; } const entries: ZipArchiveEntry[] = [ { data: Buffer.from(JSON.stringify({ appVersion: app.getVersion(), exportedAt: new Date().toISOString(), format: 'metoyou-user-data', version: 1 }, null, 2)), path: ARCHIVE_MANIFEST_PATH } ]; for (const file of await collectDataFiles(dataPath)) { const relativePath = toArchivePath(path.relative(dataPath, file)); entries.push({ data: await fsp.readFile(file), path: `${ARCHIVE_DATA_PREFIX}${relativePath}` }); } await fsp.writeFile(ensureDatExtension(filePath), createZipArchive(entries)); return { cancelled: false, exported: true, filePath: ensureDatExtension(filePath) }; } export async function importUserData(): Promise { const { canceled, filePaths } = await dialog.showOpenDialog({ filters: [ { extensions: ['dat', 'zip'], name: 'MetoYou data archive' } ], properties: ['openFile'], title: 'Import MetoYou data' }); if (canceled || filePaths.length === 0) { return { cancelled: true, imported: false, restartRequired: false }; } const archiveEntries = readZipArchive(await fsp.readFile(filePaths[0])); validateArchiveManifest(archiveEntries); const importRoot = path.join(app.getPath('temp'), `metoyou-import-${Date.now()}`); const importDataPath = path.join(importRoot, 'data'); try { await extractZipEntries( archiveEntries .filter((entry) => entry.path.startsWith(ARCHIVE_DATA_PREFIX)) .map((entry) => ({ data: entry.data, path: entry.path.slice(ARCHIVE_DATA_PREFIX.length) })), importDataPath ); await destroyDatabase(); const backupPath = await moveCurrentDataAside(); await copyDirectory(importDataPath, app.getPath('userData')); await initializeDatabase(); return { backupPath, cancelled: false, imported: true, restartRequired: true }; } catch (error) { await initializeDatabase().catch(() => {}); throw error; } finally { await fsp.rm(importRoot, { force: true, recursive: true }).catch(() => {}); } } export async function eraseUserData(): Promise { const dataPath = app.getPath('userData'); await destroyDatabase(); for (const entry of await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => [])) { await fsp.rm(path.join(dataPath, entry.name), { force: true, recursive: true }); } await fsp.mkdir(dataPath, { recursive: true }); await initializeDatabase(); return { erased: true, restartRequired: true }; } async function collectDataFiles(directoryPath: string): Promise { const files: string[] = []; const entries = await fsp.readdir(directoryPath, { withFileTypes: true }).catch(() => []); for (const entry of entries) { if (entry.name === BACKUP_DIRECTORY_NAME) { continue; } const entryPath = path.join(directoryPath, entry.name); if (entry.isDirectory()) { files.push(...await collectDataFiles(entryPath)); } else if (entry.isFile()) { files.push(entryPath); } } return files; } async function moveCurrentDataAside(): Promise { const dataPath = app.getPath('userData'); const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME); const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`); const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []); await fsp.mkdir(backupPath, { recursive: true }); let movedAny = false; for (const entry of entries) { if (entry.name === BACKUP_DIRECTORY_NAME) { continue; } const sourcePath = path.join(dataPath, entry.name); const targetPath = path.join(backupPath, entry.name); await fsp.mkdir(path.dirname(targetPath), { recursive: true }); await fsp.rename(sourcePath, targetPath).catch(async () => { await copyPath(sourcePath, targetPath); await fsp.rm(sourcePath, { force: true, recursive: true }); }); movedAny = true; } return movedAny ? backupPath : undefined; } async function copyDirectory(sourcePath: string, targetPath: string): Promise { await fsp.mkdir(targetPath, { recursive: true }); for (const entry of await fsp.readdir(sourcePath, { withFileTypes: true }).catch(() => [])) { await copyPath(path.join(sourcePath, entry.name), path.join(targetPath, entry.name)); } } async function copyPath(sourcePath: string, targetPath: string): Promise { const stats = await fsp.stat(sourcePath); if (stats.isDirectory()) { await copyDirectory(sourcePath, targetPath); return; } if (stats.isFile()) { await fsp.mkdir(path.dirname(targetPath), { recursive: true }); await fsp.copyFile(sourcePath, targetPath); } } function validateArchiveManifest(entries: ZipArchiveEntry[]): void { const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH); if (!manifest) { throw new Error('The selected file is missing a MetoYou data manifest.'); } const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number }; if (parsed.format !== 'metoyou-user-data' || parsed.version !== 1) { throw new Error('The selected file uses an unsupported data archive format.'); } } function ensureDatExtension(filePath: string): string { return path.extname(filePath).toLowerCase() === '.dat' ? filePath : `${filePath}.dat`; } function toArchivePath(filePath: string): string { return filePath.split(path.sep).join('/'); }