feat: internal page links and mentions (#604)

* Work on mentions

* fix: properly parse page slug

* fix editor suggestion bugs

* mentions must start with whitespace

* add icon to page mention render

* feat: backlinks - WIP

* UI - WIP

* permissions check
* use FTS for page suggestion

* cleanup

* WIP

* page title fallback

* feat: handle internal link paste

* link styling

* WIP

* Switch back to LIKE operator for search suggestion

* WIP
* scope to workspaceId
* still create link for pages not found

* select necessary columns

* cleanups
This commit is contained in:
Philip Okugbe
2025-02-14 15:36:44 +00:00
committed by GitHub
parent 0ef6b1978a
commit e209aaa272
46 changed files with 1679 additions and 101 deletions

View File

@ -31,6 +31,7 @@ import {
Drawio,
Excalidraw,
Embed,
Mention
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html';
@ -75,6 +76,7 @@ export const tiptapExtensions = [
Drawio,
Excalidraw,
Embed,
Mention
] as any;
export function jsonToHtml(tiptapJson: any) {

View File

@ -12,6 +12,16 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import {
extractMentions,
extractPageMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types';
@Injectable()
export class PersistenceExtension implements Extension {
@ -21,6 +31,7 @@ export class PersistenceExtension implements Extension {
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
) {}
async onLoadDocument(data: onLoadDocumentPayload) {
@ -85,12 +96,13 @@ export class PersistenceExtension implements Extension {
this.logger.warn('jsonToText' + err?.['message']);
}
try {
let page = null;
let page: Page = null;
try {
await executeTx(this.db, async (trx) => {
page = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
trx,
});
@ -99,6 +111,11 @@ export class PersistenceExtension implements Extension {
return;
}
if (isDeepStrictEqual(tiptapJson, page.content)) {
page = null;
return;
}
await this.pageRepo.updatePage(
{
content: tiptapJson,
@ -109,18 +126,30 @@ export class PersistenceExtension implements Extension {
pageId,
trx,
);
});
this.eventEmitter.emit('collab.page.updated', {
page: {
...page,
lastUpdatedById: context.user.id,
content: tiptapJson,
textContent: textContent,
},
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
});
} catch (err) {
this.logger.error(`Failed to update page ${pageId}`, err);
}
if (page) {
this.eventEmitter.emit('collab.page.updated', {
page: {
...page,
content: tiptapJson,
lastUpdatedById: context.user.id,
},
});
const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions);
await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, {
pageId: pageId,
workspaceId: page.workspaceId,
mentions: pageMentions,
} as IPageBacklinkJob);
}
}
}

View File

@ -0,0 +1,58 @@
import { Node } from '@tiptap/pm/model';
import { jsonToNode } from '../../../collaboration/collaboration.util';
export interface MentionNode {
id: string;
label: string;
entityType: 'user' | 'page';
entityId: string;
creatorId: string;
}
export function extractMentions(prosemirrorJson: any) {
const mentionList: MentionNode[] = [];
const doc = jsonToNode(prosemirrorJson);
doc.descendants((node: Node) => {
if (node.type.name === 'mention') {
if (
node.attrs.id &&
!mentionList.some((mention) => mention.id === node.attrs.id)
) {
mentionList.push({
id: node.attrs.id,
label: node.attrs.label,
entityType: node.attrs.entityType,
entityId: node.attrs.entityId,
creatorId: node.attrs.creatorId,
});
}
}
});
return mentionList;
}
export function extractUserMentions(mentionList: MentionNode[]): MentionNode[] {
const userList = [];
for (const mention of mentionList) {
if (mention.entityType === 'user') {
userList.push(mention);
}
}
return userList as MentionNode[];
}
export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
const pageMentionList = [];
for (const mention of mentionList) {
if (
mention.entityType === 'page' &&
!pageMentionList.some(
(pageMention) => pageMention.entityId === mention.entityId,
)
) {
pageMentionList.push(mention);
}
}
return pageMentionList as MentionNode[];
}

View File

@ -31,7 +31,7 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
// extract db value if present
if (pathname.length > 1) {
const value = pathname.slice(1);
if (!isNaN(parseInt(value))){
if (!isNaN(parseInt(value))) {
db = parseInt(value, 10);
}
}
@ -44,3 +44,12 @@ export function createRetryStrategy() {
return Math.max(Math.min(Math.exp(times), 20000), 3000);
};
}
export function extractDateFromUuid7(uuid7: string) {
//https://park.is/blog_posts/20240803_extracting_timestamp_from_uuid_v7/
const parts = uuid7.split('-');
const highBitsHex = parts[0] + parts[1].slice(0, 4);
const timestamp = parseInt(highBitsHex, 16);
return new Date(timestamp);
}

View File

@ -5,7 +5,7 @@ import { AttachmentService } from '../services/attachment.service';
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Space } from '@docmost/db/types/entity.types';
@Processor(QueueName.ATTACHEMENT_QUEUE)
@Processor(QueueName.ATTACHMENT_QUEUE)
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(AttachmentProcessor.name);
constructor(private readonly attachmentService: AttachmentService) {

View File

@ -33,9 +33,21 @@ export class SearchSuggestionDTO {
@IsOptional()
@IsBoolean()
includeUsers?: string;
includeUsers?: boolean;
@IsOptional()
@IsBoolean()
includeGroups?: number;
includeGroups?: boolean;
@IsOptional()
@IsBoolean()
includePages?: boolean;
@IsOptional()
@IsString()
spaceId?: string;
@IsOptional()
@IsNumber()
limit?: number;
}

View File

@ -48,11 +48,13 @@ export class SearchController {
throw new NotImplementedException();
}
@HttpCode(HttpStatus.OK)
@Post('suggest')
async searchSuggestions(
@Body() dto: SearchSuggestionDTO,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.searchService.searchSuggestions(dto, workspace.id);
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
}
}

View File

@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@ -14,6 +15,7 @@ export class SearchService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {}
async searchPage(
@ -29,15 +31,15 @@ export class SearchService {
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'slugId',
'creatorId',
'createdAt',
'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'),
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}), 'MinWords=9, MaxWords=10, MaxFragments=10')`.as(
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
])
@ -66,35 +68,59 @@ export class SearchService {
async searchSuggestions(
suggestion: SearchSuggestionDTO,
userId: string,
workspaceId: string,
) {
const limit = 25;
const userSearch = this.db
.selectFrom('users')
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb('users.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
const groupSearch = this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) => eb('groups.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
let users = [];
let groups = [];
let pages = [];
const limit = suggestion?.limit || 10;
const query = suggestion.query.toLowerCase().trim();
if (suggestion.includeUsers) {
users = await userSearch.execute();
users = await this.db
.selectFrom('users')
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit)
.execute();
}
if (suggestion.includeGroups) {
groups = await groupSearch.execute();
groups = await this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) => eb(sql`LOWER(groups.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit)
.execute();
}
return { users, groups };
if (suggestion.includePages) {
let pageSearch = this.db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
.where((eb) => eb(sql`LOWER(pages.title)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
// only search spaces the user has access to
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
if (suggestion?.spaceId) {
if (userSpaceIds.includes(suggestion.spaceId)) {
pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId);
pages = await pageSearch.execute();
}
} else if (userSpaceIds?.length > 0) {
// we need this check or the query will throw an error if the userSpaceIds array is empty
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute();
}
}
return { users, groups, pages };
}
}

View File

@ -24,7 +24,7 @@ export class SpaceService {
private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
async createSpace(

View File

@ -23,6 +23,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -68,6 +69,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
CommentRepo,
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
],
exports: [
WorkspaceRepo,
@ -81,6 +83,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
CommentRepo,
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
],
})
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {

View File

@ -0,0 +1,33 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('backlinks')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('source_page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('target_page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('backlinks_source_page_id_target_page_id_unique', [
'source_page_id',
'target_page_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('backlinks').execute();
}

View File

@ -0,0 +1,72 @@
import {
Backlink,
InsertableBacklink,
UpdatableBacklink,
} from '@docmost/db/types/entity.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
@Injectable()
export class BacklinkRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
backlinkId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Backlink> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('backlinks')
.select([
'id',
'sourcePageId',
'targetPageId',
'workspaceId',
'createdAt',
'updatedAt',
])
.where('id', '=', backlinkId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async insertBacklink(
insertableBacklink: InsertableBacklink,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.insertInto('backlinks')
.values(insertableBacklink)
.onConflict((oc) =>
oc.columns(['sourcePageId', 'targetPageId']).doNothing(),
)
.returningAll()
.executeTakeFirst();
}
async updateBacklink(
updatableBacklink: UpdatableBacklink,
backlinkId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('userTokens')
.set(updatableBacklink)
.where('id', '=', backlinkId)
.execute();
}
async deleteBacklink(
backlinkId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
}
}

View File

@ -166,7 +166,16 @@ export class PageRepo {
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId', 'spaceId'])
.select([
'id',
'slugId',
'title',
'icon',
'content',
'parentPageId',
'spaceId',
'workspaceId',
])
.where('id', '=', parentPageId)
.unionAll((exp) =>
exp
@ -179,6 +188,7 @@ export class PageRepo {
'p.content',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
])
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
),

View File

@ -42,6 +42,15 @@ export interface Attachments {
workspaceId: string;
}
export interface Backlinks {
createdAt: Generated<Timestamp>;
id: Generated<string>;
sourcePageId: string;
targetPageId: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface Comments {
content: Json | null;
createdAt: Generated<Timestamp>;
@ -51,6 +60,7 @@ export interface Comments {
id: Generated<string>;
pageId: string;
parentCommentId: string | null;
resolvedAt: Timestamp | null;
selection: string | null;
type: string | null;
workspaceId: string;
@ -59,6 +69,7 @@ export interface Comments {
export interface Groups {
createdAt: Generated<Timestamp>;
creatorId: string | null;
deletedAt: Timestamp | null;
description: string | null;
id: Generated<string>;
isDefault: boolean;
@ -118,6 +129,7 @@ export interface Pages {
export interface SpaceMembers {
addedById: string | null;
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
groupId: string | null;
id: Generated<string>;
role: string;
@ -135,7 +147,7 @@ export interface Spaces {
id: Generated<string>;
logo: string | null;
name: string | null;
slug: string | null;
slug: string;
updatedAt: Generated<Timestamp>;
visibility: Generated<string>;
workspaceId: string;
@ -155,7 +167,7 @@ export interface Users {
locale: string | null;
name: string | null;
password: string | null;
role: string;
role: string | null;
settings: Json | null;
timezone: string | null;
updatedAt: Generated<Timestamp>;
@ -186,13 +198,13 @@ export interface WorkspaceInvitations {
}
export interface Workspaces {
allowedEmailDomains: Generated<string[] | null>;
createdAt: Generated<Timestamp>;
customDomain: string | null;
defaultRole: Generated<string>;
defaultSpaceId: string | null;
deletedAt: Timestamp | null;
description: string | null;
emailDomains: Generated<string[] | null>;
hostname: string | null;
id: Generated<string>;
logo: string | null;
@ -203,6 +215,7 @@ export interface Workspaces {
export interface DB {
attachments: Attachments;
backlinks: Backlinks;
comments: Comments;
groups: Groups;
groupUsers: GroupUsers;

View File

@ -12,6 +12,7 @@ import {
SpaceMembers,
WorkspaceInvitations,
UserTokens,
Backlinks,
} from './db';
// Workspace
@ -76,4 +77,9 @@ export type UpdatableAttachment = Updateable<Omit<Attachments, 'id'>>;
// User Token
export type UserToken = Selectable<UserTokens>;
export type InsertableUserToken = Insertable<UserTokens>;
export type UpdatableUserToken = Updateable<Omit<UserTokens, 'id'>>;
export type UpdatableUserToken = Updateable<Omit<UserTokens, 'id'>>;
// Backlink
export type Backlink = Selectable<Backlinks>;
export type InsertableBacklink = Insertable<Backlink>;
export type UpdatableBacklink = Updateable<Omit<Backlink, 'id'>>;

View File

@ -76,7 +76,11 @@ export class ExportController {
return;
}
const rawContent = await this.exportService.exportPage(dto.format, page);
const rawContent = await this.exportService.exportPage(
dto.format,
page,
true,
);
res.headers({
'Content-Type': getMimeType(fileExt),

View File

@ -4,7 +4,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { jsonToHtml } from '../../collaboration/collaboration.util';
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
import { turndown } from './turndown-utils';
import { ExportFormat } from './dto/export-dto';
import { Page } from '@docmost/db/types/entity.types';
@ -24,6 +24,11 @@ import {
updateAttachmentUrls,
} from './utils';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { Node } from '@tiptap/pm/model';
import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
import { EnvironmentService } from '../environment/environment.service';
@Injectable()
export class ExportService {
@ -33,16 +38,27 @@ export class ExportService {
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
private readonly environmentService: EnvironmentService,
) {}
async exportPage(format: string, page: Page) {
async exportPage(format: string, page: Page, singlePage?: boolean) {
const titleNode = {
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: getPageTitle(page.title) }],
};
const prosemirrorJson: any = getProsemirrorContent(page.content);
let prosemirrorJson: any;
if (singlePage) {
prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content),
page.workspaceId,
);
} else {
// mentions is already turned to links during the zip process
prosemirrorJson = getProsemirrorContent(page.content);
}
if (page.title) {
prosemirrorJson.content.unshift(titleNode);
@ -115,7 +131,8 @@ export class ExportService {
'pages.title',
'pages.content',
'pages.parentPageId',
'pages.spaceId'
'pages.spaceId',
'pages.workspaceId',
])
.where('spaceId', '=', spaceId)
.execute();
@ -160,7 +177,10 @@ export class ExportService {
for (const page of children) {
const childPages = tree[page.id] || [];
const prosemirrorJson = getProsemirrorContent(page.content);
const prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content),
page.workspaceId,
);
const currentPagePath = slugIdToPath[page.slugId];
@ -219,4 +239,107 @@ export class ExportService {
);
}
}
async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) {
const doc = jsonToNode(prosemirrorJson);
const pageMentionIds = [];
doc.descendants((node: Node) => {
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
if (node.attrs.entityId) {
pageMentionIds.push(node.attrs.entityId);
}
}
});
if (pageMentionIds.length < 1) {
return prosemirrorJson;
}
const pages = await this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'creatorId',
'spaceId',
'workspaceId',
])
.select((eb) => this.pageRepo.withSpace(eb))
.where('id', 'in', pageMentionIds)
.where('workspaceId', '=', workspaceId)
.execute();
const pageMap = new Map(pages.map((page) => [page.id, page]));
let editorState = EditorState.create({
doc: doc,
});
const transaction = editorState.tr;
let offset = 0;
/**
* Helper function to replace a mention node with a link node.
*/
const replaceMentionWithLink = (
node: Node,
pos: number,
title: string,
slugId: string,
spaceSlug: string,
) => {
const linkTitle = title || 'untitled';
const truncatedTitle = linkTitle?.substring(0, 70);
const pageSlug = `${slugify(truncatedTitle)}-${slugId}`;
// Create the link URL
const link = `${this.environmentService.getAppUrl()}/s/${spaceSlug}/p/${pageSlug}`;
// Create a link mark and a text node with that mark
const linkMark = editorState.schema.marks.link.create({ href: link });
const linkTextNode = editorState.schema.text(linkTitle, [linkMark]);
// Calculate positions (adjusted by the current offset)
const from = pos + offset;
const to = pos + offset + node.nodeSize;
// Replace the node in the transaction and update the offset
transaction.replaceWith(from, to, linkTextNode);
offset += linkTextNode.nodeSize - node.nodeSize;
};
// find and convert page mentions to links
editorState.doc.descendants((node: Node, pos: number) => {
// Check if the node is a page mention
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
const { entityId: pageId, slugId, label } = node.attrs;
const page = pageMap.get(pageId);
if (page) {
replaceMentionWithLink(
node,
pos,
page.title,
page.slugId,
page.space.slug,
);
} else {
// if page is not found, default to the node label and slugId
replaceMentionWithLink(node, pos, label, slugId, 'undefined');
}
}
});
if (transaction.docChanged) {
editorState = editorState.apply(transaction);
}
const updatedDoc = editorState.doc;
return updatedDoc.toJSON();
}
}

View File

@ -7,6 +7,9 @@ import { Page } from '@docmost/db/types/entity.types';
export type PageExportTree = Record<string, Page[]>;
export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
export function getExportExtension(format: string) {
if (format === ExportFormat.HTML) {
return '.html';
@ -83,13 +86,11 @@ export function replaceInternalLinks(
currentPagePath: string,
) {
const doc = jsonToNode(prosemirrorJson);
const internalLinkRegex =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
doc.descendants((node: Node) => {
for (const mark of node.marks) {
if (mark.type.name === 'link' && mark.attrs.href) {
const match = mark.attrs.href.match(internalLinkRegex);
const match = mark.attrs.href.match(INTERNAL_LINK_REGEX);
if (match) {
const markLink = mark.attrs.href;

View File

@ -1,10 +1,16 @@
export enum QueueName {
EMAIL_QUEUE = '{email-queue}',
ATTACHEMENT_QUEUE = '{attachment-queue}',
ATTACHMENT_QUEUE = '{attachment-queue}',
GENERAL_QUEUE = '{general-queue}',
}
export enum QueueJob {
SEND_EMAIL = 'send-email',
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
PAGE_CONTENT_UPDATE = 'page-content-update',
PAGE_BACKLINKS = 'page-backlinks',
}

View File

@ -0,0 +1,8 @@
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
export interface IPageBacklinkJob {
pageId: string;
workspaceId: string;
mentions: MentionNode[];
}

View File

@ -0,0 +1,129 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../constants';
import { IPageBacklinkJob } from '../constants/queue.interface';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { executeTx } from '@docmost/db/utils';
@Processor(QueueName.GENERAL_QUEUE)
export class BacklinksProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(BacklinksProcessor.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly backlinkRepo: BacklinkRepo,
) {
super();
}
async process(job: Job<IPageBacklinkJob, void>): Promise<void> {
try {
const { pageId, mentions, workspaceId } = job.data;
switch (job.name) {
case QueueJob.PAGE_BACKLINKS:
{
await executeTx(this.db, async (trx) => {
const existingBacklinks = await trx
.selectFrom('backlinks')
.select('targetPageId')
.where('sourcePageId', '=', pageId)
.execute();
if (existingBacklinks.length === 0 && mentions.length === 0) {
return;
}
const existingTargetPageIds = existingBacklinks.map(
(backlink) => backlink.targetPageId,
);
const targetPageIds = mentions
.filter((mention) => mention.entityId !== pageId)
.map((mention) => mention.entityId);
// make sure target pages belong to the same workspace
let validTargetPages = [];
if (targetPageIds.length > 0) {
validTargetPages = await trx
.selectFrom('pages')
.select('id')
.where('id', 'in', targetPageIds)
.where('workspaceId', '=', workspaceId)
.execute();
}
const validTargetPageIds = validTargetPages.map(
(page) => page.id,
);
// new backlinks
const backlinksToAdd = validTargetPageIds.filter(
(id) => !existingTargetPageIds.includes(id),
);
// stale backlinks
const backlinksToRemove = existingTargetPageIds.filter(
(existingId) => !validTargetPageIds.includes(existingId),
);
// add new backlinks
if (backlinksToAdd.length > 0) {
const newBacklinks = backlinksToAdd.map((targetPageId) => ({
sourcePageId: pageId,
targetPageId: targetPageId,
workspaceId: workspaceId,
}));
await this.backlinkRepo.insertBacklink(newBacklinks, trx);
this.logger.debug(
`Added ${newBacklinks.length} new backlinks to ${pageId}`,
);
}
// remove stale backlinks
if (backlinksToRemove.length > 0) {
await this.db
.deleteFrom('backlinks')
.where('sourcePageId', '=', pageId)
.where('targetPageId', 'in', backlinksToRemove)
.execute();
this.logger.debug(
`Removed ${backlinksToRemove.length} outdated backlinks from ${pageId}.`,
);
}
});
}
break;
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.debug(`Completed ${job.name} job`);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}

View File

@ -3,6 +3,7 @@ import { BullModule } from '@nestjs/bullmq';
import { EnvironmentService } from '../environment/environment.service';
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
import { QueueName } from './constants';
import { BacklinksProcessor } from "./processors/backlinks.processor";
@Global()
@Module({
@ -33,9 +34,13 @@ import { QueueName } from './constants';
name: QueueName.EMAIL_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.ATTACHEMENT_QUEUE,
name: QueueName.ATTACHMENT_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.GENERAL_QUEUE,
}),
],
exports: [BullModule],
providers: [BacklinksProcessor]
})
export class QueueModule {}