258 lines
7.0 KiB
TypeScript
258 lines
7.0 KiB
TypeScript
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('/');
|
|
}
|