import { getSelectedTextContexts } from "@/aiParams"; import { ChainType } from "@/chainType"; import { RESTRICTION_MESSAGES } from "@/constants"; import { logWarn, logInfo, logError } from "@/logger"; import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing"; import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton"; import { WebViewerTimeoutError } from "@/services/webViewerService/webViewerServiceTypes"; import { FileParserManager } from "@/tools/FileParserManager"; import { isPlusChain, isTextReadableFile } from "@/utils"; import { normalizeUrlString } from "@/utils/urlNormalization"; import { TFile, Vault, Notice } from "obsidian"; import { NOTE_CONTEXT_PROMPT_TAG, EMBEDDED_PDF_TAG, EMBEDDED_NOTE_TAG, SELECTED_TEXT_TAG, WEB_SELECTED_TEXT_TAG, DATAVIEW_BLOCK_TAG, WEB_TAB_CONTEXT_TAG, ACTIVE_WEB_TAB_CONTEXT_TAG, YOUTUBE_VIDEO_CONTEXT_TAG, } from "./constants"; /** Minimal typing for the Dataview plugin API (third-party plugin, no official types). */ interface DataviewApi { query( query: string, sourcePath: string ): Promise<{ successful: boolean; error?: string; value: DataviewResult }>; } interface DataviewResult { type: string; values: DataviewRow[]; headers?: string[]; } type DataviewRow = unknown[] | DataviewTaskItem | DataviewLinkLike; interface DataviewTaskItem { completed: boolean; text?: string; } interface DataviewLinkLike { path: string; } interface EmbeddedLinkTarget { path: string | null; heading?: string; blockId?: string; } interface MarkdownSegment { content: string; found: boolean; } export class ContextProcessor { private static instance: ContextProcessor; private constructor() {} static getInstance(): ContextProcessor { if (!ContextProcessor.instance) { ContextProcessor.instance = new ContextProcessor(); } return ContextProcessor.instance; } async processEmbeddedPDFs( content: string, vault: Vault, fileParserManager: FileParserManager ): Promise { const pdfRegex = /!\[\[(.*?\.pdf)\]\]/g; const matches = [...content.matchAll(pdfRegex)]; for (const match of matches) { const pdfName = match[1]; const pdfFile = vault.getAbstractFileByPath(pdfName); if (pdfFile instanceof TFile) { try { const pdfContent = await fileParserManager.parseFile(pdfFile, vault); content = content.replace( match[0], `\n\n<${EMBEDDED_PDF_TAG}>\n${pdfName}\n\n${pdfContent}\n\n\n\n` ); } catch (error) { logError(`Error processing embedded PDF ${pdfName}:`, error); content = content.replace( match[0], `\n\n<${EMBEDDED_PDF_TAG}>\n${pdfName}\nCould not process PDF\n\n\n` ); } } } return content; } /** * Process Dataview blocks in content, executing queries and replacing them with structured results */ async processDataviewBlocks(content: string, sourcePath: string): Promise { // Check if Dataview plugin is available const dataviewPlugin = ( app as unknown as { plugins?: { plugins?: { dataview?: { api?: DataviewApi } } } } ).plugins?.plugins?.dataview; if (!dataviewPlugin) { return content; // Dataview not installed, return content as-is } const dataviewApi = dataviewPlugin.api; if (!dataviewApi) { return content; // API not available } // Match dataview and dataviewjs code blocks // Fixed regex: \s* handles trailing spaces and different line endings const blockRegex = /```(dataview|dataviewjs)\s*\n([\s\S]*?)```/g; const matches = [...content.matchAll(blockRegex)]; // Process matches in reverse order to avoid position shifts when replacing // This also handles multiple identical blocks correctly for (let i = matches.length - 1; i >= 0; i--) { const match = matches[i]; const queryType = match[1]; // 'dataview' or 'dataviewjs' const query = match[2].trim(); const matchStart = match.index; const matchEnd = matchStart + match[0].length; try { // Execute query with timeout const result = await Promise.race([ this.executeDataviewQuery(dataviewApi, query, queryType, sourcePath), new Promise((_, reject) => window.setTimeout(() => reject(new Error("Query timeout")), 5000) ), ]); // Replace block with structured output using slice (position-based, handles duplicates) const resultStr = typeof result === "string" ? result : JSON.stringify(result); const replacement = `\n\n<${DATAVIEW_BLOCK_TAG}>\n${queryType}\n\n${query}\n\n\n${resultStr}\n\n\n\n`; content = content.slice(0, matchStart) + replacement + content.slice(matchEnd); } catch (error) { logError(`Error executing Dataview query:`, error); // On error, include query with error message const replacement = `\n\n<${DATAVIEW_BLOCK_TAG}>\n${queryType}\n\n${query}\n\n${error instanceof Error ? error.message : "Query execution failed"}\n\n\n`; content = content.slice(0, matchStart) + replacement + content.slice(matchEnd); } } return content; } /** * Execute a Dataview query and format the results */ private async executeDataviewQuery( dataviewApi: DataviewApi, query: string, queryType: string, sourcePath: string ): Promise { if (queryType === "dataviewjs") { // DataviewJS requires more complex handling - for now, return a message return "[DataviewJS execution not yet supported - showing original query]"; } // Parse and execute DQL query const result = await dataviewApi.query(query, sourcePath); if (!result.successful) { throw new Error(result.error ?? "Query failed"); } // Format results based on type return this.formatDataviewResult(result.value); } /** * Format Dataview query results into readable text */ private formatDataviewResult(result: DataviewResult): string { if (!result) { return "No results"; } // Handle different result types if (result.type === "list") { return this.formatDataviewList(result.values); } else if (result.type === "table") { return this.formatDataviewTable(result.headers ?? [], result.values as unknown[][]); } else if (result.type === "task") { return this.formatDataviewTasks(result.values as DataviewTaskItem[]); } else if (Array.isArray(result)) { return (result as unknown[]).map((item) => this.formatDataviewValue(item)).join("\n"); } return JSON.stringify(result); } /** * Format Dataview list results */ private formatDataviewList(values: DataviewRow[]): string { if (!values || values.length === 0) { return "No results"; } return values.map((item) => `- ${this.formatDataviewValue(item)}`).join("\n"); } /** * Format Dataview table results */ private formatDataviewTable(headers: string[], rows: unknown[][]): string { if (!rows || rows.length === 0) { return "No results"; } // Create markdown table let table = `| ${headers.join(" | ")} |\n`; table += `| ${headers.map(() => "---").join(" | ")} |\n`; for (const row of rows) { table += `| ${row.map((cell) => this.formatDataviewValue(cell)).join(" | ")} |\n`; } return table; } /** * Format Dataview task results */ private formatDataviewTasks(tasks: DataviewTaskItem[]): string { if (!tasks || tasks.length === 0) { return "No results"; } return tasks .map((task) => { const checkbox = task.completed ? "[x]" : "[ ]"; return `- ${checkbox} ${this.formatDataviewValue(task.text ?? task)}`; }) .join("\n"); } /** * Format individual Dataview values */ private formatDataviewValue(value: unknown): string { if (value === null || value === undefined) { return ""; } // Handle links if (value && typeof value === "object" && "path" in value) { return `[[${(value as DataviewLinkLike).path}]]`; } // Handle arrays if (Array.isArray(value)) { return (value as unknown[]).map((v) => this.formatDataviewValue(v)).join(", "); } if (typeof value === "object") { return JSON.stringify(value); } // value is a primitive (string, number, boolean, bigint) at this point return `${value as string | number | boolean | bigint}`; } /** * Build markdown content for context inclusion by resolving embeds, PDFs, and Dataview blocks. */ private async buildMarkdownContextContent( note: TFile, vault: Vault, fileParserManager: FileParserManager, chainType: ChainType ): Promise { let content = await fileParserManager.parseFile(note, vault); content = await this.processEmbeddedNotes(content, note, vault, fileParserManager, chainType); if (isPlusChain(chainType)) { content = await this.processEmbeddedPDFs(content, vault, fileParserManager); } return await this.processDataviewBlocks(content, note.path); } /** * Replace embedded note syntax within markdown content. * * Scans the content for all `![[...]]` embed patterns and expands them once * into structured `` blocks. Nested embeds are left as-is to * keep processing predictable and lightweight. * * @param content - The markdown content to process * @param sourceNote - The note containing this content (for relative link resolution) * @param vault - Obsidian vault instance * @param fileParserManager - Manager for parsing different file types * @param chainType - Current chain type (affects feature availability) * @returns Content with top-level embeds replaced by structured blocks */ private async processEmbeddedNotes( content: string, sourceNote: TFile, vault: Vault, fileParserManager: FileParserManager, chainType: ChainType ): Promise { const embedRegex = /!\[\[([^\]]+)\]\]/g; let match: RegExpExecArray | null; let lastIndex = 0; let result = ""; while ((match = embedRegex.exec(content)) !== null) { result += content.slice(lastIndex, match.index); const rawTarget = match[1].trim(); const replacement = await this.buildEmbeddedNoteBlock( rawTarget, match[0], sourceNote, vault, fileParserManager, chainType ); result += replacement; lastIndex = match.index + match[0].length; } result += content.slice(lastIndex); return result; } /** * Build a rendered embedded note block for the given target. */ private async buildEmbeddedNoteBlock( rawTarget: string, rawMatch: string, sourceNote: TFile, vault: Vault, fileParserManager: FileParserManager, chainType: ChainType ): Promise { const target = this.parseEmbeddedLinkTarget(rawTarget); if (!target) { return rawMatch; } const resolvedFile = target.path === null ? sourceNote : app.metadataCache.getFirstLinkpathDest(target.path, sourceNote.path); if (!(resolvedFile instanceof TFile)) { return this.formatEmbeddedNoteBlock({ title: target.path ?? sourceNote.basename, path: target.path ?? sourceNote.path, heading: target.heading, blockId: target.blockId, error: "Embedded note not found", }); } if (resolvedFile.extension !== "md") { return rawMatch; } try { let embeddedContent = await fileParserManager.parseFile(resolvedFile, vault); if (target.heading || target.blockId) { const segment = this.extractMarkdownSegment(resolvedFile, embeddedContent, target); if (!segment.found) { const targetDescription = target.blockId ? `block reference "${target.blockId}"` : `heading "${target.heading ?? ""}"`; throw new Error(`Embedded note ${targetDescription} not found in ${resolvedFile.path}`); } embeddedContent = segment.content; } if (isPlusChain(chainType)) { embeddedContent = await this.processEmbeddedPDFs(embeddedContent, vault, fileParserManager); } embeddedContent = await this.processDataviewBlocks(embeddedContent, resolvedFile.path); return this.formatEmbeddedNoteBlock({ title: resolvedFile.basename, path: resolvedFile.path, heading: target.heading, blockId: target.blockId, content: embeddedContent, }); } catch (error) { logWarn("Failed to process embedded note", error); const message = error instanceof Error ? error.message : "Could not process embedded note"; return this.formatEmbeddedNoteBlock({ title: resolvedFile.basename, path: resolvedFile.path, heading: target.heading, blockId: target.blockId, error: message, }); } } /** * Parse embedded note syntax into a structured target. */ private parseEmbeddedLinkTarget(rawTarget: string): EmbeddedLinkTarget | null { if (!rawTarget) { return null; } const aliasIndex = rawTarget.indexOf("|"); const linkTarget = aliasIndex >= 0 ? rawTarget.slice(0, aliasIndex) : rawTarget; let cleanedTarget = linkTarget.trim(); if (!cleanedTarget) { return { path: null }; } let blockId: string | undefined; let heading: string | undefined; const blockIndex = cleanedTarget.indexOf("#^"); if (blockIndex !== -1) { blockId = cleanedTarget.slice(blockIndex + 2).trim(); cleanedTarget = cleanedTarget.slice(0, blockIndex); } const headingIndex = cleanedTarget.indexOf("#"); if (headingIndex !== -1) { heading = cleanedTarget.slice(headingIndex + 1).trim(); cleanedTarget = cleanedTarget.slice(0, headingIndex); } const path = cleanedTarget.length > 0 ? cleanedTarget : null; return { path, heading: heading && heading.length > 0 ? heading : undefined, blockId: blockId && blockId.length > 0 ? blockId : undefined, }; } /** * Extract a markdown segment representing a heading section or block reference. */ private extractMarkdownSegment( note: TFile, fileContent: string, focus: EmbeddedLinkTarget ): MarkdownSegment { const cache = app.metadataCache.getFileCache(note); if (focus.blockId) { const block = cache?.blocks?.[focus.blockId]; const startOffset = block?.position?.start?.offset; const endOffset = block?.position?.end?.offset; if (startOffset === undefined || endOffset === undefined) { return { content: "", found: false }; } return { content: fileContent.slice(startOffset, endOffset), found: true, }; } if (focus.heading) { const headings = cache?.headings ?? []; const normalizedTarget = this.normalizeHeadingForMatch(focus.heading); const targetIndex = headings.findIndex( (headingCache) => this.normalizeHeadingForMatch(headingCache.heading) === normalizedTarget ); if (targetIndex === -1) { return { content: "", found: false }; } const currentHeading = headings[targetIndex]; const startOffset = currentHeading.position?.start?.offset ?? 0; let endOffset = fileContent.length; for (let i = targetIndex + 1; i < headings.length; i++) { if (headings[i].level <= currentHeading.level) { endOffset = headings[i].position?.start?.offset ?? endOffset; break; } } return { content: fileContent.slice(startOffset, endOffset), found: true, }; } return { content: fileContent, found: true }; } /** * Normalize heading text for comparison. */ private normalizeHeadingForMatch(heading: string): string { return heading.trim().toLowerCase().replace(/\s+/g, " "); } /** * Format an embedded note payload using the shared XML-like structure. */ private formatEmbeddedNoteBlock(params: { title: string; path: string; heading?: string; blockId?: string; content?: string; error?: string; }): string { const { title, path, heading, blockId, content, error } = params; let block = `\n\n<${EMBEDDED_NOTE_TAG}>\n${title}\n${path}`; if (heading) { block += `\n${heading}`; } if (blockId) { block += `\n${blockId}`; } if (error) { block += `\n${error}`; } else { block += `\n\n${content ?? ""}\n`; } block += `\n\n\n`; return block; } /** * Processes context notes, excluding any already handled by custom prompts. * * NOTE: This method reads and includes note content as-is. URLs within note content * are NOT extracted or processed with url4llm. Only URLs directly typed in the user's * chat input are processed, not URLs that happen to be in the content of context notes. * * @param excludedNotePaths A set of file paths that should be skipped. * @param fileParserManager * @param vault * @param contextNotes * @param includeActiveNote * @param activeNote * @param currentChain * @returns The combined content string of the processed context notes. */ async processContextNotes( excludedNotePaths: Set, fileParserManager: FileParserManager, vault: Vault, contextNotes: TFile[], includeActiveNote: boolean, activeNote: TFile | null, currentChain: ChainType ): Promise { let additionalContext = ""; const processNote = async (note: TFile, prompt_tag: string = NOTE_CONTEXT_PROMPT_TAG) => { try { // Check if this note was already processed (via custom prompt) if (excludedNotePaths.has(note.path)) { logInfo(`Skipping note ${note.path} as it was included via custom prompt.`); return; } // 1. Check if the file extension is supported by any parser if (!fileParserManager.supportsExtension(note.extension)) { logWarn(`Unsupported file type: ${note.extension}`); return; } // 2. Apply chain restrictions only to supported files that are NOT text-readable if (!isPlusChain(currentChain) && !isTextReadableFile(note)) { // This file type is supported, but requires Plus mode (e.g., PDF) logWarn(`File type ${note.extension} requires Copilot Plus mode for context processing.`); // Show user-facing notice about the restriction new Notice(RESTRICTION_MESSAGES.NON_MARKDOWN_FILES_RESTRICTED); return; } // 3. If we reach here, parse the file (md, canvas, or other supported type in Plus mode) const content = note.extension === "md" ? await this.buildMarkdownContextContent(note, vault, fileParserManager, currentChain) : await fileParserManager.parseFile(note, vault); // Get file metadata const stats = await vault.adapter.stat(note.path); const ctime = stats ? new Date(stats.ctime).toISOString() : "Unknown"; const mtime = stats ? new Date(stats.mtime).toISOString() : "Unknown"; additionalContext += `\n\n<${prompt_tag}>\n${escapeXml(note.basename)}\n${note.path}\n${ctime}\n${mtime}\n\n${content}\n\n`; } catch (error) { logError(`Error processing file ${note.path}:`, error); additionalContext += `\n\n<${prompt_tag}_error>\n${escapeXml(note.basename)}\n${note.path}\n[Error: Could not process file]\n`; } }; const includedFilePaths = new Set(); // Process active note if included if (includeActiveNote && activeNote) { await processNote(activeNote, "active_note"); includedFilePaths.add(activeNote.path); } // Process context notes for (const note of contextNotes) { if (includedFilePaths.has(note.path)) { continue; } await processNote(note); includedFilePaths.add(note.path); } return additionalContext; } async hasEmbeddedPDFs(content: string): Promise { const pdfRegex = /!\[\[(.*?\.pdf)\]\]/g; return pdfRegex.test(content); } async addNoteToContext( note: TFile, vault: Vault, contextNotes: TFile[], activeNote: TFile | null, setContextNotes: (notes: TFile[] | ((prev: TFile[]) => TFile[])) => void, setIncludeActiveNote: (include: boolean) => void ): Promise { // Only check if the note exists in contextNotes if (contextNotes.some((existing) => existing.path === note.path)) { return; // Note already exists in context } // Read the note content const content = await vault.read(note); const hasEmbeddedPDFs = await this.hasEmbeddedPDFs(content); // Set includeActiveNote if it's the active note if (activeNote && note.path === activeNote.path) { setIncludeActiveNote(true); } // Add to contextNotes with wasAddedViaReference flag setContextNotes((prev: TFile[]) => [ ...prev, Object.assign(note, { wasAddedViaReference: true, hasEmbeddedPDFs, }), ]); } processSelectedTextContexts(): string { const selectedTextContexts = getSelectedTextContexts(); if (!selectedTextContexts || selectedTextContexts.length === 0) { return ""; } let additionalContext = ""; for (const selectedText of selectedTextContexts) { if (selectedText.sourceType === "web") { // Web selected text context additionalContext += `\n\n<${WEB_SELECTED_TEXT_TAG}>\n${escapeXml(selectedText.title)}\n${escapeXml(selectedText.url)}\n\n${escapeXml(selectedText.content)}\n\n`; } else { // Note selected text context (default for backward compatibility) additionalContext += `\n\n<${SELECTED_TEXT_TAG}>\n${escapeXml(selectedText.noteTitle)}\n${escapeXml(selectedText.notePath)}\n${selectedText.startLine.toString()}\n${selectedText.endLine.toString()}\n\n${selectedText.content}\n\n`; } } return additionalContext; } /** * Process web tab contexts and return formatted content for LLM. * Uses WebViewerService to fetch reader mode markdown from each tab. * Handles cases where webview content is not yet loaded (e.g., after Obsidian restart). * * Performance optimizations: * - Deduplicates tabs by URL * - Uses bounded concurrency to avoid UI blocking and resource exhaustion * * Design notes (potential future enhancements): * - Tab count limit: Currently no limit on number of tabs. Could add MAX_WEB_TABS similar to * how activeNote is handled (single note vs multiple). For now, users self-limit by only * adding tabs they need. * - Content length limit: Currently no truncation of long pages. Could add MAX_CHARS_PER_TAB * similar to how large notes are handled. For now, reader mode already strips most bloat. * - Total context budget: Could implement a shared budget across all tabs. For now, the LLM's * context window and token costs naturally discourage excessive context. */ async processContextWebTabs( webTabs: Array<{ url: string; title?: string; faviconUrl?: string; isActive?: boolean }> ): Promise { if (!webTabs || webTabs.length === 0) { return ""; } const WEBVIEW_READY_TIMEOUT_MS = 2_500; // Timeout for reader mode content extraction to avoid hanging the message send const READER_MODE_CONTENT_TIMEOUT_MS = 8_000; // Timeout for YouTube transcript extraction (longer due to DOM manipulation) const YOUTUBE_TRANSCRIPT_TIMEOUT_MS = 15_000; // Limit concurrent webview operations to avoid UI blocking and IPC congestion const MAX_CONCURRENCY = 2; /** * Build a web tab context XML block. * Centralizes XML generation to avoid duplication and ensure consistent escaping. * @param tagName - The XML tag name to use (active_web_tab or web_tab_context) */ const buildWebTabBlock = ( tagName: string, options: { title: string; url: string; mode?: string; content?: string; error?: string; } ): string => { const parts = [ `\n\n<${tagName}>`, `\n${escapeXml(options.title)}`, `\n${escapeXml(options.url)}`, ]; if (options.mode) { parts.push(`\n${escapeXml(options.mode)}`); } if (options.error) { parts.push(`\n${escapeXml(options.error)}`); } else if (options.content !== undefined) { // Content is markdown; escape to prevent XML/prompt injection parts.push(`\n\n${escapeXml(options.content)}\n`); } parts.push(`\n`); return parts.join(""); }; /** * Build a YouTube video context XML block. * All content is properly escaped to prevent XML/prompt injection. */ const buildYouTubeBlock = (options: { title: string; url: string; videoId: string; channel?: string; description?: string; uploadDate?: string; duration?: string; genre?: string; transcript?: string; error?: string; isActive?: boolean; }): string => { const parts = [ `\n\n<${YOUTUBE_VIDEO_CONTEXT_TAG}>`, `\n${escapeXml(options.title)}`, `\n${escapeXml(options.url)}`, `\n${escapeXml(options.videoId)}`, ]; if (options.isActive) { parts.push(`\ntrue`); } if (options.channel) { parts.push(`\n${escapeXml(options.channel)}`); } if (options.uploadDate) { parts.push(`\n${escapeXml(options.uploadDate)}`); } if (options.duration) { parts.push(`\n${escapeXml(options.duration)}`); } if (options.genre) { parts.push(`\n${escapeXml(options.genre)}`); } if (options.description) { parts.push(`\n${escapeXml(options.description)}`); } // Error for real failures (tab closed, extraction failed, etc.) if (options.error) { parts.push(`\n${escapeXml(options.error)}`); } // Content: transcript if available, otherwise a message const content = options.transcript || "No transcript available for this video"; parts.push(`\n\n${escapeXml(content)}\n`); parts.push(`\n`); return parts.join(""); }; // Separate active tab from normal tabs and deduplicate by URL (and videoId for YouTube) let activeTab: { url: string; title?: string; faviconUrl?: string } | null = null; const normalTabs: Array<{ url: string; title?: string; faviconUrl?: string }> = []; const seenUrls = new Set(); const seenVideoIds = new Set(); // Deduplicate YouTube videos by videoId const service = getWebViewerService(app); /** * Check if a tab should be skipped due to deduplication. * For YouTube videos, deduplicate by videoId (handles youtu.be vs youtube.com/watch). * For other URLs, deduplicate by normalized URL string. */ const isDuplicate = (url: string): boolean => { const videoId = service.getYouTubeVideoId(url); if (videoId) { if (seenVideoIds.has(videoId)) return true; seenVideoIds.add(videoId); return false; } if (seenUrls.has(url)) return true; seenUrls.add(url); return false; }; // First pass: find active tab for (const tab of webTabs) { const url = normalizeUrlString(tab.url); if (!url) continue; if (tab.isActive && !activeTab) { activeTab = { ...tab, url }; isDuplicate(url); // Mark as seen } } // Second pass: collect normal tabs (excluding duplicates) for (const tab of webTabs) { const url = normalizeUrlString(tab.url); if (!url || isDuplicate(url)) continue; normalTabs.push({ ...tab, url }); } // Check if we have any tabs to process if (!activeTab && normalTabs.length === 0) { return ""; } // Check Web Viewer availability first const availability = service.getAvailability(); if (!availability.supported || !availability.available) { const reason = availability.reason ?? (availability.supported ? "Web Viewer is not available." : "Web Viewer is not supported on this platform."); const blocks: string[] = []; if (activeTab) { blocks.push( buildWebTabBlock(ACTIVE_WEB_TAB_CONTEXT_TAG, { title: activeTab.title || "Unknown", url: activeTab.url, error: reason, }) ); } for (const tab of normalTabs) { blocks.push( buildWebTabBlock(WEB_TAB_CONTEXT_TAG, { title: tab.title || "Unknown", url: tab.url, error: reason, }) ); } return blocks.join(""); } /** * Process a single tab and return its XML block. * YouTube videos are handled specially to extract transcript. */ const processTab = async ( tab: { url: string; title?: string; faviconUrl?: string }, tagName: string ): Promise => { try { const url = tab.url; // Check if this is a YouTube video - handle specially const videoId = service.getYouTubeVideoId(url); if (videoId) { const isActive = tagName === ACTIVE_WEB_TAB_CONTEXT_TAG; return await processYouTubeTab(tab, videoId, isActive); } const leaf = service.findLeafByUrl(url, { title: tab.title }); if (!leaf) { return buildWebTabBlock(tagName, { title: tab.title || "Unknown", url, error: "Web tab not found or closed", }); } // Get initial page info (available even if webview not ready) let pageInfo = service.getPageInfo(leaf); // Check if webview is ready (content loaded) // Note: webviewMounted/webviewFirstLoadFinished are internal Obsidian fields // If they don't exist (undefined), assume ready (fallback for older versions) const view = leaf.view as { webviewMounted?: boolean; webviewFirstLoadFinished?: boolean }; const webviewReady = view.webviewMounted === undefined || view.webviewFirstLoadFinished === undefined ? true : Boolean(view.webviewMounted && view.webviewFirstLoadFinished); if (!webviewReady) { try { await service.waitForWebviewReady(leaf, WEBVIEW_READY_TIMEOUT_MS); pageInfo = service.getPageInfo(leaf); } catch (err) { logWarn(`Web tab content not loaded yet for ${url}:`, err); return buildWebTabBlock(tagName, { title: pageInfo.title || tab.title || "Untitled", url: pageInfo.url || url, mode: pageInfo.mode, error: "Web tab content not loaded yet", }); } } // Use AbortSignal for cancellable timeout const abortController = new AbortController(); const timeoutId = window.setTimeout(() => { abortController.abort(); }, READER_MODE_CONTENT_TIMEOUT_MS); try { const content = await service.getReaderModeMarkdown(leaf, { signal: abortController.signal, }); pageInfo = service.getPageInfo(leaf); return buildWebTabBlock(tagName, { title: pageInfo.title || tab.title || "Untitled", url: pageInfo.url || url, mode: pageInfo.mode, content, }); } finally { window.clearTimeout(timeoutId); } } catch (error) { logError(`Error processing web tab ${tab.url}:`, error); return buildWebTabBlock(tagName, { title: tab.title || "Unknown", url: tab.url, error: error instanceof WebViewerTimeoutError ? "Web tab content extraction timed out" : "Could not process web tab", }); } }; /** * Process a YouTube video tab and extract transcript. * Automatically clicks the transcript button, extracts content, and closes the panel. */ const processYouTubeTab = async ( tab: { url: string; title?: string; faviconUrl?: string }, videoId: string, isActive: boolean ): Promise => { try { // Find leaf by videoId (handles all URL formats and redirects) // This is more reliable than URL string matching because: // - youtu.be/xxx redirects to youtube.com/watch?v=xxx // - URL may have different query params (t=, list=, etc.) // - www vs non-www differences let leaf = null; let actualUrl = tab.url; for (const l of service.getLeaves()) { const leafUrl = service.getPageInfo(l).url; if (service.getYouTubeVideoId(leafUrl) === videoId) { leaf = l; actualUrl = leafUrl; // Use the actual URL from the leaf break; } } if (!leaf) { return buildYouTubeBlock({ title: tab.title || "YouTube Video", url: tab.url, videoId, isActive, error: "Web tab not found or closed", }); } // Wait for webview to be ready const view = leaf.view as { webviewMounted?: boolean; webviewFirstLoadFinished?: boolean }; const webviewReady = view.webviewMounted === undefined || view.webviewFirstLoadFinished === undefined ? true : Boolean(view.webviewMounted && view.webviewFirstLoadFinished); if (!webviewReady) { try { await service.waitForWebviewReady(leaf, WEBVIEW_READY_TIMEOUT_MS); } catch (err) { logWarn(`YouTube tab content not loaded yet for ${actualUrl}:`, err); return buildYouTubeBlock({ title: tab.title || "YouTube Video", url: actualUrl, videoId, isActive, error: "Web tab content not loaded yet", }); } } // Extract video metadata and transcript const result = await service.getYouTubeTranscript(leaf, { timeoutMs: YOUTUBE_TRANSCRIPT_TIMEOUT_MS, }); // Format transcript (may be empty if no transcript available) const transcriptText = result.transcript.length > 0 ? result.transcript.map((seg) => `${seg.timestamp}: ${seg.text}`).join("\n") : undefined; return buildYouTubeBlock({ title: result.title || tab.title || "YouTube Video", url: actualUrl, videoId: result.videoId, channel: result.channel, description: result.description, uploadDate: result.uploadDate, duration: result.duration, genre: result.genre, transcript: transcriptText, isActive, }); } catch (err) { logWarn(`YouTube transcript extraction failed for ${tab.url}:`, err); return buildYouTubeBlock({ title: tab.title || "YouTube Video", url: tab.url, videoId, isActive, error: err instanceof Error ? err.message : "Failed to extract video info", }); } }; // Process active tab first (if exists) const blocks: string[] = []; if (activeTab) { const activeBlock = await processTab(activeTab, ACTIVE_WEB_TAB_CONTEXT_TAG); blocks.push(activeBlock); } // Process normal tabs with bounded concurrency for (let i = 0; i < normalTabs.length; i += MAX_CONCURRENCY) { const chunk = normalTabs.slice(i, i + MAX_CONCURRENCY); const chunkResults = await Promise.all( chunk.map((tab) => processTab(tab, WEB_TAB_CONTEXT_TAG)) ); blocks.push(...chunkResults); } return blocks.join(""); } }