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)
This commit is contained in:
@@ -1,2 +1,6 @@
|
|||||||
export { LinkResolutionService } from "./link-resolution.service";
|
export { LinkResolutionService } from "./link-resolution.service";
|
||||||
export type { ResolvedEntry } from "./link-resolution.service";
|
export type {
|
||||||
|
ResolvedEntry,
|
||||||
|
ResolvedLink,
|
||||||
|
Backlink,
|
||||||
|
} from "./link-resolution.service";
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ describe("LinkResolutionService", () => {
|
|||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
},
|
},
|
||||||
|
knowledgeLink: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -403,4 +406,186 @@ describe("LinkResolutionService", () => {
|
|||||||
expect(result).toHaveLength(1);
|
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,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
|
import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a knowledge entry that matches a link target
|
* Represents a knowledge entry that matches a link target
|
||||||
@@ -9,6 +10,32 @@ export interface ResolvedEntry {
|
|||||||
title: string;
|
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
|
* Service for resolving wiki-style links to knowledge entries
|
||||||
*
|
*
|
||||||
@@ -165,4 +192,72 @@ export class LinkResolutionService {
|
|||||||
|
|
||||||
return matches;
|
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<ResolvedLink[]> {
|
||||||
|
// 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<Backlink[]> {
|
||||||
|
// 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user