From 1ce61506b23ccef8a4226698691b808736637f45 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 7 Sep 2026 02:31:03 +0200 Subject: [PATCH] feat: add wikis domain service with caching discovery search --- src/services/wikis.ts | 354 ++++++++++++++++++++++++++++++++++++++ tests/wikis.test.ts | 385 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 739 insertions(+) create mode 100644 src/services/wikis.ts create mode 100644 tests/wikis.test.ts diff --git a/src/services/wikis.ts b/src/services/wikis.ts new file mode 100644 index 0000000..c4c580e --- /dev/null +++ b/src/services/wikis.ts @@ -0,0 +1,354 @@ +/** + * Wikis Domain Service. + * Provides functions to inspect, search, and list OpenProject wiki pages and wiki page links. + */ + +import type { OpenProjectClient } from "../client/api-client.ts"; +import { extractIdFromHref } from "../client/hal-parser.ts"; +import type { HalCollection, HalLink, HalResource } from "../client/types.ts"; +import { resolveClient, resolveProjectId } from "./helper.ts"; + +export interface WikiPageAttachment { + id: number; + fileName: string; + fileSize: number; + contentType: string; + downloadUrl: string; +} + +export interface WikiPageDetail { + id: number; + title: string; + project: { id: number; identifier?: string; name?: string }; + attachments: WikiPageAttachment[]; +} + +export interface WikiPageSummary { + id: number; + title: string; + project: { id: number; identifier?: string; name?: string }; + attachmentsCount: number; +} + +export interface WikiPageLink { + id: number; + pageTitle?: string; + pageUrl?: string; + provider?: string; + workPackage?: { id: number; title?: string }; +} + +export interface PaginatedResult { + total: number; + count: number; + pageSize: number; + offset: number; + elements: T[]; + items?: T[]; +} + +export interface ListWikiPageLinksParams { + workPackageId?: number; + offset?: number; + pageSize?: number; +} + +export interface SearchWikiPagesParams { + query?: string; + projectId?: string | number; + limit?: number; + refreshCache?: boolean; +} + +// In-memory cache for discovered wiki pages keyed by client baseUrl +const wikiPagesCache = new Map>(); +const discoveredBaseUrls = new Set(); + +/** + * Clears the in-memory wiki discovery cache. + */ +export function clearWikiCache(): void { + wikiPagesCache.clear(); + discoveredBaseUrls.clear(); +} + +/** + * Normalizes a raw HAL attachment resource into a WikiPageAttachment. + */ +export function normalizeWikiAttachment(element: HalResource): WikiPageAttachment { + const links = (element._links ?? {}) as Record; + const downloadHref = + (links.downloadLocation as HalLink | undefined)?.href ?? + (links.self as HalLink | undefined)?.href ?? + ""; + + return { + id: element.id ?? extractIdFromHref(downloadHref) ?? 0, + fileName: String(element.fileName ?? element.name ?? ""), + fileSize: Number(element.fileSize ?? element.size ?? 0), + contentType: String(element.contentType ?? element.mimeType ?? ""), + downloadUrl: downloadHref, + }; +} + +/** + * Normalizes a raw HAL WikiPage resource and its attachments into a WikiPageDetail. + */ +export function normalizeWikiPageDetail( + resource: HalResource, + attachments: WikiPageAttachment[] = [] +): WikiPageDetail { + const links = (resource._links ?? {}) as Record; + const projectLink = links.project; + const embedded = (resource._embedded ?? {}) as Record; + const embeddedProject = embedded.project as { id?: number; identifier?: string; name?: string } | undefined; + + const projectId = extractIdFromHref(projectLink?.href) ?? embeddedProject?.id ?? 0; + const projectName = projectLink?.title ?? embeddedProject?.name; + const projectIdentifier = + embeddedProject?.identifier ?? (projectLink as { identifier?: string } | undefined)?.identifier; + + const project: { id: number; identifier?: string; name?: string } = { + id: projectId, + ...(projectIdentifier ? { identifier: projectIdentifier } : {}), + ...(projectName ? { name: projectName } : {}), + }; + + const id = resource.id ?? extractIdFromHref(links.self?.href) ?? 0; + const title = String(resource.title ?? ""); + + return { + id, + title, + project, + attachments, + }; +} + +/** + * Converts a WikiPageDetail into a token-efficient WikiPageSummary. + */ +export function toWikiPageSummary(detail: WikiPageDetail): WikiPageSummary { + return { + id: detail.id, + title: detail.title, + project: detail.project, + attachmentsCount: detail.attachments.length, + }; +} + +/** + * Normalizes a raw HAL WikiPageLink resource into a clean WikiPageLink. + */ +export function normalizeWikiPageLink(resource: HalResource): WikiPageLink { + const links = (resource._links ?? {}) as Record; + const selfLink = links.self; + const wpLink = links.workPackage; + const wikiPageLink = links.wikiPage ?? links["wiki_page"]; + + const embedded = (resource._embedded ?? {}) as Record; + const embeddedWp = embedded.workPackage as { id?: number; subject?: string; title?: string } | undefined; + + const id = resource.id ?? extractIdFromHref(selfLink?.href) ?? 0; + const pageTitle = + (typeof resource.pageTitle === "string" ? resource.pageTitle : undefined) ?? + (typeof resource.title === "string" ? resource.title : undefined) ?? + wikiPageLink?.title; + + const pageUrl = + (typeof resource.pageUrl === "string" ? resource.pageUrl : undefined) ?? + (typeof resource.url === "string" ? resource.url : undefined) ?? + wikiPageLink?.href; + + const provider = typeof resource.provider === "string" ? resource.provider : undefined; + + const wpId = extractIdFromHref(wpLink?.href) ?? embeddedWp?.id; + const wpTitle = wpLink?.title ?? embeddedWp?.title ?? embeddedWp?.subject; + + const workPackage = + wpId !== undefined + ? { + id: wpId, + ...(wpTitle ? { title: wpTitle } : {}), + } + : undefined; + + return { + id, + ...(pageTitle ? { pageTitle } : {}), + ...(pageUrl ? { pageUrl } : {}), + ...(provider ? { provider } : {}), + ...(workPackage ? { workPackage } : {}), + }; +} + +/** + * Retrieves a single wiki page by ID including its attachments. + */ +export async function getWikiPage( + id: number, + client?: OpenProjectClient +): Promise { + const opClient = resolveClient(client); + const pageResource = await opClient.get(`/api/v3/wiki_pages/${id}`); + + let attachments: WikiPageAttachment[] = []; + try { + const attachmentsResponse = await opClient.get>( + `/api/v3/wiki_pages/${id}/attachments` + ); + if (attachmentsResponse._embedded?.elements) { + attachments = attachmentsResponse._embedded.elements.map(normalizeWikiAttachment); + } + } catch { + // If attachments collection cannot be retrieved, default to empty list + } + + const detail = normalizeWikiPageDetail(pageResource, attachments); + + // Update in-memory cache for this client baseUrl if initialized + const cacheKey = opClient.baseUrl || "default"; + let cacheMap = wikiPagesCache.get(cacheKey); + if (!cacheMap) { + cacheMap = new Map(); + wikiPagesCache.set(cacheKey, cacheMap); + } + cacheMap.set(detail.id, detail); + + return detail; +} + +/** + * Lists wiki page links, optionally filtered by work package ID. + */ +export async function listWikiPageLinks( + params?: ListWikiPageLinksParams, + client?: OpenProjectClient +): Promise> { + const opClient = resolveClient(client); + const path = + params?.workPackageId !== undefined + ? `/api/v3/work_packages/${params.workPackageId}/wiki_page_links` + : `/api/v3/wiki_page_links`; + + const query: Record = {}; + if (params?.offset !== undefined) query.offset = params.offset; + if (params?.pageSize !== undefined) query.pageSize = params.pageSize; + + const response = await opClient.get>(path, query); + const items = (response._embedded?.elements ?? []).map(normalizeWikiPageLink); + + return { + total: response.total ?? items.length, + count: response.count ?? items.length, + pageSize: response.pageSize ?? items.length, + offset: response.offset ?? 1, + elements: items, + items, + }; +} + +/** + * Searches wiki pages using in-memory caching and smart discovery scanning. + */ +export async function searchWikiPages( + params?: SearchWikiPagesParams, + client?: OpenProjectClient +): Promise { + const opClient = resolveClient(client); + const cacheKey = opClient.baseUrl || "default"; + + let cacheMap = wikiPagesCache.get(cacheKey); + const needsDiscovery = !cacheMap || !discoveredBaseUrls.has(cacheKey) || params?.refreshCache; + + if (!cacheMap) { + cacheMap = new Map(); + wikiPagesCache.set(cacheKey, cacheMap); + } + + if (needsDiscovery) { + // 1. Check wiki_page_links to collect referenced wiki page IDs + const discoveredIds = new Set(); + try { + const linksResponse = await opClient.get>("/api/v3/wiki_page_links", { + pageSize: 100, + }); + const elements = linksResponse._embedded?.elements ?? []; + for (const element of elements) { + const links = (element._links ?? {}) as Record; + const wikiPageLink = links.wikiPage ?? links["wiki_page"]; + const idFromLink = extractIdFromHref(wikiPageLink?.href); + if (idFromLink !== undefined) { + discoveredIds.add(idFromLink); + } + if (element.pageUrl && typeof element.pageUrl === "string") { + const idFromUrl = extractIdFromHref(element.pageUrl); + if (idFromUrl !== undefined) { + discoveredIds.add(idFromUrl); + } + } + } + } catch { + // Ignore errors if wiki_page_links is not supported or accessible + } + + // 2. Sequential probing of IDs 1..MAX_PROBE_ID with consecutive 404 cutoff + const MAX_PROBE_ID = 50; + const CONSECUTIVE_404_CUTOFF = 5; + let consecutiveMisses = 0; + + for (let id = 1; id <= MAX_PROBE_ID; id++) { + try { + const pageDetail = await getWikiPage(id, opClient); + cacheMap.set(id, pageDetail); + consecutiveMisses = 0; + } catch { + consecutiveMisses++; + if (consecutiveMisses >= CONSECUTIVE_404_CUTOFF) { + break; + } + } + } + + // Also fetch any referenced wiki page IDs discovered from wiki_page_links that weren't probed + for (const id of discoveredIds) { + if (!cacheMap.has(id)) { + try { + const pageDetail = await getWikiPage(id, opClient); + cacheMap.set(id, pageDetail); + } catch { + // Ignore missing pages + } + } + } + + discoveredBaseUrls.add(cacheKey); + } + + // 3. Resolve target project ID if provided + let targetProjectId: number | undefined; + if (params?.projectId !== undefined) { + targetProjectId = await resolveProjectId(params.projectId, opClient); + } + + // 4. Filter cached pages + const allPages = Array.from(cacheMap.values()); + const querySubstring = params?.query?.trim().toLowerCase(); + + let filtered = allPages.filter((page) => { + if (targetProjectId !== undefined && page.project.id !== targetProjectId) { + return false; + } + if (querySubstring && !page.title.toLowerCase().includes(querySubstring)) { + return false; + } + return true; + }); + + // 5. Apply limit + if (params?.limit !== undefined && params.limit > 0) { + filtered = filtered.slice(0, params.limit); + } + + return filtered.map(toWikiPageSummary); +} diff --git a/tests/wikis.test.ts b/tests/wikis.test.ts new file mode 100644 index 0000000..771ca99 --- /dev/null +++ b/tests/wikis.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, test, beforeEach } from "bun:test"; +import { OpenProjectClient } from "../src/client/api-client.ts"; +import { runWithContext, type RequestContext } from "../src/context.ts"; +import { + clearWikiCache, + getWikiPage, + listWikiPageLinks, + searchWikiPages, +} from "../src/services/wikis.ts"; + +describe("Wikis Service", () => { + beforeEach(() => { + clearWikiCache(); + }); + + test("getWikiPage retrieves page and attaches files", async () => { + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path === "/api/v3/wiki_pages/1") { + return { + _type: "WikiPage", + id: 1, + title: "Project Wiki", + _links: { + project: { href: "/api/v3/projects/1", title: "Demo Project" }, + }, + }; + } + if (path === "/api/v3/wiki_pages/1/attachments") { + return { + _type: "Collection", + total: 1, + _embedded: { + elements: [ + { + _type: "Attachment", + id: 12, + fileName: "architecture.png", + fileSize: 45000, + contentType: "image/png", + _links: { + downloadLocation: { href: "/api/v3/attachments/12/content" }, + }, + }, + ], + }, + }; + } + throw new Error(`Unexpected path: ${path}`); + }, + } as unknown as OpenProjectClient; + + const page = await getWikiPage(1, mockClient); + expect(page.id).toBe(1); + expect(page.title).toBe("Project Wiki"); + expect(page.project.id).toBe(1); + expect(page.attachments).toHaveLength(1); + expect(page.attachments[0]!.fileName).toBe("architecture.png"); + expect(page.attachments[0]!.fileSize).toBe(45000); + expect(page.attachments[0]!.contentType).toBe("image/png"); + expect(page.attachments[0]!.downloadUrl).toBe("/api/v3/attachments/12/content"); + }); + + test("getWikiPage handles missing attachments collection gracefully", async () => { + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path === "/api/v3/wiki_pages/2") { + return { + _type: "WikiPage", + id: 2, + title: "Quick Notes", + _links: { + project: { href: "/api/v3/projects/3", title: "Core App" }, + }, + }; + } + if (path === "/api/v3/wiki_pages/2/attachments") { + const err = new Error("Attachments not found"); + (err as unknown as { statusCode: number }).statusCode = 404; + throw err; + } + throw new Error(`Unexpected path: ${path}`); + }, + } as unknown as OpenProjectClient; + + const page = await getWikiPage(2, mockClient); + expect(page.id).toBe(2); + expect(page.title).toBe("Quick Notes"); + expect(page.project.id).toBe(3); + expect(page.attachments).toEqual([]); + }); + + test("listWikiPageLinks retrieves global wiki page links when workPackageId is omitted", async () => { + let requestedPath = ""; + let requestedQuery: Record | undefined; + + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string, query?: Record) => { + requestedPath = path; + requestedQuery = query; + return { + _type: "Collection", + total: 1, + count: 1, + pageSize: 20, + offset: 1, + _embedded: { + elements: [ + { + _type: "WikiPageLink", + id: 10, + pageTitle: "Design Guidelines", + pageUrl: "http://localhost:8080/projects/demo/wiki/design", + provider: "openproject", + _links: { + workPackage: { href: "/api/v3/work_packages/42", title: "Refactor UI" }, + wikiPage: { href: "/api/v3/wiki_pages/5", title: "Design Guidelines" }, + }, + }, + ], + }, + }; + }, + } as unknown as OpenProjectClient; + + const result = await listWikiPageLinks({ offset: 1, pageSize: 20 }, mockClient); + expect(requestedPath).toBe("/api/v3/wiki_page_links"); + expect(requestedQuery?.offset).toBe(1); + expect(requestedQuery?.pageSize).toBe(20); + expect(result.total).toBe(1); + expect(result.elements).toHaveLength(1); + expect(result.elements[0]!.id).toBe(10); + expect(result.elements[0]!.pageTitle).toBe("Design Guidelines"); + expect(result.elements[0]!.pageUrl).toBe("http://localhost:8080/projects/demo/wiki/design"); + expect(result.elements[0]!.workPackage?.id).toBe(42); + expect(result.elements[0]!.workPackage?.title).toBe("Refactor UI"); + expect(result.items).toHaveLength(1); + }); + + test("listWikiPageLinks queries work package specific endpoint when workPackageId is provided", async () => { + let requestedPath = ""; + + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + requestedPath = path; + return { + _type: "Collection", + total: 0, + count: 0, + pageSize: 10, + offset: 1, + _embedded: { + elements: [], + }, + }; + }, + } as unknown as OpenProjectClient; + + const result = await listWikiPageLinks({ workPackageId: 99 }, mockClient); + expect(requestedPath).toBe("/api/v3/work_packages/99/wiki_page_links"); + expect(result.total).toBe(0); + expect(result.elements).toEqual([]); + }); + + test("searchWikiPages matches discovered pages by title substring", async () => { + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/1") { + return { + _type: "WikiPage", + id: 1, + title: "System Architecture", + _links: { project: { href: "/api/v3/projects/1", title: "Demo" } }, + }; + } + if (path === "/api/v3/wiki_pages/1/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/2") { + return { + _type: "WikiPage", + id: 2, + title: "Sprint Retrospective", + _links: { project: { href: "/api/v3/projects/1", title: "Demo" } }, + }; + } + if (path === "/api/v3/wiki_pages/2/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + const err = new Error("Not found"); + (err as unknown as { statusCode: number }).statusCode = 404; + throw err; + }, + } as unknown as OpenProjectClient; + + const results = await searchWikiPages({ query: "architecture" }, mockClient); + expect(results).toHaveLength(1); + expect(results[0]!.title).toBe("System Architecture"); + expect(results[0]!.id).toBe(1); + expect(results[0]!.project.id).toBe(1); + expect(results[0]!.attachmentsCount).toBe(0); + }); + + test("searchWikiPages filters by numeric projectId and string identifier", async () => { + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/1") { + return { + _type: "WikiPage", + id: 1, + title: "Project One Architecture", + _links: { project: { href: "/api/v3/projects/1", title: "Project 1" } }, + }; + } + if (path === "/api/v3/wiki_pages/1/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/2") { + return { + _type: "WikiPage", + id: 2, + title: "Project Two Architecture", + _links: { project: { href: "/api/v3/projects/2", title: "Project 2" } }, + }; + } + if (path === "/api/v3/wiki_pages/2/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "projects/proj-two" || path === "/api/v3/projects/proj-two") { + return { id: 2 }; + } + const err = new Error("Not found"); + (err as unknown as { statusCode: number }).statusCode = 404; + throw err; + }, + } as unknown as OpenProjectClient; + + // Filter by numeric projectId = 1 + const resultsProject1 = await searchWikiPages({ projectId: 1 }, mockClient); + expect(resultsProject1).toHaveLength(1); + expect(resultsProject1[0]!.id).toBe(1); + + // Filter by string identifier "proj-two" (resolves to ID 2) + const resultsProject2 = await searchWikiPages({ projectId: "proj-two" }, mockClient); + expect(resultsProject2).toHaveLength(1); + expect(resultsProject2[0]!.id).toBe(2); + }); + + test("searchWikiPages respects limit parameter", async () => { + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/1") { + return { + _type: "WikiPage", + id: 1, + title: "Docs Page 1", + _links: { project: { href: "/api/v3/projects/1", title: "Project 1" } }, + }; + } + if (path === "/api/v3/wiki_pages/1/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/2") { + return { + _type: "WikiPage", + id: 2, + title: "Docs Page 2", + _links: { project: { href: "/api/v3/projects/1", title: "Project 1" } }, + }; + } + if (path === "/api/v3/wiki_pages/2/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + const err = new Error("Not found"); + (err as unknown as { statusCode: number }).statusCode = 404; + throw err; + }, + } as unknown as OpenProjectClient; + + const results = await searchWikiPages({ query: "Docs", limit: 1 }, mockClient); + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe(1); + }); + + test("searchWikiPages caches discovered pages and serves subsequent searches without probing", async () => { + let probeCount = 0; + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/1") { + probeCount++; + return { + _type: "WikiPage", + id: 1, + title: "API Architecture Guide", + _links: { project: { href: "/api/v3/projects/1", title: "Demo" } }, + }; + } + if (path === "/api/v3/wiki_pages/1/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + const err = new Error("Not found"); + (err as unknown as { statusCode: number }).statusCode = 404; + throw err; + }, + } as unknown as OpenProjectClient; + + // First search: performs discovery + const firstResults = await searchWikiPages({ query: "Architecture" }, mockClient); + expect(firstResults).toHaveLength(1); + expect(probeCount).toBe(1); + + // Second search: served from in-memory cache without hitting /api/v3/wiki_pages/1 again + const secondResults = await searchWikiPages({ query: "Guide" }, mockClient); + expect(secondResults).toHaveLength(1); + expect(secondResults[0]!.id).toBe(1); + expect(probeCount).toBe(1); + + // Third search with refreshCache: true forces re-discovery + const thirdResults = await searchWikiPages( + { query: "Guide", refreshCache: true }, + mockClient + ); + expect(thirdResults).toHaveLength(1); + expect(probeCount).toBe(2); + }); + + test("resolves ambient RequestContext client when client parameter is omitted", async () => { + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path === "/api/v3/wiki_pages/1") { + return { + _type: "WikiPage", + id: 1, + title: "Context Wiki", + _links: { project: { href: "/api/v3/projects/1", title: "Demo" } }, + }; + } + if (path === "/api/v3/wiki_pages/1/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + const err = new Error("Not found"); + (err as unknown as { statusCode: number }).statusCode = 404; + throw err; + }, + } as unknown as OpenProjectClient; + + const context: RequestContext = { + client: mockClient, + isReadOnly: false, + }; + + await runWithContext(context, async () => { + const page = await getWikiPage(1); + expect(page.title).toBe("Context Wiki"); + + const searchRes = await searchWikiPages({ query: "Context" }); + expect(searchRes).toHaveLength(1); + expect(searchRes[0]!.title).toBe("Context Wiki"); + }); + }); +});