From 088ae0ac0acdd3cd2889d81f651eb6116f4cdfef Mon Sep 17 00:00:00 2001 From: pengx17 Date: Wed, 22 Jan 2025 03:11:28 +0000 Subject: [PATCH] feat(electron): backup panel (#9738) fix PD-2071, PD-2059, PD-2069, PD-2068 --- .../apps/electron-renderer/src/app.tsx | 2 + .../apps/electron/src/helper/dialog/dialog.ts | 21 +- .../apps/electron/src/helper/dialog/index.ts | 4 +- .../src/helper/nbstore/v1/ensure-db.ts | 1 + .../helper/nbstore/v1/workspace-db-adapter.ts | 21 +- .../electron/src/helper/workspace/handlers.ts | 171 +++++++++++- .../electron/src/helper/workspace/index.ts | 11 +- .../apps/electron/src/main/helper-process.ts | 1 + .../setting/general-setting/backup/index.tsx | 246 ++++++++++++++++++ .../general-setting/backup/styles.css.ts | 70 +++++ .../dialogs/setting/general-setting/index.tsx | 13 + .../frontend/core/src/modules/backup/index.ts | 9 + .../core/src/modules/backup/services/index.ts | 71 +++++ .../core/src/modules/dialogs/constant.ts | 1 + packages/frontend/i18n/src/i18n.gen.ts | 53 +++- packages/frontend/i18n/src/resources/en.json | 12 + tests/affine-desktop/e2e/basic.spec.ts | 10 - tests/affine-desktop/e2e/workspace.spec.ts | 61 +++++ tests/affine-desktop/package.json | 2 +- tests/affine-desktop/playwright.config.ts | 1 - tests/kit/src/electron.ts | 1 - tests/kit/src/utils/utils.ts | 2 - 22 files changed, 754 insertions(+), 30 deletions(-) create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/backup/index.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/backup/styles.css.ts create mode 100644 packages/frontend/core/src/modules/backup/index.ts create mode 100644 packages/frontend/core/src/modules/backup/services/index.ts diff --git a/packages/frontend/apps/electron-renderer/src/app.tsx b/packages/frontend/apps/electron-renderer/src/app.tsx index 7ec15d1713..336c414a2f 100644 --- a/packages/frontend/apps/electron-renderer/src/app.tsx +++ b/packages/frontend/apps/electron-renderer/src/app.tsx @@ -4,6 +4,7 @@ import { AppContainer } from '@affine/core/desktop/components/app-container'; import { router } from '@affine/core/desktop/router'; import { configureCommonModules } from '@affine/core/modules'; import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header'; +import { configureDesktopBackupModule } from '@affine/core/modules/backup'; import { ValidatorProvider } from '@affine/core/modules/cloud'; import { configureDesktopApiModule, @@ -77,6 +78,7 @@ configureAppTabsHeaderModule(framework); configureFindInPageModule(framework); configureDesktopApiModule(framework); configureSpellCheckSettingModule(framework); +configureDesktopBackupModule(framework); framework.impl(NbstoreProvider, { openStore(key, options) { const { port1: portForOpClient, port2: portForWorker } = diff --git a/packages/frontend/apps/electron/src/helper/dialog/dialog.ts b/packages/frontend/apps/electron/src/helper/dialog/dialog.ts index a0ed0c0f2e..bc5a5a0acd 100644 --- a/packages/frontend/apps/electron/src/helper/dialog/dialog.ts +++ b/packages/frontend/apps/electron/src/helper/dialog/dialog.ts @@ -176,10 +176,21 @@ export async function selectDBFileLocation(): Promise { +export async function loadDBFile( + dbFilePath?: string +): Promise { try { - const ret = + const provided = getFakedResult() ?? + (dbFilePath + ? { + filePath: dbFilePath, + filePaths: [dbFilePath], + canceled: false, + } + : undefined); + const ret = + provided ?? (await mainRPC.showOpenDialog({ properties: ['openFile'], title: 'Load Workspace', @@ -249,6 +260,12 @@ async function cpV1DBFile( return { error: 'DB_FILE_INVALID' }; // invalid db file } + // checkout to make sure wal is flushed + const connection = new SqliteConnection(originalPath); + await connection.connect(); + await connection.checkpoint(); + await connection.close(); + const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId); await fs.ensureDir(await getWorkspacesBasePath()); diff --git a/packages/frontend/apps/electron/src/helper/dialog/index.ts b/packages/frontend/apps/electron/src/helper/dialog/index.ts index 625ac4a836..19bf184049 100644 --- a/packages/frontend/apps/electron/src/helper/dialog/index.ts +++ b/packages/frontend/apps/electron/src/helper/dialog/index.ts @@ -6,8 +6,8 @@ import { } from './dialog'; export const dialogHandlers = { - loadDBFile: async () => { - return loadDBFile(); + loadDBFile: async (dbFilePath?: string) => { + return loadDBFile(dbFilePath); }, saveDBFileAs: async (universalId: string, name: string) => { return saveDBFileAs(universalId, name); diff --git a/packages/frontend/apps/electron/src/helper/nbstore/v1/ensure-db.ts b/packages/frontend/apps/electron/src/helper/nbstore/v1/ensure-db.ts index 11610ba2eb..d7cb5d37d4 100644 --- a/packages/frontend/apps/electron/src/helper/nbstore/v1/ensure-db.ts +++ b/packages/frontend/apps/electron/src/helper/nbstore/v1/ensure-db.ts @@ -64,6 +64,7 @@ export async function ensureSQLiteDisconnected( const db = await ensureSQLiteDB(spaceType, id); if (db) { + await db.checkpoint(); await db.destroy(); } } diff --git a/packages/frontend/apps/electron/src/helper/nbstore/v1/workspace-db-adapter.ts b/packages/frontend/apps/electron/src/helper/nbstore/v1/workspace-db-adapter.ts index dd3fb5390e..71731f34fa 100644 --- a/packages/frontend/apps/electron/src/helper/nbstore/v1/workspace-db-adapter.ts +++ b/packages/frontend/apps/electron/src/helper/nbstore/v1/workspace-db-adapter.ts @@ -10,7 +10,7 @@ import { mergeUpdate } from './merge-update'; const TRIM_SIZE = 1; -export class WorkspaceSQLiteDB { +export class WorkspaceSQLiteDB implements AsyncDisposable { lock = new AsyncLock(); update$ = new Subject(); adapter = new SQLiteAdapter(this.path); @@ -32,17 +32,32 @@ export class WorkspaceSQLiteDB { this.update$.complete(); } + [Symbol.asyncDispose] = async () => { + await this.destroy(); + }; + private readonly toDBDocId = (docId: string) => { return this.workspaceId === docId ? undefined : docId; }; - getWorkspaceName = async () => { + getWorkspaceMeta = async () => { const ydoc = new YDoc(); const updates = await this.adapter.getUpdates(); updates.forEach(update => { applyUpdate(ydoc, update.data); }); - return ydoc.getMap('meta').get('name') as string; + logger.log( + `ydoc.getMap('meta').get('name')`, + ydoc.getMap('meta').get('name'), + this.path, + updates.length + ); + return ydoc.getMap('meta').toJSON(); + }; + + getWorkspaceName = async () => { + const meta = await this.getWorkspaceMeta(); + return meta.name; }; async init() { diff --git a/packages/frontend/apps/electron/src/helper/workspace/handlers.ts b/packages/frontend/apps/electron/src/helper/workspace/handlers.ts index 01b527f968..cf5b0872c6 100644 --- a/packages/frontend/apps/electron/src/helper/workspace/handlers.ts +++ b/packages/frontend/apps/electron/src/helper/workspace/handlers.ts @@ -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 { + 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 { + 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}` + ); +} diff --git a/packages/frontend/apps/electron/src/helper/workspace/index.ts b/packages/frontend/apps/electron/src/helper/workspace/index.ts index a6a4b6788d..b442950337 100644 --- a/packages/frontend/apps/electron/src/helper/workspace/index.ts +++ b/packages/frontend/apps/electron/src/helper/workspace/index.ts @@ -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; export const workspaceHandlers = { delete: deleteWorkspace, moveToTrash: trashWorkspace, + getBackupWorkspaces: async () => { + return getDeletedWorkspaces(); + }, + deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id), }; diff --git a/packages/frontend/apps/electron/src/main/helper-process.ts b/packages/frontend/apps/electron/src/main/helper-process.ts index d068d49d4e..f009e514b1 100644 --- a/packages/frontend/apps/electron/src/main/helper-process.ts +++ b/packages/frontend/apps/electron/src/main/helper-process.ts @@ -51,6 +51,7 @@ class HelperProcessManager { const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH, [], { // todo: port number should not being used execArgv: isDev ? ['--inspect=40894'] : [], + serviceName: 'affine-helper', }); this.#process = helperProcess; this.ready = new Promise((resolve, reject) => { diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/backup/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/backup/index.tsx new file mode 100644 index 0000000000..439951dd9c --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/backup/index.tsx @@ -0,0 +1,246 @@ +import { + IconButton, + Loading, + Menu, + MenuItem, + notify, + Skeleton, + useConfirmModal, +} from '@affine/component'; +import { Pagination } from '@affine/component/member-components'; +import { SettingHeader } from '@affine/component/setting-components'; +import { Avatar } from '@affine/component/ui/avatar'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; +import { BackupService } from '@affine/core/modules/backup/services'; +import { i18nTime, useI18n } from '@affine/i18n'; +import { + DeleteIcon, + LocalWorkspaceIcon, + MoreVerticalIcon, +} from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import bytes from 'bytes'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import * as styles from './styles.css'; + +const Empty = () => { + const t = useI18n(); + return ( +
+ {t['com.affine.settings.workspace.backup.empty']()} +
+ ); +}; + +const BlobAvatar = ({ + blob, + name, +}: { + blob: Uint8Array | null; + name: string; +}) => { + const [url, setUrl] = useState(null); + useEffect(() => { + if (!blob) return; + const url = URL.createObjectURL(new Blob([blob])); + setUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [blob]); + return ( + + ); +}; + +type BackupWorkspaceItem = { + id: string; + name: string; + fileSize: number; + updatedAt: Date; + avatar: Uint8Array | null; + dbPath: string; +}; + +const BackupWorkspaceItem = ({ item }: { item: BackupWorkspaceItem }) => { + const [menuOpen, setMenuOpen] = useState(false); + + const { openConfirmModal } = useConfirmModal(); + const backupService = useService(BackupService); + const t = useI18n(); + const [importing, setImporting] = useState(false); + + const { jumpToPage } = useNavigateHelper(); + + const handleImport = useAsyncCallback(async () => { + setImporting(true); + const workspaceId = await backupService.recoverBackupWorkspace(item.dbPath); + if (!workspaceId) { + setImporting(false); + return; + } + notify.success({ + title: t['com.affine.settings.workspace.backup.import.success'](), + action: { + label: + t['com.affine.settings.workspace.backup.import.success.action'](), + onClick: () => { + jumpToPage(workspaceId, 'all'); + }, + autoClose: false, + }, + }); + setMenuOpen(false); + setImporting(false); + }, [backupService, item.dbPath, jumpToPage, t]); + + const handleDelete = useCallback( + (backupWorkspaceId: string) => { + openConfirmModal({ + title: t['com.affine.workspaceDelete.title'](), + children: t['com.affine.settings.workspace.backup.delete.warning'](), + onConfirm: async () => { + await backupService.deleteBackupWorkspace(backupWorkspaceId); + notify.success({ + title: t['com.affine.settings.workspace.backup.delete.success'](), + }); + }, + confirmText: t['Confirm'](), + cancelText: t['Cancel'](), + confirmButtonOptions: { + variant: 'error', + }, + }); + }, + [backupService, openConfirmModal, t] + ); + + return ( +
setMenuOpen(v => !v)} + > + +
+
{item.name}
+
+ {bytes(item.fileSize)} +
+
+
+ {t['com.affine.settings.workspace.backup.delete-at']({ + date: i18nTime(item.updatedAt, { + absolute: { + accuracy: 'day', + }, + }), + time: i18nTime(item.updatedAt, { + absolute: { + accuracy: 'minute', + noDate: true, + noYear: true, + }, + }), + })} + + } + onClick={handleImport} + > + {t['com.affine.settings.workspace.backup.import']()} + + } + onClick={() => handleDelete(item.id)} + type="danger" + > + {t['Delete']()} + + + } + contentOptions={{ align: 'end' }} + > + + {importing ? : } + + +
+
+ ); +}; + +const PAGE_SIZE = 6; + +export const BackupSettingPanel = () => { + const t = useI18n(); + const backupService = useService(BackupService); + + const handlePageChange = useCallback(() => { + backupService.revalidate(); + }, [backupService]); + + useEffect(() => { + backupService.revalidate(); + }, [backupService, handlePageChange]); + + const isLoading = useLiveData(backupService.isLoading$); + const backupWorkspaces = useLiveData(backupService.pageBackupWorkspaces$); + + const [pageNum, setPageNum] = useState(0); + + const innerElement = useMemo(() => { + if (isLoading) { + return ( + + ); + } + if (backupWorkspaces?.items.length === 0) { + return ; + } + return ( + <> +
+ {backupWorkspaces?.items + .slice(pageNum * PAGE_SIZE, (pageNum + 1) * PAGE_SIZE) + .map(item => )} +
+
+ { + setPageNum(pageNum); + }} + /> +
+ + ); + }, [isLoading, backupWorkspaces, pageNum]); + + return ( + <> + +
{innerElement}
+ + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/backup/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/backup/styles.css.ts new file mode 100644 index 0000000000..b03c1f005c --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/backup/styles.css.ts @@ -0,0 +1,70 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const listContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', + borderRadius: '8px', + background: cssVarV2('layer/background/primary'), + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + overflow: 'hidden', +}); + +export const list = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', +}); + +export const listItem = style({ + display: 'flex', + padding: '8px 8px 8px 16px', + height: '60px', + alignItems: 'center', + gap: 12, + ':hover': { + background: cssVarV2('layer/background/hoverOverlay'), + }, +}); + +export const listItemLeftLabel = style({ + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: 2, +}); + +export const listItemLeftLabelTitle = style({ + flex: 1, + maxWidth: '250px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: cssVar('fontSm'), +}); + +export const listItemLeftLabelDesc = style({ + fontSize: cssVar('fontXs'), + color: cssVarV2('text/secondary'), +}); + +export const listItemRightLabel = style({ + flex: 1, + fontSize: cssVar('fontXs'), + color: cssVarV2('text/secondary'), + gap: 10, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + whiteSpace: 'nowrap', +}); + +export const empty = style({ + padding: '8px 16px', +}); + +export const pagination = style({ + paddingBottom: '4px', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/index.tsx index f963fd35ff..88bc43e43e 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/index.tsx @@ -5,6 +5,7 @@ import { useI18n } from '@affine/i18n'; import { AppearanceIcon, ExperimentIcon, + FolderIcon, InformationIcon, KeyboardIcon, PenIcon, @@ -16,6 +17,7 @@ import { AuthService, ServerService } from '../../../../modules/cloud'; import type { SettingSidebarItem, SettingState } from '../types'; import { AboutAffine } from './about'; import { AppearanceSettings } from './appearance'; +import { BackupSettingPanel } from './backup'; import { BillingSettings } from './billing'; import { EditorSettings } from './editor'; import { ExperimentalFeatures } from './experimental-features'; @@ -93,6 +95,15 @@ export const useGeneralSettingList = (): GeneralSettingList => { } } + if (BUILD_CONFIG.isElectron) { + settings.push({ + key: 'backup', + title: t['com.affine.settings.workspace.backup'](), + icon: , + testId: 'backup-panel-trigger', + }); + } + settings.push({ key: 'experimental-features', title: t['com.affine.settings.workspace.experimental-features'](), @@ -129,6 +140,8 @@ export const GeneralSetting = ({ return ; case 'experimental-features': return ; + case 'backup': + return ; default: return null; } diff --git a/packages/frontend/core/src/modules/backup/index.ts b/packages/frontend/core/src/modules/backup/index.ts new file mode 100644 index 0000000000..bcf8c4b7f3 --- /dev/null +++ b/packages/frontend/core/src/modules/backup/index.ts @@ -0,0 +1,9 @@ +import { type Framework } from '@toeverything/infra'; + +import { DesktopApiService } from '../desktop-api'; +import { WorkspacesService } from '../workspace'; +import { BackupService } from './services'; + +export function configureDesktopBackupModule(framework: Framework) { + framework.service(BackupService, [DesktopApiService, WorkspacesService]); +} diff --git a/packages/frontend/core/src/modules/backup/services/index.ts b/packages/frontend/core/src/modules/backup/services/index.ts new file mode 100644 index 0000000000..aea5817c2f --- /dev/null +++ b/packages/frontend/core/src/modules/backup/services/index.ts @@ -0,0 +1,71 @@ +import { + catchErrorInto, + effect, + fromPromise, + LiveData, + onComplete, + onStart, + Service, +} from '@toeverything/infra'; +import { EMPTY, mergeMap, switchMap } from 'rxjs'; + +import type { DesktopApiService } from '../../desktop-api'; +import type { WorkspacesService } from '../../workspace'; +import { _addLocalWorkspace } from '../../workspace-engine'; + +type BackupWorkspaceResult = Awaited< + ReturnType +>; + +export class BackupService extends Service { + constructor( + private readonly desktopApiService: DesktopApiService, + private readonly workspacesService: WorkspacesService + ) { + super(); + } + + isLoading$ = new LiveData(false); + error$ = new LiveData(null); + + pageBackupWorkspaces$ = new LiveData( + undefined + ); + + readonly revalidate = effect( + switchMap(() => + fromPromise(async () => { + return this.desktopApiService.handler.workspace.getBackupWorkspaces(); + }).pipe( + mergeMap(data => { + this.pageBackupWorkspaces$.setValue(data); + return EMPTY; + }), + catchErrorInto(this.error$), + onStart(() => this.isLoading$.setValue(true)), + onComplete(() => this.isLoading$.setValue(false)) + ) + ) + ); + + async recoverBackupWorkspace(dbPath: string) { + const result = + await this.desktopApiService.handler.dialog.loadDBFile(dbPath); + if (result.workspaceId) { + _addLocalWorkspace(result.workspaceId); + this.workspacesService.list.revalidate(); + } + return result.workspaceId; + } + + async deleteBackupWorkspace(backupWorkspaceId: string) { + await this.desktopApiService.handler.workspace.deleteBackupWorkspace( + backupWorkspaceId + ); + this.revalidate(); + } + + override dispose(): void { + this.revalidate.unsubscribe(); + } +} diff --git a/packages/frontend/core/src/modules/dialogs/constant.ts b/packages/frontend/core/src/modules/dialogs/constant.ts index e4238c3408..66eba913a5 100644 --- a/packages/frontend/core/src/modules/dialogs/constant.ts +++ b/packages/frontend/core/src/modules/dialogs/constant.ts @@ -8,6 +8,7 @@ export type SettingTab = | 'about' | 'plans' | 'billing' + | 'backup' // electron only | 'experimental-features' | 'editor' | 'account' diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 5334b6c10c..bf98546433 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -4231,6 +4231,14 @@ export function useAFFiNEI18N(): { ["com.affine.quicksearch.group.searchfor"](options: { readonly query: string; }): string; + /** + * `Reset sync` + */ + ["com.affine.resetSyncStatus.button"](): string; + /** + * `This operation may fix some synchronization issues.` + */ + ["com.affine.resetSyncStatus.description"](): string; /** * `Collections` */ @@ -5362,7 +5370,7 @@ export function useAFFiNEI18N(): { */ ["com.affine.settings.workspace.preferences"](): string; /** - * `Team's Billing` + * `Team's Team's Billing` */ ["com.affine.settings.workspace.billing"](): string; /** @@ -5505,6 +5513,49 @@ export function useAFFiNEI18N(): { * `Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.` */ ["com.affine.settings.workspace.affine-ai.description"](): string; + /** + * `Backup` + */ + ["com.affine.settings.workspace.backup"](): string; + /** + * `Management in local workspace backup files` + */ + ["com.affine.settings.workspace.backup.subtitle"](): string; + /** + * `No backup files found` + */ + ["com.affine.settings.workspace.backup.empty"](): string; + /** + * `Delete backup workspace` + */ + ["com.affine.settings.workspace.backup.delete"](): string; + /** + * `Are you sure you want to delete this workspace. This action cannot be undone. Make sure you no longer need them before proceeding.` + */ + ["com.affine.settings.workspace.backup.delete.warning"](): string; + /** + * `Workspace backup deleted successfully` + */ + ["com.affine.settings.workspace.backup.delete.success"](): string; + /** + * `Workspace enabled successfully` + */ + ["com.affine.settings.workspace.backup.import.success"](): string; + /** + * `Enable local workspace` + */ + ["com.affine.settings.workspace.backup.import"](): string; + /** + * `Open` + */ + ["com.affine.settings.workspace.backup.import.success.action"](): string; + /** + * `Deleted {{date}} at {{time}}` + */ + ["com.affine.settings.workspace.backup.delete-at"](options: Readonly<{ + date: string; + time: string; + }>): string; /** * `Sharing doc requires AFFiNE Cloud.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 122bd541c6..e2c626565a 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1049,6 +1049,8 @@ "com.affine.peek-view-controls.open-doc-in-center-peek": "Open in center peek", "com.affine.quicksearch.group.creation": "New", "com.affine.quicksearch.group.searchfor": "Search for \"{{query}}\"", + "com.affine.resetSyncStatus.button": "Reset sync", + "com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.", "com.affine.rootAppSidebar.collections": "Collections", "com.affine.rootAppSidebar.doc.link-doc-only": "Only doc can be placed on here", "com.affine.rootAppSidebar.docs.no-subdoc": "No linked docs", @@ -1376,6 +1378,16 @@ "com.affine.settings.workspace.affine-ai.title": "AFFiNE AI", "com.affine.settings.workspace.affine-ai.label": "Allow AFFiNE AI Assistant", "com.affine.settings.workspace.affine-ai.description": "Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.", + "com.affine.settings.workspace.backup": "Backup", + "com.affine.settings.workspace.backup.subtitle": "Management in local workspace backup files", + "com.affine.settings.workspace.backup.empty": "No backup files found", + "com.affine.settings.workspace.backup.delete": "Delete backup workspace", + "com.affine.settings.workspace.backup.delete.warning": "Are you sure you want to delete this workspace. This action cannot be undone. Make sure you no longer need them before proceeding.", + "com.affine.settings.workspace.backup.delete.success": "Workspace backup deleted successfully", + "com.affine.settings.workspace.backup.import.success": "Workspace enabled successfully", + "com.affine.settings.workspace.backup.import": "Enable local workspace", + "com.affine.settings.workspace.backup.import.success.action": "Open", + "com.affine.settings.workspace.backup.delete-at": "Deleted on {{date}} at {{time}}", "com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.", "com.affine.share-menu.ShareMode": "Share mode", "com.affine.share-menu.SharePage": "Share doc", diff --git a/tests/affine-desktop/e2e/basic.spec.ts b/tests/affine-desktop/e2e/basic.spec.ts index 9dc3ce8160..a38794c0fd 100644 --- a/tests/affine-desktop/e2e/basic.spec.ts +++ b/tests/affine-desktop/e2e/basic.spec.ts @@ -125,16 +125,6 @@ test('delete workspace', async ({ page }) => { await expect(page.getByTestId('workspace-name-input')).toHaveValue( 'Delete Me' ); - const contentElement = page.getByTestId('setting-modal-content'); - const boundingBox = await contentElement.boundingBox(); - if (!boundingBox) { - throw new Error('boundingBox is null'); - } - await page.mouse.move( - boundingBox.x + boundingBox.width / 2, - boundingBox.y + boundingBox.height / 2 - ); - await page.mouse.wheel(0, 500); await page.getByTestId('delete-workspace-button').click(); await page.getByTestId('delete-workspace-input').fill('Delete Me'); await page.getByTestId('delete-workspace-confirm-button').click(); diff --git a/tests/affine-desktop/e2e/workspace.spec.ts b/tests/affine-desktop/e2e/workspace.spec.ts index 56b0fb334b..3cdb671f61 100644 --- a/tests/affine-desktop/e2e/workspace.spec.ts +++ b/tests/affine-desktop/e2e/workspace.spec.ts @@ -101,3 +101,64 @@ test('export then add', async ({ page, appInfo, workspace }) => { const title = page.locator('[data-block-is-title] >> text="test1"'); await expect(title).toBeVisible(); }); + +test('delete workspace and then restore it from backup', async ({ page }) => { + //#region 1. create a new workspace + await clickSideBarCurrentWorkspaceBanner(page); + const newWorkspaceName = 'new-test-name'; + + await page.getByTestId('new-workspace').click(); + await page.getByTestId('create-workspace-input').fill(newWorkspaceName); + await page.getByTestId('create-workspace-create-button').click(); + //#endregion + + //#region 2. create a page in the new workspace (will verify later if it is successfully recovered) + await clickNewPageButton(page); + + await getBlockSuiteEditorTitle(page).fill('test1'); + //#endregion + + //#region 3. delete the workspace + await page.getByTestId('slider-bar-workspace-setting-button').click(); + await expect(page.getByTestId('setting-modal')).toBeVisible(); + + await page.getByTestId('workspace-setting:preference').click(); + await page.getByTestId('delete-workspace-button').click(); + await page.getByTestId('delete-workspace-input').fill(newWorkspaceName); + + await page.getByTestId('delete-workspace-confirm-button').click(); + + // we are back to the original workspace + await expect(page.getByTestId('workspace-name')).toContainText( + 'Demo Workspace' + ); + //#endregion + + //#region 4. restore the workspace from backup + await page.getByTestId('slider-bar-workspace-setting-button').click(); + await expect(page.getByTestId('setting-modal')).toBeVisible(); + + await page.getByTestId('backup-panel-trigger').click(); + await expect(page.getByTestId('backup-workspace-item')).toHaveCount(1); + await page.getByTestId('backup-workspace-item').click(); + await page.getByRole('menuitem', { name: 'Enable local workspace' }).click(); + const toast = page.locator( + '[data-sonner-toast]:has-text("Workspace enabled successfully")' + ); + await expect(toast).toBeVisible(); + await toast.getByRole('button', { name: 'Open' }).click(); + //#endregion + + await page.waitForTimeout(1000); + + // verify the workspace name & page title + await expect(page.getByTestId('workspace-name')).toContainText( + newWorkspaceName + ); + // find button which has the title "test1" + const test1PageButton = await page.waitForSelector(`text="test1"`); + await test1PageButton.click(); + + const title = page.locator('[data-block-is-title] >> text="test1"'); + await expect(title).toBeVisible(); +}); diff --git a/tests/affine-desktop/package.json b/tests/affine-desktop/package.json index 0db5350488..2442c1d332 100644 --- a/tests/affine-desktop/package.json +++ b/tests/affine-desktop/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "e2e": "DEBUG=pw:browser yarn playwright test" + "e2e": "yarn playwright test --reporter=list" }, "devDependencies": { "@affine-test/kit": "workspace:*", diff --git a/tests/affine-desktop/playwright.config.ts b/tests/affine-desktop/playwright.config.ts index 3dbefc2e9f..e484cd9780 100644 --- a/tests/affine-desktop/playwright.config.ts +++ b/tests/affine-desktop/playwright.config.ts @@ -29,7 +29,6 @@ const config: PlaywrightTestConfig = { }; if (process.env.CI) { - // todo: fix the flaky tests config.retries = 5; } diff --git a/tests/kit/src/electron.ts b/tests/kit/src/electron.ts index bfa4e60457..ae53d04c94 100644 --- a/tests/kit/src/electron.ts +++ b/tests/kit/src/electron.ts @@ -160,7 +160,6 @@ export const test = base.extend<{ }); await use(electronApp); - console.log('Cleaning up...'); const pages = electronApp.windows(); for (const page of pages) { await page.close(); diff --git a/tests/kit/src/utils/utils.ts b/tests/kit/src/utils/utils.ts index 2345a8945b..ee787eee8c 100644 --- a/tests/kit/src/utils/utils.ts +++ b/tests/kit/src/utils/utils.ts @@ -24,11 +24,9 @@ export async function removeWithRetry( for (let i = 0; i < maxRetries; i++) { try { await fs.remove(filePath); - console.log(`File ${filePath} successfully deleted.`); return true; } catch (err: any) { if (err.code === 'EBUSY' || err.code === 'EPERM') { - console.log(`File ${filePath} is busy or locked, retrying...`); await setTimeout(delay); } else { console.error(`Failed to delete file ${filePath}:`, err);