From 61635aa77af51f24b5dafa7f9341d3249d9461c2 Mon Sep 17 00:00:00 2001 From: EYHN Date: Wed, 5 Mar 2025 04:22:02 +0000 Subject: [PATCH] feat(core): add clipper import interface (#10619) --- packages/common/nbstore/src/frontend/doc.ts | 6 +- .../desktop/pages/import-clipper/index.tsx | 199 ++++++++++++++++++ .../desktop/pages/import-clipper/style.css.ts | 50 +++++ packages/frontend/core/src/desktop/router.tsx | 4 + .../core/src/modules/import-clipper/index.ts | 10 + .../modules/import-clipper/services/import.ts | 76 +++++++ .../import-template/entities/dialog.ts | 22 -- .../core/src/modules/import-template/index.ts | 2 - packages/frontend/core/src/modules/index.ts | 2 + packages/frontend/i18n/src/i18n.gen.ts | 22 ++ packages/frontend/i18n/src/resources/en.json | 5 + 11 files changed, 373 insertions(+), 25 deletions(-) create mode 100644 packages/frontend/core/src/desktop/pages/import-clipper/index.tsx create mode 100644 packages/frontend/core/src/desktop/pages/import-clipper/style.css.ts create mode 100644 packages/frontend/core/src/modules/import-clipper/index.ts create mode 100644 packages/frontend/core/src/modules/import-clipper/services/import.ts delete mode 100644 packages/frontend/core/src/modules/import-template/entities/dialog.ts diff --git a/packages/common/nbstore/src/frontend/doc.ts b/packages/common/nbstore/src/frontend/doc.ts index f5b6aa2df..065b64c4e 100644 --- a/packages/common/nbstore/src/frontend/doc.ts +++ b/packages/common/nbstore/src/frontend/doc.ts @@ -217,6 +217,7 @@ export class DocFrontend { while (true) { throwIfAborted(signal); + const docId = await this.status.jobDocQueue.asyncPop(signal); const jobs = this.status.jobMap.get(docId); this.status.jobMap.delete(docId); @@ -244,6 +245,9 @@ export class DocFrontend { if (save?.length) { await this.jobs.save(docId, save as any, signal); } + + this.status.currentJob = null; + this.statusUpdatedSubject$.next(docId); } } finally { dispose(); @@ -486,7 +490,7 @@ export class DocFrontend { return Promise.race([ new Promise(resolve => { sub = this.docState$(docId).subscribe(state => { - if (state.syncing) { + if (state.synced && !state.updating) { resolve(); } }); diff --git a/packages/frontend/core/src/desktop/pages/import-clipper/index.tsx b/packages/frontend/core/src/desktop/pages/import-clipper/index.tsx new file mode 100644 index 000000000..d1261521b --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/import-clipper/index.tsx @@ -0,0 +1,199 @@ +import { Button } from '@affine/component'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; +import { useWorkspaceName } from '@affine/core/components/hooks/use-workspace-info'; +import { WorkspaceSelector } from '@affine/core/components/workspace-selector'; +import { AuthService } from '@affine/core/modules/cloud'; +import { + type ClipperInput, + ImportClipperService, +} from '@affine/core/modules/import-clipper'; +import { + type WorkspaceMetadata, + WorkspacesService, +} from '@affine/core/modules/workspace'; +import { useI18n } from '@affine/i18n'; +import { AllDocsIcon } from '@blocksuite/icons/rc'; +import { LiveData, useLiveData, useService } from '@toeverything/infra'; +import { cssVar } from '@toeverything/theme'; +import { useCallback, useEffect, useState } from 'react'; + +import * as styles from './style.css'; + +const clipperInput$ = new LiveData(null); + +window.addEventListener('message', event => { + if ( + typeof event.data === 'object' && + event.data.type === 'affine-clipper:import' + ) { + clipperInput$.value = event.data.payload; + } +}); + +export const Component = () => { + const importClipperService = useService(ImportClipperService); + const t = useI18n(); + const session = useService(AuthService).session; + const notLogin = useLiveData(session.status$) === 'unauthenticated'; + const isSessionRevalidating = useLiveData(session.isRevalidating$); + + const [importing, setImporting] = useState(false); + const [importingError, setImportingError] = useState(null); + const clipperInput = useLiveData(clipperInput$); + const [clipperInputSnapshot, setClipperInputSnapshot] = + useState(null); + const isMissingInput = !clipperInputSnapshot; + const workspacesService = useService(WorkspacesService); + const workspaces = useLiveData(workspacesService.list.workspaces$); + const [rawSelectedWorkspace, setSelectedWorkspace] = + useState(null); + const selectedWorkspace = + rawSelectedWorkspace ?? + workspaces.find(w => w.flavour !== 'local') ?? + workspaces.at(0); + const selectedWorkspaceName = useWorkspaceName(selectedWorkspace); + const { jumpToSignIn } = useNavigateHelper(); + + const noWorkspace = workspaces.length === 0; + + useEffect(() => { + workspacesService.list.revalidate(); + }, [workspacesService]); + + useEffect(() => { + session.revalidate(); + }, [session]); + + useEffect(() => { + if (!isSessionRevalidating && notLogin) { + jumpToSignIn('/clipper/import'); + } + }, [isSessionRevalidating, jumpToSignIn, notLogin]); + + useEffect(() => { + if (!clipperInputSnapshot) { + setClipperInputSnapshot(clipperInput); + } + }, [clipperInput, clipperInputSnapshot]); + + const handleSelectedWorkspace = useCallback( + (workspaceMetadata: WorkspaceMetadata) => { + return setSelectedWorkspace(workspaceMetadata); + }, + [] + ); + + const handleCreatedWorkspace = useCallback( + (payload: { metadata: WorkspaceMetadata; defaultDocId?: string }) => { + return setSelectedWorkspace(payload.metadata); + }, + [] + ); + + const handleImportToSelectedWorkspace = useAsyncCallback(async () => { + if (clipperInputSnapshot && selectedWorkspace) { + setImporting(true); + try { + await importClipperService.importToWorkspace( + selectedWorkspace, + clipperInputSnapshot + ); + window.close(); + } catch (err) { + setImportingError(err); + } finally { + setImporting(false); + } + } + }, [clipperInputSnapshot, importClipperService, selectedWorkspace]); + + const handleImportToNewWorkspace = useAsyncCallback(async () => { + if (!clipperInputSnapshot) { + return; + } + setImporting(true); + try { + await importClipperService.importToNewWorkspace( + 'affine-cloud', + 'Workspace', + clipperInputSnapshot + ); + window.close(); + } catch (err) { + setImportingError(err); + } finally { + setImporting(false); + } + }, [clipperInputSnapshot, importClipperService]); + + const disabled = isMissingInput || importing || notLogin; + + return ( +
+ +
+ {t['com.affine.import-clipper.dialog.createDocFromClipper']()} +
+ {noWorkspace ? ( +

A new workspace will be created.

+ ) : ( + <> +

Choose a workspace.

+ + + )} +
+ {importingError && ( + + {t['com.affine.import-clipper.dialog.errorImport']()} + + )} + {isMissingInput ? ( + + {t['com.affine.import-clipper.dialog.errorLoad']()} + + ) : selectedWorkspace ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/pages/import-clipper/style.css.ts b/packages/frontend/core/src/desktop/pages/import-clipper/style.css.ts new file mode 100644 index 000000000..aea601709 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/import-clipper/style.css.ts @@ -0,0 +1,50 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + maxWidth: '400px', + margin: '0 auto', + color: cssVarV2('text/primary'), + padding: '16px', +}); + +export const buttonContainer = style({ + paddingTop: '16px', +}); + +export const mainIcon = style({ + width: 36, + height: 36, + color: cssVarV2('icon/primary'), +}); + +export const mainTitle = style({ + fontSize: '18px', + lineHeight: '26px', + textAlign: 'center', + marginTop: '16px', + fontWeight: 600, +}); + +export const desc = style({ + textAlign: 'center', + color: cssVarV2('text/secondary'), + marginBottom: '20px', +}); + +export const mainButton = style({ + width: '100%', + fontSize: '14px', + height: '42px', +}); + +export const workspaceSelector = style({ + margin: '0 -16px', + width: 'calc(100% + 32px)', + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + padding: '0 16px', +}); diff --git a/packages/frontend/core/src/desktop/router.tsx b/packages/frontend/core/src/desktop/router.tsx index c455c0570..9779d1823 100644 --- a/packages/frontend/core/src/desktop/router.tsx +++ b/packages/frontend/core/src/desktop/router.tsx @@ -103,6 +103,10 @@ export const topLevelRoutes = [ path: '/theme-editor', lazy: () => import('./pages/theme-editor'), }, + { + path: '/clipper/import', + lazy: () => import('./pages/import-clipper'), + }, { path: '/template/import', lazy: () => import('./pages/import-template'), diff --git a/packages/frontend/core/src/modules/import-clipper/index.ts b/packages/frontend/core/src/modules/import-clipper/index.ts new file mode 100644 index 000000000..450869735 --- /dev/null +++ b/packages/frontend/core/src/modules/import-clipper/index.ts @@ -0,0 +1,10 @@ +import { type Framework } from '@toeverything/infra'; + +import { WorkspacesService } from '../workspace'; +import { ImportClipperService } from './services/import'; + +export { type ClipperInput, ImportClipperService } from './services/import'; + +export function configureImportClipperModule(framework: Framework) { + framework.service(ImportClipperService, [WorkspacesService]); +} diff --git a/packages/frontend/core/src/modules/import-clipper/services/import.ts b/packages/frontend/core/src/modules/import-clipper/services/import.ts new file mode 100644 index 000000000..ae8cd5bcd --- /dev/null +++ b/packages/frontend/core/src/modules/import-clipper/services/import.ts @@ -0,0 +1,76 @@ +import { MarkdownTransformer } from '@blocksuite/affine/blocks'; +import { Service } from '@toeverything/infra'; + +import { DocsService } from '../../doc'; +import { + getAFFiNEWorkspaceSchema, + type WorkspaceMetadata, + type WorkspacesService, +} from '../../workspace'; + +export interface ClipperInput { + title: string; + contentMarkdown: string; + contentHtml: string; + attachments: Record; +} + +export class ImportClipperService extends Service { + constructor(private readonly workspacesService: WorkspacesService) { + super(); + } + + async importToWorkspace( + workspaceMetadata: WorkspaceMetadata, + clipperInput: ClipperInput + ) { + const { workspace, dispose: disposeWorkspace } = + this.workspacesService.open({ + metadata: workspaceMetadata, + }); + await workspace.engine.doc.waitForDocReady(workspace.id); // wait for root doc ready + const docId = await MarkdownTransformer.importMarkdownToDoc({ + collection: workspace.docCollection, + schema: getAFFiNEWorkspaceSchema(), + markdown: clipperInput.contentMarkdown, + }); + const docsService = workspace.scope.get(DocsService); + if (docId) { + // only support page mode for now + docsService.list.setPrimaryMode(docId, 'page'); + workspace.engine.doc.addPriority(workspace.id, 100); + workspace.engine.doc.addPriority(docId, 100); + await workspace.engine.doc.waitForDocSynced(workspace.id); + await workspace.engine.doc.waitForDocSynced(docId); + disposeWorkspace(); + return docId; + } else { + throw new Error('Failed to import doc'); + } + } + + async importToNewWorkspace( + flavour: string, + workspaceName: string, + clipperInput: ClipperInput + ) { + // oxlint-disable-next-line @typescript-eslint/no-non-null-assertion + let docId: string | undefined; + const { id: workspaceId } = await this.workspacesService.create( + flavour, + async docCollection => { + docCollection.meta.initialize(); + docCollection.meta.setName(workspaceName); + docId = await MarkdownTransformer.importMarkdownToDoc({ + collection: docCollection, + schema: getAFFiNEWorkspaceSchema(), + markdown: clipperInput.contentMarkdown, + }); + } + ); + if (!docId) { + throw new Error('Failed to import doc'); + } + return { workspaceId, docId }; + } +} diff --git a/packages/frontend/core/src/modules/import-template/entities/dialog.ts b/packages/frontend/core/src/modules/import-template/entities/dialog.ts deleted file mode 100644 index caed7787c..000000000 --- a/packages/frontend/core/src/modules/import-template/entities/dialog.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { DocMode } from '@blocksuite/affine/blocks'; -import { Entity, LiveData } from '@toeverything/infra'; - -interface TemplateOptions { - templateName: string; - snapshotUrl: string; - templateMode: DocMode; -} - -export class ImportTemplateDialog extends Entity { - readonly isOpen$ = new LiveData(false); - readonly template$ = new LiveData(null); - - open(options: TemplateOptions) { - this.template$.next(options); - this.isOpen$.next(true); - } - - close() { - this.isOpen$.next(false); - } -} diff --git a/packages/frontend/core/src/modules/import-template/index.ts b/packages/frontend/core/src/modules/import-template/index.ts index c9d5ff326..a677bd00b 100644 --- a/packages/frontend/core/src/modules/import-template/index.ts +++ b/packages/frontend/core/src/modules/import-template/index.ts @@ -1,7 +1,6 @@ import { type Framework } from '@toeverything/infra'; import { WorkspacesService } from '../workspace'; -import { ImportTemplateDialog } from './entities/dialog'; import { TemplateDownloader } from './entities/downloader'; import { TemplateDownloaderService } from './services/downloader'; import { ImportTemplateService } from './services/import'; @@ -12,7 +11,6 @@ export { ImportTemplateService } from './services/import'; export function configureImportTemplateModule(framework: Framework) { framework - .entity(ImportTemplateDialog) .service(TemplateDownloaderService) .entity(TemplateDownloader, [TemplateDownloaderStore]) .store(TemplateDownloaderStore) diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 296dbd6f8..55cf43d7e 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -26,6 +26,7 @@ import { configureFavoriteModule } from './favorite'; import { configureFeatureFlagModule } from './feature-flag'; import { configureGlobalContextModule } from './global-context'; import { configureI18nModule } from './i18n'; +import { configureImportClipperModule } from './import-clipper'; import { configureImportTemplateModule } from './import-template'; import { configureJournalModule } from './journal'; import { configureLifecycleModule } from './lifecycle'; @@ -100,4 +101,5 @@ export function configureCommonModules(framework: Framework) { configureAIButtonModule(framework); configureTemplateDocModule(framework); configureBlobManagementModule(framework); + configureImportClipperModule(framework); } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index c5f3f9c65..afb227e0d 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -2221,6 +2221,28 @@ export function useAFFiNEI18N(): { * `Failed to load template, please try again.` */ ["com.affine.import-template.dialog.errorLoad"](): string; + /** + * `Create into a New Workspace` + */ + ["com.affine.import-clipper.dialog.createDocToNewWorkspace"](): string; + /** + * `Create doc to "{{workspace}}"` + */ + ["com.affine.import-clipper.dialog.createDocToWorkspace"](options: { + readonly workspace: string; + }): string; + /** + * `Create doc from Web Clipper` + */ + ["com.affine.import-clipper.dialog.createDocFromClipper"](): string; + /** + * `Failed to import content, please try again.` + */ + ["com.affine.import-clipper.dialog.errorImport"](): string; + /** + * `Failed to load content, please try again.` + */ + ["com.affine.import-clipper.dialog.errorLoad"](): string; /** * `Support Markdown/Notion` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 71964ad56..1e2fc709b 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -555,6 +555,11 @@ "com.affine.import-template.dialog.createDocWithTemplate": "Create doc with \"{{templateName}}\" template", "com.affine.import-template.dialog.errorImport": "Failed to import template, please try again.", "com.affine.import-template.dialog.errorLoad": "Failed to load template, please try again.", + "com.affine.import-clipper.dialog.createDocToNewWorkspace": "Create into a New Workspace", + "com.affine.import-clipper.dialog.createDocToWorkspace": "Create doc to \"{{workspace}}\"", + "com.affine.import-clipper.dialog.createDocFromClipper": "Create doc from Web Clipper", + "com.affine.import-clipper.dialog.errorImport": "Failed to import content, please try again.", + "com.affine.import-clipper.dialog.errorLoad": "Failed to load content, please try again.", "com.affine.import_file": "Support Markdown/Notion", "com.affine.import.affine-workspace-data": "AFFiNE workspace data", "com.affine.import.html-files": "HTML",