diff --git a/src/features/chat/composer/wikilink-context.ts b/src/features/chat/composer/wikilink-context.ts index 6d844597..d47acb97 100644 --- a/src/features/chat/composer/wikilink-context.ts +++ b/src/features/chat/composer/wikilink-context.ts @@ -1,5 +1,6 @@ import type { SkillMetadata } from "../../../generated/app-server/v2/SkillMetadata"; import type { UserInput } from "../../../generated/app-server/v2/UserInput"; +import { parseObsidianWikiLink } from "../../../shared/obsidian/wikilinks"; export interface ParsedWikiLink { raw: string; @@ -76,24 +77,8 @@ export function parsedSkillReferences(text: string): string[] { } function parseWikiLink(raw: string): ParsedWikiLink | null { - const separator = raw.indexOf("|"); - const linkPart = (separator === -1 ? raw : raw.slice(0, separator)).trim(); - const display = separator === -1 ? "" : raw.slice(separator + 1).trim(); - if (!linkPart) return null; - - const subpathStart = firstSubpathIndex(linkPart); - const target = subpathStart === -1 ? linkPart : linkPart.slice(0, subpathStart).trim(); - const subpath = subpathStart === -1 ? "" : linkPart.slice(subpathStart).trim(); - if (!target) return null; - return { raw, target, subpath, display }; -} - -function firstSubpathIndex(linkPart: string): number { - const headingIndex = linkPart.indexOf("#"); - const blockIndex = linkPart.indexOf("^"); - if (headingIndex === -1) return blockIndex; - if (blockIndex === -1) return headingIndex; - return Math.min(headingIndex, blockIndex); + const parsed = parseObsidianWikiLink(raw); + return parsed ? { raw, ...parsed } : null; } function firstEnabledSkillByName(skills: SkillMetadata[]): Map { diff --git a/src/shared/obsidian/wikilinks.ts b/src/shared/obsidian/wikilinks.ts new file mode 100644 index 00000000..08297c35 --- /dev/null +++ b/src/shared/obsidian/wikilinks.ts @@ -0,0 +1,23 @@ +import { parseLinktext } from "obsidian"; + +export interface ParsedObsidianWikiLink { + target: string; + subpath: string; + display: string; +} + +export function parseObsidianWikiLink(raw: string): ParsedObsidianWikiLink | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const separator = trimmed.indexOf("|"); + const linktext = (separator === -1 ? trimmed : trimmed.slice(0, separator)).trim(); + const display = separator === -1 ? "" : trimmed.slice(separator + 1).trim(); + if (!linktext) return null; + + const parsed = parseLinktext(linktext); + const target = parsed.path.trim(); + const subpath = parsed.subpath.trim(); + if (!target) return null; + return { target, subpath, display }; +} diff --git a/src/shared/ui/dom.ts b/src/shared/ui/dom.ts index 3fa2f68b..0524467b 100644 --- a/src/shared/ui/dom.ts +++ b/src/shared/ui/dom.ts @@ -1,3 +1,5 @@ +import { parseObsidianWikiLink } from "../obsidian/wikilinks"; + export function shortSignature(value: string): string { let hash = 0; for (let index = 0; index < value.length; index += 1) { @@ -19,13 +21,13 @@ export function renderTextWithWikiLinks(parent: HTMLElement, text: string, openL const rawLink = match[1]; const rawMatch = match[0]; if (rawLink === undefined) continue; - const separator = rawLink.indexOf("|"); - const target = (separator === -1 ? rawLink : rawLink.slice(0, separator)).trim(); - const label = (separator === -1 ? rawLink : rawLink.slice(separator + 1)).trim() || target; + const parsed = parseObsidianWikiLink(rawLink); - if (target.length === 0) { + if (!parsed) { parent.appendChild(doc.createTextNode(rawMatch)); } else { + const target = `${parsed.target}${parsed.subpath}`; + const label = parsed.display || target; const link = parent.createEl("a", { cls: "internal-link codex-panel__wikilink", text: label, diff --git a/tests/features/chat/ui/view-dom.test.ts b/tests/features/chat/ui/view-dom.test.ts index c5ecbd7d..3fcd31ab 100644 --- a/tests/features/chat/ui/view-dom.test.ts +++ b/tests/features/chat/ui/view-dom.test.ts @@ -24,15 +24,15 @@ describe("view DOM helpers", () => { const parent = document.createElement("div"); const openLink = vi.fn(); - renderTextWithWikiLinks(parent, "See [[Target Note|label]] and [[Other]].", openLink); + renderTextWithWikiLinks(parent, "See [[Target Note#Heading|label]] and [[Other]].", openLink); const links = parent.querySelectorAll("a.internal-link"); const firstLink = expectPresent(links[0]); expect(links).toHaveLength(2); expect(firstLink.textContent).toBe("label"); - expect(firstLink.getAttribute("href")).toBe("Target Note"); + expect(firstLink.getAttribute("href")).toBe("Target Note#Heading"); firstLink.click(); - expect(openLink).toHaveBeenCalledWith("Target Note"); + expect(openLink).toHaveBeenCalledWith("Target Note#Heading"); }); }); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 26fda2ce..a7494d96 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -89,6 +89,15 @@ export function sortSearchResults(results: { match: { score: number } }[]): void results.sort((a, b) => b.match.score - a.match.score); } +export function parseLinktext(linktext: string): { path: string; subpath: string } { + const headingIndex = linktext.indexOf("#"); + const blockIndex = linktext.indexOf("^"); + const subpathStart = headingIndex === -1 ? blockIndex : blockIndex === -1 ? headingIndex : Math.min(headingIndex, blockIndex); + return subpathStart === -1 + ? { path: linktext, subpath: "" } + : { path: linktext.slice(0, subpathStart), subpath: linktext.slice(subpathStart) }; +} + export class Modal { readonly contentEl: HTMLElement;