feat: Data management
This commit is contained in:
257
electron/data-management.ts
Normal file
257
electron/data-management.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
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<boolean> {
|
||||
const error = await shell.openPath(app.getPath('userData'));
|
||||
|
||||
return error.length === 0;
|
||||
}
|
||||
|
||||
export async function exportUserData(): Promise<ExportUserDataResult> {
|
||||
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<ImportUserDataResult> {
|
||||
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<EraseUserDataResult> {
|
||||
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<string[]> {
|
||||
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<string | undefined> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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('/');
|
||||
}
|
||||
Reference in New Issue
Block a user