mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 06:42:37 +10:00
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:
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
apps/server/src/common/helpers/prosemirror/utils.ts
Normal file
58
apps/server/src/common/helpers/prosemirror/utils.ts
Normal 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[];
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
72
apps/server/src/database/repos/backlink/backlink.repo.ts
Normal file
72
apps/server/src/database/repos/backlink/backlink.repo.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
|
||||
19
apps/server/src/database/types/db.d.ts
vendored
19
apps/server/src/database/types/db.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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'>>;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
|
||||
|
||||
|
||||
export interface IPageBacklinkJob {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
mentions: MentionNode[];
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user