From 24768bd6643bf2af433d96c7ad7ebb7ea41fbe0e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 19:34:57 -0600 Subject: [PATCH] feat(knowledge): add link resolution service - Add resolveLinksFromContent() to parse wiki links from content and resolve them - Add getBacklinks() to find all entries that link to a target entry - Import parseWikiLinks from utils for content parsing - Export new types: ResolvedLink, Backlink - Add comprehensive tests for new functionality (27 tests total) --- apps/api/src/knowledge/services/index.ts | 6 +- .../services/link-resolution.service.spec.ts | 185 ++++++++++++++++++ .../services/link-resolution.service.ts | 95 +++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) diff --git a/apps/api/src/knowledge/services/index.ts b/apps/api/src/knowledge/services/index.ts index 94b803f..ed6384e 100644 --- a/apps/api/src/knowledge/services/index.ts +++ b/apps/api/src/knowledge/services/index.ts @@ -1,2 +1,6 @@ export { LinkResolutionService } from "./link-resolution.service"; -export type { ResolvedEntry } from "./link-resolution.service"; +export type { + ResolvedEntry, + ResolvedLink, + Backlink, +} from "./link-resolution.service"; diff --git a/apps/api/src/knowledge/services/link-resolution.service.spec.ts b/apps/api/src/knowledge/services/link-resolution.service.spec.ts index cf23a27..629f834 100644 --- a/apps/api/src/knowledge/services/link-resolution.service.spec.ts +++ b/apps/api/src/knowledge/services/link-resolution.service.spec.ts @@ -63,6 +63,9 @@ describe("LinkResolutionService", () => { findFirst: vi.fn(), findMany: vi.fn(), }, + knowledgeLink: { + findMany: vi.fn(), + }, }; beforeEach(async () => { @@ -403,4 +406,186 @@ describe("LinkResolutionService", () => { expect(result).toHaveLength(1); }); }); + + describe("resolveLinksFromContent", () => { + it("should parse and resolve wiki links from content", async () => { + const content = + "Check out [[TypeScript Guide]] and [[React Hooks]] for more info."; + + // Mock resolveLink for each target + mockPrismaService.knowledgeEntry.findFirst + .mockResolvedValueOnce({ id: "entry-1" }) // TypeScript Guide + .mockResolvedValueOnce({ id: "entry-2" }); // React Hooks + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toHaveLength(2); + expect(result[0].link.target).toBe("TypeScript Guide"); + expect(result[0].entryId).toBe("entry-1"); + expect(result[1].link.target).toBe("React Hooks"); + expect(result[1].entryId).toBe("entry-2"); + }); + + it("should return null entryId for unresolved links", async () => { + const content = "See [[Non-existent Page]] for details."; + + mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null); + mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null); + mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]); + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toHaveLength(1); + expect(result[0].link.target).toBe("Non-existent Page"); + expect(result[0].entryId).toBeNull(); + }); + + it("should return empty array for content with no wiki links", async () => { + const content = "This content has no wiki links."; + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toEqual([]); + expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled(); + }); + + it("should handle content with display text syntax", async () => { + const content = "Read the [[typescript-guide|TS Guide]] first."; + + mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null); + mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce({ + id: "entry-1", + }); + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toHaveLength(1); + expect(result[0].link.target).toBe("typescript-guide"); + expect(result[0].link.displayText).toBe("TS Guide"); + expect(result[0].entryId).toBe("entry-1"); + }); + + it("should preserve link position information", async () => { + const content = "Start [[Page One]] middle [[Page Two]] end."; + + mockPrismaService.knowledgeEntry.findFirst + .mockResolvedValueOnce({ id: "entry-1" }) + .mockResolvedValueOnce({ id: "entry-2" }); + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toHaveLength(2); + expect(result[0].link.start).toBe(6); + expect(result[0].link.end).toBe(18); + expect(result[1].link.start).toBe(26); + expect(result[1].link.end).toBe(38); + }); + }); + + describe("getBacklinks", () => { + it("should return all entries that link to the target entry", async () => { + const targetEntryId = "entry-target"; + const mockBacklinks = [ + { + id: "link-1", + sourceId: "entry-1", + targetId: targetEntryId, + linkText: "Target Page", + displayText: "Target Page", + positionStart: 10, + positionEnd: 25, + resolved: true, + context: null, + createdAt: new Date(), + source: { + id: "entry-1", + title: "TypeScript Guide", + slug: "typescript-guide", + }, + }, + { + id: "link-2", + sourceId: "entry-2", + targetId: targetEntryId, + linkText: "Target Page", + displayText: "See Target", + positionStart: 50, + positionEnd: 70, + resolved: true, + context: null, + createdAt: new Date(), + source: { + id: "entry-2", + title: "React Hooks", + slug: "react-hooks", + }, + }, + ]; + + mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce( + mockBacklinks + ); + + const result = await service.getBacklinks(targetEntryId); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + sourceId: "entry-1", + sourceTitle: "TypeScript Guide", + sourceSlug: "typescript-guide", + linkText: "Target Page", + displayText: "Target Page", + }); + expect(result[1]).toEqual({ + sourceId: "entry-2", + sourceTitle: "React Hooks", + sourceSlug: "react-hooks", + linkText: "Target Page", + displayText: "See Target", + }); + + expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith({ + where: { + targetId: targetEntryId, + resolved: true, + }, + include: { + source: { + select: { + id: true, + title: true, + slug: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + }); + + it("should return empty array when no backlinks exist", async () => { + mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]); + + const result = await service.getBacklinks("entry-with-no-backlinks"); + + expect(result).toEqual([]); + }); + + it("should only return resolved backlinks", async () => { + const targetEntryId = "entry-target"; + + mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]); + + await service.getBacklinks(targetEntryId); + + expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + resolved: true, + }), + }) + ); + }); + }); }); diff --git a/apps/api/src/knowledge/services/link-resolution.service.ts b/apps/api/src/knowledge/services/link-resolution.service.ts index e869100..f00129f 100644 --- a/apps/api/src/knowledge/services/link-resolution.service.ts +++ b/apps/api/src/knowledge/services/link-resolution.service.ts @@ -1,5 +1,6 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../../prisma/prisma.service"; +import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser"; /** * Represents a knowledge entry that matches a link target @@ -9,6 +10,32 @@ export interface ResolvedEntry { title: string; } +/** + * Represents a resolved wiki link with entry information + */ +export interface ResolvedLink { + /** The parsed wiki link */ + link: WikiLink; + /** The resolved entry ID, or null if not found */ + entryId: string | null; +} + +/** + * Represents a backlink - an entry that links to a target entry + */ +export interface Backlink { + /** The source entry ID */ + sourceId: string; + /** The source entry title */ + sourceTitle: string; + /** The source entry slug */ + sourceSlug: string; + /** The link text used to reference the target */ + linkText: string; + /** The display text shown for the link */ + displayText: string; +} + /** * Service for resolving wiki-style links to knowledge entries * @@ -165,4 +192,72 @@ export class LinkResolutionService { return matches; } + + /** + * Parse wiki links from content and resolve them to knowledge entries + * + * @param content - The markdown content containing wiki links + * @param workspaceId - The workspace scope for resolution + * @returns Array of resolved links with entry IDs (or null if not found) + */ + async resolveLinksFromContent( + content: string, + workspaceId: string + ): Promise { + // Parse wiki links from content + const parsedLinks = parseWikiLinks(content); + + if (parsedLinks.length === 0) { + return []; + } + + // Resolve each link + const resolvedLinks: ResolvedLink[] = []; + + for (const link of parsedLinks) { + const entryId = await this.resolveLink(workspaceId, link.target); + resolvedLinks.push({ + link, + entryId, + }); + } + + return resolvedLinks; + } + + /** + * Get all entries that link TO a specific entry (backlinks) + * + * @param entryId - The target entry ID + * @returns Array of backlinks with source entry information + */ + async getBacklinks(entryId: string): Promise { + // Find all links where this entry is the target + const links = await this.prisma.knowledgeLink.findMany({ + where: { + targetId: entryId, + resolved: true, + }, + include: { + source: { + select: { + id: true, + title: true, + slug: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return links.map((link) => ({ + sourceId: link.source.id, + sourceTitle: link.source.title, + sourceSlug: link.source.slug, + linkText: link.linkText, + displayText: link.displayText, + })); + } } -- 2.49.1