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

@ -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'>>;