- Migration: acadenice_backlink table (source/target/link_type/excerpt/workspace) with 3 indexes and UNIQUE(source,target,type) constraint. Up+down. - Backend module AcadeniceBacklinksModule: BacklinkParserService: walks Tiptap JSON, extracts wikilinks/mentions/databaseView. BacklinkIndexerService: idempotent delete-then-insert per page save. BacklinkService: permission-aware query (space_members / public visibility). BacklinksController: GET /api/acadenice/pages/:pageId/backlinks (JWT auth). PageContentUpdatedListener: OnEvent handler for collab saves -> async reindex. Tests: 16 Vitest specs (parser/indexer/service/controller). - PersistenceExtension patch: emits ACADENICE_PAGE_CONTENT_UPDATED_EVENT after each collab onStoreDocument (fire-and-forget, no impact on save path). - CoreModule patch: imports AcadeniceBacklinksModule. - Frontend WikilinkExtension: Tiptap inline atom node, [[Title]] / [[Title|alias]], Suggestion popup (reuses mention pattern + floating-ui), ReactNodeView with broken-link state, insertWikilink command. Tests: 9 Vitest specs (schema/attrs/commands/HTML parse+render). - LinkedReferencesPanel: React Query useBacklinks(pageId, staleTime=30s), accordion grouped by link_type, excerpt preview, navigate to source page. Tests: 7 Vitest specs (loading/error/empty/render/navigate/groups). - extensions.ts patch: + WikilinkExtension in mainExtensions[]. - full-editor.tsx patch: + LinkedReferencesPanel below editor (Divider + panel). - i18n: 11 keys added in en-US and fr-FR (backlinks.* + wikilink.*). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
84 lines
2.6 KiB
TypeScript
84 lines
2.6 KiB
TypeScript
import { Kysely, sql } from 'kysely';
|
|
|
|
/**
|
|
* DocAdenice backlink table (R3.2).
|
|
*
|
|
* Stores directed edges: source_page_id -> target_page_id.
|
|
* Populated by the BacklinkIndexerService each time a page is saved.
|
|
*
|
|
* link_type discriminates the relationship:
|
|
* - 'wikilink' : [[Page Title]] or [[Page Title|alias]] syntax
|
|
* - 'mention' : @mention node (entityType = 'page' from Docmost Mention)
|
|
* - 'database_embed' : R3.1.c databaseView node that references a page embed
|
|
*
|
|
* context_excerpt: ~200 chars of surrounding text captured at index time for
|
|
* the "Linked references" panel preview. Nullable — populated when text context
|
|
* is extractable.
|
|
*
|
|
* UNIQUE(source_page_id, target_page_id, link_type) ensures the indexer can
|
|
* do a simple DELETE-then-INSERT (full reindex per save) without duplicates.
|
|
*
|
|
* Idempotent: ifNotExists on every CREATE so re-runs never fail.
|
|
*/
|
|
export async function up(db: Kysely<any>): Promise<void> {
|
|
await db.schema
|
|
.createTable('acadenice_backlink')
|
|
.ifNotExists()
|
|
.addColumn('id', 'uuid', (col) =>
|
|
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
|
)
|
|
.addColumn('source_page_id', 'uuid', (col) =>
|
|
col.notNull().references('pages.id').onDelete('cascade'),
|
|
)
|
|
.addColumn('target_page_id', 'uuid', (col) =>
|
|
col.notNull().references('pages.id').onDelete('cascade'),
|
|
)
|
|
.addColumn('link_type', 'varchar(20)', (col) =>
|
|
col
|
|
.notNull()
|
|
.check(
|
|
sql`link_type IN ('wikilink', 'mention', 'database_embed')`,
|
|
),
|
|
)
|
|
.addColumn('context_excerpt', 'text')
|
|
.addColumn('created_at', 'timestamptz', (col) =>
|
|
col.notNull().defaultTo(sql`now()`),
|
|
)
|
|
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
col.notNull().defaultTo(sql`now()`),
|
|
)
|
|
.addColumn('workspace_id', 'uuid', (col) =>
|
|
col.notNull().references('workspaces.id').onDelete('cascade'),
|
|
)
|
|
.addUniqueConstraint('acadenice_backlink_source_target_type_unique', [
|
|
'source_page_id',
|
|
'target_page_id',
|
|
'link_type',
|
|
])
|
|
.execute();
|
|
|
|
await db.schema
|
|
.createIndex('idx_backlink_target')
|
|
.ifNotExists()
|
|
.on('acadenice_backlink')
|
|
.column('target_page_id')
|
|
.execute();
|
|
|
|
await db.schema
|
|
.createIndex('idx_backlink_source')
|
|
.ifNotExists()
|
|
.on('acadenice_backlink')
|
|
.column('source_page_id')
|
|
.execute();
|
|
|
|
await db.schema
|
|
.createIndex('idx_backlink_workspace')
|
|
.ifNotExists()
|
|
.on('acadenice_backlink')
|
|
.column('workspace_id')
|
|
.execute();
|
|
}
|
|
|
|
export async function down(db: Kysely<any>): Promise<void> {
|
|
await db.schema.dropTable('acadenice_backlink').ifExists().execute();
|
|
}
|