From 61bca755e0eb48c48c9d95e5e365252c1248c345 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 08:24:24 +0100 Subject: [PATCH] feat: posts also get structured forward links --- src/main/engine/ChatEngine.ts | 6 ++-- src/main/engine/MCPServer.ts | 34 +++++++++++++++------ src/main/engine/OpenCodeManager.ts | 18 +++++++++-- tests/engine/OpenCodeManagerTools.test.ts | 37 ++++++++++++++++++----- 4 files changed, 72 insertions(+), 23 deletions(-) diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index 7810a41..3398a45 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -307,9 +307,9 @@ You can ONLY access information through the tools listed below. Do not claim oth Available Data Tools: - get_blog_stats: Get comprehensive blog statistics (total posts, date range, posts per year, tag/category counts, media count). ALWAYS call this first when you need to understand the scope of the data. -- search_posts: Search blog posts using full-text search. Supports category/tag/year/month filters and pagination (offset/limit). -- read_post: Read the full content and metadata of a specific post by ID. -- list_posts: List posts with optional filtering by status, category, tags, year, and month. Supports pagination (offset/limit). Returns "total" (global count) and "filteredTotal" (matching filter). ALWAYS use the year filter when you need posts from a specific year — this is much faster than paginating through all posts. +- search_posts: Search blog posts using full-text search. Supports category/tag/year/month filters and pagination (offset/limit). Results include backlinks and linksTo. +- read_post: Read the full content and metadata of a specific post by ID. Includes backlinks and linksTo. +- list_posts: List posts with optional filtering by status, category, tags, year, and month. Supports pagination (offset/limit). Returns "total" (global count) and "filteredTotal" (matching filter). Includes backlinks and linksTo. ALWAYS use the year filter when you need posts from a specific year — this is much faster than paginating through all posts. - get_media: Get information about a specific media file by ID. - list_media: List media files with optional MIME type, year, month, and tag filtering. Supports pagination (offset/limit). Use year/month filters to narrow efficiently. - view_image: View an image to analyze its visual content. Use this when you need to describe or analyze what an image looks like. diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index cabcc36..fbced1e 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -431,8 +431,15 @@ export class MCPServer { server.registerResource('post', new ResourceTemplate('bds://posts/{id}', { list: undefined }), { description: 'A single post by ID' }, async (uri, { id }) => { const postId = id as string; const result = await this.deps.postEngine.getPost(postId); - const backlinks = await this.deps.postEngine.getLinkedBy(postId); - const enriched = { ...result, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })) }; + const [backlinks, linksTo] = await Promise.all([ + this.deps.postEngine.getLinkedBy(postId), + this.deps.postEngine.getLinksTo(postId), + ]); + const enriched = { + ...result, + backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })), + linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })), + }; return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(enriched) }] }; }); @@ -483,7 +490,7 @@ export class MCPServer { private registerReadTools(server: McpServer): void { server.registerTool('search_posts', { title: 'Search Posts', - description: 'Search blog posts by query, category, tags, or date range. Each result includes backlinks (posts linking to it).', + description: 'Search blog posts by query, category, tags, or date range. Each result includes backlinks (posts linking to it) and linksTo (posts it links to).', inputSchema: { query: z.string().optional().describe('Full-text search query'), category: z.string().optional().describe('Filter by category'), @@ -500,11 +507,18 @@ export class MCPServer { const offset = args.offset ?? 0; const limit = args.limit ?? 50; - // Helper: enrich posts with backlinks - const enrichWithBacklinks = async (posts: T[]) => { + // Helper: enrich posts with backlinks and linksTo + const enrichWithLinks = async (posts: T[]) => { return Promise.all(posts.map(async (p) => { - const backlinks = await this.deps.postEngine.getLinkedBy(p.id); - return { ...p, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })) }; + const [backlinks, linksTo] = await Promise.all([ + this.deps.postEngine.getLinkedBy(p.id), + this.deps.postEngine.getLinksTo(p.id), + ]); + return { + ...p, + backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })), + linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })), + }; })); }; @@ -512,7 +526,7 @@ export class MCPServer { // Pure text search — use FTS const results = await this.deps.postEngine.searchPosts(args.query); const paginated = results.slice(offset, offset + limit); - const enriched = await enrichWithBacklinks(paginated); + const enriched = await enrichWithLinks(paginated); return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] }; } @@ -529,14 +543,14 @@ export class MCPServer { const results = await this.deps.postEngine.searchPostsFiltered( args.query, filter, { offset, limit }, ); - const enriched = await enrichWithBacklinks(results); + const enriched = await enrichWithLinks(results); return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] }; } // Filter-only query (no text search) const results = await this.deps.postEngine.getPostsFiltered(filter); const paginated = results.slice(offset, offset + limit); - const enriched = await enrichWithBacklinks(paginated); + const enriched = await enrichWithLinks(paginated); return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] }; }); } diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 4e7fef5..80c83ba 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -1283,13 +1283,17 @@ export class OpenCodeManager { offset, limit, posts: await Promise.all(filteredPosts.map(async p => { - const backlinks = await this.postEngine.getLinkedBy(p.id); + const [backlinks, linksTo] = await Promise.all([ + this.postEngine.getLinkedBy(p.id), + this.postEngine.getLinksTo(p.id), + ]); return { id: p.id, title: p.title, slug: p.slug, excerpt: p.excerpt, status: p.status, categories: p.categories, tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })), + linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })), }; })), }; @@ -1298,7 +1302,10 @@ export class OpenCodeManager { case 'read_post': { const post = await this.postEngine.getPost(args.postId as string); if (!post) return { success: false, error: 'Post not found' }; - const backlinks = await this.postEngine.getLinkedBy(post.id); + const [backlinks, linksTo] = await Promise.all([ + this.postEngine.getLinkedBy(post.id), + this.postEngine.getLinksTo(post.id), + ]); return { success: true, post: { @@ -1309,6 +1316,7 @@ export class OpenCodeManager { createdAt: post.createdAt, updatedAt: post.updatedAt, publishedAt: post.publishedAt, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })), + linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })), }, }; } @@ -1350,12 +1358,16 @@ export class OpenCodeManager { offset, limit, posts: await Promise.all(pageItems.map(async p => { - const backlinks = await this.postEngine.getLinkedBy(p.id); + const [backlinks, linksTo] = await Promise.all([ + this.postEngine.getLinkedBy(p.id), + this.postEngine.getLinksTo(p.id), + ]); return { id: p.id, title: p.title, slug: p.slug, status: p.status, categories: p.categories, tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt, backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })), + linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })), }; })), }; diff --git a/tests/engine/OpenCodeManagerTools.test.ts b/tests/engine/OpenCodeManagerTools.test.ts index 3fab360..c173ff7 100644 --- a/tests/engine/OpenCodeManagerTools.test.ts +++ b/tests/engine/OpenCodeManagerTools.test.ts @@ -2,7 +2,7 @@ * OpenCodeManager Tool Execution Tests * * Tests the executeTool method for post-related tools, - * specifically that backlinks are included in results. + * specifically that backlinks and linksTo are included in results. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -73,7 +73,7 @@ function createManager(postEngine: ReturnType, medi return manager; } -describe('OpenCodeManager tool execution – backlinks', () => { +describe('OpenCodeManager tool execution – backlinks & linksTo', () => { let mockPostEngine: ReturnType; let manager: OpenCodeManager; @@ -84,7 +84,7 @@ describe('OpenCodeManager tool execution – backlinks', () => { }); describe('read_post', () => { - it('includes backlinks in the response', async () => { + it('includes backlinks and linksTo in the response', async () => { const post = { id: 'p1', title: 'Target Post', slug: 'target-post', content: '# Hello', excerpt: 'Hello', status: 'published', @@ -97,6 +97,9 @@ describe('OpenCodeManager tool execution – backlinks', () => { { id: 'p2', title: 'Linking Post A', slug: 'linking-a' }, { id: 'p3', title: 'Linking Post B', slug: 'linking-b' }, ]); + mockPostEngine.getLinksTo.mockResolvedValue([ + { id: 'p4', title: 'Linked Target', slug: 'linked-target' }, + ]); const result = await (manager as any).executeTool('read_post', { postId: 'p1' }); @@ -105,10 +108,14 @@ describe('OpenCodeManager tool execution – backlinks', () => { { id: 'p2', title: 'Linking Post A', slug: 'linking-a' }, { id: 'p3', title: 'Linking Post B', slug: 'linking-b' }, ]); + expect(result.post.linksTo).toEqual([ + { id: 'p4', title: 'Linked Target', slug: 'linked-target' }, + ]); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1'); + expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1'); }); - it('returns empty backlinks array when no backlinks exist', async () => { + it('returns empty backlinks and linksTo arrays when none exist', async () => { const post = { id: 'p1', title: 'Lonely Post', slug: 'lonely-post', content: '# Alone', excerpt: '', status: 'draft', @@ -117,16 +124,18 @@ describe('OpenCodeManager tool execution – backlinks', () => { }; mockPostEngine.getPost.mockResolvedValue(post); mockPostEngine.getLinkedBy.mockResolvedValue([]); + mockPostEngine.getLinksTo.mockResolvedValue([]); const result = await (manager as any).executeTool('read_post', { postId: 'p1' }); expect(result.success).toBe(true); expect(result.post.backlinks).toEqual([]); + expect(result.post.linksTo).toEqual([]); }); }); describe('search_posts', () => { - it('includes backlinks for each post in search results', async () => { + it('includes backlinks and linksTo for each post in search results', async () => { const posts = [ { id: 'p1', title: 'Post One', slug: 'post-one', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() }, { id: 'p2', title: 'Post Two', slug: 'post-two', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() }, @@ -135,18 +144,24 @@ describe('OpenCodeManager tool execution – backlinks', () => { mockPostEngine.getLinkedBy .mockResolvedValueOnce([{ id: 'p3', title: 'Linker', slug: 'linker' }]) .mockResolvedValueOnce([]); + mockPostEngine.getLinksTo + .mockResolvedValueOnce([{ id: 'p4', title: 'Target', slug: 'target' }]) + .mockResolvedValueOnce([{ id: 'p5', title: 'Other', slug: 'other' }]); const result = await (manager as any).executeTool('search_posts', { query: 'test' }); expect(result.success).toBe(true); expect(result.posts[0].backlinks).toEqual([{ id: 'p3', title: 'Linker', slug: 'linker' }]); + expect(result.posts[0].linksTo).toEqual([{ id: 'p4', title: 'Target', slug: 'target' }]); expect(result.posts[1].backlinks).toEqual([]); + expect(result.posts[1].linksTo).toEqual([{ id: 'p5', title: 'Other', slug: 'other' }]); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledTimes(2); + expect(mockPostEngine.getLinksTo).toHaveBeenCalledTimes(2); }); }); describe('list_posts', () => { - it('includes backlinks for each post in listed results', async () => { + it('includes backlinks and linksTo for each post in listed results', async () => { const posts = [ { id: 'p1', title: 'Post A', slug: 'post-a', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() }, ]; @@ -154,26 +169,34 @@ describe('OpenCodeManager tool execution – backlinks', () => { mockPostEngine.getLinkedBy.mockResolvedValue([ { id: 'px', title: 'Cross Ref', slug: 'cross-ref' }, ]); + mockPostEngine.getLinksTo.mockResolvedValue([ + { id: 'py', title: 'Forward Ref', slug: 'forward-ref' }, + ]); const result = await (manager as any).executeTool('list_posts', {}); expect(result.success).toBe(true); expect(result.posts[0].backlinks).toEqual([{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' }]); + expect(result.posts[0].linksTo).toEqual([{ id: 'py', title: 'Forward Ref', slug: 'forward-ref' }]); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1'); + expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1'); }); - it('includes backlinks for filtered list results', async () => { + it('includes backlinks and linksTo for filtered list results', async () => { const posts = [ { id: 'p5', title: 'Tagged Post', slug: 'tagged', status: 'published', categories: [], tags: ['js'], createdAt: new Date(), updatedAt: new Date() }, ]; mockPostEngine.getPostsFiltered.mockResolvedValue(posts); mockPostEngine.getLinkedBy.mockResolvedValue([]); + mockPostEngine.getLinksTo.mockResolvedValue([]); const result = await (manager as any).executeTool('list_posts', { tags: ['js'] }); expect(result.success).toBe(true); expect(result.posts[0].backlinks).toEqual([]); + expect(result.posts[0].linksTo).toEqual([]); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p5'); + expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p5'); }); }); });