From b74dd1c92e41f6b6370d003fe2f77fb3b54d6bc0 Mon Sep 17 00:00:00 2001 From: fundon Date: Wed, 11 Sep 2024 11:08:12 +0000 Subject: [PATCH] feat(core): support block links on Bi-Directional Links (#8169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clsoes [AF-1348](https://linear.app/affine-design/issue/AF-1348/修复-bi-directional-links-里面的链接地址) * Links to the current document should be ignored on `Backlinks` * Links to the current document should be ignored on `Outgoing links` https://github.com/user-attachments/assets/dbc43cea-5aca-4c6f-886a-356e3a91c1f1 --- .../affine/reference-link/index.tsx | 49 ++--- .../bi-directional-link-panel.tsx | 8 +- .../block-suite-editor/lit-adaper.tsx | 5 +- .../modules/doc-link/entities/doc-links.ts | 1 + .../docs-search/entities/docs-indexer.ts | 2 +- .../core/src/modules/docs-search/schema.ts | 7 +- .../docs-search/services/docs-search.ts | 173 +++++++++++++----- .../modules/docs-search/worker/in-worker.ts | 47 +++-- .../src/modules/quicksearch/impls/docs.ts | 17 +- packages/frontend/core/src/utils/url.ts | 10 + 10 files changed, 221 insertions(+), 98 deletions(-) diff --git a/packages/frontend/core/src/components/affine/reference-link/index.tsx b/packages/frontend/core/src/components/affine/reference-link/index.tsx index bdf09246f1..0a6eac7cf3 100644 --- a/packages/frontend/core/src/components/affine/reference-link/index.tsx +++ b/packages/frontend/core/src/components/affine/reference-link/index.tsx @@ -35,9 +35,8 @@ export interface PageReferenceRendererOptions { journalHelper: ReturnType; t: ReturnType; docMode?: DocMode; - // linking doc with block or element - blockIds?: string[]; - elementIds?: string[]; + // Link to block or element + linkToNode?: boolean; } // use a function to be rendered in the lit renderer export function pageReferenceRenderer({ @@ -46,8 +45,7 @@ export function pageReferenceRenderer({ journalHelper, t, docMode, - blockIds, - elementIds, + linkToNode = false, }: PageReferenceRendererOptions) { const { isPageJournal, getLocalizedJournalDateString } = journalHelper; const referencedPage = pageMetaHelper.getDocMeta(pageId); @@ -62,7 +60,7 @@ export function pageReferenceRenderer({ } else { Icon = LinkedPageIcon; } - if (blockIds?.length || elementIds?.length) { + if (linkToNode) { Icon = BlockLinkIcon; } } @@ -89,33 +87,33 @@ export function AffinePageReference({ docCollection, wrapper: Wrapper, mode = 'page', - params = {}, + params, }: { pageId: string; docCollection: DocCollection; wrapper?: React.ComponentType; mode?: DocMode; - params?: { - mode?: DocMode; - blockIds?: string[]; - elementIds?: string[]; - }; + params?: URLSearchParams; }) { const pageMetaHelper = useDocMetaHelper(docCollection); const journalHelper = useJournalHelper(docCollection); const t = useI18n(); - const { mode: linkedWithMode, blockIds, elementIds } = params; + let linkWithMode: DocMode | null = null; + let linkToNode = false; + if (params) { + linkWithMode = params.get('mode') as DocMode; + linkToNode = params.has('blockIds') || params.has('elementIds'); + } const el = pageReferenceRenderer({ - docMode: linkedWithMode ?? mode, + docMode: linkWithMode ?? mode, pageId, pageMetaHelper, journalHelper, docCollection, t, - blockIds, - elementIds, + linkToNode, }); const ref = useRef(null); @@ -154,20 +152,11 @@ export function AffinePageReference({ const query = useMemo(() => { // A block/element reference link - const search = new URLSearchParams(); - if (linkedWithMode) { - search.set('mode', linkedWithMode); - } - if (blockIds?.length) { - search.set('blockIds', blockIds.join(',')); - } - if (elementIds?.length) { - search.set('elementIds', elementIds.join(',')); - } - search.set('refreshKey', refreshKey); - - return search.size > 0 ? `?${search.toString()}` : ''; - }, [blockIds, elementIds, linkedWithMode, refreshKey]); + let str = params?.toString() ?? ''; + if (str.length) str += '&'; + str += `refreshKey=${refreshKey}`; + return '?' + str; + }, [params, refreshKey]); return ( { {t['com.affine.page-properties.outgoing-links']()} ·{' '} {links.length} - {links.map(link => ( -
+ {links.map((link, i) => ( +
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index fe7eba6774..ad7412c1a9 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -7,6 +7,7 @@ import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; import { EditorService } from '@affine/core/modules/editor'; import { EditorSettingService } from '@affine/core/modules/editor-settting'; import { PeekViewService } from '@affine/core/modules/peek-view'; +import { toURLSearchParams } from '@affine/core/utils'; import type { DocMode } from '@blocksuite/blocks'; import { DocTitle, EdgelessEditor, PageEditor } from '@blocksuite/presets'; import type { Doc } from '@blocksuite/store'; @@ -90,12 +91,14 @@ const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => { const pageId = data.pageId; if (!pageId) return ; + const params = toURLSearchParams(data.params); + return ( ); }; diff --git a/packages/frontend/core/src/modules/doc-link/entities/doc-links.ts b/packages/frontend/core/src/modules/doc-link/entities/doc-links.ts index 3f3033a4c2..0f76dbb1db 100644 --- a/packages/frontend/core/src/modules/doc-link/entities/doc-links.ts +++ b/packages/frontend/core/src/modules/doc-link/entities/doc-links.ts @@ -6,6 +6,7 @@ import type { DocsSearchService } from '../../docs-search'; export interface Link { docId: string; title: string; + params?: URLSearchParams; } export class DocLinks extends Entity { diff --git a/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts b/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts index a54e304f31..23c2e93fcf 100644 --- a/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts +++ b/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts @@ -36,7 +36,7 @@ export class DocsIndexer extends Entity { /** * increase this number to re-index all docs */ - static INDEXER_VERSION = 1; + static INDEXER_VERSION = 2; private readonly jobQueue: JobQueue = new IndexedDBJobQueue( diff --git a/packages/frontend/core/src/modules/docs-search/schema.ts b/packages/frontend/core/src/modules/docs-search/schema.ts index 6f9e426deb..5e401ab90a 100644 --- a/packages/frontend/core/src/modules/docs-search/schema.ts +++ b/packages/frontend/core/src/modules/docs-search/schema.ts @@ -11,8 +11,13 @@ export const blockIndexSchema = defineSchema({ blockId: 'String', content: 'FullText', flavour: 'String', - ref: 'String', blob: 'String', + // reference doc id + // ['xxx','yyy'] + refDocId: 'String', + // reference info + // [{"docId":"xxx","mode":"page","blockIds":["gt5Yfq1maYvgNgpi13rIq"]},{"docId":"yyy","mode":"edgeless","blockIds":["k5prpOlDF-9CzfatmO0W7"]}] + ref: 'String', }); export type BlockIndexSchema = typeof blockIndexSchema; diff --git a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts index da44eddd61..b76755353d 100644 --- a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts +++ b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts @@ -1,3 +1,4 @@ +import { toURLSearchParams } from '@affine/core/utils'; import type { WorkspaceService } from '@toeverything/infra'; import { fromPromise, @@ -5,6 +6,7 @@ import { Service, WorkspaceEngineBeforeStart, } from '@toeverything/infra'; +import { isEmpty, omit } from 'lodash-es'; import { type Observable, switchMap } from 'rxjs'; import { DocsIndexer } from '../entities/docs-indexer'; @@ -250,36 +252,64 @@ export class DocsSearchService extends Service { field: 'docId', match: docId, }, + // Ignore if it is a link to the current document. + { + type: 'boolean', + occur: 'must_not', + queries: [ + { + type: 'match', + field: 'refDocId', + match: docId, + }, + ], + }, { type: 'exists', - field: 'ref', + field: 'refDocId', }, ], }, { - fields: ['ref'], + fields: ['refDocId', 'ref'], pagination: { limit: 100, }, } ); - const docIds = new Set( - nodes.flatMap(node => { - const refs = node.fields.ref; - return typeof refs === 'string' ? [refs] : refs; - }) + const refs: { + docId: string; + mode?: string; + blockIds?: string[]; + elementIds?: string[]; + }[] = nodes.flatMap(node => { + const { ref } = node.fields; + return typeof ref === 'string' + ? [JSON.parse(ref)] + : ref.map(item => JSON.parse(item)); + }); + + const docData = await this.indexer.docIndex.getAll( + Array.from(new Set(refs.map(ref => ref.docId))) ); - const docData = await this.indexer.docIndex.getAll(Array.from(docIds)); + return refs + .flatMap(ref => { + const doc = docData.find(doc => doc.id === ref.docId); + if (!doc) return null; - return docData.map(doc => { - const title = doc.get('title'); - return { - docId: doc.id, - title: title ? (typeof title === 'string' ? title : title[0]) : '', - }; - }); + const titles = doc.get('title'); + const title = (Array.isArray(titles) ? titles[0] : titles) ?? ''; + const params = omit(ref, ['docId']); + + return { + title, + docId: doc.id, + params: isEmpty(params) ? undefined : toURLSearchParams(params), + }; + }) + .filter(ref => !!ref); } watchRefsFrom(docId: string) { @@ -294,14 +324,26 @@ export class DocsSearchService extends Service { field: 'docId', match: docId, }, + // Ignore if it is a link to the current document. + { + type: 'boolean', + occur: 'must_not', + queries: [ + { + type: 'match', + field: 'refDocId', + match: docId, + }, + ], + }, { type: 'exists', - field: 'ref', + field: 'refDocId', }, ], }, { - fields: ['ref'], + fields: ['refDocId', 'ref'], pagination: { limit: 100, }, @@ -310,28 +352,41 @@ export class DocsSearchService extends Service { .pipe( switchMap(({ nodes }) => { return fromPromise(async () => { - const docIds = new Set( - nodes.flatMap(node => { - const refs = node.fields.ref; - return typeof refs === 'string' ? [refs] : refs; - }) - ); + const refs: { + docId: string; + mode?: string; + blockIds?: string[]; + elementIds?: string[]; + }[] = nodes.flatMap(node => { + const { ref } = node.fields; + return typeof ref === 'string' + ? [JSON.parse(ref)] + : ref.map(item => JSON.parse(item)); + }); const docData = await this.indexer.docIndex.getAll( - Array.from(docIds) + Array.from(new Set(refs.map(ref => ref.docId))) ); - return docData.map(doc => { - const title = doc.get('title'); - return { - docId: doc.id, - title: title - ? typeof title === 'string' - ? title - : title[0] - : '', - }; - }); + return refs + .flatMap(ref => { + const doc = docData.find(doc => doc.id === ref.docId); + if (!doc) return null; + + const titles = doc.get('title'); + const title = + (Array.isArray(titles) ? titles[0] : titles) ?? ''; + const params = omit(ref, ['docId']); + + return { + title, + docId: doc.id, + params: isEmpty(params) + ? undefined + : toURLSearchParams(params), + }; + }) + .filter(ref => !!ref); }); }) ); @@ -346,9 +401,27 @@ export class DocsSearchService extends Service { > { const { buckets } = await this.indexer.blockIndex.aggregate( { - type: 'match', - field: 'ref', - match: docId, + type: 'boolean', + occur: 'must', + queries: [ + { + type: 'match', + field: 'refDocId', + match: docId, + }, + // Ignore if it is a link to the current document. + { + type: 'boolean', + occur: 'must_not', + queries: [ + { + type: 'match', + field: 'docId', + match: docId, + }, + ], + }, + ], }, 'docId', { @@ -384,9 +457,27 @@ export class DocsSearchService extends Service { return this.indexer.blockIndex .aggregate$( { - type: 'match', - field: 'ref', - match: docId, + type: 'boolean', + occur: 'must', + queries: [ + { + type: 'match', + field: 'refDocId', + match: docId, + }, + // Ignore if it is a link to the current document. + { + type: 'boolean', + occur: 'must_not', + queries: [ + { + type: 'match', + field: 'docId', + match: docId, + }, + ], + }, + ], }, 'docId', { diff --git a/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts b/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts index 640d142fc7..7ba7151f3f 100644 --- a/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts +++ b/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts @@ -3,7 +3,7 @@ import type { DeltaInsert } from '@blocksuite/inline'; import { Document } from '@toeverything/infra'; import { toHexString } from 'lib0/buffer.js'; import { digest as lib0Digest } from 'lib0/hash/sha256'; -import { difference } from 'lodash-es'; +import { difference, uniq } from 'lodash-es'; import { applyUpdate, Array as YArray, @@ -130,18 +130,25 @@ async function crawlingDocData({ } const deltas: DeltaInsert[] = text.toDelta(); - const ref = deltas - .map(delta => { - if ( - delta.attributes && - delta.attributes.reference && - delta.attributes.reference.pageId - ) { - return delta.attributes.reference.pageId; - } - return null; - }) - .filter((link): link is string => !!link); + const refs = uniq( + deltas + .flatMap(delta => { + if ( + delta.attributes && + delta.attributes.reference && + delta.attributes.reference.pageId + ) { + const { pageId: refDocId, params = {} } = + delta.attributes.reference; + return { + refDocId, + ref: JSON.stringify({ docId: refDocId, ...params }), + }; + } + return null; + }) + .filter(ref => !!ref) + ); blockDocuments.push( Document.from(`${docId}:${blockId}`, { @@ -149,7 +156,14 @@ async function crawlingDocData({ flavour, blockId, content: text.toString(), - ref, + ...refs.reduce<{ refDocId: string[]; ref: string[] }>( + (prev, curr) => { + prev.refDocId.push(curr.refDocId); + prev.ref.push(curr.ref); + return prev; + }, + { refDocId: [], ref: [] } + ), }) ); } @@ -160,12 +174,15 @@ async function crawlingDocData({ ) { const pageId = block.get('prop:pageId'); if (typeof pageId === 'string') { + // reference info + const params = block.get('prop:params') ?? {}; blockDocuments.push( Document.from(`${docId}:${blockId}`, { docId, flavour, blockId, - ref: pageId, + refDocId: [pageId], + ref: [JSON.stringify({ docId: pageId, ...params })], }) ); } diff --git a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts index 4f0bce51e6..6bc60d8eb4 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts @@ -55,25 +55,28 @@ export class DocsQuickSearchSession if (!query) { out = of([] as QuickSearchItem<'docs', DocsPayload>[]); } else { + const resolvedDoc = resolveLinkToDoc(query); + const resolvedDocId = resolvedDoc?.docId; + const resolvedBlockId = resolvedDoc?.blockIds?.[0]; + out = this.docsSearchService.search$(query).pipe( map(docs => { - const resolvedDoc = resolveLinkToDoc(query); if ( - resolvedDoc && - !docs.some(doc => doc.docId === resolvedDoc.docId) + resolvedDocId && + !docs.some(doc => doc.docId === resolvedDocId) ) { return [ { - docId: resolvedDoc.docId, + docId: resolvedDocId, score: 100, - blockId: resolvedDoc.blockIds?.[0], + blockId: resolvedBlockId, blockContent: '', }, ...docs, ]; - } else { - return docs; } + + return docs; }), map(docs => docs diff --git a/packages/frontend/core/src/utils/url.ts b/packages/frontend/core/src/utils/url.ts index b916efefe4..2601d794b2 100644 --- a/packages/frontend/core/src/utils/url.ts +++ b/packages/frontend/core/src/utils/url.ts @@ -31,3 +31,13 @@ export function buildAppUrl(path: string, opts: AppUrlOptions = {}) { return new URL(path, webBase).toString(); } } + +export function toURLSearchParams(params?: Record) { + if (!params) return; + return new URLSearchParams( + Object.entries(params).map(([k, v]) => [ + k, + Array.isArray(v) ? v.join(',') : v, + ]) + ); +}