mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 01:51:09 +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:
@ -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'>>;
|
||||
|
||||
Reference in New Issue
Block a user