From 4f6732da5d9bc34d998b79e0bdcb47df9be0a817 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 7 Jul 2026 19:57:31 +0900 Subject: [PATCH] Add Obsidian tag composer completions --- README.md | 2 +- .../chat/application/composer/note-context.ts | 1 + .../chat/application/composer/suggestions.ts | 57 +++++++++++++++++ .../vault-note-candidate-provider.obsidian.ts | 32 +++++++++- .../chat/panel/composer-controller.ts | 2 + .../markdown-renderer.obsidian.ts | 34 ++++++++++ .../application/composer/suggestions.test.ts | 45 ++++++++++++- .../vault-note-candidate-provider.test.ts | 32 +++++++++- .../chat/panel/composer-controller.test.ts | 37 +++++++++++ tests/features/chat/panel/shell.test.tsx | 1 + .../surface/message-stream-presenter.test.ts | 64 ++++++++++++++++++- tests/mocks/obsidian.ts | 11 ++++ 12 files changed, 312 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 24911a44..3dfcc3c0 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The composer treats Obsidian content as prompt context. It suggests vault files For context outside the current composer, use `/refer ` to send a message with recent turns from another Codex thread, or `/clip [message]` to save a readable Markdown clipping from a web page and send a wikilink to the saved note. -Completions cover slash commands, `$skill-name` skills, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list. +Completions cover slash commands, `$skill-name` skills, Obsidian tags, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list. While a turn is running, the panel streams the conversation, reasoning, tool activity, file changes, plans, and agent activity. You can answer Codex questions, approve commands, respond to requests, steer or interrupt the turn, and manage active goals above the message stream. diff --git a/src/features/chat/application/composer/note-context.ts b/src/features/chat/application/composer/note-context.ts index 483bb883..e1c84658 100644 --- a/src/features/chat/application/composer/note-context.ts +++ b/src/features/chat/application/composer/note-context.ts @@ -7,6 +7,7 @@ export interface WikiLinkMention { export interface NoteCandidateProvider { candidates(sourcePath: string): readonly NoteCandidate[]; + tags(): readonly string[]; resolveMention(target: string, sourcePath: string): WikiLinkMention | null; dispose(): void; } diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index 5b23f4ef..ae8bd385 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -31,6 +31,7 @@ export interface ComposerSuggestionOptions { activeThreadId?: string | null; contextReferences?: ComposerContextReferences; permissionProfiles?: readonly RuntimePermissionProfileSummary[]; + tagCandidates?: readonly string[] | (() => readonly string[]); } export interface NoteCandidate { @@ -107,6 +108,7 @@ export function activeComposerSuggestions( return ( activeWikiLinkSuggestions(beforeCursor, notes) ?? activeContextReferenceSuggestions(beforeCursor, options.contextReferences) ?? + activeTagSuggestions(beforeCursor, options.tagCandidates ?? []) ?? activeSlashSubcommandSuggestions(beforeCursor) ?? activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ?? modelOverrideSuggestions(beforeCursor, models) ?? @@ -194,6 +196,61 @@ function activeWikiLinkSuggestions(beforeCursor: string, notes: NoteCandidate[]) return findWikiLinkSuggestions(queryText, start, notes); } +function activeTagSuggestions( + beforeCursor: string, + tagCandidates: readonly string[] | (() => readonly string[]), +): ComposerSuggestion[] | null { + const match = /(^|[\s[{])#([^\s\]})#]{0,120})$/.exec(beforeCursor); + if (match?.index === undefined) return null; + + const prefix = match[1]; + const rawQuery = match[2]; + if (prefix === undefined || rawQuery === undefined) return null; + const query = normalizeTag(rawQuery).toLowerCase(); + const normalizedTags = normalizedUniqueTags(tagCandidateList(tagCandidates)); + if (query.length > 0 && normalizedTags.some((tag) => tag.toLowerCase() === query)) return null; + + const start = match.index + prefix.length; + return normalizedTags + .map((tag) => ({ tag, lower: tag.toLowerCase() })) + .filter(({ lower }) => query.length === 0 || lower.startsWith(query) || lower.includes(query)) + .sort((a, b) => tagSuggestionScore(a.lower, query) - tagSuggestionScore(b.lower, query) || a.tag.localeCompare(b.tag)) + .slice(0, 8) + .map(({ tag }) => ({ + display: `#${tag}`, + detail: "Obsidian tag", + replacement: `#${tag}`, + start, + appendSpaceOnInsert: true, + })); +} + +function tagCandidateList(tagCandidates: readonly string[] | (() => readonly string[])): readonly string[] { + return typeof tagCandidates === "function" ? tagCandidates() : tagCandidates; +} + +function normalizedUniqueTags(tags: readonly string[]): string[] { + const seen = new Set(); + const normalizedTags: string[] = []; + for (const tag of tags) { + const normalized = normalizeTag(tag); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) continue; + seen.add(key); + normalizedTags.push(normalized); + } + return normalizedTags.sort((a, b) => a.localeCompare(b)); +} + +function normalizeTag(tag: string): string { + return tag.trim().replace(/^#+/, ""); +} + +function tagSuggestionScore(tag: string, query: string): number { + if (query.length === 0) return 0; + return tag.startsWith(query) ? 0 : 1; +} + function findWikiLinkSuggestions(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] { const headingCompletion = wikiLinkHeadingCompletion(queryText, start, notes); if (headingCompletion) return headingCompletion; diff --git a/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts b/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts index f82ba581..484edc1f 100644 --- a/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts +++ b/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts @@ -1,5 +1,5 @@ import type { App, EventRef } from "obsidian"; -import { stripHeadingForLink, TFile } from "obsidian"; +import { getAllTags, stripHeadingForLink, TFile } from "obsidian"; import type { NoteCandidateProvider, WikiLinkMention } from "../../application/composer/note-context"; import type { NoteCandidate } from "../../application/composer/suggestions"; @@ -21,6 +21,7 @@ interface EventSource { export class VaultNoteCandidateProvider implements NoteCandidateProvider { private readonly unregisterEvents: (() => void)[] = []; private fileCandidatesCache: FileCandidate[] | null = null; + private tagCandidatesCache: string[] | null = null; private readonly projectedCandidatesBySourcePath = new Map(); constructor(private readonly app: App) { @@ -53,6 +54,21 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider { return candidates; } + tags(): readonly string[] { + this.tagCandidatesCache ??= this.readTags(); + return this.tagCandidatesCache; + } + + private readTags(): string[] { + const tags: string[] = []; + for (const file of this.app.vault.getMarkdownFiles()) { + const cache = this.app.metadataCache.getFileCache(file); + if (!cache) continue; + tags.push(...(getAllTags(cache) ?? [])); + } + return normalizedTags(tags); + } + resolveMention(target: string, sourcePath: string): WikiLinkMention | null { const linkedFile = this.app.metadataCache.getFirstLinkpathDest(target, sourcePath); if (linkedFile?.path) return { name: linkedFile.basename, path: linkedFile.path }; @@ -78,6 +94,7 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider { private invalidate(): void { this.fileCandidatesCache = null; + this.tagCandidatesCache = null; this.projectedCandidatesBySourcePath.clear(); } @@ -94,6 +111,19 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider { } } +function normalizedTags(tags: readonly string[]): string[] { + const seen = new Set(); + const normalizedTags: string[] = []; + for (const tag of tags) { + const normalized = tag.trim().replace(/^#+/, ""); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) continue; + seen.add(key); + normalizedTags.push(normalized); + } + return normalizedTags.sort((a, b) => a.localeCompare(b)); +} + function noteHeadings(app: App, file: TFile): NoteCandidate["headings"] { return (app.metadataCache.getFileCache(file)?.headings ?? []).map((heading) => ({ heading: heading.heading, diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index b9125c3c..dd4a84db 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -247,6 +247,7 @@ export class ChatComposerController { activeThreadId: state.activeThread.id, contextReferences: this.contextReferences(), permissionProfiles: state.connection.availablePermissionProfiles, + tagCandidates: () => this.options.noteCandidateProvider.tags(), }, ); @@ -296,6 +297,7 @@ export class ChatComposerController { activeThreadId: state.activeThread.id, contextReferences: this.contextReferences(), permissionProfiles: state.connection.availablePermissionProfiles, + tagCandidates: () => this.options.noteCandidateProvider.tags(), }, ); return { diff --git a/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts b/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts index 2793848b..9002c70e 100644 --- a/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts +++ b/src/features/chat/ui/message-stream/markdown-renderer.obsidian.ts @@ -31,6 +31,7 @@ export class MarkdownMessageRenderer { parent.replaceChildren(...Array.from(staging.childNodes)); bindRenderedWikiLinks(parent, sourcePath, this.options); bindRenderedMarkdownFileLinks(parent, sourcePath, this.options); + bindRenderedTags(parent, this.options); notifyMessageContentRendered(parent); }); } @@ -83,6 +84,39 @@ function bindRenderedMarkdownFileLinks(parent: HTMLElement, sourcePath: string, }); } +function bindRenderedTags(parent: HTMLElement, context: RenderedMarkdownLinkContext): void { + parent.querySelectorAll("a.tag").forEach((link) => { + link.onclick = (event) => { + event.preventDefault(); + const tag = renderedTagName(link); + if (!tag) return; + void openTagSearch(context.app, tag); + }; + }); +} + +function renderedTagName(link: HTMLAnchorElement): string | null { + const text = link.textContent.trim(); + const value = text || link.getAttribute("data-tag") || link.getAttribute("href"); + if (!value) return null; + const normalized = value.trim().replace(/^#*/, ""); + return normalized.length > 0 ? normalized : null; +} + +async function openTagSearch(app: App, tag: string): Promise { + const leaf = app.workspace.getLeftLeaf(false); + if (!leaf) { + new Notice("Cannot open Obsidian search."); + return; + } + await leaf.setViewState({ + type: "search", + active: true, + state: { query: `tag:#${tag}` }, + }); + await app.workspace.revealLeaf(leaf); +} + function bindStreamMarkdownFileLinks(parent: HTMLElement, context: StreamMarkdownRenderContext): void { const sourcePath = context.app.workspace.getActiveFile()?.path ?? ""; parent.querySelectorAll("a[href]").forEach((link) => { diff --git a/tests/features/chat/application/composer/suggestions.test.ts b/tests/features/chat/application/composer/suggestions.test.ts index 7e04f92f..d1f4d93c 100644 --- a/tests/features/chat/application/composer/suggestions.test.ts +++ b/tests/features/chat/application/composer/suggestions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ModelMetadata, ReasoningEffort, SkillMetadata } from "../../../../../src/domain/catalog/metadata"; import type { Thread } from "../../../../../src/domain/threads/model"; import { emptyComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references"; @@ -281,6 +281,49 @@ describe("composer suggestions", () => { expect(activeComposerSuggestions("/help", notes, [])).toEqual([]); }); + it("suggests Obsidian tags after a hash trigger", () => { + const options = { + tagCandidates: ["project/codex", "project/obsidian", "daily-note", "web"], + }; + + expect(activeComposerSuggestions("#pro", notes, [], [], [], null, options)[0]).toMatchObject({ + display: "#project/codex", + detail: "Obsidian tag", + replacement: "#project/codex", + appendSpaceOnInsert: true, + }); + expect(suggestionReplacements(activeComposerSuggestions("please tag #project/o", notes, [], [], [], null, options))).toEqual([ + "#project/obsidian", + ]); + expect(suggestionReplacements(activeComposerSuggestions("#", notes, [], [], [], null, options))).toEqual([ + "#daily-note", + "#project/codex", + "#project/obsidian", + "#web", + ]); + expect(activeComposerSuggestions("#project/codex", notes, [], [], [], null, options)).toEqual([]); + expect(activeComposerSuggestions("https://example.com/#pro", notes, [], [], [], null, options)).toEqual([]); + expect(activeComposerSuggestions("[Section](#pro", notes, [], [], [], null, options)).toEqual([]); + expect( + applyComposerSuggestionInsertion("#pro", 4, expectPresent(activeComposerSuggestions("#pro", notes, [], [], [], null, options)[0])), + ).toEqual({ + value: "#project/codex ", + cursor: 15, + }); + }); + + it("loads Obsidian tags lazily only after a hash trigger", () => { + const tagCandidates = vi.fn(() => ["project/codex"]); + + expect(activeComposerSuggestions("plain text", notes, [], [], [], null, { tagCandidates })).toEqual([]); + expect(tagCandidates).not.toHaveBeenCalled(); + + expect(activeComposerSuggestions("#pro", notes, [], [], [], null, { tagCandidates })[0]).toMatchObject({ + replacement: "#project/codex", + }); + expect(tagCandidates).toHaveBeenCalledOnce(); + }); + it("limits slash command suggestions to the start of the composer", () => { const threads = [thread({ id: "019abcde-0000-7000-8000-000000000001", name: "Codex Panel実装" })]; const models = [model("gpt-5.5", ["low", "medium", "high"])]; diff --git a/tests/features/chat/host/vault-note-candidate-provider.test.ts b/tests/features/chat/host/vault-note-candidate-provider.test.ts index ddd76e53..d7faab28 100644 --- a/tests/features/chat/host/vault-note-candidate-provider.test.ts +++ b/tests/features/chat/host/vault-note-candidate-provider.test.ts @@ -102,6 +102,22 @@ describe("VaultNoteCandidateProvider", () => { expect(fileToLinktext).toHaveBeenCalledTimes(2); }); + it("returns normalized Obsidian tag candidates from file metadata", () => { + const app = appFixture({ + files: [ + { basename: "Alpha", path: "Alpha.md", stat: { mtime: 1 } }, + { basename: "Beta", path: "Beta.md", stat: { mtime: 2 } }, + ], + tags: new Map([ + ["Alpha.md", { inline: ["#project/codex"], frontmatter: ["daily"] }], + ["Beta.md", ["#web", "#project/codex"]], + ]), + }); + const provider = new VaultNoteCandidateProvider(app); + + expect(provider.tags()).toEqual(["daily", "project/codex", "web"]); + }); + it("invalidates cached candidates when the vault changes", () => { const files = [{ basename: "Alpha", path: "notes/Alpha.md", stat: { mtime: 100 } }]; const app = appFixture({ files }); @@ -213,6 +229,7 @@ function appFixture( linktexts?: Map; fileToLinktext?: (file: TFile, sourcePath: string, omitMdExtension?: boolean) => string; headings?: Map; + tags?: Map; activeFile?: TFile | null; activeView?: unknown; } = {}, @@ -251,7 +268,11 @@ function appFixture( options.fileToLinktext ?? ((file: TFile, _sourcePath: string, omitMdExtension?: boolean) => options.linktexts?.get(file.path) ?? (omitMdExtension === true ? file.path.replace(/\.md$/i, "") : file.path)), - getFileCache: (file: TFile) => ({ headings: options.headings?.get(file.path) ?? [] }), + getFileCache: (file: TFile) => ({ + headings: options.headings?.get(file.path) ?? [], + tags: tagFixtureForPath(options.tags, file.path).inline.map((tag) => ({ tag })), + frontmatter: { tags: tagFixtureForPath(options.tags, file.path).frontmatter }, + }), }, vault: { on: on("vault"), @@ -283,6 +304,15 @@ function tFile(path: string, basename: string): TFile { return Object.assign(new TFile(), { path, basename, extension, name }); } +function tagFixtureForPath( + tags: Map | undefined, + path: string, +): { inline: string[]; frontmatter: string[] } { + const value = tags?.get(path); + if (!value) return { inline: [], frontmatter: [] }; + return Array.isArray(value) ? { inline: value, frontmatter: [] } : { inline: value.inline ?? [], frontmatter: value.frontmatter ?? [] }; +} + function vaultFiles(files: { basename: string; path: string; stat: { mtime: number } }[]): TFile[] { return files.map((file) => Object.assign(tFile(file.path, file.basename), { stat: file.stat })); } diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 68d1459e..f508d771 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -122,6 +122,42 @@ describe("ChatComposerController", () => { expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("/"); }); + it("updates Obsidian tag suggestions when the input changes", () => { + const { controller, parent, stateStore } = composerControllerFixture({ + controller: { + noteCandidateProvider: noteProvider({ tags: () => ["project/codex"] }), + }, + }); + + renderComposerController(parent, controller, stateStore); + setTextAreaValue(composer(parent), "#pro"); + composer(parent).setSelectionRange(4, 4); + composer(parent).dispatchEvent(new Event("input", { bubbles: true })); + + expect(stateStore.getState().composer.suggestions[0]).toMatchObject({ + display: "#project/codex", + replacement: "#project/codex", + }); + expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("#project/codex"); + }); + + it("does not read Obsidian tags for non-tag suggestions", () => { + const tags = vi.fn(() => ["project/codex"]); + const { controller, parent, stateStore } = composerControllerFixture({ + controller: { + noteCandidateProvider: noteProvider({ tags }), + }, + }); + + renderComposerController(parent, controller, stateStore); + setTextAreaValue(composer(parent), "/"); + composer(parent).setSelectionRange(1, 1); + composer(parent).dispatchEvent(new Event("input", { bubbles: true })); + + expect(stateStore.getState().composer.suggestions.length).toBeGreaterThan(0); + expect(tags).not.toHaveBeenCalled(); + }); + it("keeps Tab wikilink insertion before closing brackets while Enter lands after them", () => { const stateStore = createChatStateStore(); const parent = document.createElement("div"); @@ -819,6 +855,7 @@ describe("ChatComposerController", () => { function noteProvider(overrides: Partial = {}): NoteCandidateProvider { return { candidates: () => [], + tags: () => [], resolveMention: () => null, dispose: vi.fn(), ...overrides, diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 1facb231..eb1cd944 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -501,6 +501,7 @@ function setTextAreaValue(textarea: HTMLTextAreaElement, value: string): void { function noteProvider(overrides: Partial = {}): NoteCandidateProvider { return { candidates: () => [], + tags: () => [], resolveMention: () => null, dispose: vi.fn(), ...overrides, diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index b025e795..968b0669 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -198,6 +198,33 @@ describe("MessageStreamPresenter scroll pinning", () => { cleanup(); }); + it("opens Obsidian search when rendered tags are clicked", async () => { + const searchLeaf = { + setViewState: vi.fn().mockResolvedValue(undefined), + }; + const revealLeaf = vi.fn().mockResolvedValue(undefined); + const context = markdownLinkContext(); + context.app.workspace.getLeftLeaf = vi.fn(() => searchLeaf); + context.app.workspace.revealLeaf = revealLeaf; + const { link, cleanup } = await renderedTag(context, { + cls: "tag", + text: "#project/codex", + attr: { href: "#project/codex" }, + }); + + link.click(); + await Promise.resolve(); + + expect(searchLeaf.setViewState).toHaveBeenCalledWith({ + type: "search", + active: true, + state: { query: "tag:#project/codex" }, + }); + expect(revealLeaf).toHaveBeenCalledWith(searchLeaf); + expect(context.app.workspace.getLeftLeaf).toHaveBeenCalledWith(false); + cleanup(); + }); + it("pins to the scroll container bottom without aligning the last message element", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); @@ -565,12 +592,14 @@ function markdownLinkContext(openLinkText = vi.fn(), vaultPath = "/vault", vault workspace: { getActiveFile: vi.fn(() => tFile("Inbox.md")), openLinkText, + getLeftLeaf: vi.fn((): unknown | null => null), + revealLeaf: vi.fn().mockResolvedValue(undefined), }, vault: { configDir: "vault-config", getAbstractFileByPath: (path: string) => files.get(path) ?? null, }, - } as never, + }, vaultPath, }; } @@ -586,7 +615,7 @@ async function renderedInternalLink( return Promise.resolve(); }); const markdownRenderer = new MarkdownMessageRenderer({ - app: context.app, + app: context.app as never, owner: {} as never, vaultPath: context.vaultPath, }); @@ -606,6 +635,37 @@ async function renderedInternalLink( }; } +async function renderedTag( + context: ReturnType, + linkOptions: Parameters[1], +): Promise<{ link: HTMLAnchorElement; cleanup: () => void }> { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const renderMarkdown = vi.spyOn(MarkdownRenderer, "render").mockImplementationOnce((_app, _text, staging) => { + staging.createEl("a", linkOptions); + return Promise.resolve(); + }); + const markdownRenderer = new MarkdownMessageRenderer({ + app: context.app as never, + owner: {} as never, + vaultPath: context.vaultPath, + }); + + markdownRenderer.renderObsidianMarkdown(parent, "#tag"); + await Promise.resolve(); + await Promise.resolve(); + renderMarkdown.mockRestore(); + + const link = parent.querySelector("a.tag"); + if (!link) throw new Error("Expected rendered tag"); + return { + link, + cleanup: () => { + parent.remove(); + }, + }; +} + interface TestMessageStreamPresenter { presenter: MessageStreamPresenter; scrollPortBinding: ChatMessageStreamScrollBinding; diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index dae3dc4d..94f25105 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -89,6 +89,17 @@ export function stripHeadingForLink(heading: string): string { return heading.trim(); } +export function getAllTags(cache: { tags?: { tag: string }[]; frontmatter?: { tags?: unknown } }): string[] | null { + const tags = [...(cache.tags?.map((tag) => tag.tag) ?? []), ...frontmatterTags(cache.frontmatter?.tags)]; + return tags.length > 0 ? tags : null; +} + +function frontmatterTags(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (!Array.isArray(value)) return []; + return value.filter((tag): tag is string => typeof tag === "string"); +} + export function parseLinktext(linktext: string): { path: string; subpath: string } { const headingIndex = linktext.indexOf("#"); const blockIndex = linktext.indexOf("^");