From 10c69cec3f8d1d3301d8d3a5b2bb8ebb84566ba6 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 14 Jul 2026 10:10:55 +0900 Subject: [PATCH] Replace persistent /clip with transient /web context --- README.md | 2 +- .../application/composer/slash-commands.ts | 6 +- .../chat/application/turns/composition.ts | 21 ++- .../turns/slash-command-execution.ts | 13 +- .../turns/slash-command-executor.ts | 6 +- .../chat/application/web-clipping/web-clip.ts | 133 ------------------ src/features/chat/host/bundles/turn-bundle.ts | 14 +- src/features/chat/host/contracts.ts | 3 - .../host/obsidian/web-clipper.obsidian.ts | 96 ------------- .../host/obsidian/web-context.obsidian.ts | 84 +++++++++++ src/plugin-runtime.ts | 3 - src/settings/model.ts | 13 -- src/settings/tab-shell.tsx | 116 +++++---------- src/settings/tab.obsidian.tsx | 38 +---- .../application/composer/suggestions.test.ts | 2 +- .../turns/composer-submit-actions.test.ts | 22 +-- .../application/turns/composition.test.ts | 2 +- .../turns/slash-command-execution.test.ts | 54 +++---- .../turns/slash-command-executor.test.ts | 6 +- .../application/web-clipping/web-clip.test.ts | 110 --------------- .../chat/host/web-clipper.integration.test.ts | 100 ------------- tests/features/chat/host/web-clipper.test.ts | 95 ------------- .../chat/host/web-context.integration.test.ts | 61 ++++++++ tests/features/chat/host/web-context.test.ts | 119 ++++++++++++++++ tests/features/chat/support/settings.ts | 3 - tests/mocks/obsidian.ts | 8 +- tests/settings/settings-tab.test.ts | 31 ---- tests/settings/settings.test.ts | 31 ---- 28 files changed, 385 insertions(+), 807 deletions(-) delete mode 100644 src/features/chat/application/web-clipping/web-clip.ts delete mode 100644 src/features/chat/host/obsidian/web-clipper.obsidian.ts create mode 100644 src/features/chat/host/obsidian/web-context.obsidian.ts delete mode 100644 tests/features/chat/application/web-clipping/web-clip.test.ts delete mode 100644 tests/features/chat/host/web-clipper.integration.test.ts delete mode 100644 tests/features/chat/host/web-clipper.test.ts create mode 100644 tests/features/chat/host/web-context.integration.test.ts create mode 100644 tests/features/chat/host/web-context.test.ts diff --git a/README.md b/README.md index d0a987a8..e935e5a6 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Each panel can keep a separate conversation, so related work can stay open side The composer treats Obsidian content as prompt context. It suggests vault files and recent notes for wikilinks, keeps links readable while attaching resolved files, and opens file links from Codex replies back in Obsidian. Use `@active` or `@selection` to include the active file or current Markdown selection without pasting it manually, or enable **Reference active file on send** to reference the current active file with each composer send. When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` insert wikilinks using that daily-note folder and date format. Paste or drop files to save them in the attachment folder and reference them from the same prompt. -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. +For context outside the current composer, use `/refer ` to send a message with recent turns from another Codex thread, or `/web [message]` to fetch readable web content and attach it to the next turn without saving a note. Completions cover slash commands, `$skill-name` skills, Obsidian tags, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list. diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index bb5e5502..c9a6c59b 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -66,11 +66,11 @@ export const SLASH_COMMANDS = [ detail: "Send a message with recent turns from another non-archived thread.", }, { - command: "/clip", - usage: "/clip [message]", + command: "/web", + usage: "/web [message]", argsKind: "urlAndOptionalMessage", surface: "composition", - detail: "Clip a URL into a vault note and send a wikilink reference with an optional message.", + detail: "Fetch readable web content as context and send it with an optional message.", }, { command: "/fork", usage: "/fork", argsKind: "none", surface: "panelAction", detail: "Fork the active Codex thread." }, { command: "/btw", usage: "/btw", argsKind: "none", surface: "panelAction", detail: "Open a temporary side chat." }, diff --git a/src/features/chat/application/turns/composition.ts b/src/features/chat/application/turns/composition.ts index a9518273..0ec462d5 100644 --- a/src/features/chat/application/turns/composition.ts +++ b/src/features/chat/application/turns/composition.ts @@ -9,7 +9,7 @@ import type { GoalActions } from "../threads/goal-actions"; import type { ThreadManagementActions } from "../threads/thread-management-actions"; import { type ComposerSubmitActions, type ComposerSubmitActionsHost, submitComposer } from "./composer-submit-actions"; import { implementPlan, type PlanImplementationHost } from "./plan-implementation"; -import type { ClipUrlInput, ThreadReferenceInput } from "./slash-command-execution"; +import type { ThreadReferenceInput, WebUrlInput } from "./slash-command-execution"; import { executeSlashCommandWithState, type SlashCommandExecutorHost } from "./slash-command-executor"; import { createTurnSubmissionActions } from "./turn-submission-actions"; import type { ChatTurnTransport } from "./turn-transport"; @@ -20,7 +20,7 @@ export interface TurnWorkflowContext { connectionAvailable: () => boolean; turnTransport: ChatTurnTransport; referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; - clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; + readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; status: { set: (status: string) => void; addSystemMessage: (text: string) => void; @@ -75,8 +75,19 @@ export interface TurnWorkflowActions { } export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: TurnWorkflowRefs): TurnWorkflowActions { - const { stateStore, localItemIds, connectionAvailable, turnTransport, referThread, clipUrl, status, runtime, thread, composer, scroll } = - context; + const { + stateStore, + localItemIds, + connectionAvailable, + turnTransport, + referThread, + readWebUrl, + status, + runtime, + thread, + composer, + scroll, + } = context; const turnSubmission = createTurnSubmissionActions({ stateStore, localItemIds, @@ -95,7 +106,7 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu stateStore, connectionAvailable, referThread, - clipUrl, + readWebUrl, startNewThread: thread.startNewThread, startThreadForGoal: (objective) => startThreadForGoal(refs.threadStarter, objective), resumeThread: thread.selectThread, diff --git a/src/features/chat/application/turns/slash-command-execution.ts b/src/features/chat/application/turns/slash-command-execution.ts index e2f87a44..91b3e487 100644 --- a/src/features/chat/application/turns/slash-command-execution.ts +++ b/src/features/chat/application/turns/slash-command-execution.ts @@ -69,7 +69,7 @@ export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts activeThreadEphemeral: boolean; listedThreads: readonly Thread[]; referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; - clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; + readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; supportedReasoningEfforts: () => readonly ReasoningEffort[]; inputSnapshot?: ComposerInputSnapshot; } @@ -87,7 +87,7 @@ export interface ThreadReferenceInput { referencedThread: ReferencedThreadMetadata; } -export interface ClipUrlInput { +export interface WebUrlInput { text: string; input: CodexInput; } @@ -145,19 +145,18 @@ export async function executeSlashCommand( if (!reference) return; return { sendText: reference.text, sendInput: reference.input, referencedThread: reference.referencedThread }; } - case "clip": { + case "web": { const parsed = parseUrlAndMessageArgs(args); if (!parsed) { context.addSystemMessage(usageError(command, "requires a URL")); return; } if (!context.inputSnapshot) { - context.addSystemMessage("Cannot clip a URL without composer input context."); + context.addSystemMessage("Cannot read a web URL without composer input context."); return; } - const clipped = await context.clipUrl(parsed.url, parsed.message, context.inputSnapshot); - if (!clipped) return; - return { sendText: clipped.text, sendInput: clipped.input }; + const web = await context.readWebUrl(parsed.url, parsed.message, context.inputSnapshot); + return { sendText: web.text, sendInput: web.input }; } case "fork": if (!context.activeThreadId) { diff --git a/src/features/chat/application/turns/slash-command-executor.ts b/src/features/chat/application/turns/slash-command-executor.ts index c052df97..a033e5a6 100644 --- a/src/features/chat/application/turns/slash-command-executor.ts +++ b/src/features/chat/application/turns/slash-command-executor.ts @@ -8,11 +8,11 @@ import type { SlashCommandName } from "../composer/slash-commands"; import { runtimeSnapshotForChatState } from "../runtime/snapshot"; import type { ChatStateStore } from "../state/store"; import { - type ClipUrlInput, executeSlashCommand as runSlashCommand, type SlashCommandExecutionPorts, type SlashCommandExecutionResult, type ThreadReferenceInput, + type WebUrlInput, } from "./slash-command-execution"; import { submissionStateSnapshot } from "./submission-state"; @@ -20,7 +20,7 @@ export interface SlashCommandExecutorHost extends SlashCommandExecutionPorts { stateStore: ChatStateStore; connectionAvailable: () => boolean; referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; - clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; + readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; setStatus: (status: string) => void; } @@ -41,7 +41,7 @@ export async function executeSlashCommandWithState( activeThreadEphemeral: state.activeThreadEphemeral, listedThreads: state.listedThreads, referThread: host.referThread, - clipUrl: host.clipUrl, + readWebUrl: host.readWebUrl, ...(inputSnapshot !== undefined ? { inputSnapshot } : {}), supportedReasoningEfforts: () => supportedReasoningEfforts(host.stateStore.getState()), }); diff --git a/src/features/chat/application/web-clipping/web-clip.ts b/src/features/chat/application/web-clipping/web-clip.ts deleted file mode 100644 index 71b0ccf3..00000000 --- a/src/features/chat/application/web-clipping/web-clip.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../../../../domain/markdown/frontmatter"; -import { - vaultMarkdownFilenameFromTemplate, - vaultMarkdownFolderPath, - vaultMarkdownTemplateDate, - vaultMarkdownTemplateTime, -} from "../../../../domain/vault/markdown-write-templates"; -import { - ensureVaultFolder, - sanitizeVaultPathSegment, - uniqueVaultPath, - type VaultMarkdownDestination, - withVaultWriteLock, -} from "../../../../domain/vault/write-paths"; - -export interface WebClipSettings { - clipFolder: string; - clipFilenameTemplate: string; - clipTags?: string; -} - -export interface WebClipPage { - url: string; - title: string; - content: string; - site?: string | null; - domain?: string | null; -} - -export type WebClipDestination = VaultMarkdownDestination; - -export interface WebClipResult { - path: string; - wikilink: string; -} - -interface TemplateContext { - date: string; - time: string; - title: string; - site: string; - domain: string; -} - -const DEFAULT_CLIP_TITLE = "Untitled clip"; - -export async function saveWebClipMarkdown( - page: WebClipPage, - settings: WebClipSettings, - destination: WebClipDestination, - now = new Date(), -): Promise { - const context = templateContext(page, now); - const normalizePath = (path: string): string => destination.normalizePath(path); - const folder = folderPath(settings.clipFolder, normalizePath); - const filename = filenameFromTemplate(settings.clipFilenameTemplate, context, normalizePath); - return withVaultWriteLock(destination, async () => { - await ensureVaultFolder(destination, folder); - const path = await uniqueVaultPath(destination, folder, filename); - await destination.createMarkdownFile(path, webClipMarkdown(page, settings, context.title, now)); - return { path, wikilink: `[[${path}]]` }; - }); -} - -export function webClipMarkdown(page: WebClipPage, settings: Pick, title?: string, now = new Date()): string { - const normalizedTitle = normalizedDisplayTitle(title ?? page.title); - const tags = normalizedClipTags(settings.clipTags ?? ""); - const frontmatter = [ - "---", - `title: ${yamlFrontmatterString(normalizedTitle)}`, - `url: ${yamlFrontmatterString(page.url)}`, - `created: ${yamlFrontmatterString(now.toISOString())}`, - ...frontmatterTagsLines(tags), - "---", - "", - ]; - return [...frontmatter, `# ${normalizedTitle}`, "", page.content.trim(), ""].join("\n"); -} - -function templateContext(page: WebClipPage, now: Date): TemplateContext { - const title = sanitizeVaultPathSegment(normalizedDisplayTitle(page.title)); - return { - date: vaultMarkdownTemplateDate(now), - time: vaultMarkdownTemplateTime(now), - title, - site: sanitizeVaultPathSegment(page.site?.trim() || ""), - domain: sanitizeVaultPathSegment(page.domain?.trim() || hostnameFromUrl(page.url) || ""), - }; -} - -function normalizedDisplayTitle(title: string): string { - return title.replace(/\s+/g, " ").trim() || DEFAULT_CLIP_TITLE; -} - -function folderPath(value: string, normalizePath: (path: string) => string): string { - return vaultMarkdownFolderPath(value, normalizePath, { - emptyPathMessage: "Clip folder produced an empty path.", - absolutePathMessage: "Clip folder must be relative to the vault.", - relativeSegmentMessage: "Clip folder cannot contain relative path segments.", - }); -} - -function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string { - return vaultMarkdownFilenameFromTemplate(template, context, normalizePath, "Clip filename template produced an empty filename."); -} - -function normalizedClipTags(input: string): string[] { - const tags: string[] = []; - const seen = new Set(); - for (const raw of input.split(",")) { - const tag = raw - .trim() - .replace(/^#/, "") - .replace(/^["']|["']$/g, "") - .trim(); - if (!tag || seen.has(tag)) continue; - seen.add(tag); - tags.push(tag); - } - return tags; -} - -function frontmatterTagsLines(tags: string[]): string[] { - return tags.length > 0 ? [`tags: ${yamlFrontmatterInlineList(tags)}`] : []; -} - -function hostnameFromUrl(url: string): string { - try { - return new URL(url).hostname; - } catch { - return ""; - } -} diff --git a/src/features/chat/host/bundles/turn-bundle.ts b/src/features/chat/host/bundles/turn-bundle.ts index 39da675a..c2535221 100644 --- a/src/features/chat/host/bundles/turn-bundle.ts +++ b/src/features/chat/host/bundles/turn-bundle.ts @@ -10,7 +10,7 @@ import type { ThreadStreamNoticeSection } from "../../domain/thread-stream/items import type { ChatComposerController } from "../../panel/composer-controller"; import type { ChatPanelRuntimeProjection } from "../../panel/runtime-status-projection"; import type { ChatPanelEnvironment } from "../contracts"; -import { createVaultWebClipper } from "../obsidian/web-clipper.obsidian"; +import { createWebContextReader } from "../obsidian/web-context.obsidian"; import type { ChatPanelRuntimeSettingsActions } from "./runtime-bundle"; import type { ChatPanelGoalActions, @@ -99,17 +99,11 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn connectionAvailable: () => appServer.connectionAvailable(), turnTransport: appServer.turn, referThread: (thread, message, snapshot) => threadReferenceResolver.referThread(thread, message, snapshot), - clipUrl: (url, message, snapshot) => - createVaultWebClipper({ - vault: host.environment.obsidian.app.vault, - settings: () => ({ - clipFolder: host.environment.plugin.settingsRef.settings.clipFolder(), - clipFilenameTemplate: host.environment.plugin.settingsRef.settings.clipFilenameTemplate(), - clipTags: host.environment.plugin.settingsRef.settings.clipTags(), - }), + readWebUrl: (url, message, snapshot) => + createWebContextReader({ prepareInput: (text, inputSnapshot) => composerController.preparedInput(text, inputSnapshot), viewWindow: host.environment.view.viewWindow, - }).clipUrl(url, message, snapshot), + }).readUrl(url, message, snapshot), status, runtime: { connectionDiagnosticDetails: runtimeProjection.connectionDiagnosticDetails, diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index 469e8680..ea80849d 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -29,9 +29,6 @@ interface ChatPanelSettingsRef { export interface ChatPanelSettingsAccess { referenceActiveNoteOnSend(): boolean; attachmentFolder(): string; - clipFolder(): string; - clipFilenameTemplate(): string; - clipTags(): string; archiveExportEnabled(): boolean; archiveExportSettings(): ArchiveExportSettings; codexPath(): string; diff --git a/src/features/chat/host/obsidian/web-clipper.obsidian.ts b/src/features/chat/host/obsidian/web-clipper.obsidian.ts deleted file mode 100644 index 4f41c6e5..00000000 --- a/src/features/chat/host/obsidian/web-clipper.obsidian.ts +++ /dev/null @@ -1,96 +0,0 @@ -import Defuddle from "defuddle/full"; -import { requestUrl, TFile, type Vault } from "obsidian"; - -import type { CodexInput } from "../../../../domain/chat/input"; -import { codexTextInputWithAttachments } from "../../../../domain/chat/input"; -import type { CodexPanelSettings } from "../../../../settings/model"; -import { createObsidianVaultMarkdownDestination } from "../../../../shared/obsidian/vault-write-destination.obsidian"; -import type { ComposerInputSnapshot } from "../../application/composer/input-snapshot"; -import { saveWebClipMarkdown } from "../../application/web-clipping/web-clip"; -import { displayNameForFile } from "./vault-note-links.obsidian"; - -export interface WebClipInput { - text: string; - input: CodexInput; -} - -interface VaultWebClipperOptions { - vault: Vault; - settings: () => Pick; - prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput }; - viewWindow: () => Window | null; - now?: () => Date; -} - -interface DefuddleResult { - title: string; - content: string; - site?: string | null; - domain?: string | null; -} - -type DomParserWindow = Window & { DOMParser: typeof DOMParser }; - -export function createVaultWebClipper(options: VaultWebClipperOptions) { - return { - clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot): Promise => - clipUrlToInput(options, url, message, inputSnapshot), - }; -} - -async function clipUrlToInput( - options: VaultWebClipperOptions, - url: string, - message: string, - inputSnapshot: ComposerInputSnapshot, -): Promise { - const parsedUrl = normalizedHttpUrl(url); - if (!parsedUrl) throw new Error(`Unsupported clip URL: ${url}`); - - const result = await defuddleUrl(options, parsedUrl); - const content = result.content.trim(); - if (!content) throw new Error(`No readable content found for ${parsedUrl}`); - - const destination = createObsidianVaultMarkdownDestination(options.vault); - const page = { - url: parsedUrl, - title: result.title, - content, - ...(result.site !== undefined ? { site: result.site } : {}), - ...(result.domain !== undefined ? { domain: result.domain } : {}), - }; - const saved = await saveWebClipMarkdown(page, options.settings(), destination, options.now?.() ?? new Date()); - const file = options.vault.getAbstractFileByPath(saved.path); - const name = file instanceof TFile ? displayNameForFile(file) : saved.path; - const messageInput = options.prepareInput(message.trim(), inputSnapshot); - const text = [saved.wikilink, messageInput.text].filter(Boolean).join(" "); - return { - text, - input: codexTextInputWithAttachments(text, [{ type: "mention", name, path: saved.path }, ...messageInput.input]), - }; -} - -async function defuddleUrl(options: VaultWebClipperOptions, url: string): Promise { - const response = await requestUrl({ url, method: "GET" }); - const html = response.text; - const viewWindow = options.viewWindow() as DomParserWindow | null; - const Parser = viewWindow?.DOMParser ?? DOMParser; - const document = new Parser().parseFromString(html, "text/html"); - const result = new Defuddle(document, { url, markdown: true, useAsync: false }).parse(); - return { - title: result.title, - content: result.content, - site: result.site, - domain: result.domain, - }; -} - -function normalizedHttpUrl(value: string): string | null { - try { - const url = new URL(value); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.toString(); - } catch { - return null; - } -} diff --git a/src/features/chat/host/obsidian/web-context.obsidian.ts b/src/features/chat/host/obsidian/web-context.obsidian.ts new file mode 100644 index 00000000..73659250 --- /dev/null +++ b/src/features/chat/host/obsidian/web-context.obsidian.ts @@ -0,0 +1,84 @@ +import Defuddle from "defuddle"; +import { htmlToMarkdown, requestUrl } from "obsidian"; + +import type { CodexInput } from "../../../../domain/chat/input"; +import { codexTextInputWithAttachments } from "../../../../domain/chat/input"; +import type { ComposerInputSnapshot } from "../../application/composer/input-snapshot"; + +const WEB_CONTEXT_KEY = "codex_panel_web_context"; + +export interface WebUrlInput { + text: string; + input: CodexInput; +} + +interface WebContextReaderOptions { + prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput }; + viewWindow: () => Window | null; +} + +type DomParserWindow = Window & { DOMParser: typeof DOMParser }; + +export function createWebContextReader(options: WebContextReaderOptions) { + return { + readUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot): Promise => + readUrlToInput(options, url, message, inputSnapshot), + }; +} + +async function readUrlToInput( + options: WebContextReaderOptions, + url: string, + message: string, + inputSnapshot: ComposerInputSnapshot, +): Promise { + const parsedUrl = normalizedHttpUrl(url); + if (!parsedUrl) throw new Error(`Unsupported web URL: ${url}`); + + const page = await fetchWebPage(options, parsedUrl); + const content = htmlToMarkdown(page.content).trim(); + if (!content) throw new Error(`No readable web content found for ${parsedUrl}`); + + const messageInput = options.prepareInput(message.trim(), inputSnapshot); + const text = [parsedUrl, messageInput.text].filter(Boolean).join(" "); + const context = webContextValue(parsedUrl, page.title, content); + return { + text, + input: codexTextInputWithAttachments(text, [ + ...messageInput.input, + { type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context }, + ]), + }; +} + +async function fetchWebPage(options: WebContextReaderOptions, url: string): Promise<{ title: string; content: string }> { + const response = await requestUrl({ url, method: "GET", throw: false }); + if (response.status < 200 || response.status >= 300) { + throw new Error(`Web request failed for ${url} (HTTP ${String(response.status)}).`); + } + const viewWindow = options.viewWindow() as DomParserWindow | null; + const Parser = viewWindow?.DOMParser ?? DOMParser; + const document = new Parser().parseFromString(response.text, "text/html"); + const result = new Defuddle(document, { url, useAsync: false }).parse(); + return { title: result.title, content: result.content }; +} + +function webContextValue(url: string, title: string, content: string): string { + return [ + "Web page context for the current user input:", + `Source: ${url}`, + ...(title.trim() ? [`Title: ${title.trim()}`] : []), + "", + content, + ].join("\n"); +} + +function normalizedHttpUrl(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.toString(); + } catch { + return null; + } +} diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 90e045ce..1eb8df00 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -140,9 +140,6 @@ export class CodexPanelRuntime implements AppServerClientAccess { return { referenceActiveNoteOnSend: () => this.options.settingsRef.settings.referenceActiveNoteOnSend, attachmentFolder: () => this.options.settingsRef.settings.attachmentFolder, - clipFolder: () => this.options.settingsRef.settings.clipFolder, - clipFilenameTemplate: () => this.options.settingsRef.settings.clipFilenameTemplate, - clipTags: () => this.options.settingsRef.settings.clipTags, archiveExportEnabled: () => this.options.settingsRef.settings.archiveExportEnabled, archiveExportSettings: () => ({ archiveExportFolderTemplate: this.options.settingsRef.settings.archiveExportFolderTemplate, diff --git a/src/settings/model.ts b/src/settings/model.ts index 2d5e8530..2ef11907 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -16,9 +16,6 @@ export interface CodexPanelSettings { scrollThreadFromComposerEdges: boolean; referenceActiveNoteOnSend: boolean; attachmentFolder: string; - clipFolder: string; - clipFilenameTemplate: string; - clipTags: string; archiveExportEnabled: boolean; archiveExportFolderTemplate: string; archiveExportFilenameTemplate: string; @@ -26,8 +23,6 @@ export interface CodexPanelSettings { } export const DEFAULT_ATTACHMENT_FOLDER = "Codex Attachments"; -export const DEFAULT_CLIP_FOLDER = "Codex Clippings"; -export const DEFAULT_CLIP_FILENAME_TEMPLATE = "{{title}}.md"; export const DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE = "Codex Archives"; export const DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE = "{{date}} {{time}} {{title}} {{shortId}}.md"; @@ -42,9 +37,6 @@ export const DEFAULT_SETTINGS: CodexPanelSettings = { scrollThreadFromComposerEdges: false, referenceActiveNoteOnSend: false, attachmentFolder: DEFAULT_ATTACHMENT_FOLDER, - clipFolder: DEFAULT_CLIP_FOLDER, - clipFilenameTemplate: DEFAULT_CLIP_FILENAME_TEMPLATE, - clipTags: "", archiveExportEnabled: false, archiveExportFolderTemplate: DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE, archiveExportFilenameTemplate: DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE, @@ -70,11 +62,6 @@ export function normalizeSettings(storedSettings: unknown): CodexPanelSettings { referenceActiveNoteOnSend: booleanOrDefault(record["referenceActiveNoteOnSend"], DEFAULT_SETTINGS.referenceActiveNoteOnSend), attachmentFolder: stringOrDefault(record["attachmentFolder"], DEFAULT_SETTINGS.attachmentFolder).trim() || DEFAULT_SETTINGS.attachmentFolder, - clipFolder: stringOrDefault(record["clipFolder"], DEFAULT_SETTINGS.clipFolder).trim() || DEFAULT_SETTINGS.clipFolder, - clipFilenameTemplate: - stringOrDefault(record["clipFilenameTemplate"], DEFAULT_SETTINGS.clipFilenameTemplate).trim() || - DEFAULT_SETTINGS.clipFilenameTemplate, - clipTags: stringOrDefault(record["clipTags"], DEFAULT_SETTINGS.clipTags).trim(), archiveExportEnabled: booleanOrDefault(record["archiveExportEnabled"], DEFAULT_SETTINGS.archiveExportEnabled), archiveExportFolderTemplate: stringOrDefault(record["archiveExportFolderTemplate"], DEFAULT_SETTINGS.archiveExportFolderTemplate).trim() || diff --git a/src/settings/tab-shell.tsx b/src/settings/tab-shell.tsx index fbb3b682..9090fc5c 100644 --- a/src/settings/tab-shell.tsx +++ b/src/settings/tab-shell.tsx @@ -6,7 +6,7 @@ import { IconButton, ObsidianCommitTextInput, ObsidianDropdown, ObsidianToggle } import { ArchivedThreadSection } from "./archived-section"; import { HelperSettingsSection } from "./helper-section"; import { HookSection } from "./hook-section"; -import { DEFAULT_ATTACHMENT_FOLDER, DEFAULT_CLIP_FILENAME_TEMPLATE, DEFAULT_CLIP_FOLDER } from "./model"; +import { DEFAULT_ATTACHMENT_FOLDER } from "./model"; import type { SettingsSectionsState } from "./section-state"; import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components"; @@ -26,9 +26,6 @@ interface SettingsTabPanelState { scrollThreadFromComposerEdges: boolean; referenceActiveNoteOnSend: boolean; attachmentFolder: string; - clipFolder: string; - clipFilenameTemplate: string; - clipTags: string; } interface SettingsTabShellActions { @@ -39,9 +36,6 @@ interface SettingsTabShellActions { setScrollThreadFromComposerEdges: (value: boolean) => void; setReferenceActiveNoteOnSend: (value: boolean) => void; setAttachmentFolder: (value: string) => void; - setClipFolder: (value: string) => void; - setClipFilenameTemplate: (value: string) => void; - setClipTags: (value: string) => void; } interface SettingsTabShellProps { @@ -109,76 +103,42 @@ function GeneralSettingsSection({ panel, actions }: { panel: SettingsTabPanelSta function ComposerSettingsSection({ panel, actions }: { panel: SettingsTabPanelState; actions: SettingsTabShellActions }): UiNode { return ( - <> - - - - - { - actions.setSendShortcut(value === "mod-enter" ? "mod-enter" : "enter"); - }} - options={SEND_SHORTCUT_OPTIONS} - /> - - - - - - - - - value.trim() || DEFAULT_ATTACHMENT_FOLDER} - onCommit={actions.setAttachmentFolder} - /> - - - - - - - - value.trim() || DEFAULT_CLIP_FOLDER} - onCommit={actions.setClipFolder} - /> - - - value.trim() || DEFAULT_CLIP_FILENAME_TEMPLATE} - onCommit={actions.setClipFilenameTemplate} - /> - - - value.trim()} - onCommit={actions.setClipTags} - /> - - - - + + + + + { + actions.setSendShortcut(value === "mod-enter" ? "mod-enter" : "enter"); + }} + options={SEND_SHORTCUT_OPTIONS} + /> + + + + + + + + + value.trim() || DEFAULT_ATTACHMENT_FOLDER} + onCommit={actions.setAttachmentFolder} + /> + + + ); } diff --git a/src/settings/tab.obsidian.tsx b/src/settings/tab.obsidian.tsx index 35c2a8b5..0e5d9a03 100644 --- a/src/settings/tab.obsidian.tsx +++ b/src/settings/tab.obsidian.tsx @@ -7,13 +7,7 @@ import { renderUiRoot, unmountUiRoot } from "../shared/dom/preact-root.dom"; import { SettingsDynamicSectionsController } from "./dynamic-sections-controller"; import type { CodexPanelSettingTabHost } from "./host"; import type { CodexPanelSettings } from "./model"; -import { - DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE, - DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE, - DEFAULT_ATTACHMENT_FOLDER, - DEFAULT_CLIP_FILENAME_TEMPLATE, - DEFAULT_CLIP_FOLDER, -} from "./model"; +import { DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE, DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE, DEFAULT_ATTACHMENT_FOLDER } from "./model"; import type { SettingsSectionsState } from "./section-state"; import { SettingsTabShell } from "./tab-shell"; @@ -95,9 +89,6 @@ export class CodexPanelSettingTab extends PluginSettingTab { scrollThreadFromComposerEdges: this.plugin.settings.scrollThreadFromComposerEdges, referenceActiveNoteOnSend: this.plugin.settings.referenceActiveNoteOnSend, attachmentFolder: this.plugin.settings.attachmentFolder, - clipFolder: this.plugin.settings.clipFolder, - clipFilenameTemplate: this.plugin.settings.clipFilenameTemplate, - clipTags: this.plugin.settings.clipTags, }} sections={this.settingsSectionsState()} actions={{ @@ -122,15 +113,6 @@ export class CodexPanelSettingTab extends PluginSettingTab { setAttachmentFolder: (value) => { void this.setAttachmentFolder(value); }, - setClipFolder: (value) => { - void this.setClipFolder(value); - }, - setClipFilenameTemplate: (value) => { - void this.setClipFilenameTemplate(value); - }, - setClipTags: (value) => { - void this.setClipTags(value); - }, }} />, ); @@ -255,24 +237,6 @@ export class CodexPanelSettingTab extends PluginSettingTab { }); } - private setClipFolder(value: string): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.clipFolder = value.trim() || DEFAULT_CLIP_FOLDER; - }); - } - - private setClipFilenameTemplate(value: string): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.clipFilenameTemplate = value.trim() || DEFAULT_CLIP_FILENAME_TEMPLATE; - }); - } - - private setClipTags(value: string): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.clipTags = value.trim(); - }); - } - private setArchiveExportEnabled(enabled: boolean): Promise { return this.queueSettingsMutation(() => { this.plugin.settings.archiveExportEnabled = enabled; diff --git a/tests/features/chat/application/composer/suggestions.test.ts b/tests/features/chat/application/composer/suggestions.test.ts index 7137c69c..0727af3d 100644 --- a/tests/features/chat/application/composer/suggestions.test.ts +++ b/tests/features/chat/application/composer/suggestions.test.ts @@ -126,7 +126,7 @@ describe("composer suggestions", () => { { input: "/clear", expected: { command: "clear", args: "" } }, { input: "/resume thread-1", expected: { command: "resume", args: "thread-1" } }, { input: "/refer thread-1 続きです", expected: { command: "refer", args: "thread-1 続きです" } }, - { input: "/clip https://example.com/article 要約して", expected: { command: "clip", args: "https://example.com/article 要約して" } }, + { input: "/web https://example.com/article 要約して", expected: { command: "web", args: "https://example.com/article 要約して" } }, { input: "/fork", expected: { command: "fork", args: "" } }, { input: "/archive thread-1", expected: { command: "archive", args: "thread-1" } }, { input: "/rename thread-1 New name", expected: { command: "rename", args: "thread-1 New name" } }, diff --git a/tests/features/chat/application/turns/composer-submit-actions.test.ts b/tests/features/chat/application/turns/composer-submit-actions.test.ts index aa85ddeb..54e5b952 100644 --- a/tests/features/chat/application/turns/composer-submit-actions.test.ts +++ b/tests/features/chat/application/turns/composer-submit-actions.test.ts @@ -148,12 +148,12 @@ describe("submitComposer", () => { }); it("restores slash command text when command send results are not submitted", async () => { - const { host, execute, inputSnapshot, sendTurnText, setDraft } = createHost("/clip https://example.com [[Note]]"); + const { host, execute, inputSnapshot, sendTurnText, setDraft } = createHost("/web https://example.com [[Note]]"); execute.mockResolvedValue({ - sendText: "[[Codex Clippings/Example.md]] [[Note]]", + sendText: "https://example.com/ [[Note]]", sendInput: [ - { type: "text", text: "[[Codex Clippings/Example.md]] [[Note]]" }, - { type: "mention", name: "Example", path: "Codex Clippings/Example.md" }, + { type: "text", text: "https://example.com/ [[Note]]" }, + { type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "Readable article" }, { type: "mention", name: "Note", path: "Note.md" }, ], }); @@ -162,16 +162,16 @@ describe("submitComposer", () => { await submitComposer(host); expect(sendTurnText).toHaveBeenCalledWith({ - text: "[[Codex Clippings/Example.md]] [[Note]]", + text: "https://example.com/ [[Note]]", inputSnapshot, codexInputOverride: [ - { type: "text", text: "[[Codex Clippings/Example.md]] [[Note]]" }, - { type: "mention", name: "Example", path: "Codex Clippings/Example.md" }, + { type: "text", text: "https://example.com/ [[Note]]" }, + { type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "Readable article" }, { type: "mention", name: "Note", path: "Note.md" }, ], preserveComposerContextOnFailure: true, }); - expect(setDraft).toHaveBeenCalledWith("/clip https://example.com [[Note]]", { focus: true, clearSuggestions: true }); + expect(setDraft).toHaveBeenCalledWith("/web https://example.com [[Note]]", { focus: true, clearSuggestions: true }); }); it("does not execute connection-dependent slash commands when connection fails", async () => { @@ -219,17 +219,17 @@ describe("submitComposer", () => { }); it("restores slash command text and reports executor errors", async () => { - const { host, execute, sendTurnText, setDraft, showLatest } = createHost("/clip https://obsidian.md/help/plugins/web-viewer 読める?"); + const { host, execute, sendTurnText, setDraft, showLatest } = createHost("/web https://obsidian.md/help/plugins/web-viewer 読める?"); execute.mockRejectedValue(new Error("No readable content found for https://obsidian.md/help/plugins/web-viewer")); await submitComposer(host); - expect(setDraft).toHaveBeenCalledWith("/clip https://obsidian.md/help/plugins/web-viewer 読める?", { + expect(setDraft).toHaveBeenCalledWith("/web https://obsidian.md/help/plugins/web-viewer 読める?", { focus: true, clearSuggestions: true, }); expect(setDraft.mock.calls.at(-1)).toEqual([ - "/clip https://obsidian.md/help/plugins/web-viewer 読める?", + "/web https://obsidian.md/help/plugins/web-viewer 読める?", { focus: true, clearSuggestions: true }, ]); expect(host.status.addSystemMessage).toHaveBeenCalledWith("No readable content found for https://obsidian.md/help/plugins/web-viewer"); diff --git a/tests/features/chat/application/turns/composition.test.ts b/tests/features/chat/application/turns/composition.test.ts index 4a17af1d..794738c2 100644 --- a/tests/features/chat/application/turns/composition.test.ts +++ b/tests/features/chat/application/turns/composition.test.ts @@ -89,7 +89,7 @@ describe("createTurnWorkflowActions", () => { interruptTurn: vi.fn().mockResolvedValue(true), }, referThread: vi.fn(), - clipUrl: vi.fn(), + readWebUrl: vi.fn(), status: { set: vi.fn(), addSystemMessage: vi.fn(), diff --git a/tests/features/chat/application/turns/slash-command-execution.test.ts b/tests/features/chat/application/turns/slash-command-execution.test.ts index 01b2aded..b9a9a1e4 100644 --- a/tests/features/chat/application/turns/slash-command-execution.test.ts +++ b/tests/features/chat/application/turns/slash-command-execution.test.ts @@ -21,11 +21,11 @@ function context(overrides: Partial = {}): SlashCo input: [{ type: "text", text: "referenced" }], referencedThread: { threadId: "thread-2", title: "Referenced", includedTurns: 1, turnLimit: 20 }, }), - clipUrl: vi.fn().mockResolvedValue({ - text: "[[Codex Clippings/Example.md]] 要約して", + readWebUrl: vi.fn().mockResolvedValue({ + text: "https://example.com/article 要約して", input: [ - { type: "text", text: "[[Codex Clippings/Example.md]] 要約して" }, - { type: "mention", name: "Example", path: "Codex Clippings/Example.md" }, + { type: "text", text: "https://example.com/article 要約して" }, + { type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "Readable article" }, ], }), threadActions: { @@ -231,56 +231,56 @@ describe("slash commands", () => { expect(result).toEqual({ sendText: "質問です", sendInput: input, referencedThread }); }); - it("returns clipped wikilink input for /clip", async () => { + it("returns fetched web context input for /web", async () => { const inputSnapshot = { sourcePath: "snapshot.md" } as never; const input = [ - { type: "text" as const, text: "[[Codex Clippings/Example.md]] 要約して" }, - { type: "mention" as const, name: "Example", path: "Codex Clippings/Example.md" }, + { type: "text" as const, text: "https://example.com/article 要約して" }, + { type: "additionalContext" as const, key: "codex_panel_web_context", kind: "untrusted" as const, value: "Readable article" }, ]; const ctx = context({ inputSnapshot, - clipUrl: vi.fn().mockResolvedValue({ text: "[[Codex Clippings/Example.md]] 要約して", input }), + readWebUrl: vi.fn().mockResolvedValue({ text: "https://example.com/article 要約して", input }), }); - const result = await executeSlashCommand("clip", "https://example.com/article 要約して", ctx); + const result = await executeSlashCommand("web", "https://example.com/article 要約して", ctx); - expect(ctx.clipUrl).toHaveBeenCalledWith("https://example.com/article", "要約して", inputSnapshot); - expect(result).toEqual({ sendText: "[[Codex Clippings/Example.md]] 要約して", sendInput: input }); + expect(ctx.readWebUrl).toHaveBeenCalledWith("https://example.com/article", "要約して", inputSnapshot); + expect(result).toEqual({ sendText: "https://example.com/article 要約して", sendInput: input }); }); - it("returns clipped wikilink input for /clip without a message", async () => { + it("returns fetched web context input for /web without a message", async () => { const inputSnapshot = { sourcePath: "snapshot.md" } as never; const input = [ - { type: "text" as const, text: "[[Codex Clippings/Example.md]]" }, - { type: "mention" as const, name: "Example", path: "Codex Clippings/Example.md" }, + { type: "text" as const, text: "https://example.com/article" }, + { type: "additionalContext" as const, key: "codex_panel_web_context", kind: "untrusted" as const, value: "Readable article" }, ]; const ctx = context({ inputSnapshot, - clipUrl: vi.fn().mockResolvedValue({ text: "[[Codex Clippings/Example.md]]", input }), + readWebUrl: vi.fn().mockResolvedValue({ text: "https://example.com/article", input }), }); - const result = await executeSlashCommand("clip", "https://example.com/article", ctx); + const result = await executeSlashCommand("web", "https://example.com/article", ctx); - expect(ctx.clipUrl).toHaveBeenCalledWith("https://example.com/article", "", inputSnapshot); - expect(result).toEqual({ sendText: "[[Codex Clippings/Example.md]]", sendInput: input }); + expect(ctx.readWebUrl).toHaveBeenCalledWith("https://example.com/article", "", inputSnapshot); + expect(result).toEqual({ sendText: "https://example.com/article", sendInput: input }); }); - it("rejects /clip without a URL", async () => { + it("rejects /web without a URL", async () => { const ctx = context(); - await executeSlashCommand("clip", "", ctx); + await executeSlashCommand("web", "", ctx); - expect(ctx.clipUrl).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("/clip requires a URL. Usage: /clip [message]"); + expect(ctx.readWebUrl).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("/web requires a URL. Usage: /web [message]"); }); - it("rejects /clip when no composer input snapshot is available", async () => { + it("rejects /web when no composer input snapshot is available", async () => { const ctx = context(); - await executeSlashCommand("clip", "https://example.com/article 要約して", ctx); + await executeSlashCommand("web", "https://example.com/article 要約して", ctx); - expect(ctx.clipUrl).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("Cannot clip a URL without composer input context."); + expect(ctx.readWebUrl).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Cannot read a web URL without composer input context."); }); it("forks the active thread for /fork", async () => { @@ -647,7 +647,7 @@ describe("slash commands", () => { expect.arrayContaining(["/plan [message]", "/goal", "/permissions [profile|default]", "/model [model|default]"]), ); expect(slashCommandHelpKeys("Diagnostics")).toEqual(expect.arrayContaining(["/status", "/doctor", "/tools", "/help"])); - expect(slashCommandHelpKeys("Composition")).toEqual(["/refer ", "/clip [message]"]); + expect(slashCommandHelpKeys("Composition")).toEqual(["/refer ", "/web [message]"]); }); it("rejects /help arguments", async () => { diff --git a/tests/features/chat/application/turns/slash-command-executor.test.ts b/tests/features/chat/application/turns/slash-command-executor.test.ts index 9667947e..3ad80d0c 100644 --- a/tests/features/chat/application/turns/slash-command-executor.test.ts +++ b/tests/features/chat/application/turns/slash-command-executor.test.ts @@ -29,12 +29,12 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) { const stateStore = createChatStateStore(createChatState()); const compactThread = vi.fn().mockResolvedValue(undefined); const referThread = vi.fn().mockResolvedValue(null); - const clipUrl = vi.fn().mockResolvedValue(null); + const readWebUrl = vi.fn(); const host: SlashCommandExecutorHost = { stateStore, connectionAvailable: () => true, referThread, - clipUrl, + readWebUrl, startNewThread: vi.fn().mockResolvedValue(undefined), startThreadForGoal: vi.fn().mockResolvedValue("thread-new"), resumeThread: vi.fn().mockResolvedValue(undefined), @@ -74,7 +74,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) { effortStatusDetails: () => [], ...overrides, }; - return { clipUrl, compactThread, host, referThread, stateStore }; + return { compactThread, host, readWebUrl, referThread, stateStore }; } describe("executeSlashCommandWithState", () => { diff --git a/tests/features/chat/application/web-clipping/web-clip.test.ts b/tests/features/chat/application/web-clipping/web-clip.test.ts deleted file mode 100644 index f8d61886..00000000 --- a/tests/features/chat/application/web-clipping/web-clip.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - saveWebClipMarkdown, - type WebClipDestination, - webClipMarkdown, -} from "../../../../../src/features/chat/application/web-clipping/web-clip"; - -describe("web clipping", () => { - it("writes clipped markdown with fixed frontmatter and tags", () => { - const output = webClipMarkdown( - { - url: "https://example.com/post", - title: "Example Post", - content: "Body", - }, - { clipTags: '#web, "clipping", web, {{title}}' }, - "Example Post", - new Date("2026-07-05T03:04:05.000Z"), - ); - - expect(output).toBe( - [ - "---", - 'title: "Example Post"', - 'url: "https://example.com/post"', - 'created: "2026-07-05T03:04:05.000Z"', - 'tags: ["web", "clipping", "{{title}}"]', - "---", - "", - "# Example Post", - "", - "Body", - "", - ].join("\n"), - ); - }); - - it("escapes frontmatter strings with YAML-safe JSON string literals", () => { - const output = webClipMarkdown( - { - url: "https://example.com/post?title=line\nbreak", - title: "Example Post", - content: "Body", - }, - { clipTags: "web\nclip, tab\tvalue" }, - "Example\nPost", - new Date("2026-07-05T03:04:05.000Z"), - ); - - expect(output).toContain('title: "Example Post"'); - expect(output).toContain('url: "https://example.com/post?title=line\\nbreak"'); - expect(output).toContain('tags: ["web\\nclip", "tab\\tvalue"]'); - }); - - it("saves clips under a unique path from the filename template", async () => { - const destination = memoryDestination(["Codex Clippings/Example Site - Example-Post.md"]); - - const result = await saveWebClipMarkdown( - { - url: "https://example.com/post", - title: "Example/Post", - site: "Example Site", - domain: "example.com", - content: "Body", - }, - { - clipFolder: "Codex Clippings", - clipFilenameTemplate: "{{site}} - {{title}}", - clipTags: "", - }, - destination, - new Date("2026-07-05T03:04:05.000Z"), - ); - - expect(result).toEqual({ - path: "Codex Clippings/Example Site - Example-Post 2.md", - wikilink: "[[Codex Clippings/Example Site - Example-Post 2.md]]", - }); - expect(destination.createFolder).toHaveBeenCalledWith("Codex Clippings"); - expect(destination.createMarkdownFile).toHaveBeenCalledWith("Codex Clippings/Example Site - Example-Post 2.md", expect.any(String)); - }); - - it("rejects absolute and relative clip folders", async () => { - const destination = memoryDestination([]); - - await expect( - saveWebClipMarkdown( - { url: "https://example.com", title: "Example", content: "Body" }, - { clipFolder: "../outside", clipFilenameTemplate: "{{title}}.md" }, - destination, - ), - ).rejects.toThrow("Clip folder cannot contain relative path segments."); - }); -}); - -function memoryDestination(existingPaths: readonly string[]): WebClipDestination & { - createFolder: ReturnType>; - createMarkdownFile: ReturnType>; -} { - const paths = new Set(existingPaths); - return { - normalizePath: (path) => path.replace(/[\\/]+/g, "/").replace(/^\/+|\/+$/g, ""), - exists: vi.fn(async (path) => paths.has(path)), - createFolder: vi.fn().mockResolvedValue(undefined), - createMarkdownFile: vi.fn().mockImplementation(async (path) => { - paths.add(path); - }), - }; -} diff --git a/tests/features/chat/host/web-clipper.integration.test.ts b/tests/features/chat/host/web-clipper.integration.test.ts deleted file mode 100644 index e11226e2..00000000 --- a/tests/features/chat/host/web-clipper.integration.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -// @vitest-environment jsdom - -import type { Vault } from "obsidian"; -import { describe, expect, it, vi } from "vitest"; - -import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot"; - -const mocks = vi.hoisted(() => ({ - requestUrl: vi.fn(), -})); - -vi.mock("obsidian", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - requestUrl: mocks.requestUrl, - }; -}); - -const { TFile } = await import("obsidian"); -const { createVaultWebClipper } = await import("../../../../src/features/chat/host/obsidian/web-clipper.obsidian"); - -describe("vault web clipper parser integration", () => { - it("extracts article HTML with Defuddle and saves it as Markdown", async () => { - mocks.requestUrl.mockResolvedValue({ - text: ` - - Integration Article - - -
-
-

Parser contract heading

-

This readable paragraph exercises the real Defuddle full browser bundle.

-

A second paragraph makes the article content unambiguous.

-
-
- - `, - }); - const { vault, createdContent } = memoryVault(); - - const result = await createVaultWebClipper({ - vault, - settings: () => ({ clipFolder: "Clips", clipFilenameTemplate: "{{title}}.md", clipTags: "" }), - prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }), - viewWindow: () => window, - now: () => new Date("2026-07-10T00:00:00.000Z"), - }).clipUrl("https://example.com/article", "", {} as ComposerInputSnapshot); - - const markdown = createdContent.get("Clips/Integration Article.md"); - expect(markdown).toContain("## Parser contract heading"); - expect(markdown).toContain("This readable paragraph exercises the real Defuddle full browser bundle."); - expect(markdown).not.toContain(" { - mocks.requestUrl.mockResolvedValue({ - text: `Rich Article
-

Rich heading

Formula x

-

See the note1.

-

Obsidian callout

-
const value = 1;
-
  1. Footnote detail
-
`, - }); - const { vault, createdContent } = memoryVault(); - - await createVaultWebClipper({ - vault, - settings: () => ({ clipFolder: "Clips", clipFilenameTemplate: "{{title}}.md", clipTags: "" }), - prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }), - viewWindow: () => window, - now: () => new Date("2026-07-10T00:00:00.000Z"), - }).clipUrl("https://example.com/rich", "", {} as ComposerInputSnapshot); - - const markdown = createdContent.get("Clips/Rich Article.md") ?? ""; - expect(markdown).toContain("Rich heading"); - expect(markdown).toMatch(/\$|\\frac|math/); - expect(markdown).toContain("Footnote detail"); - expect(markdown).toContain("```\nconst value = 1;\n```"); - }); -}); - -function memoryVault(): { vault: Vault; createdContent: Map } { - const files = new Map>(); - const createdContent = new Map(); - const vault = { - getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null), - createFolder: vi.fn().mockResolvedValue(undefined), - create: vi.fn().mockImplementation(async (path: string, content: string) => { - const File = TFile as unknown as new (path: string) => InstanceType; - files.set(path, new File(path)); - createdContent.set(path, content); - }), - }; - return { vault: vault as unknown as Vault, createdContent }; -} diff --git a/tests/features/chat/host/web-clipper.test.ts b/tests/features/chat/host/web-clipper.test.ts deleted file mode 100644 index 8cc95f16..00000000 --- a/tests/features/chat/host/web-clipper.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Vault } from "obsidian"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { CodexInput } from "../../../../src/domain/chat/input"; -import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot"; - -const mocks = vi.hoisted(() => ({ - defuddleParse: vi.fn(), - requestUrl: vi.fn(), -})); - -vi.mock("obsidian", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - requestUrl: mocks.requestUrl, - }; -}); - -vi.mock("defuddle/full", () => ({ - default: vi.fn().mockImplementation(function MockDefuddle() { - return { parse: mocks.defuddleParse }; - }), -})); - -const { TFile } = await import("obsidian"); -const { createVaultWebClipper } = await import("../../../../src/features/chat/host/obsidian/web-clipper.obsidian"); - -describe("vault web clipper", () => { - beforeEach(() => { - mocks.requestUrl.mockResolvedValue({ text: "Article" }); - mocks.defuddleParse.mockReturnValue({ - title: "Example", - contentMarkdown: "Readable article", - content: "Readable article", - site: "Example Site", - domain: "example.com", - }); - }); - - it("preserves composer-prepared message context alongside the clipped note mention", async () => { - const vault = memoryVault(); - const inputSnapshot = { sourcePath: "source.md" } as ComposerInputSnapshot; - const messageInput = [ - { type: "text" as const, text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" }, - { type: "mention" as const, name: "Alpha", path: "Notes/Alpha.md" }, - { type: "additionalContext" as const, key: "codex_panel_obsidian_context", kind: "untrusted" as const, value: "selection" }, - { type: "mention" as const, name: "Sketch.png", path: "Files/Sketch.png" }, - { type: "localImage" as const, path: "Files/Sketch.png" }, - ] satisfies CodexInput; - const prepareInput = vi.fn(() => ({ - text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]", - input: messageInput, - })); - - const result = await createVaultWebClipper({ - vault, - settings: () => ({ clipFolder: "Codex Clippings", clipFilenameTemplate: "{{title}}.md", clipTags: "" }), - prepareInput, - viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window, - now: () => new Date("2026-07-05T00:00:00.000Z"), - }).clipUrl("https://example.com/article", " Summarize [[Alpha]] [[Files/Sketch.png]] ", inputSnapshot); - - expect(prepareInput).toHaveBeenCalledWith("Summarize [[Alpha]] [[Files/Sketch.png]]", inputSnapshot); - expect(result).toEqual({ - text: "[[Codex Clippings/Example.md]] Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]", - input: [ - { type: "text", text: "[[Codex Clippings/Example.md]] Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" }, - { type: "mention", name: "Example", path: "Codex Clippings/Example.md" }, - { type: "mention", name: "Alpha", path: "Notes/Alpha.md" }, - { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selection" }, - { type: "mention", name: "Sketch.png", path: "Files/Sketch.png" }, - { type: "localImage", path: "Files/Sketch.png" }, - ], - }); - }); -}); - -class FakeDOMParser { - parseFromString(): Document { - return {} as Document; - } -} - -function memoryVault(): Vault { - const files = new Map>(); - const vault = { - getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null), - createFolder: vi.fn().mockResolvedValue(undefined), - create: vi.fn().mockImplementation(async (path: string) => { - const File = TFile as unknown as new (path: string) => InstanceType; - files.set(path, new File(path)); - }), - }; - return vault as unknown as Vault; -} diff --git a/tests/features/chat/host/web-context.integration.test.ts b/tests/features/chat/host/web-context.integration.test.ts new file mode 100644 index 00000000..82071133 --- /dev/null +++ b/tests/features/chat/host/web-context.integration.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot"; + +const mocks = vi.hoisted(() => ({ + htmlToMarkdown: vi.fn(), + requestUrl: vi.fn(), +})); + +vi.mock("obsidian", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + htmlToMarkdown: mocks.htmlToMarkdown, + requestUrl: mocks.requestUrl, + }; +}); + +const { createWebContextReader } = await import("../../../../src/features/chat/host/obsidian/web-context.obsidian"); + +describe("web context parser integration", () => { + it("extracts article HTML with the Defuddle core bundle before Markdown conversion", async () => { + mocks.requestUrl.mockResolvedValue({ + status: 200, + text: ` + + Integration Article + + +
+
+

Parser contract heading

+

This readable paragraph exercises the real Defuddle core browser bundle.

+

A second paragraph makes the article content unambiguous.

+
+
+ + `, + }); + mocks.htmlToMarkdown.mockReturnValue("## Parser contract heading\n\nReadable article"); + + const result = await createWebContextReader({ + prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }), + viewWindow: () => window, + }).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot); + + const extractedHtml = mocks.htmlToMarkdown.mock.calls[0]?.[0] as string; + expect(extractedHtml).toContain("This readable paragraph exercises the real Defuddle core browser bundle."); + expect(extractedHtml).not.toContain("Navigation that should not be included"); + expect(result.text).toBe("https://example.com/article"); + expect(result.input).toContainEqual({ + type: "additionalContext", + key: "codex_panel_web_context", + kind: "untrusted", + value: + "Web page context for the current user input:\nSource: https://example.com/article\nTitle: Integration Article\n\n## Parser contract heading\n\nReadable article", + }); + }); +}); diff --git a/tests/features/chat/host/web-context.test.ts b/tests/features/chat/host/web-context.test.ts new file mode 100644 index 00000000..1994f18a --- /dev/null +++ b/tests/features/chat/host/web-context.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CodexInput } from "../../../../src/domain/chat/input"; +import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot"; + +const mocks = vi.hoisted(() => ({ + defuddleParse: vi.fn(), + htmlToMarkdown: vi.fn(), + requestUrl: vi.fn(), +})); + +vi.mock("obsidian", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + htmlToMarkdown: mocks.htmlToMarkdown, + requestUrl: mocks.requestUrl, + }; +}); + +vi.mock("defuddle", () => ({ + default: vi.fn().mockImplementation(function MockDefuddle() { + return { parse: mocks.defuddleParse }; + }), +})); + +const { createWebContextReader } = await import("../../../../src/features/chat/host/obsidian/web-context.obsidian"); + +describe("web context reader", () => { + beforeEach(() => { + mocks.requestUrl.mockReset(); + mocks.defuddleParse.mockReset(); + mocks.htmlToMarkdown.mockReset(); + mocks.requestUrl.mockResolvedValue({ status: 200, text: "Article" }); + mocks.defuddleParse.mockReturnValue({ title: "Example", content: "
Readable article
" }); + mocks.htmlToMarkdown.mockReturnValue("Readable article"); + }); + + it("attaches fetched Markdown as untrusted context while preserving prepared message input", async () => { + const inputSnapshot = { sourcePath: "source.md" } as ComposerInputSnapshot; + const messageInput = [ + { type: "text" as const, text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" }, + { type: "mention" as const, name: "Alpha", path: "Notes/Alpha.md" }, + { type: "additionalContext" as const, key: "codex_panel_obsidian_context", kind: "untrusted" as const, value: "selection" }, + { type: "mention" as const, name: "Sketch.png", path: "Files/Sketch.png" }, + { type: "localImage" as const, path: "Files/Sketch.png" }, + ] satisfies CodexInput; + const prepareInput = vi.fn(() => ({ + text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]", + input: messageInput, + })); + + const result = await createWebContextReader({ + prepareInput, + viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window, + }).readUrl("https://example.com/article", " Summarize [[Alpha]] [[Files/Sketch.png]] ", inputSnapshot); + + expect(mocks.requestUrl).toHaveBeenCalledWith({ url: "https://example.com/article", method: "GET", throw: false }); + expect(mocks.htmlToMarkdown).toHaveBeenCalledWith("
Readable article
"); + expect(prepareInput).toHaveBeenCalledWith("Summarize [[Alpha]] [[Files/Sketch.png]]", inputSnapshot); + expect(result).toEqual({ + text: "https://example.com/article Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]", + input: [ + { type: "text", text: "https://example.com/article Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" }, + { type: "mention", name: "Alpha", path: "Notes/Alpha.md" }, + { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selection" }, + { type: "mention", name: "Sketch.png", path: "Files/Sketch.png" }, + { type: "localImage", path: "Files/Sketch.png" }, + { + type: "additionalContext", + key: "codex_panel_web_context", + kind: "untrusted", + value: "Web page context for the current user input:\nSource: https://example.com/article\nTitle: Example\n\nReadable article", + }, + ], + }); + }); + + it.each([400, 500])("rejects HTTP %i responses", async (status) => { + mocks.requestUrl.mockResolvedValue({ status, text: "Error" }); + + await expect( + createWebContextReader({ + prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }), + viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window, + }).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot), + ).rejects.toThrow(`Web request failed for https://example.com/article (HTTP ${status}).`); + + expect(mocks.defuddleParse).not.toHaveBeenCalled(); + }); + + it("rejects empty converted content", async () => { + mocks.htmlToMarkdown.mockReturnValue(" \n"); + + await expect( + createWebContextReader({ + prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }), + viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window, + }).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot), + ).rejects.toThrow("No readable web content found for https://example.com/article"); + }); + + it("rejects non-HTTP URLs before fetching", async () => { + await expect( + createWebContextReader({ + prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }), + viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window, + }).readUrl("file:///tmp/article.html", "", {} as ComposerInputSnapshot), + ).rejects.toThrow("Unsupported web URL: file:///tmp/article.html"); + + expect(mocks.requestUrl).not.toHaveBeenCalled(); + }); +}); + +class FakeDOMParser { + parseFromString(): Document { + return {} as Document; + } +} diff --git a/tests/features/chat/support/settings.ts b/tests/features/chat/support/settings.ts index 4cc785bb..8dd0eab6 100644 --- a/tests/features/chat/support/settings.ts +++ b/tests/features/chat/support/settings.ts @@ -5,9 +5,6 @@ export function chatPanelSettingsAccess(settings: CodexPanelSettings): ChatPanel return { referenceActiveNoteOnSend: () => settings.referenceActiveNoteOnSend, attachmentFolder: () => settings.attachmentFolder, - clipFolder: () => settings.clipFolder, - clipFilenameTemplate: () => settings.clipFilenameTemplate, - clipTags: () => settings.clipTags, archiveExportEnabled: () => settings.archiveExportEnabled, archiveExportSettings: () => ({ archiveExportFolderTemplate: settings.archiveExportFolderTemplate, diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index cc57f231..5776f6b4 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -54,8 +54,12 @@ export class Notice { } } -export async function requestUrl(_request: unknown): Promise<{ text: string }> { - return { text: "" }; +export function htmlToMarkdown(html: string | HTMLElement | Document | DocumentFragment): string { + return typeof html === "string" ? html : ""; +} + +export async function requestUrl(_request: unknown): Promise<{ status: number; text: string }> { + return { status: 200, text: "" }; } export function prepareFuzzySearch(query: string): (text: string) => { score: number; matches: unknown[] } | null { diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 3656210f..62e2ffd1 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -70,10 +70,6 @@ describe("settings tab", () => { "Scroll conversation from composer line edges", "Reference active file on send", "Attachment folder", - "Web clipping", - "Clipped note folder", - "Clipped note filename", - "Clipped note tags", "Thread archiving", "Save note by default", "Saved note folder", @@ -268,33 +264,6 @@ describe("settings tab", () => { expect(settingDesc(tab, "Attachment folder")).toContain("pasted or dropped"); }); - it("saves web clip settings", async () => { - const saveSettings = vi.fn().mockResolvedValue(undefined); - const tab = newSettingsTab({ saveSettings }); - - tab.display(); - const folder = inputForSetting(tab, "Clipped note folder"); - const filename = inputForSetting(tab, "Clipped note filename"); - const tags = inputForSetting(tab, "Clipped note tags"); - if (!folder || !filename || !tags) throw new Error("Missing clip controls"); - expect(folder.type).toBe("text"); - expect(filename.type).toBe("text"); - expect(tags.type).toBe("text"); - - folder.value = "Web Clips"; - folder.dispatchEvent(new Event("blur")); - filename.value = "{{site}} {{title}}.md"; - filename.dispatchEvent(new Event("blur")); - tags.value = "web, clipping"; - tags.dispatchEvent(new Event("blur")); - await flushPromises(); - - expect(saveSettings).toHaveBeenCalledTimes(3); - expect(settingDesc(tab, "Web clipping")).toBe(""); - expect(settingDesc(tab, "Clipped note filename")).toContain("{{domain}}"); - expect(settingDesc(tab, "Clipped note tags")).toContain("Comma-separated"); - }); - it("saves archive export settings", async () => { const saveSettings = vi.fn().mockResolvedValue(undefined); const tab = newSettingsTab({ saveSettings }); diff --git a/tests/settings/settings.test.ts b/tests/settings/settings.test.ts index 6820faf1..4e963dc4 100644 --- a/tests/settings/settings.test.ts +++ b/tests/settings/settings.test.ts @@ -49,9 +49,6 @@ describe("settings", () => { scrollThreadFromComposerEdges: true, referenceActiveNoteOnSend: true, attachmentFolder: "Codex Uploads", - clipFolder: "Codex Clippings", - clipFilenameTemplate: "{{title}}.md", - clipTags: "web, clipping", archiveExportEnabled: true, archiveExportFolderTemplate: "Codex Archives/{{date}}", archiveExportFilenameTemplate: "{{title}} {{shortId}}.md", @@ -78,9 +75,6 @@ describe("settings", () => { scrollThreadFromComposerEdges: true, referenceActiveNoteOnSend: true, attachmentFolder: "Codex Attachments", - clipFolder: "Codex Clippings", - clipFilenameTemplate: "{{title}}.md", - clipTags: "web, clipping", archiveExportEnabled: true, archiveExportFolderTemplate: "Codex Archives", archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md", @@ -146,31 +140,6 @@ describe("settings", () => { expect(normalizeSettings({ attachmentFolder: 1 }).attachmentFolder).toBe(DEFAULT_SETTINGS.attachmentFolder); }); - it("normalizes web clip settings", () => { - expect( - normalizeSettings({ - clipFolder: " Web/Clips ", - clipFilenameTemplate: " {{site}} - {{title}}.md ", - clipTags: " #web, clipping ", - }), - ).toMatchObject({ - clipFolder: "Web/Clips", - clipFilenameTemplate: "{{site}} - {{title}}.md", - clipTags: "#web, clipping", - }); - expect( - normalizeSettings({ - clipFolder: " ", - clipFilenameTemplate: " ", - clipTags: 1, - }), - ).toMatchObject({ - clipFolder: DEFAULT_SETTINGS.clipFolder, - clipFilenameTemplate: DEFAULT_SETTINGS.clipFilenameTemplate, - clipTags: DEFAULT_SETTINGS.clipTags, - }); - }); - it("normalizes archive export settings", () => { expect( normalizeSettings({