Add wikilink index, backlinks API, and phantom resolution (#47)
All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m16s
CI / Auth e2e pack (push) Successful in 2m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 3m2s
CI / Lint, typecheck, test (push) Successful in 2m16s
CI / Auth e2e pack (push) Successful in 2m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
Maintain a server-side `page_links` index on every content change so
backlinks and missing-target ("phantom") links can be queried.
- prisma: `PageLink` (from_page_id, nullable to_page_id, target_slug;
unique per (from, slug); cascade on source purge, set-null on target
purge); migration.
- shared: `extractWikilinkSlugs(doc)` (distinct target slugs) and the
`BacklinkView` / `PhantomLinkView` read shapes.
- collab: the persistence hook (#35) now rewrites the source page's outgoing
links in the same transaction as the content cache — one row per distinct
wikilink slug, resolved to a page in the same pond (null = phantom).
- api: `GET /pages/:id/backlinks` (permission-filtered — wikilinks resolve
within a pond, so seeing the pond is the read right) and
`GET /ponds/:id/phantom-links` (missing targets grouped with their
referrers). Creating or renaming a page to a slug that pages already link
to resolves those phantom rows; because links store the target's id,
backlinks survive a later rename of the target's slug.
- tests: shared extraction unit test; collab persistence db test (store
writes resolved + phantom rows and rewrites the index); api LinksService
db test (backlinks, permission filter, phantom aggregation, create/rename
resolution, id-based backlinks survive target rename).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
parent
7244b89215
commit
14e69b399c
@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "page_links" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"from_page_id" TEXT NOT NULL,
|
||||||
|
"to_page_id" TEXT,
|
||||||
|
"target_slug" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "page_links_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "page_links_to_page_id_idx" ON "page_links"("to_page_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "page_links_target_slug_idx" ON "page_links"("target_slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "page_links_from_page_id_target_slug_key" ON "page_links"("from_page_id", "target_slug");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "page_links" ADD CONSTRAINT "page_links_from_page_id_fkey" FOREIGN KEY ("from_page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "page_links" ADD CONSTRAINT "page_links_to_page_id_fkey" FOREIGN KEY ("to_page_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -110,6 +110,8 @@ model Page {
|
|||||||
versions PageVersion[]
|
versions PageVersion[]
|
||||||
pendingContributors PagePendingContributor[]
|
pendingContributors PagePendingContributor[]
|
||||||
labels PageLabel[]
|
labels PageLabel[]
|
||||||
|
outgoingLinks PageLink[] @relation("outgoingLinks")
|
||||||
|
incomingLinks PageLink[] @relation("incomingLinks")
|
||||||
|
|
||||||
@@unique([pondId, slug])
|
@@unique([pondId, slug])
|
||||||
@@index([pondId])
|
@@index([pondId])
|
||||||
@ -234,6 +236,27 @@ model Label {
|
|||||||
@@map("labels")
|
@@map("labels")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wikilink index maintained on every content change (data-model.md §page_links,
|
||||||
|
/// issue #47). One row per (source page, distinct target slug). `toPageId` is
|
||||||
|
/// the resolved target within the same pond, or null for a "phantom" link whose
|
||||||
|
/// target does not exist yet; creating/renaming a page to that slug resolves the
|
||||||
|
/// row. Backlinks query by `toPageId`. Collab (the content writer) maintains the
|
||||||
|
/// outgoing rows over raw SQL; it does not use Prisma.
|
||||||
|
model PageLink {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
fromPageId String @map("from_page_id")
|
||||||
|
toPageId String? @map("to_page_id")
|
||||||
|
targetSlug String @map("target_slug")
|
||||||
|
|
||||||
|
fromPage Page @relation("outgoingLinks", fields: [fromPageId], references: [id], onDelete: Cascade)
|
||||||
|
toPage Page? @relation("incomingLinks", fields: [toPageId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([fromPageId, targetSlug])
|
||||||
|
@@index([toPageId])
|
||||||
|
@@index([targetSlug])
|
||||||
|
@@map("page_links")
|
||||||
|
}
|
||||||
|
|
||||||
/// Assignment of a label to a page (data-model.md §labels). Cascades on both
|
/// Assignment of a label to a page (data-model.md §labels). Cascades on both
|
||||||
/// sides: purging a page or deleting a label removes the assignment.
|
/// sides: purging a page or deleting a label removes the assignment.
|
||||||
model PageLabel {
|
model PageLabel {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { ConfigModule } from './config/config.module';
|
|||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { LabelsModule } from './labels/labels.module';
|
import { LabelsModule } from './labels/labels.module';
|
||||||
|
import { LinksModule } from './links/links.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
import { PagesModule } from './pages/pages.module';
|
import { PagesModule } from './pages/pages.module';
|
||||||
import { PondsModule } from './ponds/ponds.module';
|
import { PondsModule } from './ponds/ponds.module';
|
||||||
@ -36,6 +37,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
CompactionModule,
|
CompactionModule,
|
||||||
VersionsModule,
|
VersionsModule,
|
||||||
LabelsModule,
|
LabelsModule,
|
||||||
|
LinksModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
|
|||||||
26
apps/api/src/links/links.controller.ts
Normal file
26
apps/api/src/links/links.controller.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Get, Param, Req } from '@nestjs/common';
|
||||||
|
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
|
import { LinksService } from './links.service';
|
||||||
|
|
||||||
|
/** Wikilink index reads (issue #47): backlinks and a pond's phantom links. */
|
||||||
|
@Controller()
|
||||||
|
export class LinksController {
|
||||||
|
constructor(private readonly links: LinksService) {}
|
||||||
|
|
||||||
|
/** Pages that link to this page (permission-filtered). */
|
||||||
|
@Get('pages/:id/backlinks')
|
||||||
|
async backlinks(@Param('id') id: string, @Req() request: AuthedRequest): Promise<BacklinkView[]> {
|
||||||
|
return this.links.backlinks(request.user!, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Referenced-but-missing pages in the pond, with their referrers. */
|
||||||
|
@Get('ponds/:pondId/phantom-links')
|
||||||
|
async phantomLinks(
|
||||||
|
@Param('pondId') pondId: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PhantomLinkView[]> {
|
||||||
|
return this.links.phantomLinks(request.user!, pondId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/links/links.module.ts
Normal file
14
apps/api/src/links/links.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
|
|
||||||
|
import { LinksController } from './links.controller';
|
||||||
|
import { LinksService } from './links.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PondsModule],
|
||||||
|
controllers: [LinksController],
|
||||||
|
providers: [LinksService],
|
||||||
|
exports: [LinksService],
|
||||||
|
})
|
||||||
|
export class LinksModule {}
|
||||||
142
apps/api/src/links/links.service.db.test.ts
Normal file
142
apps/api/src/links/links.service.db.test.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { INestApplication, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaClient, User } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { PagesService } from '../pages/pages.service';
|
||||||
|
import { createTestApp } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { LinksService } from './links.service';
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let links: LinksService;
|
||||||
|
let pages: PagesService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
let owner: User;
|
||||||
|
let outsider: User;
|
||||||
|
let pondId: string;
|
||||||
|
const pageIds: string[] = [];
|
||||||
|
|
||||||
|
/** Creates a page with an explicit slug (bypassing slug generation). */
|
||||||
|
async function makePage(slug: string, title = slug): Promise<string> {
|
||||||
|
const id = randomUUID();
|
||||||
|
await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
pondId,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
|
||||||
|
sortKey: `a${pageIds.length}`,
|
||||||
|
createdBy: owner.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pageIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulates what the collab persistence writes for one outgoing link. */
|
||||||
|
async function link(fromId: string, targetSlug: string, toId: string | null): Promise<void> {
|
||||||
|
await prisma.pageLink.create({
|
||||||
|
data: { fromPageId: fromId, toPageId: toId, targetSlug },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
app = await createTestApp();
|
||||||
|
links = app.get(LinksService);
|
||||||
|
pages = app.get(PagesService);
|
||||||
|
|
||||||
|
owner = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `lnk-owner-${suffix}`,
|
||||||
|
email: `lnk-owner-${suffix}@example.test`,
|
||||||
|
displayName: 'Link Owner',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outsider = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `lnk-out-${suffix}`,
|
||||||
|
email: `lnk-out-${suffix}@example.test`,
|
||||||
|
displayName: 'Link Outsider',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pond = await prisma.pond.create({
|
||||||
|
data: { slug: `lnk-pond-${suffix}`, name: 'Link Pond', type: 'PERSONAL', ownerId: owner.id },
|
||||||
|
});
|
||||||
|
pondId = pond.id;
|
||||||
|
// Headroom for the pages the create test makes.
|
||||||
|
await prisma.quotaOverride.create({
|
||||||
|
data: { subjectType: 'USER', subjectId: owner.id, quotaKey: 'additional_ponds', value: 100 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.pageLink.deleteMany({ where: { fromPage: { pondId } } });
|
||||||
|
await prisma.page.deleteMany({ where: { pondId } });
|
||||||
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: owner.id } });
|
||||||
|
await prisma.pond.deleteMany({ where: { id: pondId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists backlinks to a page', async () => {
|
||||||
|
const target = await makePage(`target-${suffix}`);
|
||||||
|
const source = await makePage(`source-${suffix}`, 'Source Page');
|
||||||
|
await link(source, `target-${suffix}`, target);
|
||||||
|
|
||||||
|
const backlinks = await links.backlinks(owner, target);
|
||||||
|
expect(backlinks).toEqual([{ pageId: source, title: 'Source Page', slug: `source-${suffix}` }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides backlinks from users who cannot see the pond', async () => {
|
||||||
|
const target = await makePage(`hidden-${suffix}`);
|
||||||
|
await expect(links.backlinks(outsider, target)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates phantom links by target slug', async () => {
|
||||||
|
const a = await makePage(`ph-a-${suffix}`, 'A');
|
||||||
|
const b = await makePage(`ph-b-${suffix}`, 'B');
|
||||||
|
await link(a, `ghost-${suffix}`, null);
|
||||||
|
await link(b, `ghost-${suffix}`, null);
|
||||||
|
|
||||||
|
const phantoms = await links.phantomLinks(owner, pondId);
|
||||||
|
const ghost = phantoms.find((p) => p.targetSlug === `ghost-${suffix}`);
|
||||||
|
expect(ghost?.referencedBy.map((r) => r.title).sort()).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves phantom links when a page with the target slug is created', async () => {
|
||||||
|
const source = await makePage(`res-src-${suffix}`, 'Res Source');
|
||||||
|
await link(source, 'brand-new-page', null);
|
||||||
|
|
||||||
|
const created = await pages.create(owner, pondId, { title: 'Brand New Page' });
|
||||||
|
pageIds.push(created.id);
|
||||||
|
expect(created.slug).toBe('brand-new-page');
|
||||||
|
|
||||||
|
// The phantom now points at the created page — it is a backlink, not phantom.
|
||||||
|
const backlinks = await links.backlinks(owner, created.id);
|
||||||
|
expect(backlinks.map((b) => b.pageId)).toContain(source);
|
||||||
|
const phantoms = await links.phantomLinks(owner, pondId);
|
||||||
|
expect(phantoms.some((p) => p.targetSlug === 'brand-new-page')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-points phantom links on rename, and id-based backlinks survive a later target rename', async () => {
|
||||||
|
const source = await makePage(`rn-src-${suffix}`, 'Rename Source');
|
||||||
|
await link(source, 'wanted-slug', null);
|
||||||
|
const target = await makePage(`rn-tgt-${suffix}`, 'Rename Target');
|
||||||
|
|
||||||
|
// Rename the target to the wanted slug → the phantom resolves.
|
||||||
|
await pages.update(owner, target, { slug: 'wanted-slug' });
|
||||||
|
expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source);
|
||||||
|
|
||||||
|
// Rename the target's slug again: the backlink stores the id, so it survives.
|
||||||
|
await pages.update(owner, target, { slug: 'wanted-slug-renamed' });
|
||||||
|
expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
apps/api/src/links/links.service.ts
Normal file
76
apps/api/src/links/links.service.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
|
||||||
|
import { User } from '@prisma/client';
|
||||||
|
|
||||||
|
import { InterimAccessService } from '../ponds/interim-access.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads over the wikilink index (`page_links`, issue #47). The index is written
|
||||||
|
* by the collab server on every content change; here it is queried for a page's
|
||||||
|
* backlinks and a pond's phantom (missing-target) links. Wikilinks resolve only
|
||||||
|
* within a pond, so every backlink lives in the same pond as its target —
|
||||||
|
* seeing the pond (InterimAccessService) is therefore the read permission.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LinksService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly access: InterimAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Pages linking to `pageId`, filtered to what the user may read (issue #47). */
|
||||||
|
async backlinks(user: User, pageId: string): Promise<BacklinkView[]> {
|
||||||
|
const page = await this.prisma.page.findFirst({
|
||||||
|
where: { id: pageId, deletedAt: null },
|
||||||
|
include: { pond: true },
|
||||||
|
});
|
||||||
|
if (!page) throw new NotFoundException();
|
||||||
|
this.access.assertCanSee(user, page.pond);
|
||||||
|
|
||||||
|
const links = await this.prisma.pageLink.findMany({
|
||||||
|
where: { toPageId: pageId, fromPage: { deletedAt: null } },
|
||||||
|
include: { fromPage: { select: { id: true, title: true, slug: true } } },
|
||||||
|
orderBy: { fromPage: { title: 'asc' } },
|
||||||
|
});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const backlinks: BacklinkView[] = [];
|
||||||
|
for (const link of links) {
|
||||||
|
if (seen.has(link.fromPage.id)) continue;
|
||||||
|
seen.add(link.fromPage.id);
|
||||||
|
backlinks.push({
|
||||||
|
pageId: link.fromPage.id,
|
||||||
|
title: link.fromPage.title,
|
||||||
|
slug: link.fromPage.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return backlinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Referenced-but-missing targets in a pond, with the pages referencing them. */
|
||||||
|
async phantomLinks(user: User, pondId: string): Promise<PhantomLinkView[]> {
|
||||||
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||||
|
this.access.assertCanSee(user, pond);
|
||||||
|
|
||||||
|
const rows = await this.prisma.pageLink.findMany({
|
||||||
|
where: { toPageId: null, fromPage: { pondId, deletedAt: null } },
|
||||||
|
include: { fromPage: { select: { id: true, title: true, slug: true } } },
|
||||||
|
orderBy: [{ targetSlug: 'asc' }, { fromPage: { title: 'asc' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = new Map<string, PhantomLinkView>();
|
||||||
|
for (const row of rows) {
|
||||||
|
let entry = grouped.get(row.targetSlug);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { targetSlug: row.targetSlug, referencedBy: [] };
|
||||||
|
grouped.set(row.targetSlug, entry);
|
||||||
|
}
|
||||||
|
entry.referencedBy.push({
|
||||||
|
pageId: row.fromPage.id,
|
||||||
|
title: row.fromPage.title,
|
||||||
|
slug: row.fromPage.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...grouped.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -155,10 +155,26 @@ export class PagesService {
|
|||||||
contentCache: { create: contentCacheData(content) },
|
contentCache: { create: contentCacheData(content) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// A new page may satisfy phantom wikilinks that referenced its slug (#47).
|
||||||
|
await this.resolvePhantomLinks(pond.id, slug, page.id);
|
||||||
this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created');
|
this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created');
|
||||||
return this.viewOf(page);
|
return this.viewOf(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point phantom `page_links` (unresolved, `to_page_id` null) whose
|
||||||
|
* `target_slug` matches `slug` within the pond at `pageId` (issue #47).
|
||||||
|
* Called when a page is created or renamed to a slug that pages already link
|
||||||
|
* to. Backlinks store the target's id, so once resolved a later target rename
|
||||||
|
* keeps them connected.
|
||||||
|
*/
|
||||||
|
private async resolvePhantomLinks(pondId: string, slug: string, pageId: string): Promise<void> {
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
UPDATE page_links SET to_page_id = ${pageId}
|
||||||
|
WHERE to_page_id IS NULL AND target_slug = ${slug}
|
||||||
|
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
|
||||||
|
}
|
||||||
|
|
||||||
async getState(user: User, id: string): Promise<PageStateView> {
|
async getState(user: User, id: string): Promise<PageStateView> {
|
||||||
const page = await this.findVisiblePage(user, id);
|
const page = await this.findVisiblePage(user, id);
|
||||||
return this.stateViewOf(page);
|
return this.stateViewOf(page);
|
||||||
@ -229,6 +245,8 @@ export class PagesService {
|
|||||||
where: { id: page.id },
|
where: { id: page.id },
|
||||||
data: { title: input.title, slug },
|
data: { title: input.title, slug },
|
||||||
});
|
});
|
||||||
|
// Renaming to a slug pages already link to resolves those phantom links (#47).
|
||||||
|
if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id);
|
||||||
return this.viewOf(updated);
|
return this.viewOf(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,19 @@ function makeDoc(text: string): Y.Doc {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A Y.Doc whose paragraph contains a `[[slug]]` wikilink per slug (issue #47). */
|
||||||
|
function makeDocWithWikilinks(...slugs: string[]): Y.Doc {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const inline = [
|
||||||
|
editorSchema.text('See '),
|
||||||
|
...slugs.map((slug) => editorSchema.node('wikilink', { targetSlug: slug, displayText: null })),
|
||||||
|
];
|
||||||
|
const paragraph = editorSchema.node('paragraph', null, inline);
|
||||||
|
const pmDoc = editorSchema.node('doc', null, [paragraph]);
|
||||||
|
prosemirrorJSONToYXmlFragment(editorSchema, pmDoc.toJSON(), doc.getXmlFragment('default'));
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A minimal valid Yjs state with no document content. Seeding pages with an
|
* A minimal valid Yjs state with no document content. Seeding pages with an
|
||||||
* empty state (rather than an empty paragraph) keeps these SQL round-trip tests
|
* empty state (rather than an empty paragraph) keeps these SQL round-trip tests
|
||||||
@ -149,4 +162,33 @@ describe.skipIf(!url)('PostgresPagePersistence (DB-backed)', () => {
|
|||||||
const loaded = await persistence.loadInto(missingId, new Y.Doc());
|
const loaded = await persistence.loadInto(missingId, new Y.Doc());
|
||||||
expect(loaded).toBe(false);
|
expect(loaded).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maintains the page_links index on store — resolved and phantom (issue #47)', async () => {
|
||||||
|
const target = await createPage();
|
||||||
|
const targetSlug = `wl-target-${target.slice(0, 8)}`;
|
||||||
|
await pool.query('UPDATE pages SET slug = $2 WHERE id = $1', [target, targetSlug]);
|
||||||
|
const source = await createPage();
|
||||||
|
const persistence = new PostgresPagePersistence(pool);
|
||||||
|
|
||||||
|
// The doc links an existing page and a missing one.
|
||||||
|
const doc = makeDocWithWikilinks(targetSlug, 'ghost-slug');
|
||||||
|
const result = await persistence.store(source, doc);
|
||||||
|
expect(result.outcome).toBe('stored');
|
||||||
|
|
||||||
|
const rows = await pool.query<{ target_slug: string; to_page_id: string | null }>(
|
||||||
|
'SELECT target_slug, to_page_id FROM page_links WHERE from_page_id = $1 ORDER BY target_slug',
|
||||||
|
[source],
|
||||||
|
);
|
||||||
|
const bySlug = new Map(rows.rows.map((r) => [r.target_slug, r.to_page_id]));
|
||||||
|
expect(bySlug.get(targetSlug)).toBe(target); // resolved
|
||||||
|
expect(bySlug.has('ghost-slug')).toBe(true);
|
||||||
|
expect(bySlug.get('ghost-slug')).toBeNull(); // phantom
|
||||||
|
|
||||||
|
// Re-storing without the ghost link removes its row (index is rewritten).
|
||||||
|
await persistence.store(source, makeDocWithWikilinks(targetSlug));
|
||||||
|
const after = await pool.query('SELECT target_slug FROM page_links WHERE from_page_id = $1', [
|
||||||
|
source,
|
||||||
|
]);
|
||||||
|
expect(after.rows.map((r) => r.target_slug)).toEqual([targetSlug]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -165,6 +165,21 @@ export class PostgresPagePersistence implements PagePersistence {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rewrite this page's outgoing wikilink index (issue #47): replace all its
|
||||||
|
// rows with one per distinct target slug, resolved to a page in the same
|
||||||
|
// pond (null `to_page_id` = phantom, target does not exist yet).
|
||||||
|
await client.query('DELETE FROM page_links WHERE from_page_id = $1', [pageId]);
|
||||||
|
if (derived.wikilinkSlugs.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO page_links (id, from_page_id, to_page_id, target_slug)
|
||||||
|
SELECT gen_random_uuid(), $1, target.id, s.link_slug
|
||||||
|
FROM unnest($2::text[]) AS s(link_slug)
|
||||||
|
LEFT JOIN pages AS target
|
||||||
|
ON target.pond_id = $3 AND target.slug = s.link_slug AND target.deleted_at IS NULL`,
|
||||||
|
[pageId, derived.wikilinkSlugs, pondId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
this.lastStoredVector.set(pageId, nextVector);
|
this.lastStoredVector.set(pageId, nextVector);
|
||||||
return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged };
|
return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged };
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
docToPlainText,
|
docToPlainText,
|
||||||
editorSchema,
|
editorSchema,
|
||||||
extractOutline,
|
extractOutline,
|
||||||
|
extractWikilinkSlugs,
|
||||||
type OutlineEntry,
|
type OutlineEntry,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import { Node } from 'prosemirror-model';
|
import { Node } from 'prosemirror-model';
|
||||||
@ -40,6 +41,9 @@ export interface DerivedPageContent {
|
|||||||
* `Attachment.pageId` pointed at the page that embeds the file (issue #31),
|
* `Attachment.pageId` pointed at the page that embeds the file (issue #31),
|
||||||
* mirroring the api's REST save path. */
|
* mirroring the api's REST save path. */
|
||||||
imageFileIds: string[];
|
imageFileIds: string[];
|
||||||
|
/** Distinct target slugs of every `[[wikilink]]`, for the `page_links`
|
||||||
|
* index (issue #47). */
|
||||||
|
wikilinkSlugs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageFileIdsOf(doc: Node): string[] {
|
function imageFileIdsOf(doc: Node): string[] {
|
||||||
@ -65,5 +69,6 @@ export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent {
|
|||||||
html: docToHtml(doc),
|
html: docToHtml(doc),
|
||||||
outline: extractOutline(doc),
|
outline: extractOutline(doc),
|
||||||
imageFileIds: imageFileIdsOf(doc),
|
imageFileIds: imageFileIdsOf(doc),
|
||||||
|
wikilinkSlugs: extractWikilinkSlugs(doc),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,3 +3,4 @@ export * from './markdown';
|
|||||||
export * from './html';
|
export * from './html';
|
||||||
export * from './plain-text';
|
export * from './plain-text';
|
||||||
export * from './outline';
|
export * from './outline';
|
||||||
|
export * from './wikilinks';
|
||||||
|
|||||||
15
packages/shared/src/editor-schema/wikilinks.test.ts
Normal file
15
packages/shared/src/editor-schema/wikilinks.test.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { markdownToDoc } from './markdown';
|
||||||
|
import { extractWikilinkSlugs } from './wikilinks';
|
||||||
|
|
||||||
|
describe('extractWikilinkSlugs (issue #47)', () => {
|
||||||
|
it('collects distinct target slugs in order of first appearance', () => {
|
||||||
|
const doc = markdownToDoc('Link to [[alpha]] and [[beta|Bee]] and again [[alpha]].');
|
||||||
|
expect(extractWikilinkSlugs(doc)).toEqual(['alpha', 'beta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list when there are no wikilinks', () => {
|
||||||
|
expect(extractWikilinkSlugs(markdownToDoc('Just plain text, no links.'))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
packages/shared/src/editor-schema/wikilinks.ts
Normal file
22
packages/shared/src/editor-schema/wikilinks.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Node } from 'prosemirror-model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinct target slugs of every `[[wikilink]]` in a document (issue #47).
|
||||||
|
* Used server-side to maintain the `page_links` index on every content change,
|
||||||
|
* so backlinks and phantom (missing-target) links can be queried. Order of
|
||||||
|
* first appearance is preserved; duplicates are collapsed.
|
||||||
|
*/
|
||||||
|
export function extractWikilinkSlugs(doc: Node): string[] {
|
||||||
|
const slugs: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'wikilink') {
|
||||||
|
const slug = node.attrs.targetSlug as string;
|
||||||
|
if (slug && !seen.has(slug)) {
|
||||||
|
seen.add(slug);
|
||||||
|
slugs.push(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return slugs;
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ export * from './files';
|
|||||||
export * from './health';
|
export * from './health';
|
||||||
export * from './i18n-tools';
|
export * from './i18n-tools';
|
||||||
export * from './labels';
|
export * from './labels';
|
||||||
|
export * from './links';
|
||||||
export * from './pages';
|
export * from './pages';
|
||||||
export * from './ponds';
|
export * from './ponds';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
|
|||||||
18
packages/shared/src/links.ts
Normal file
18
packages/shared/src/links.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Wikilink index views shared between api and web (issue #47/#48). The index
|
||||||
|
* (`page_links`) is maintained on every content change; these are the read
|
||||||
|
* shapes for the backlinks panel and the pond's missing-pages view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A page that links to the page being viewed (its backlink). */
|
||||||
|
export interface BacklinkView {
|
||||||
|
pageId: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A referenced-but-missing target and the pages that link to it. */
|
||||||
|
export interface PhantomLinkView {
|
||||||
|
targetSlug: string;
|
||||||
|
referencedBy: BacklinkView[];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user