feat(core): support block links on Bi-Directional Links (#8169)

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
This commit is contained in:
fundon
2024-09-11 11:08:12 +00:00
parent b7d05d2078
commit b74dd1c92e
10 changed files with 221 additions and 98 deletions

View File

@@ -35,9 +35,8 @@ export interface PageReferenceRendererOptions {
journalHelper: ReturnType<typeof useJournalHelper>; journalHelper: ReturnType<typeof useJournalHelper>;
t: ReturnType<typeof useI18n>; t: ReturnType<typeof useI18n>;
docMode?: DocMode; docMode?: DocMode;
// linking doc with block or element // Link to block or element
blockIds?: string[]; linkToNode?: boolean;
elementIds?: string[];
} }
// use a function to be rendered in the lit renderer // use a function to be rendered in the lit renderer
export function pageReferenceRenderer({ export function pageReferenceRenderer({
@@ -46,8 +45,7 @@ export function pageReferenceRenderer({
journalHelper, journalHelper,
t, t,
docMode, docMode,
blockIds, linkToNode = false,
elementIds,
}: PageReferenceRendererOptions) { }: PageReferenceRendererOptions) {
const { isPageJournal, getLocalizedJournalDateString } = journalHelper; const { isPageJournal, getLocalizedJournalDateString } = journalHelper;
const referencedPage = pageMetaHelper.getDocMeta(pageId); const referencedPage = pageMetaHelper.getDocMeta(pageId);
@@ -62,7 +60,7 @@ export function pageReferenceRenderer({
} else { } else {
Icon = LinkedPageIcon; Icon = LinkedPageIcon;
} }
if (blockIds?.length || elementIds?.length) { if (linkToNode) {
Icon = BlockLinkIcon; Icon = BlockLinkIcon;
} }
} }
@@ -89,33 +87,33 @@ export function AffinePageReference({
docCollection, docCollection,
wrapper: Wrapper, wrapper: Wrapper,
mode = 'page', mode = 'page',
params = {}, params,
}: { }: {
pageId: string; pageId: string;
docCollection: DocCollection; docCollection: DocCollection;
wrapper?: React.ComponentType<PropsWithChildren>; wrapper?: React.ComponentType<PropsWithChildren>;
mode?: DocMode; mode?: DocMode;
params?: { params?: URLSearchParams;
mode?: DocMode;
blockIds?: string[];
elementIds?: string[];
};
}) { }) {
const pageMetaHelper = useDocMetaHelper(docCollection); const pageMetaHelper = useDocMetaHelper(docCollection);
const journalHelper = useJournalHelper(docCollection); const journalHelper = useJournalHelper(docCollection);
const t = useI18n(); 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({ const el = pageReferenceRenderer({
docMode: linkedWithMode ?? mode, docMode: linkWithMode ?? mode,
pageId, pageId,
pageMetaHelper, pageMetaHelper,
journalHelper, journalHelper,
docCollection, docCollection,
t, t,
blockIds, linkToNode,
elementIds,
}); });
const ref = useRef<HTMLAnchorElement>(null); const ref = useRef<HTMLAnchorElement>(null);
@@ -154,20 +152,11 @@ export function AffinePageReference({
const query = useMemo(() => { const query = useMemo(() => {
// A block/element reference link // A block/element reference link
const search = new URLSearchParams(); let str = params?.toString() ?? '';
if (linkedWithMode) { if (str.length) str += '&';
search.set('mode', linkedWithMode); str += `refreshKey=${refreshKey}`;
} return '?' + str;
if (blockIds?.length) { }, [params, refreshKey]);
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]);
return ( return (
<WorkbenchLink <WorkbenchLink

View File

@@ -63,10 +63,14 @@ export const BiDirectionalLinkPanel = () => {
{t['com.affine.page-properties.outgoing-links']()} ·{' '} {t['com.affine.page-properties.outgoing-links']()} ·{' '}
{links.length} {links.length}
</div> </div>
{links.map(link => ( {links.map((link, i) => (
<div key={link.docId} className={styles.link}> <div
key={`${link.docId}-${link.params?.toString()}-${i}`}
className={styles.link}
>
<AffinePageReference <AffinePageReference
pageId={link.docId} pageId={link.docId}
params={link.params}
docCollection={workspaceService.workspace.docCollection} docCollection={workspaceService.workspace.docCollection}
/> />
</div> </div>

View File

@@ -7,6 +7,7 @@ import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { EditorService } from '@affine/core/modules/editor'; import { EditorService } from '@affine/core/modules/editor';
import { EditorSettingService } from '@affine/core/modules/editor-settting'; import { EditorSettingService } from '@affine/core/modules/editor-settting';
import { PeekViewService } from '@affine/core/modules/peek-view'; import { PeekViewService } from '@affine/core/modules/peek-view';
import { toURLSearchParams } from '@affine/core/utils';
import type { DocMode } from '@blocksuite/blocks'; import type { DocMode } from '@blocksuite/blocks';
import { DocTitle, EdgelessEditor, PageEditor } from '@blocksuite/presets'; import { DocTitle, EdgelessEditor, PageEditor } from '@blocksuite/presets';
import type { Doc } from '@blocksuite/store'; import type { Doc } from '@blocksuite/store';
@@ -90,12 +91,14 @@ const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => {
const pageId = data.pageId; const pageId = data.pageId;
if (!pageId) return <span />; if (!pageId) return <span />;
const params = toURLSearchParams(data.params);
return ( return (
<AffinePageReference <AffinePageReference
docCollection={page.collection} docCollection={page.collection}
pageId={pageId} pageId={pageId}
mode={mode} mode={mode}
params={data.params} params={params}
/> />
); );
}; };

View File

@@ -6,6 +6,7 @@ import type { DocsSearchService } from '../../docs-search';
export interface Link { export interface Link {
docId: string; docId: string;
title: string; title: string;
params?: URLSearchParams;
} }
export class DocLinks extends Entity { export class DocLinks extends Entity {

View File

@@ -36,7 +36,7 @@ export class DocsIndexer extends Entity {
/** /**
* increase this number to re-index all docs * increase this number to re-index all docs
*/ */
static INDEXER_VERSION = 1; static INDEXER_VERSION = 2;
private readonly jobQueue: JobQueue<IndexerJobPayload> = private readonly jobQueue: JobQueue<IndexerJobPayload> =
new IndexedDBJobQueue<IndexerJobPayload>( new IndexedDBJobQueue<IndexerJobPayload>(

View File

@@ -11,8 +11,13 @@ export const blockIndexSchema = defineSchema({
blockId: 'String', blockId: 'String',
content: 'FullText', content: 'FullText',
flavour: 'String', flavour: 'String',
ref: 'String',
blob: '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; export type BlockIndexSchema = typeof blockIndexSchema;

View File

@@ -1,3 +1,4 @@
import { toURLSearchParams } from '@affine/core/utils';
import type { WorkspaceService } from '@toeverything/infra'; import type { WorkspaceService } from '@toeverything/infra';
import { import {
fromPromise, fromPromise,
@@ -5,6 +6,7 @@ import {
Service, Service,
WorkspaceEngineBeforeStart, WorkspaceEngineBeforeStart,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { isEmpty, omit } from 'lodash-es';
import { type Observable, switchMap } from 'rxjs'; import { type Observable, switchMap } from 'rxjs';
import { DocsIndexer } from '../entities/docs-indexer'; import { DocsIndexer } from '../entities/docs-indexer';
@@ -250,36 +252,64 @@ export class DocsSearchService extends Service {
field: 'docId', field: 'docId',
match: 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', type: 'exists',
field: 'ref', field: 'refDocId',
}, },
], ],
}, },
{ {
fields: ['ref'], fields: ['refDocId', 'ref'],
pagination: { pagination: {
limit: 100, limit: 100,
}, },
} }
); );
const docIds = new Set( const refs: {
nodes.flatMap(node => { docId: string;
const refs = node.fields.ref; mode?: string;
return typeof refs === 'string' ? [refs] : refs; 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 titles = doc.get('title');
const title = doc.get('title'); const title = (Array.isArray(titles) ? titles[0] : titles) ?? '';
return { const params = omit(ref, ['docId']);
docId: doc.id,
title: title ? (typeof title === 'string' ? title : title[0]) : '', return {
}; title,
}); docId: doc.id,
params: isEmpty(params) ? undefined : toURLSearchParams(params),
};
})
.filter(ref => !!ref);
} }
watchRefsFrom(docId: string) { watchRefsFrom(docId: string) {
@@ -294,14 +324,26 @@ export class DocsSearchService extends Service {
field: 'docId', field: 'docId',
match: 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', type: 'exists',
field: 'ref', field: 'refDocId',
}, },
], ],
}, },
{ {
fields: ['ref'], fields: ['refDocId', 'ref'],
pagination: { pagination: {
limit: 100, limit: 100,
}, },
@@ -310,28 +352,41 @@ export class DocsSearchService extends Service {
.pipe( .pipe(
switchMap(({ nodes }) => { switchMap(({ nodes }) => {
return fromPromise(async () => { return fromPromise(async () => {
const docIds = new Set( const refs: {
nodes.flatMap(node => { docId: string;
const refs = node.fields.ref; mode?: string;
return typeof refs === 'string' ? [refs] : refs; 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( const docData = await this.indexer.docIndex.getAll(
Array.from(docIds) Array.from(new Set(refs.map(ref => ref.docId)))
); );
return docData.map(doc => { return refs
const title = doc.get('title'); .flatMap(ref => {
return { const doc = docData.find(doc => doc.id === ref.docId);
docId: doc.id, if (!doc) return null;
title: title
? typeof title === 'string' const titles = doc.get('title');
? title const title =
: title[0] (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( const { buckets } = await this.indexer.blockIndex.aggregate(
{ {
type: 'match', type: 'boolean',
field: 'ref', occur: 'must',
match: docId, 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', 'docId',
{ {
@@ -384,9 +457,27 @@ export class DocsSearchService extends Service {
return this.indexer.blockIndex return this.indexer.blockIndex
.aggregate$( .aggregate$(
{ {
type: 'match', type: 'boolean',
field: 'ref', occur: 'must',
match: docId, 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', 'docId',
{ {

View File

@@ -3,7 +3,7 @@ import type { DeltaInsert } from '@blocksuite/inline';
import { Document } from '@toeverything/infra'; import { Document } from '@toeverything/infra';
import { toHexString } from 'lib0/buffer.js'; import { toHexString } from 'lib0/buffer.js';
import { digest as lib0Digest } from 'lib0/hash/sha256'; import { digest as lib0Digest } from 'lib0/hash/sha256';
import { difference } from 'lodash-es'; import { difference, uniq } from 'lodash-es';
import { import {
applyUpdate, applyUpdate,
Array as YArray, Array as YArray,
@@ -130,18 +130,25 @@ async function crawlingDocData({
} }
const deltas: DeltaInsert<AffineTextAttributes>[] = text.toDelta(); const deltas: DeltaInsert<AffineTextAttributes>[] = text.toDelta();
const ref = deltas const refs = uniq(
.map(delta => { deltas
if ( .flatMap(delta => {
delta.attributes && if (
delta.attributes.reference && delta.attributes &&
delta.attributes.reference.pageId delta.attributes.reference &&
) { delta.attributes.reference.pageId
return delta.attributes.reference.pageId; ) {
} const { pageId: refDocId, params = {} } =
return null; delta.attributes.reference;
}) return {
.filter((link): link is string => !!link); refDocId,
ref: JSON.stringify({ docId: refDocId, ...params }),
};
}
return null;
})
.filter(ref => !!ref)
);
blockDocuments.push( blockDocuments.push(
Document.from<BlockIndexSchema>(`${docId}:${blockId}`, { Document.from<BlockIndexSchema>(`${docId}:${blockId}`, {
@@ -149,7 +156,14 @@ async function crawlingDocData({
flavour, flavour,
blockId, blockId,
content: text.toString(), 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'); const pageId = block.get('prop:pageId');
if (typeof pageId === 'string') { if (typeof pageId === 'string') {
// reference info
const params = block.get('prop:params') ?? {};
blockDocuments.push( blockDocuments.push(
Document.from<BlockIndexSchema>(`${docId}:${blockId}`, { Document.from<BlockIndexSchema>(`${docId}:${blockId}`, {
docId, docId,
flavour, flavour,
blockId, blockId,
ref: pageId, refDocId: [pageId],
ref: [JSON.stringify({ docId: pageId, ...params })],
}) })
); );
} }

View File

@@ -55,25 +55,28 @@ export class DocsQuickSearchSession
if (!query) { if (!query) {
out = of([] as QuickSearchItem<'docs', DocsPayload>[]); out = of([] as QuickSearchItem<'docs', DocsPayload>[]);
} else { } else {
const resolvedDoc = resolveLinkToDoc(query);
const resolvedDocId = resolvedDoc?.docId;
const resolvedBlockId = resolvedDoc?.blockIds?.[0];
out = this.docsSearchService.search$(query).pipe( out = this.docsSearchService.search$(query).pipe(
map(docs => { map(docs => {
const resolvedDoc = resolveLinkToDoc(query);
if ( if (
resolvedDoc && resolvedDocId &&
!docs.some(doc => doc.docId === resolvedDoc.docId) !docs.some(doc => doc.docId === resolvedDocId)
) { ) {
return [ return [
{ {
docId: resolvedDoc.docId, docId: resolvedDocId,
score: 100, score: 100,
blockId: resolvedDoc.blockIds?.[0], blockId: resolvedBlockId,
blockContent: '', blockContent: '',
}, },
...docs, ...docs,
]; ];
} else {
return docs;
} }
return docs;
}), }),
map(docs => map(docs =>
docs docs

View File

@@ -31,3 +31,13 @@ export function buildAppUrl(path: string, opts: AppUrlOptions = {}) {
return new URL(path, webBase).toString(); return new URL(path, webBase).toString();
} }
} }
export function toURLSearchParams(params?: Record<string, string | string[]>) {
if (!params) return;
return new URLSearchParams(
Object.entries(params).map(([k, v]) => [
k,
Array.isArray(v) ? v.join(',') : v,
])
);
}