feat(electron): backup panel (#9738)

fix PD-2071, PD-2059, PD-2069, PD-2068
This commit is contained in:
pengx17
2025-01-22 03:11:28 +00:00
committed by Peng Xiao
parent 862a9d0bc4
commit 088ae0ac0a
22 changed files with 754 additions and 30 deletions
@@ -1,11 +1,18 @@
import path from 'node:path';
import { parseUniversalId } from '@affine/nbstore';
import { DocStorage } from '@affine/native';
import {
parseUniversalId,
universalId as generateUniversalId,
} from '@affine/nbstore';
import fs from 'fs-extra';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { isWindows } from '../../shared/utils';
import { logger } from '../logger';
import { getDocStoragePool } from '../nbstore';
import { ensureSQLiteDisconnected } from '../nbstore/v1/ensure-db';
import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
import type { WorkspaceMeta } from '../type';
import {
getDeletedWorkspacesBasePath,
@@ -50,12 +57,28 @@ export async function trashWorkspace(universalId: string) {
await deleteWorkspaceV1(id);
const dbPath = await getSpaceDBPath(peer, type, id);
const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`);
const basePath = await getDeletedWorkspacesBasePath();
const movedPath = path.join(basePath, `${id}`);
try {
await getDocStoragePool().disconnect(universalId);
return await fs.move(path.dirname(dbPath), movedPath, {
overwrite: true,
});
const storage = new DocStorage(dbPath);
if (await storage.validate()) {
const pool = getDocStoragePool();
await pool.checkpoint(universalId);
await pool.disconnect(universalId);
}
await fs.ensureDir(movedPath);
// todo(@pengx17): it seems the db file is still being used at the point
// on windows so that it cannot be moved. we will fallback to copy the dir instead.
if (isWindows()) {
await fs.copy(path.dirname(dbPath), movedPath, {
overwrite: true,
});
await fs.rmdir(path.dirname(dbPath), { recursive: true });
} else {
return await fs.move(path.dirname(dbPath), movedPath, {
overwrite: true,
});
}
} catch (error) {
logger.error('trashWorkspace', error);
}
@@ -79,3 +102,139 @@ export async function storeWorkspaceMeta(
logger.error('storeWorkspaceMeta failed', err);
}
}
type WorkspaceDocMeta = {
id: string;
name: string;
avatar: Uint8Array | null;
fileSize: number;
updatedAt: Date;
createdAt: Date;
docCount: number;
dbPath: string;
};
async function getWorkspaceDocMetaV1(
workspaceId: string,
dbPath: string
): Promise<WorkspaceDocMeta | null> {
try {
await using db = new WorkspaceSQLiteDB(dbPath, workspaceId);
await db.init();
await db.checkpoint();
const meta = await db.getWorkspaceMeta();
const dbFileSize = await fs.stat(dbPath);
return {
id: workspaceId,
name: meta.name,
avatar: await db.getBlob(meta.avatar),
fileSize: dbFileSize.size,
updatedAt: dbFileSize.mtime,
createdAt: dbFileSize.birthtime,
docCount: meta.pages.length,
dbPath,
};
} catch {
// ignore
}
return null;
}
async function getWorkspaceDocMeta(
workspaceId: string,
dbPath: string
): Promise<WorkspaceDocMeta | null> {
const pool = getDocStoragePool();
const universalId = generateUniversalId({
peer: 'deleted-local',
type: 'workspace',
id: workspaceId,
});
try {
await pool.connect(universalId, dbPath);
await pool.checkpoint(universalId);
const snapshot = await pool.getDocSnapshot(universalId, workspaceId);
const pendingUpdates = await pool.getDocUpdates(universalId, workspaceId);
if (snapshot) {
const updates = snapshot.bin;
const ydoc = new YDoc();
applyUpdate(ydoc, updates);
pendingUpdates.forEach(update => {
applyUpdate(ydoc, update.bin);
});
const meta = ydoc.getMap('meta').toJSON();
const dbFileStat = await fs.stat(dbPath);
const blob = meta.avatar
? await pool.getBlob(universalId, meta.avatar)
: null;
return {
id: workspaceId,
name: meta.name,
avatar: blob ? blob.data : null,
fileSize: dbFileStat.size,
updatedAt: dbFileStat.mtime,
createdAt: dbFileStat.birthtime,
docCount: meta.pages.length,
dbPath,
};
}
} catch {
// try using v1
return await getWorkspaceDocMetaV1(workspaceId, dbPath);
} finally {
await pool.disconnect(universalId);
}
return null;
}
export async function getDeletedWorkspaces() {
const basePath = await getDeletedWorkspacesBasePath();
const directories = await fs.readdir(basePath);
const workspaceEntries = await Promise.all(
directories.map(async dir => {
const stats = await fs.stat(path.join(basePath, dir));
if (!stats.isDirectory()) {
return null;
}
const dbfileStats = await fs.stat(path.join(basePath, dir, 'storage.db'));
return {
id: dir,
mtime: new Date(dbfileStats.mtime),
};
})
);
const workspaceIds = workspaceEntries
.filter(v => v !== null)
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.map(entry => entry.id);
const items: WorkspaceDocMeta[] = [];
// todo(@pengx17): add cursor based pagination
for (const id of workspaceIds) {
const meta = await getWorkspaceDocMeta(
id,
path.join(basePath, id, 'storage.db')
);
if (meta) {
items.push(meta);
} else {
logger.warn('getDeletedWorkspaces', `No meta found for ${id}`);
}
}
return {
items: items,
};
}
export async function deleteBackupWorkspace(id: string) {
const basePath = await getDeletedWorkspacesBasePath();
const workspacePath = path.join(basePath, id);
await fs.rmdir(workspacePath, { recursive: true });
logger.info(
'deleteBackupWorkspace',
`Deleted backup workspace: ${workspacePath}`
);
}
@@ -1,5 +1,10 @@
import type { MainEventRegister } from '../type';
import { deleteWorkspace, trashWorkspace } from './handlers';
import {
deleteBackupWorkspace,
deleteWorkspace,
getDeletedWorkspaces,
trashWorkspace,
} from './handlers';
export * from './handlers';
export * from './subjects';
@@ -9,4 +14,8 @@ export const workspaceEvents = {} as Record<string, MainEventRegister>;
export const workspaceHandlers = {
delete: deleteWorkspace,
moveToTrash: trashWorkspace,
getBackupWorkspaces: async () => {
return getDeletedWorkspaces();
},
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
};