mirror of
https://github.com/rmccorkl/TubeSage.git
synced 2026-07-22 06:45:31 +00:00
6434 lines
307 KiB
TypeScript
6434 lines
307 KiB
TypeScript
import { App, Plugin, PluginSettingTab, Setting, Modal, Platform, DropdownComponent, TextComponent, ExtraButtonComponent, ButtonComponent, TFile, ToggleComponent, setTooltip, setIcon } from 'obsidian';
|
||
import { YouTubeTranscriptExtractor, TranscriptSegment } from './src/youtube-transcript';
|
||
import { TranscriptSummarizer } from './src/llm/transcript-summarizer';
|
||
import { sanitizeFilename } from './src/utils/filename-sanitizer';
|
||
import { handleApiError, getSafeErrorMessage } from './src/utils/error-utils';
|
||
import { getLogger, LogLevel, setGlobalLogLevel, clearLogs, getLogsForCallout } from './src/utils/logger';
|
||
import { normalizePath, ensureFolder, joinPaths, sanitizePathComponent, collectUnder } from './src/utils/path-utils';
|
||
import { validateRequired, validateYouTubeUrl, ValidationResult, displayValidationResult } from './src/utils/form-utils';
|
||
import { getPromptConfig, cleanTranscript, SummaryMode, getTimestampLinkConfig } from './src/utils/prompt-utils';
|
||
import { showNotice, isYoutubeUrl, isYoutubeChannelOrPlaylistUrl, extractChannelName, YOUTUBE_URL_PLACEHOLDER } from './src/utils/youtube-utils';
|
||
import { obsidianFetch } from './src/utils/fetch-shim';
|
||
import { ProcessingSpinner } from './src/utils/processing-spinner';
|
||
import {
|
||
extractDocumentComponents,
|
||
reconstructDocument,
|
||
validateEnhancedContent,
|
||
createOptimizedChunks,
|
||
countTimestampLinks,
|
||
ensureTrailingNewline,
|
||
hasProperHeading,
|
||
hasTimestampLinks,
|
||
convertTimestampToSeconds,
|
||
convertTimeIndexToWatchUrls
|
||
} from './src/utils/timestamp-utils';
|
||
import type { Provider } from './src/utils/model-limits-registry';
|
||
import { getEffectiveLimits, isModelSupported, upsertModel } from './src/utils/model-limits-registry';
|
||
|
||
// Initialize logger here
|
||
const logger = getLogger('PLUGIN');
|
||
const transcriptLogger = getLogger('TRANSCRIPT');
|
||
const llmLogger = getLogger('LLM');
|
||
|
||
function truncateForLogs(text: string, maxLength: number = 500): string {
|
||
if (!text) return '';
|
||
if (text.length <= maxLength) return text;
|
||
return text.substring(0, maxLength) + '...[truncated]';
|
||
}
|
||
|
||
// Define a minimal folder item interface
|
||
interface FolderItem {
|
||
path: string;
|
||
name: string;
|
||
}
|
||
|
||
interface Closeable {
|
||
close: () => void;
|
||
}
|
||
|
||
type TranscriptInputSegment = TranscriptSegment & {
|
||
tStartMs?: string;
|
||
segs?: Array<{ utf8?: string }>;
|
||
};
|
||
|
||
type UnknownRecord = Record<string, unknown>;
|
||
|
||
const isRecord = (value: unknown): value is UnknownRecord =>
|
||
typeof value === 'object' && value !== null;
|
||
|
||
type AppWithPlugins = App & {
|
||
plugins?: {
|
||
plugins?: Record<string, unknown>;
|
||
manifest?: Record<string, { id?: string }>;
|
||
};
|
||
};
|
||
|
||
type TemplaterContext = {
|
||
user?: Record<string, unknown>;
|
||
};
|
||
|
||
type TemplaterApi = {
|
||
current_functions_object?: unknown;
|
||
create_running_config: (templateFile: TFile, targetFile: TFile, mode: number) => unknown;
|
||
functions_generator: {
|
||
generate_object: (config: unknown) => Promise<TemplaterContext>;
|
||
};
|
||
parser: {
|
||
parse_commands: (content: string, ctx: TemplaterContext) => Promise<string>;
|
||
};
|
||
};
|
||
|
||
type TemplaterSettings = {
|
||
templates_folder?: string;
|
||
};
|
||
|
||
type TemplaterPlugin = {
|
||
templater: TemplaterApi;
|
||
settings?: TemplaterSettings;
|
||
};
|
||
|
||
const getPluginRegistry = (app: App): Record<string, unknown> | null => {
|
||
const plugins = (app as AppWithPlugins).plugins?.plugins;
|
||
if (!plugins || typeof plugins !== 'object') {
|
||
return null;
|
||
}
|
||
return plugins;
|
||
};
|
||
|
||
const getTemplaterPlugin = (app: App): TemplaterPlugin | null => {
|
||
const registry = getPluginRegistry(app);
|
||
const candidate = registry?.['templater-obsidian'];
|
||
if (!isRecord(candidate)) {
|
||
return null;
|
||
}
|
||
const templater = candidate['templater'];
|
||
if (!isRecord(templater)) {
|
||
return null;
|
||
}
|
||
return candidate as TemplaterPlugin;
|
||
};
|
||
|
||
const getTemplaterSettings = (app: App): TemplaterSettings | null => {
|
||
const registry = getPluginRegistry(app);
|
||
const candidate = registry?.['templater-obsidian'];
|
||
if (!isRecord(candidate)) {
|
||
return null;
|
||
}
|
||
const settings = candidate['settings'];
|
||
if (!isRecord(settings)) {
|
||
return null;
|
||
}
|
||
return settings;
|
||
};
|
||
|
||
const getPluginIdFromManifest = (app: App, fallback: string): string => {
|
||
const manifest = (app as AppWithPlugins).plugins?.manifest;
|
||
if (!manifest || typeof manifest !== 'object') {
|
||
return fallback;
|
||
}
|
||
const entry = manifest[fallback];
|
||
if (!isRecord(entry)) {
|
||
return fallback;
|
||
}
|
||
const id = entry['id'];
|
||
return typeof id === 'string' && id.trim() ? id : fallback;
|
||
};
|
||
|
||
interface YouTubePlaylistItem {
|
||
snippet?: {
|
||
title?: string;
|
||
resourceId?: {
|
||
videoId?: string;
|
||
};
|
||
};
|
||
}
|
||
|
||
interface PlaylistItemsResponse {
|
||
items?: YouTubePlaylistItem[];
|
||
nextPageToken?: string;
|
||
}
|
||
|
||
interface PlaylistResponse {
|
||
items?: Array<{
|
||
snippet?: {
|
||
title?: string;
|
||
};
|
||
}>;
|
||
}
|
||
|
||
interface ChannelResponse {
|
||
items?: Array<{
|
||
snippet?: {
|
||
title?: string;
|
||
};
|
||
contentDetails?: {
|
||
relatedPlaylists?: {
|
||
uploads?: string;
|
||
};
|
||
};
|
||
}>;
|
||
}
|
||
|
||
interface ChannelIdResponse {
|
||
items?: Array<{
|
||
id?: string;
|
||
}>;
|
||
}
|
||
|
||
interface YouTubeTranscriptSettings {
|
||
// Template settings
|
||
templaterTemplateFile: string;
|
||
|
||
// LLM Settings
|
||
selectedLLM: string;
|
||
apiKeys: Record<string, string>;
|
||
selectedModels: Record<string, string>;
|
||
temperature: number;
|
||
maxTokens: number;
|
||
|
||
// Custom model parameters (for models not in registry)
|
||
customModelLimits: Record<string, {
|
||
contextK: number; // Context window in thousands (e.g., 400 for 400K)
|
||
maxOutputK: number; // Max output in thousands (e.g., 128 for 128K)
|
||
inputMaxK?: number; // Optional explicit input cap in thousands
|
||
reservePct?: number; // Optional reserve percentage (default: 0.10 for cloud, 0.15 for local)
|
||
}>;
|
||
|
||
// Fetched model IDs per provider — populated by the per-provider refresh
|
||
// button (fetchOpenAIModels / fetchGoogleModels / fetchAnthropicModels).
|
||
// Decoupled from customModelLimits so providers whose API doesn't return
|
||
// token limits (OpenAI) still survive a settings re-render with their
|
||
// discovered model list intact.
|
||
fetchedModels: Record<string, string[]>;
|
||
|
||
// Prompt settings
|
||
systemPrompt: string;
|
||
userPrompt: string;
|
||
// Extensive prompt settings
|
||
extensiveSystemPrompt: string;
|
||
extensiveUserPrompt: string;
|
||
// Summary mode
|
||
useFastSummary: boolean;
|
||
// Second pass - timestamp linking prompt
|
||
timestampSystemPrompt: string;
|
||
timestampUserPrompt: string;
|
||
// Timestamp links
|
||
addTimestampLinks: boolean;
|
||
|
||
// Transcript settings
|
||
translateLanguage: string;
|
||
translateCountry: string;
|
||
youtubeApiKey: string;
|
||
supadataApiKey: string;
|
||
scrapcreatorsApiKey: string;
|
||
|
||
// Folder settings
|
||
transcriptRootFolder: string;
|
||
|
||
// Date format settings
|
||
dateFormat: string;
|
||
prependDate: boolean;
|
||
|
||
// Debug settings
|
||
debugLogging: boolean;
|
||
|
||
// License settings
|
||
licenseAccepted: boolean;
|
||
|
||
// Cookie management settings
|
||
youtubeCookies?: {
|
||
desktop?: string;
|
||
mobile?: string;
|
||
lastBootstrap?: number;
|
||
timestamp?: number;
|
||
};
|
||
|
||
}
|
||
|
||
const DEFAULT_SETTINGS: YouTubeTranscriptSettings = {
|
||
templaterTemplateFile: 'Templates/YouTubeTranscript.md',
|
||
selectedLLM: 'openai',
|
||
apiKeys: {
|
||
openai: '',
|
||
anthropic: '',
|
||
google: '',
|
||
ollama: 'http://localhost:11434',
|
||
openrouter: ''
|
||
},
|
||
selectedModels: {
|
||
openai: 'gpt-4-turbo',
|
||
anthropic: 'claude-3-sonnet-20240229',
|
||
google: 'gemini-1.5-pro',
|
||
ollama: 'llama3.1',
|
||
openrouter: 'openai/gpt-4o'
|
||
},
|
||
temperature: 0.7,
|
||
maxTokens: 1000,
|
||
|
||
// Custom model parameters (for models not in registry)
|
||
customModelLimits: {},
|
||
|
||
// Fetched model IDs per provider — populated by the refresh button
|
||
fetchedModels: {
|
||
openai: [],
|
||
anthropic: [],
|
||
google: [],
|
||
ollama: [],
|
||
openrouter: [],
|
||
},
|
||
|
||
|
||
// Short (Fast) Summary prompt
|
||
systemPrompt: `You are a helpful assistant that summarizes YouTube transcripts clearly and concisely using Markdown.
|
||
When you reply output plain Markdown only.
|
||
Do NOT wrap responses in \`\`\` markdown code fences.
|
||
Use code fences ONLY for code snippets that should appear as code.
|
||
Do not label any fence with "markdown"`,
|
||
userPrompt: `Extract structured notes from the transcript below without explanation or preface. Extract key points, main ideas, and important details.
|
||
FORMAT USING PROPER MARKDOWN HEADINGS with # syntax (not bold text).
|
||
Specifically:
|
||
2. Use markdown numbered subheadings (e.g., "## 1. Topic")
|
||
3. Use markdown numbered section headings (e.g., "### 1.1. Sub Topic")
|
||
4. Do NOT use bold text (**text**) for headings
|
||
5. Use bullet points for lists
|
||
This document will be processed as Markdown for Obsidian, so proper heading syntax is essential.
|
||
Provide only the summary notes.
|
||
Do not explain what you're doing or include any introductory sentence.
|
||
Your output should be clean Markdown content only. Do not introduce, explain, or narrate anything about the task. Begin directly with content.
|
||
Start the output with the actual summary content only, no headers, no preamble, no postamble.
|
||
Respond only with the raw answer, no intro or outro text.
|
||
Begin with a short opening summary paragraph (2–4 sentences, no heading) that captures the main themes, no headers, no preamble, no postamble. Then produce the numbered sections below.
|
||
At the end, have a conclusion section and list any books, people, or resources mentioned, along with a short explanation of their relevance.`,
|
||
// Extensive Summary prompt
|
||
extensiveSystemPrompt: `You are a highly analytical assistant that produces comprehensive, structured, and insightful notes from transcripts in proper Obsidian Markdown format.
|
||
You specialize in creating deep, paragraph-level breakdowns of complex material with clarity and nuance.
|
||
Your notes help readers understand both what is said and the reasoning or implications behind it.
|
||
The objective is to extract all meaningful content, ideas, and knowledge from the transcript
|
||
so that a reader can fully understand and review the material through structured notes without needing to watch or re-watch the video.
|
||
IMPORTANT: Always use proper Markdown heading syntax with # characters (not bold text) for all headings and section titles.
|
||
When you reply output plain Markdown only.
|
||
Do NOT wrap responses in \`\`\` markdown code fences.
|
||
Use code fences ONLY for code snippets that should appear as code.
|
||
Do not label any fence with "markdown"`,
|
||
extensiveUserPrompt: `From the transcript below, create detailed and structured notes for someone who wants to understand the material in depth.
|
||
Organize the content into clearly numbered sections based on major topic or theme changes.
|
||
Extract structured notes from the transcript below without explanation or preface, extract key points, main ideas, and important details.
|
||
FORMAT USING PROPER MARKDOWN HEADINGS with # syntax (not bold text).
|
||
Specifically:
|
||
2. Use markdown numbered subheadings (e.g., "## 1. Topic")
|
||
3. Use markdown numbered section headings (e.g., "### 1.1. Sub Topic")
|
||
4. Do NOT use bold text (**text**) for headings
|
||
5. Use bullet points for lists
|
||
|
||
This document will be processed as Markdown for Obsidian, so proper heading syntax is essential. Treat this as a document for training future analysts in this field.
|
||
Provide only the summary notes.
|
||
Do not explain what you're doing or include any introductory sentence.
|
||
Your output should be clean Markdown content only. Do not introduce, explain, or narrate anything about the task. Begin directly with content.
|
||
Start the output with the actual summary content only, no headers, no preamble, no postamble.
|
||
Respond only with the raw answer, no intro or outro text.
|
||
Begin with a short opening summary paragraph (2–4 sentences, no heading) that captures the main themes, no headers, no preamble, no postamble. Then produce the numbered sections below.
|
||
|
||
For each section:
|
||
- Number sections sequentially (1, 2, 3, etc.). IMPORTANT: Use actual Obsidian Markdown heading syntax with # symbols, not bold text.
|
||
- Write multiple detailed paragraphs, that explain the content and any theory, technical terms or definitions, models and frameworks thoroughly and in great detail drawn from the transcript.
|
||
- Include below the paragraphs key concepts, terms, taxonomy, ontology , or ideas, and explain them clearly with examples where relevant.
|
||
- Incorporate and explain important quotations direct from subject (person) , analogies, or references.
|
||
- Explore the reasoning, implications, or broader significance behind the ideas.
|
||
- Explicitly identify and analyze any contrasts, tensions, contradictions, or shifts in perspective throughout the discussion. Pay special attention to dialectical relationships between concepts.
|
||
|
||
At the end, have a conclusion section and list any books, people, or resources mentioned, along with a short explanation of their relevance.`,
|
||
// Second pass - timestamp linking prompt
|
||
timestampSystemPrompt: 'You are a highly analytical assistant that adds TimeIndex markers to section headings by deeply analyzing the content under each heading. Your expertise is in content analysis - reading the detailed content of each section and matching it to where that specific content is substantially discussed in the transcript. You focus on semantic content matching, not superficial title matching. You never include any reference material (like video IDs or transcripts) in your output.',
|
||
timestampUserPrompt: `TASK: Add TimeIndex markers to each section heading in this document.
|
||
|
||
CRITICAL: You must output TimeIndex markers in format [TimeIndex:SECONDS] - NOT YouTube Watch URLs!
|
||
|
||
RULES:
|
||
1. NEVER summarize or modify the content unless translation is requested
|
||
2. NEVER remove any content
|
||
3. ALWAYS return the FULL original content PLUS TimeIndex markers at the end of section headings
|
||
4. If processing multiple sections, add TimeIndex markers to ALL headings
|
||
5. ONLY process markdown numbered headings
|
||
a. for subheadings (e.g., "## 1. Topic")
|
||
b. for section headings (e.g., "## 1.1. Sub Topic")
|
||
6. DO NOT process headings without numbers or dots
|
||
7. DO NOT process horizontal rules (single #)
|
||
8. Do NOT add a preamble or postamble or headers or titles, ONLY ADD TimeIndex markers to headings
|
||
9. Respond only with the raw answer, no intro or outro text.
|
||
10. NEVER include any reference material marked by ----- REFERENCE MATERIAL ----- blocks in your response.
|
||
|
||
EXACTLY HOW TO DO THIS:
|
||
1. Identify ALL section headings in the document that follow the markedown format
|
||
2. Look at the transcript which has timestamps in format: [HH:MM:SS] [TimeIndex:X] where X is the exact seconds value
|
||
3. For each section heading, THOROUGHLY READ AND ANALYZE THE ENTIRE CONTENT UNDER THAT HEADING:
|
||
- Read every paragraph, bullet point, and detail in that section
|
||
- Identify the key concepts, specific examples, and main arguments discussed
|
||
- Note specific terminology, names, numbers, or unique phrases used
|
||
- The heading title alone is NOT sufficient - you must understand what the section actually covers
|
||
4. Then find where in the transcript this SPECIFIC CONTENT is BEST and MOST COMPREHENSIVELY DISCUSSED:
|
||
- Look for transcript segments that contain the same specific details, examples, and concepts
|
||
- Find where the speaker begins to substantively address the topics covered in that section
|
||
- The goal is to link to where the content actually starts being discussed, not just mentioned
|
||
5. When matching section content to transcript timestamps:
|
||
- Match based on CONTENT SUBSTANCE, not just heading titles or keyword mentions
|
||
- A section about "Investment Strategies" should link to where investment strategies are actually explained, not just where the phrase appears
|
||
- Look for where the speaker begins the detailed discussion that led to the content in that section
|
||
- Simply use the TimeIndex value from the relevant transcript section
|
||
- Example: If you find the relevant transcript section has [TimeIndex:175], add [TimeIndex:175] to the heading
|
||
- DO NOT calculate seconds manually - just use the TimeIndex value directly
|
||
- IMPORTANT: Only use TimeIndex values that actually appear in the transcript
|
||
- ENSURE the TimeIndex value does not exceed the length of the video
|
||
6. Add the TimeIndex marker in the format: [TimeIndex:SECONDS] where SECONDS is the number of seconds
|
||
- Example: If transcript shows [TimeIndex:175], add [TimeIndex:175] to the heading
|
||
- Always use the exact seconds value from the transcript's TimeIndex
|
||
- Transform heading "## 1. Introduction" to "## 1. Introduction [TimeIndex:175]"
|
||
- Another example: "### 3.1. The Scam of Government Bonds [TimeIndex:338]"
|
||
7. Place the TimeIndex marker at the end of the heading line, after the heading text`,
|
||
// Default to Extensive Summary
|
||
useFastSummary: false,
|
||
|
||
translateLanguage: 'en',
|
||
translateCountry: 'US',
|
||
youtubeApiKey: '',
|
||
supadataApiKey: '',
|
||
scrapcreatorsApiKey: '',
|
||
transcriptRootFolder: 'Inbox', // Default to Inbox for backward compatibility
|
||
dateFormat: 'YYYY-MM-DD',
|
||
prependDate: true,
|
||
addTimestampLinks: true,
|
||
debugLogging: false,
|
||
|
||
// License settings
|
||
licenseAccepted: false,
|
||
|
||
// Cookie management - undefined means no cookies stored yet
|
||
youtubeCookies: undefined,
|
||
|
||
};
|
||
|
||
// Cloud LLM providers whose API keys are secrets stored in Obsidian's
|
||
// secret storage. 'ollama' is intentionally excluded — its apiKeys entry
|
||
// is a server URL, not a secret, and stays in data.json.
|
||
const CLOUD_PROVIDERS = ['openai', 'anthropic', 'google', 'openrouter'] as const;
|
||
|
||
// Secret-storage IDs, one per cloud provider. IDs must be lowercase
|
||
// alphanumeric with optional dashes (required by SecretStorage.setSecret).
|
||
const SECRET_IDS: Record<string, string> = {
|
||
openai: 'tubesage-openai-key',
|
||
anthropic: 'tubesage-anthropic-key',
|
||
google: 'tubesage-google-key',
|
||
openrouter: 'tubesage-openrouter-key',
|
||
};
|
||
|
||
// Define a simple interface for the model object from OpenAI API
|
||
interface OpenAIModel {
|
||
id: string;
|
||
object: string;
|
||
created: number;
|
||
owned_by: string;
|
||
// Add other relevant properties if needed in the future
|
||
}
|
||
|
||
// Define a simple interface for the model object from Google Generative AI API
|
||
interface GoogleModel {
|
||
name: string; // e.g., "models/gemini-1.5-pro-latest"
|
||
displayName?: string;
|
||
version?: string;
|
||
description?: string;
|
||
supportedGenerationMethods?: string[];
|
||
inputTokenLimit?: number;
|
||
outputTokenLimit?: number;
|
||
}
|
||
|
||
interface FetchedModelInfo {
|
||
id: string;
|
||
contextK?: number; // context window in thousands
|
||
maxOutputK?: number; // max output in thousands
|
||
}
|
||
|
||
interface ApiErrorResponse {
|
||
error?: {
|
||
message?: string;
|
||
};
|
||
message?: string;
|
||
}
|
||
|
||
interface OpenAIModelsResponse {
|
||
data?: OpenAIModel[];
|
||
}
|
||
|
||
interface GoogleModelsResponse {
|
||
models?: GoogleModel[];
|
||
}
|
||
|
||
export default class YouTubeTranscriptPlugin extends Plugin {
|
||
settings: YouTubeTranscriptSettings;
|
||
private summarizer: TranscriptSummarizer;
|
||
private fileWatcher: Closeable | null = null;
|
||
|
||
// Replace the duplicated showNotice method with a wrapper that calls the shared utility
|
||
showNotice(message: string, timeout: number = 5000): void {
|
||
showNotice(message, timeout);
|
||
}
|
||
|
||
// Get plugin version from manifest
|
||
getVersion(): string {
|
||
return this.manifest.version || 'Unknown';
|
||
}
|
||
|
||
async onload() {
|
||
await this.loadSettings();
|
||
|
||
// Set appropriate max tokens based on current provider and model using registry
|
||
const effectiveMaxTokens = this.getEffectiveMaxTokens();
|
||
|
||
// Only update if the current setting is a legacy hardcoded value
|
||
if (this.settings.maxTokens === 4096 || this.settings.maxTokens === 8192 || this.settings.maxTokens === 1000) {
|
||
this.settings.maxTokens = effectiveMaxTokens;
|
||
await this.saveSettings();
|
||
|
||
if (this.settings.debugLogging) {
|
||
logger.debug(`[onload] Updated maxTokens from legacy value to ${effectiveMaxTokens} for ${this.settings.selectedLLM}:${this.settings.selectedModels[this.settings.selectedLLM]}`);
|
||
}
|
||
}
|
||
|
||
// Set log level based on settings
|
||
if (this.settings.debugLogging) {
|
||
setGlobalLogLevel(LogLevel.DEBUG);
|
||
} else {
|
||
setGlobalLogLevel(LogLevel.INFO);
|
||
}
|
||
|
||
this.initializeSummarizer();
|
||
|
||
|
||
this.addSettingTab(new YouTubeTranscriptSettingTab(this.app, this));
|
||
this.checkDependencies();
|
||
|
||
// Add ribbon icon
|
||
this.addRibbonIcon('youtube', 'Tubesage: create note from YouTube transcript', () => {
|
||
// Check if license has been accepted
|
||
if (!this.settings.licenseAccepted) {
|
||
// Show license required modal if not accepted
|
||
new LicenseRequiredModal(this.app).open();
|
||
return;
|
||
}
|
||
|
||
// Check if API key is set for the selected LLM provider
|
||
const selectedLlm = this.settings.selectedLLM;
|
||
if (!this.settings.apiKeys[selectedLlm] || this.settings.apiKeys[selectedLlm].trim() === '') {
|
||
// Show error notice
|
||
showNotice(`Youtube Transcript Plugin: No API key configured for ${selectedLlm}. Please add your API key in the plugin settings.`, 7000);
|
||
return;
|
||
}
|
||
|
||
// If license is accepted and API key is set, proceed with the usual workflow
|
||
new YouTubeTranscriptModal(this.app, this).open();
|
||
});
|
||
|
||
// Add command
|
||
this.addCommand({
|
||
id: 'extract-youtube-transcript',
|
||
name: 'Extract YouTube transcript',
|
||
callback: () => {
|
||
// Check if license has been accepted
|
||
if (!this.settings.licenseAccepted) {
|
||
// Show license required modal if not accepted
|
||
new LicenseRequiredModal(this.app).open();
|
||
return;
|
||
}
|
||
|
||
// Check if API key is set for the selected LLM provider
|
||
const selectedLlm = this.settings.selectedLLM;
|
||
if (!this.settings.apiKeys[selectedLlm] || this.settings.apiKeys[selectedLlm].trim() === '') {
|
||
// Show error notice
|
||
showNotice(`Youtube transcript plugin: No API key configured for ${selectedLlm}. Please add your API key in the plugin settings.`, 7000);
|
||
return;
|
||
}
|
||
|
||
// If license is accepted and API key is set, proceed with the usual workflow
|
||
new YouTubeTranscriptModal(this.app, this).open();
|
||
}
|
||
});
|
||
|
||
// Note: The file watcher setup has been removed as it was dependent on the anthropic proxy
|
||
|
||
}
|
||
|
||
onunload() {
|
||
logger.debug('Unloading youtube transcript plugin');
|
||
|
||
// Clean up file watcher if it exists
|
||
if (this.fileWatcher) {
|
||
try {
|
||
this.fileWatcher.close();
|
||
this.fileWatcher = null;
|
||
logger.debug('Closed file watcher');
|
||
} catch (error) {
|
||
logger.error('Error closing file watcher:', error);
|
||
}
|
||
}
|
||
|
||
|
||
// Any other cleanup needed
|
||
logger.info('Youtube transcript plugin unloaded');
|
||
}
|
||
|
||
private initializeSummarizer() {
|
||
const selectedProvider = this.settings.selectedLLM;
|
||
const selectedModel = this.getModelForProvider(selectedProvider);
|
||
|
||
logger.debug(`[initializeSummarizer] Selected provider: '${selectedProvider}'`);
|
||
logger.debug(`[initializeSummarizer] Selected model: '${selectedModel}'`);
|
||
logger.debug(`[initializeSummarizer] Temperature: ${this.settings.temperature}, MaxTokens: ${this.settings.maxTokens}`);
|
||
logger.debug(`[initializeSummarizer] API Keys present:`, Object.keys(this.settings.apiKeys).reduce((acc, key) => {
|
||
acc[key] = !!this.settings.apiKeys[key];
|
||
return acc;
|
||
}, {} as Record<string, boolean>));
|
||
|
||
this.summarizer = new TranscriptSummarizer({
|
||
model: selectedModel,
|
||
temperature: this.settings.temperature,
|
||
maxTokens: this.getEffectiveMaxTokens(), // Use dynamic calculation for all models
|
||
systemPrompt: this.settings.systemPrompt,
|
||
userPrompt: this.settings.userPrompt
|
||
}, this.settings.apiKeys);
|
||
}
|
||
|
||
private getModelForProvider(provider: string): string {
|
||
logger.debug(`[getModelForProvider] Getting model for provider: '${provider}'`);
|
||
logger.debug(`[getModelForProvider] selectedModels object:`, JSON.stringify(this.settings.selectedModels, null, 2));
|
||
|
||
if (this.settings.selectedModels[provider]) {
|
||
const selectedModel = this.settings.selectedModels[provider];
|
||
logger.debug(`[getModelForProvider] Found selected model for ${provider}: '${selectedModel}'`);
|
||
return selectedModel;
|
||
}
|
||
|
||
// Fallback to defaults if no selection exists
|
||
logger.debug(`[getModelForProvider] No selected model found for ${provider}, using fallback`);
|
||
switch (provider) {
|
||
case 'openai':
|
||
logger.debug(`[getModelForProvider] Using OpenAI fallback: 'gpt-4-turbo'`);
|
||
return 'gpt-4-turbo';
|
||
case 'anthropic':
|
||
logger.debug(`[getModelForProvider] Using Anthropic fallback: 'claude-3-sonnet-20240229'`);
|
||
return 'claude-3-sonnet-20240229';
|
||
case 'google':
|
||
logger.debug(`[getModelForProvider] Using Google fallback: 'gemini-1.5-pro'`);
|
||
return 'gemini-1.5-pro';
|
||
case 'ollama':
|
||
return 'llama3.1';
|
||
case 'openrouter':
|
||
logger.debug(`[getModelForProvider] Using OpenRouter fallback: 'openai/gpt-4o'`);
|
||
return 'openai/gpt-4o';
|
||
default:
|
||
throw new Error(`Unsupported LLM provider: ${provider}`);
|
||
}
|
||
}
|
||
|
||
async loadSettings() {
|
||
const loadedData: unknown = await this.loadData();
|
||
logger.debug('[SETTINGS DEBUG] Loaded data from storage:', loadedData);
|
||
logger.debug('[SETTINGS DEBUG] DEFAULT_SETTINGS.selectedLLM:', DEFAULT_SETTINGS.selectedLLM);
|
||
|
||
const loadedSettings: Partial<YouTubeTranscriptSettings> = isRecord(loadedData)
|
||
? loadedData
|
||
: {};
|
||
|
||
this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings };
|
||
|
||
logger.debug('[SETTINGS DEBUG] Final settings.selectedLLM:', this.settings.selectedLLM);
|
||
logger.debug('[SETTINGS DEBUG] All settings keys:', Object.keys(this.settings));
|
||
// --- Fix legacy string booleans (mobile settings files might contain "true"/"false" strings) ---
|
||
const coerceBool = (val: unknown, defaultVal: boolean): boolean => {
|
||
if (typeof val === 'boolean') return val;
|
||
if (typeof val === 'string') return val.toLowerCase() === 'true';
|
||
return defaultVal;
|
||
};
|
||
|
||
// Ensure all boolean flags are actual booleans
|
||
this.settings.debugLogging = coerceBool(this.settings.debugLogging, DEFAULT_SETTINGS.debugLogging);
|
||
this.settings.prependDate = coerceBool(this.settings.prependDate, DEFAULT_SETTINGS.prependDate);
|
||
this.settings.addTimestampLinks = coerceBool(this.settings.addTimestampLinks, DEFAULT_SETTINGS.addTimestampLinks);
|
||
this.settings.useFastSummary = coerceBool(this.settings.useFastSummary, DEFAULT_SETTINGS.useFastSummary);
|
||
this.settings.licenseAccepted = coerceBool(this.settings.licenseAccepted, DEFAULT_SETTINGS.licenseAccepted);
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// One-time migration: wipe polluted customModelLimits entries left by
|
||
// the 1.2.15-1.2.17 auto-populate / per-keystroke bugs. Those bugs
|
||
// wrote one entry per keystroke in the custom-name field and one
|
||
// entry per fetched-but-unknown model, all with the fingerprint
|
||
// {contextK: 128, maxOutputK: 16, reservePct: 0.10} for non-Ollama
|
||
// providers (Ollama legitimately uses 128/16 as its preset default
|
||
// since model sizes vary too widely to ship a registry). We delete
|
||
// only the exact-match fingerprint entries to avoid wiping legitimate
|
||
// user overrides that happen to coincide with the fallback shape.
|
||
const polluted: string[] = [];
|
||
for (const [key, val] of Object.entries(this.settings.customModelLimits ?? {})) {
|
||
if (!val || typeof val !== 'object') continue;
|
||
const isFingerprint =
|
||
val.contextK === 128 &&
|
||
val.maxOutputK === 16 &&
|
||
val.reservePct === 0.10 &&
|
||
val.inputMaxK === undefined;
|
||
// Skip Ollama: 128/16/0.10 might be a legitimate user choice there.
|
||
// (Ollama's default reservePct is 0.15, not 0.10, so any 0.10
|
||
// entry is suspicious anyway — but keep them just in case.)
|
||
if (isFingerprint && !key.startsWith('ollama:')) {
|
||
polluted.push(key);
|
||
}
|
||
}
|
||
if (polluted.length > 0) {
|
||
logger.info(`[migration] Removing ${polluted.length} polluted customModelLimits entries: ${polluted.join(', ')}`);
|
||
for (const key of polluted) {
|
||
delete this.settings.customModelLimits[key];
|
||
}
|
||
await this.persist();
|
||
}
|
||
|
||
// --- API key secret-storage migration ---------------------------------
|
||
// Cloud-provider keys live in Obsidian secret storage, not data.json.
|
||
// 1. Migrate any key still present in data.json into secret storage.
|
||
// 2. Populate the runtime settings.apiKeys cloud entries from storage.
|
||
// 3. If data.json held any cloud key, persist once to scrub it out.
|
||
const dataApiKeys: Record<string, string> =
|
||
isRecord(loadedSettings.apiKeys) ? loadedSettings.apiKeys : {};
|
||
let hadCloudKeysInData = false;
|
||
for (const provider of CLOUD_PROVIDERS) {
|
||
const fromData = dataApiKeys[provider];
|
||
if (typeof fromData === 'string' && fromData.trim() !== '') {
|
||
hadCloudKeysInData = true;
|
||
if (!this.app.secretStorage.getSecret(SECRET_IDS[provider])) {
|
||
this.app.secretStorage.setSecret(SECRET_IDS[provider], fromData);
|
||
}
|
||
}
|
||
}
|
||
for (const provider of CLOUD_PROVIDERS) {
|
||
this.settings.apiKeys[provider] =
|
||
this.app.secretStorage.getSecret(SECRET_IDS[provider]) ?? '';
|
||
}
|
||
if (hadCloudKeysInData) {
|
||
logger.info('[migration] Moved cloud API keys to secret storage; scrubbing data.json');
|
||
await this.persist();
|
||
}
|
||
// ----------------------------------------------------------------------
|
||
}
|
||
|
||
/**
|
||
* Persist settings to data.json with cloud-provider API keys stripped out.
|
||
* Cloud keys live in Obsidian secret storage, never in data.json.
|
||
*/
|
||
private async persist(): Promise<void> {
|
||
const sanitizedApiKeys: Record<string, string> = {
|
||
ollama: this.settings.apiKeys.ollama ?? DEFAULT_SETTINGS.apiKeys.ollama,
|
||
};
|
||
const toSave = { ...this.settings, apiKeys: sanitizedApiKeys };
|
||
await this.saveData(toSave);
|
||
}
|
||
|
||
async saveSettings() {
|
||
await this.persist();
|
||
this.initializeSummarizer();
|
||
}
|
||
|
||
async extractTranscript(videoUrl: string): Promise<string> {
|
||
const result = await this.extractTranscriptWithMetadata(videoUrl);
|
||
return result.transcript;
|
||
}
|
||
|
||
async extractTranscriptWithMetadata(videoUrl: string): Promise<{transcript: string, metadata: {title?: string, author?: string}}> {
|
||
return await this.extractTranscriptsWithMetadata([videoUrl]).then(results => results[0]);
|
||
}
|
||
|
||
async extractTranscriptsWithMetadata(videoUrls: string[]): Promise<Array<{transcript: string, metadata: {title?: string, author?: string}}>> {
|
||
try {
|
||
transcriptLogger.debug(`Starting transcript extraction for ${videoUrls.length} URLs`);
|
||
|
||
const results: Array<{transcript: string, metadata: {title?: string, author?: string}}> = [];
|
||
|
||
// Process each URL
|
||
for (let i = 0; i < videoUrls.length; i++) {
|
||
const videoUrl = videoUrls[i];
|
||
try {
|
||
transcriptLogger.debug(`Processing video ${i + 1}/${videoUrls.length}: ${videoUrl}`);
|
||
|
||
// Extract video ID from URL
|
||
const videoId = YouTubeTranscriptExtractor.extractVideoId(videoUrl);
|
||
|
||
if (!videoId) {
|
||
throw new Error(`Invalid youtube URL: '${videoUrl}'. Please ensure the URL is properly formatted without extra characters like quotes.`);
|
||
}
|
||
|
||
// Get transcript segments and metadata using direct ScrapeCreators method
|
||
const result = await YouTubeTranscriptExtractor.fetchTranscript(videoId, {
|
||
lang: this.settings.translateLanguage,
|
||
country: this.settings.translateCountry,
|
||
supadataApiKey: this.settings.supadataApiKey || undefined,
|
||
scrapcreatorsApiKey: this.settings.scrapcreatorsApiKey || undefined
|
||
});
|
||
|
||
// Format transcript with timestamps
|
||
const formattedTranscript = this.formatTranscriptForYaml(result.segments);
|
||
results.push({
|
||
transcript: formattedTranscript,
|
||
metadata: result.metadata
|
||
});
|
||
|
||
} catch (error) {
|
||
transcriptLogger.error(`Failed to extract transcript for video ${i + 1}/${videoUrls.length}:`, error);
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
// Continue with other videos, but include error result
|
||
results.push({
|
||
transcript: `[TRANSCRIPT EXTRACTION FAILED: ${errorMessage}]`,
|
||
metadata: { title: `Error extracting from URL: ${videoUrl}`, author: 'Unknown' }
|
||
});
|
||
}
|
||
}
|
||
|
||
return results;
|
||
|
||
} catch (error) {
|
||
throw handleApiError(error, 'Youtube API', 'Transcript extraction');
|
||
}
|
||
}
|
||
|
||
// Helper method to format transcript segments for YAML frontmatter
|
||
private formatTranscriptForYaml(segments: TranscriptInputSegment[]): string {
|
||
// Process segments into formatted text with timestamps
|
||
let formattedTranscript = '';
|
||
|
||
transcriptLogger.debug("Formatting transcript segments:",
|
||
(Array.isArray(segments) ? `${segments.length} segments` : 'not an array'));
|
||
|
||
if (Array.isArray(segments)) {
|
||
// Create exactly 1-minute chunks based on actual timestamps
|
||
const ONE_MINUTE_SECONDS = 60; // 1 minute in seconds
|
||
let chunks: {time: string, text: string, seconds: number}[] = [];
|
||
|
||
// Track the current chunk being built
|
||
let currentChunk = {
|
||
time: '',
|
||
text: '',
|
||
seconds: 0,
|
||
startSeconds: 0
|
||
};
|
||
|
||
let isFirstSegment = true;
|
||
|
||
// Function to parse and convert timestamp to seconds
|
||
const timestampToSeconds = (time: number | string): number => {
|
||
// If time is already a number (seconds), return it
|
||
if (typeof time === 'number') {
|
||
return time;
|
||
}
|
||
|
||
// Handle milliseconds (convert to seconds)
|
||
if (typeof time === 'string' && time.includes('ms')) {
|
||
return parseInt(time) / 1000;
|
||
}
|
||
|
||
// If we don't recognize the format, return 0
|
||
return 0;
|
||
};
|
||
|
||
// Sort segments by timestamp if needed
|
||
const sortedSegments = [...segments].sort((a, b) => {
|
||
const aTime = timestampToSeconds(a.start || (a.tStartMs ? parseInt(a.tStartMs) / 1000 : 0));
|
||
const bTime = timestampToSeconds(b.start || (b.tStartMs ? parseInt(b.tStartMs) / 1000 : 0));
|
||
return aTime - bTime;
|
||
});
|
||
|
||
// Process and group segments into chunks based on actual timestamps
|
||
sortedSegments.forEach((segment, _index) => {
|
||
// Get segment start time in seconds
|
||
let segmentTimeSeconds = 0;
|
||
if (typeof segment.start === 'number') {
|
||
segmentTimeSeconds = segment.start;
|
||
} else if (segment.tStartMs) {
|
||
segmentTimeSeconds = parseInt(segment.tStartMs) / 1000;
|
||
}
|
||
|
||
// Format segment time as HH:MM:SS
|
||
const segmentTimeFormatted = this.formatTimestamp(segmentTimeSeconds);
|
||
|
||
// Extract text from segment
|
||
let segmentText = '';
|
||
if (segment.text) {
|
||
segmentText = segment.text.trim();
|
||
} else if (segment.segs && Array.isArray(segment.segs)) {
|
||
segmentText = segment.segs.map((s) => s.utf8 || '').join('').trim();
|
||
}
|
||
|
||
// Skip empty segments
|
||
if (!segmentText) return;
|
||
|
||
// If this is the first segment or we've reached/exceeded a minute boundary
|
||
if (isFirstSegment ||
|
||
(segmentTimeSeconds - currentChunk.startSeconds >= ONE_MINUTE_SECONDS)) {
|
||
|
||
// Add the previous chunk if it exists and isn't the first segment
|
||
if (!isFirstSegment && currentChunk.text) {
|
||
chunks.push({
|
||
time: this.formatTimestamp(currentChunk.startSeconds),
|
||
text: currentChunk.text,
|
||
seconds: currentChunk.startSeconds
|
||
});
|
||
}
|
||
|
||
// Start a new chunk
|
||
currentChunk = {
|
||
time: segmentTimeFormatted,
|
||
text: segmentText,
|
||
seconds: segmentTimeSeconds,
|
||
startSeconds: segmentTimeSeconds
|
||
};
|
||
|
||
isFirstSegment = false;
|
||
} else {
|
||
// Add to current chunk with a space
|
||
currentChunk.text += ' ' + segmentText;
|
||
}
|
||
});
|
||
|
||
// Add the last chunk if it has content
|
||
if (currentChunk.text) {
|
||
chunks.push({
|
||
time: this.formatTimestamp(currentChunk.startSeconds),
|
||
text: currentChunk.text,
|
||
seconds: currentChunk.startSeconds
|
||
});
|
||
}
|
||
|
||
transcriptLogger.debug(`Created ${chunks.length} exactly time-based chunks`);
|
||
|
||
// Format chunks for YAML frontmatter
|
||
// Start with a newline to ensure proper YAML block format
|
||
formattedTranscript = "\n";
|
||
|
||
chunks.forEach((chunk) => {
|
||
// Create the TimeIndex marker with unescaped colon
|
||
const timeIndexMarker = `[TimeIndex:${Math.round(chunk.seconds)}]`;
|
||
|
||
// Handle escaping of colons in the text portion only
|
||
let textContent = chunk.text;
|
||
|
||
// Remove any existing TimeIndex markers from the text
|
||
const timeIndexRegex = /\[TimeIndex:(\d+)\]/g;
|
||
textContent = textContent.replace(timeIndexRegex, '');
|
||
|
||
// Now escape colons only in the text
|
||
const escapedText = textContent.replace(/:/g, "\\:");
|
||
|
||
// Position the TimeIndex marker right after the timestamp
|
||
formattedTranscript += ` [${chunk.time}] ${timeIndexMarker} ${escapedText}\n`;
|
||
});
|
||
} else {
|
||
// Fallback if segments is not an array
|
||
formattedTranscript = "\n Unable to format transcript properly";
|
||
}
|
||
|
||
return formattedTranscript;
|
||
}
|
||
|
||
// Format seconds into HH:MM:SS format
|
||
private formatTimestamp(seconds: number): string {
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = Math.floor(seconds % 60);
|
||
|
||
return [
|
||
h.toString().padStart(2, '0'),
|
||
m.toString().padStart(2, '0'),
|
||
s.toString().padStart(2, '0')
|
||
].join(':');
|
||
}
|
||
|
||
async summarizeTranscript(transcript: string): Promise<string> {
|
||
try {
|
||
// Clean the transcript using our utility function
|
||
const cleanedTranscript = cleanTranscript(transcript);
|
||
|
||
// Determine which prompt to use based on settings
|
||
const summaryMode = this.settings.useFastSummary ? SummaryMode.FAST : SummaryMode.EXTENSIVE;
|
||
|
||
// Get the prompt configuration with dynamic max tokens
|
||
const promptConfig = getPromptConfig(this.settings, summaryMode, this.getEffectiveMaxTokens());
|
||
|
||
// --- Add logging for the summarization step ---
|
||
llmLogger.debug(`[summarizeTranscript] Starting ${summaryMode} summary.`);
|
||
llmLogger.debug(`[summarizeTranscript] Using model: ${this.settings.selectedLLM} - ${this.getModelForProvider(this.settings.selectedLLM)}`);
|
||
llmLogger.debug(`[summarizeTranscript] Max Tokens: ${promptConfig.maxTokens}, Temperature: ${promptConfig.temperature}`);
|
||
if (this.settings.debugLogging) { // Only log prompts/transcript in debug mode
|
||
llmLogger.debug("--- System Prompt ---");
|
||
llmLogger.debug(truncateForLogs(promptConfig.systemPrompt, 200));
|
||
llmLogger.debug("--- User Prompt ---");
|
||
llmLogger.debug(truncateForLogs(promptConfig.userPrompt, 200));
|
||
// Log truncated transcript to avoid excessive length
|
||
llmLogger.debug("--- Cleaned Transcript (Excerpt) ---");
|
||
llmLogger.debug(truncateForLogs(cleanedTranscript, 300));
|
||
llmLogger.debug("---------------------------------");
|
||
}
|
||
// --- End added logging ---
|
||
|
||
llmLogger.debug("Using token limit:", promptConfig.maxTokens); // Keep existing token log
|
||
|
||
// Store provider value before any potential context corruption
|
||
const selectedProvider = this.settings.selectedLLM;
|
||
|
||
// Debug the config being passed to TranscriptSummarizer
|
||
const model = this.getModelForProvider(selectedProvider);
|
||
llmLogger.debug(`[DEBUG] Model: ${model}`);
|
||
llmLogger.debug(`[DEBUG] Temperature: ${promptConfig.temperature}`);
|
||
llmLogger.debug(`[DEBUG] MaxTokens: ${promptConfig.maxTokens}`);
|
||
llmLogger.debug(`[DEBUG] SystemPrompt length: ${promptConfig.systemPrompt?.length || 'undefined'}`);
|
||
llmLogger.debug(`[DEBUG] UserPrompt length: ${promptConfig.userPrompt?.length || 'undefined'}`);
|
||
llmLogger.debug(`[DEBUG] SelectedLLM: ${selectedProvider}`);
|
||
|
||
// Safety check for settings and API keys
|
||
llmLogger.debug(`[DEBUG] Checking settings - exists: ${!!this.settings}`);
|
||
if (!this.settings) {
|
||
throw new Error('Plugin settings are not loaded');
|
||
}
|
||
|
||
llmLogger.debug(`[DEBUG] Checking apiKeys - exists: ${!!this.settings.apiKeys}`);
|
||
llmLogger.debug(`[DEBUG] ApiKeys type: ${typeof this.settings.apiKeys}`);
|
||
const apiKeyKeys = this.settings.apiKeys ? Object.keys(this.settings.apiKeys) : [];
|
||
llmLogger.debug(`[DEBUG] ApiKeys keys: ${apiKeyKeys.length ? apiKeyKeys.join(', ') : 'none'}`);
|
||
|
||
if (!this.settings.apiKeys) {
|
||
throw new Error('API keys are not configured in settings');
|
||
}
|
||
|
||
// Create a summarizer with the prompt configuration
|
||
llmLogger.debug(`[DEBUG] About to create TranscriptSummarizer...`);
|
||
const tempSummarizer = new TranscriptSummarizer({
|
||
model: model,
|
||
temperature: promptConfig.temperature,
|
||
maxTokens: promptConfig.maxTokens,
|
||
systemPrompt: promptConfig.systemPrompt,
|
||
userPrompt: promptConfig.userPrompt
|
||
}, this.settings.apiKeys);
|
||
llmLogger.debug(`[DEBUG] TranscriptSummarizer created successfully`);
|
||
|
||
llmLogger.debug(`[DEBUG] About to call summarize with provider: '${selectedProvider}'`);
|
||
llmLogger.debug(`[DEBUG] this object type:`, typeof this, this.constructor.name);
|
||
llmLogger.debug(`[DEBUG] this.settings exists:`, !!this.settings);
|
||
llmLogger.debug(`[DEBUG] Full settings.selectedLLM value:`, this.settings?.selectedLLM);
|
||
llmLogger.debug(`[DEBUG] Settings object keys:`, this.settings ? Object.keys(this.settings) : 'settings is null/undefined');
|
||
const summary = await tempSummarizer.summarize(cleanedTranscript, selectedProvider);
|
||
|
||
// Add the creator support message at the beginning of the summary
|
||
const supportMessage = "Support content creators: If you found this content valuable, please consider supporting the Youtube creator by liking 👍 the video and subscribing to their channel. ";
|
||
|
||
// Sanitize the beginning of the summary to ensure clean paragraph flow
|
||
let sanitizedSummary = summary;
|
||
|
||
// Remove leading newlines, spaces, and markdown formatting from the summary
|
||
sanitizedSummary = sanitizedSummary.replace(/^[\s\n\r]*/, '');
|
||
|
||
// If the summary starts with list markers or headers, we need a line break
|
||
if (/^(#|-|\*|\d+\.)/.test(sanitizedSummary)) {
|
||
// Summary starts with Markdown formatting, need to keep them separated
|
||
return supportMessage + "\n\n" + sanitizedSummary;
|
||
} else {
|
||
// Get the first paragraph from the summary (up to first double newline)
|
||
const firstParagraphMatch = sanitizedSummary.match(/^([^\n]+(?:\n[^\n]+)*)/);
|
||
if (firstParagraphMatch) {
|
||
const firstParagraph = firstParagraphMatch[0];
|
||
// Replace any single newlines with spaces in the first paragraph
|
||
const cleanFirstParagraph = firstParagraph.replace(/\n/g, ' ');
|
||
// Rest of the summary after the first paragraph
|
||
const restOfSummary = sanitizedSummary.substring(firstParagraph.length);
|
||
// Combine: support message + clean first paragraph + rest of summary
|
||
return supportMessage + cleanFirstParagraph + restOfSummary;
|
||
}
|
||
|
||
// Fallback if we can't match a first paragraph
|
||
return supportMessage + sanitizedSummary;
|
||
}
|
||
} catch (error) {
|
||
// Use the error handling utility
|
||
throw handleApiError(error, this.settings.selectedLLM, 'Summarization');
|
||
} finally {
|
||
// If using Anthropic provider and fast summary mode or not adding timestamp links,
|
||
// log information about completion
|
||
if (this.settings.selectedLLM === 'anthropic' &&
|
||
(this.settings.useFastSummary || !this.settings.addTimestampLinks)) {
|
||
llmLogger.info('[summarizeTranscript] Completed Anthropic processing (fast summary or no timestamp links)');
|
||
}
|
||
}
|
||
}
|
||
|
||
async applyTemplate(title: string, videoUrl: string, transcript: string, summary: string, folder?: string, _contentType?: string): Promise<void> {
|
||
// Check if Templater plugin is available
|
||
const templaterPlugin = getTemplaterPlugin(this.app);
|
||
|
||
if (!templaterPlugin) {
|
||
this.showNotice('Error: Templater plugin is required but not installed or enabled', 5000);
|
||
throw new Error('Templater plugin is required but not installed or enabled.');
|
||
}
|
||
|
||
try {
|
||
// Get the Templater instance
|
||
const templater = templaterPlugin.templater;
|
||
|
||
// If Templater has never run, do a dummy parse to initialize.
|
||
if (!templater.current_functions_object) {
|
||
// We'll initialize with the actual template processing below
|
||
}
|
||
|
||
// Sanitize the title for use as a filename
|
||
const sanitizedTitle = sanitizeFilename(title);
|
||
|
||
// Normalize video URL data for templating (e.g., shorts/playlist URLs)
|
||
const videoId = YouTubeTranscriptExtractor.extractVideoId(videoUrl);
|
||
const watchUrl = videoId ? `https://www.youtube.com/watch?v=${videoId}` : videoUrl;
|
||
const thumbnailUrl = videoId ? `https://img.youtube.com/vi/${videoId}/hqdefault.jpg` : '';
|
||
|
||
// Format date according to settings
|
||
let datePrefix = '';
|
||
if (this.settings.prependDate) {
|
||
const now = new Date();
|
||
|
||
// Format date based on the selected format
|
||
switch (this.settings.dateFormat) {
|
||
case 'YYYY-MM-DD':
|
||
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
|
||
break;
|
||
case 'MM-DD-YYYY':
|
||
datePrefix = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${now.getFullYear()} `;
|
||
break;
|
||
case 'DD-MM-YYYY':
|
||
datePrefix = `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()} `;
|
||
break;
|
||
default:
|
||
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
|
||
}
|
||
}
|
||
|
||
// REDO THE TRANSCRIPT FORMATTING FOR YAML
|
||
// We'll re-process the transcript no matter what format it's in
|
||
logger.debug("Processing transcript for YAML format");
|
||
|
||
// Format the transcript with original timestamps preserved
|
||
let formattedTranscript = "";
|
||
|
||
// Check if the transcript already has timestamps in format [HH:MM:SS]
|
||
if (transcript.includes('[00:') || transcript.includes('[01:') || transcript.match(/\[\d{2}:\d{2}:\d{2}\]/)) {
|
||
logger.debug("Transcript contains timestamps, organizing into ≥60 second blocks");
|
||
|
||
// Split the transcript into lines
|
||
const originalLines = transcript.split('\n').filter(line => line.trim().length > 0);
|
||
|
||
// Parse each line with its timestamp
|
||
const parsedLines: {timestamp: string, seconds: number, text: string}[] = [];
|
||
|
||
originalLines.forEach(line => {
|
||
// Look for timestamp pattern [HH:MM:SS]
|
||
const timestampMatch = line.match(/^\s*\[(\d{2}:\d{2}:\d{2})\]\s*(.*)/);
|
||
|
||
if (timestampMatch) {
|
||
const timestamp = timestampMatch[1];
|
||
let text = timestampMatch[2].trim();
|
||
|
||
// Remove any escaped backslashes from TimeIndex markers
|
||
text = text.replace(/\[TimeIndex\\?:(\d+)\]/g, '[TimeIndex:$1]');
|
||
|
||
// Convert timestamp to seconds for comparison
|
||
const parts = timestamp.split(':').map(Number);
|
||
const seconds = (parts[0] * 3600) + (parts[1] * 60) + parts[2];
|
||
|
||
parsedLines.push({
|
||
timestamp,
|
||
seconds,
|
||
text
|
||
});
|
||
} else if (line.trim().length > 0) {
|
||
// Handle lines without timestamps - append to the last segment if it exists
|
||
if (parsedLines.length > 0) {
|
||
// Add this content to the last parsed line
|
||
parsedLines[parsedLines.length - 1].text += ' ' + line.trim();
|
||
} else {
|
||
// If no timestamps yet, create a placeholder entry for time 0
|
||
parsedLines.push({
|
||
timestamp: '00:00:00',
|
||
seconds: 0,
|
||
text: line.trim()
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Group lines into blocks of ≥60 seconds
|
||
const segments: {timestamp: string, text: string}[] = [];
|
||
|
||
// Process lines into segments
|
||
if (parsedLines.length > 0) {
|
||
// Sort parsed lines by seconds to ensure chronological order
|
||
parsedLines.sort((a, b) => a.seconds - b.seconds);
|
||
|
||
// Initialize with the first line
|
||
let segmentTimestamp = parsedLines[0].timestamp;
|
||
let segmentStartSeconds = parsedLines[0].seconds;
|
||
let segmentLines: string[] = [parsedLines[0].text];
|
||
|
||
// Process remaining lines
|
||
for (let i = 1; i < parsedLines.length; i++) {
|
||
const line = parsedLines[i];
|
||
|
||
// If this line is at least 60 seconds from the start of current segment,
|
||
// finalize the current segment and start a new one
|
||
if (line.seconds - segmentStartSeconds >= 60) {
|
||
// Add completed segment
|
||
segments.push({
|
||
timestamp: segmentTimestamp,
|
||
text: segmentLines.join(' ')
|
||
});
|
||
|
||
// Start a new segment - ALWAYS use the actual timestamp from the current line
|
||
segmentTimestamp = line.timestamp;
|
||
segmentStartSeconds = line.seconds;
|
||
segmentLines = [line.text];
|
||
} else {
|
||
// Add to current segment
|
||
segmentLines.push(line.text);
|
||
}
|
||
}
|
||
|
||
// Add the final segment if it has any content
|
||
if (segmentLines.length > 0) {
|
||
segments.push({
|
||
timestamp: segmentTimestamp,
|
||
text: segmentLines.join(' ')
|
||
});
|
||
}
|
||
}
|
||
|
||
logger.debug(`Organized ${originalLines.length} lines into ${segments.length} ≥60-second blocks`);
|
||
|
||
// Format segments for YAML frontmatter
|
||
segments.forEach(segment => {
|
||
// Convert timestamp to seconds using our custom function
|
||
const timeIndex = convertTimestampToSeconds(segment.timestamp);
|
||
|
||
// Create the TimeIndex marker with unescaped colon
|
||
const timeIndexMarker = `[TimeIndex:${timeIndex}]`;
|
||
|
||
// Handle text content
|
||
let textContent = segment.text;
|
||
|
||
// Remove any existing TimeIndex markers from the text
|
||
const timeIndexRegex = /\[TimeIndex:(\d+)\]/g;
|
||
textContent = textContent.replace(timeIndexRegex, '');
|
||
|
||
// Only escape colons in the text content
|
||
const escapedText = textContent.replace(/:/g, "\\:");
|
||
|
||
// Position the TimeIndex marker right after the timestamp
|
||
formattedTranscript += ` [${segment.timestamp}] ${timeIndexMarker} ${escapedText}\n`;
|
||
});
|
||
} else {
|
||
logger.debug("Transcript does not contain timestamps");
|
||
|
||
// Provide a warning message in the transcript text
|
||
formattedTranscript = " [ERROR] No timestamps found in transcript. Please ensure the Youtube transcript contains timestamps.";
|
||
|
||
// Show an error notice
|
||
this.showNotice("Warning: No timestamps found in transcript. Timestamps are required for proper processing.", 5000);
|
||
}
|
||
|
||
// Now use this properly formatted transcript
|
||
transcript = formattedTranscript;
|
||
|
||
// Final cleanup - ensure all TimeIndex markers are unescaped
|
||
transcript = transcript.replace(/\[TimeIndex\\:(\d+)\]/g, '[TimeIndex:$1]');
|
||
|
||
// Normalize the folder path
|
||
const normalizedFolder = normalizePath(folder || '');
|
||
|
||
// Create folder if needed
|
||
if (normalizedFolder) {
|
||
await ensureFolder(this.app.vault, normalizedFolder);
|
||
}
|
||
|
||
// Normalize the template path
|
||
const normalizedTemplatePath = normalizePath(this.settings.templaterTemplateFile);
|
||
|
||
// Get template file and verify it exists
|
||
const templateFile = this.app.vault.getAbstractFileByPath(normalizedTemplatePath);
|
||
if (!(templateFile instanceof TFile)) {
|
||
throw new Error(`Template file not found: ${this.settings.templaterTemplateFile}`);
|
||
}
|
||
|
||
// 1. Initialize Templater if needed (force a one-time run)
|
||
if (!templater.current_functions_object) {
|
||
// We'll initialize with the actual template processing below
|
||
}
|
||
|
||
// 2. Create a running config for the actual template
|
||
const config = templater.create_running_config(
|
||
templateFile,
|
||
templateFile, // Use the template file itself as target to avoid null path errors
|
||
0 // Numeric value for "CreateNewFromTemplate"
|
||
);
|
||
|
||
// 3. Generate the Templater context (tp object)
|
||
const ctx = await templater.functions_generator.generate_object(config);
|
||
|
||
// 4. Inject our custom data into ctx.user as functions
|
||
const user = ctx.user ?? {};
|
||
ctx.user = user;
|
||
|
||
// Set up our data as functions in ctx.user
|
||
user.title = sanitizedTitle;
|
||
// Use a normalized watch URL so template parsing doesn't break on shorts/live URLs
|
||
user.videoUrl = watchUrl || videoUrl;
|
||
user.originalVideoUrl = videoUrl;
|
||
user.videoId = videoId || '';
|
||
user.watchUrl = watchUrl || videoUrl;
|
||
user.thumbnailUrl = thumbnailUrl;
|
||
user.transcript = transcript;
|
||
user.summary = summary;
|
||
|
||
// Add LLM provider and model info
|
||
const llmProvider = this.settings.selectedLLM;
|
||
const llmModel = this.settings.selectedModels[llmProvider];
|
||
user.llmProvider = llmProvider;
|
||
user.llmModel = llmModel;
|
||
|
||
// Add tags for LLM provider and model in proper YAML array format
|
||
const baseTags = ["youtube", "transcript"];
|
||
const llmProviderTag = `llm/${llmProvider}`;
|
||
const llmModelTag = `model/${llmModel.replace(/[:.]/g, "-")}`;
|
||
const allTags = [...baseTags, llmProviderTag, llmModelTag];
|
||
const llmTags = `[${allTags.join(", ")}]`;
|
||
user.llmTags = llmTags;
|
||
|
||
// Add plugin version for frontmatter tracking
|
||
user.version = this.getVersion();
|
||
|
||
// Debug info is only logged, not included in notes
|
||
if (this.settings.debugLogging) {
|
||
logger.debug(`Transcript info:
|
||
- Length: ${transcript ? transcript.length : 'unknown'} characters
|
||
- Contains timestamps: ${transcript ? transcript.includes('[00:') : 'unknown'}
|
||
- LLM Provider: ${llmProvider}
|
||
- LLM Model: ${llmModel}`);
|
||
}
|
||
|
||
// 5. Read and parse the template with our custom context
|
||
const templateContent = await this.app.vault.read(templateFile);
|
||
|
||
// Debug logging to check template content and tags
|
||
if (this.settings.debugLogging) {
|
||
logger.debug(`Template content (first 500 chars): ${templateContent.substring(0, 500)}`);
|
||
logger.debug(`ctx.user.llmTags value: ${llmTags}`);
|
||
}
|
||
|
||
const parsedContent = await templater.parser.parse_commands(templateContent, ctx);
|
||
|
||
// Debug logging to check parsed content
|
||
if (this.settings.debugLogging) {
|
||
const frontmatterEnd = parsedContent.indexOf('---', 3);
|
||
if (frontmatterEnd !== -1) {
|
||
const frontmatter = parsedContent.substring(0, frontmatterEnd + 3);
|
||
logger.debug(`Parsed frontmatter: ${frontmatter}`);
|
||
}
|
||
}
|
||
|
||
// 6. Create the new file with parsed content
|
||
let finalContent = parsedContent;
|
||
|
||
// The support message is now added directly to the summary in summarizeTranscript method
|
||
// So we don't need to insert it here anymore
|
||
|
||
const fileName = `${datePrefix}${sanitizedTitle}.md`;
|
||
const filePath = normalizedFolder ? joinPaths(normalizedFolder, fileName) : fileName;
|
||
|
||
// @ts-ignore - Using Obsidian API
|
||
const newFile = await this.app.vault.create(filePath, finalContent);
|
||
|
||
// 7. Open the new file
|
||
// @ts-ignore - Using Obsidian API
|
||
const leaf = this.app.workspace.getLeaf(true);
|
||
await leaf.openFile(newFile);
|
||
|
||
this.showNotice(`Created note: ${datePrefix}${sanitizedTitle}`, 5000);
|
||
} catch (error) {
|
||
logger.error("Error applying template:", error);
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
this.showNotice(`Error creating note: ${errorMessage}`, 5000);
|
||
// Clear logs on error too
|
||
clearLogs();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Simplified method to ensure a folder exists - wrapper for the utility
|
||
private async ensureFolderExists(folderPath: string): Promise<void> {
|
||
await ensureFolder(this.app.vault, folderPath);
|
||
}
|
||
|
||
// Add a debugging method to help troubleshoot
|
||
debugSettings() {
|
||
logger.debug('=== DEBUG SETTINGS ===');
|
||
logger.info('Selected LLM:', this.settings.selectedLLM);
|
||
logger.debug('Selected Models:', JSON.stringify(this.settings.selectedModels));
|
||
|
||
const apiKeyStatus = Object.entries(this.settings.apiKeys).map(([provider, key]) => {
|
||
return `${provider}: ${key ? '✓' : '✗'}`;
|
||
}).join(', ');
|
||
|
||
logger.info('API Keys configured:', apiKeyStatus);
|
||
|
||
if (!this.settings.apiKeys[this.settings.selectedLLM]) {
|
||
logger.warn(`No API key configured for selected LLM: ${this.settings.selectedLLM}`);
|
||
}
|
||
|
||
logger.debug('=====================');
|
||
}
|
||
|
||
// Check for required dependencies
|
||
private checkDependencies(): void {
|
||
// Check for Templater plugin
|
||
const templater = getTemplaterPlugin(this.app);
|
||
|
||
if (!templater) {
|
||
// Show a notice with instructions on how to install Templater
|
||
window.setTimeout(() => {
|
||
this.showNotice('Youtube transcript plugin requires the Templater plugin. Please install and enable it.', 5000);
|
||
}, 3000); // Delay to ensure it's seen after initial plugin load
|
||
}
|
||
|
||
// Check for LLM API key
|
||
const selectedLlm = this.settings.selectedLLM;
|
||
if (!this.settings.apiKeys[selectedLlm] || this.settings.apiKeys[selectedLlm].trim() === '') {
|
||
window.setTimeout(() => {
|
||
this.showNotice(`Youtube transcript plugin: no API key configured for ${selectedLlm}. Please add your API key in settings.`, 5000);
|
||
}, 4500);
|
||
}
|
||
}
|
||
|
||
// Use imported utility method
|
||
private isYoutubeUrl(this: void, url: string): boolean {
|
||
return isYoutubeUrl(url);
|
||
}
|
||
|
||
// Use imported utility method
|
||
private isYoutubeChannelOrPlaylistUrl(this: void, url: string): boolean {
|
||
return isYoutubeChannelOrPlaylistUrl(url);
|
||
}
|
||
|
||
// Use imported utility method
|
||
private extractChannelName(url: string): string {
|
||
return extractChannelName(url);
|
||
}
|
||
|
||
// Method to fetch videos from a YouTube channel or playlist
|
||
async fetchCollectionVideos(sourceUrl: string, limit: number = 0): Promise<{ title: string, url: string }[]> {
|
||
try {
|
||
// Use the main YouTube API key
|
||
const API_KEY = this.settings.youtubeApiKey;
|
||
|
||
if (!API_KEY) {
|
||
throw new Error('Youtube API key is required. Please set it in the plugin settings.');
|
||
}
|
||
|
||
this.showNotice('Fetching collection information...', 5000);
|
||
|
||
// Determine if it's a playlist or channel and get appropriate ID
|
||
let isPlaylist = sourceUrl.includes('/playlist') || sourceUrl.includes('list=');
|
||
let sourceId;
|
||
let sourceTitle = '';
|
||
let videoResults: Array<{title: string, url: string}> = [];
|
||
|
||
if (isPlaylist) {
|
||
// Extract playlist ID
|
||
if (sourceUrl.includes('list=')) {
|
||
const match = sourceUrl.match(/list=([^&]+)/);
|
||
if (match && match[1]) {
|
||
sourceId = match[1];
|
||
this.showNotice(`Fetching playlist with ID: ${sourceId}`, 3000);
|
||
|
||
// Get playlist details
|
||
const playlistResponse = await obsidianFetch(
|
||
`https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=${sourceId}&key=${API_KEY}`
|
||
);
|
||
|
||
if (!playlistResponse.ok) {
|
||
throw new Error(`Failed to fetch playlist data: HTTP status ${playlistResponse.status}`);
|
||
}
|
||
|
||
const playlistData = await playlistResponse.json() as PlaylistResponse;
|
||
|
||
if (!playlistData.items || playlistData.items.length === 0) {
|
||
throw new Error('Playlist not found');
|
||
}
|
||
|
||
// Get playlist name for display
|
||
sourceTitle = playlistData.items[0].snippet?.title ?? '';
|
||
|
||
// Fetch videos from playlist with pagination support
|
||
let nextPageToken: string | null = null;
|
||
let videosCount = 0;
|
||
const MAX_RESULTS_PER_PAGE = 50; // YouTube API limit
|
||
const SAFETY_LIMIT = 50; // Safety limit to prevent excessive API calls
|
||
|
||
do {
|
||
// Build URL with page token if we have one
|
||
let videosUrl = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=${MAX_RESULTS_PER_PAGE}&playlistId=${sourceId}&key=${API_KEY}`;
|
||
if (nextPageToken) {
|
||
videosUrl += `&pageToken=${nextPageToken}`;
|
||
}
|
||
|
||
this.showNotice(`Fetching playlist videos${nextPageToken ? ' (continued)' : ''}...`, 3000);
|
||
|
||
const videosResponse = await obsidianFetch(videosUrl);
|
||
|
||
if (!videosResponse.ok) {
|
||
throw new Error(`Failed to fetch playlist videos: HTTP status ${videosResponse.status}`);
|
||
}
|
||
|
||
const videosData = await videosResponse.json() as PlaylistItemsResponse;
|
||
|
||
if (!videosData.items || videosData.items.length === 0) {
|
||
break;
|
||
}
|
||
|
||
// Extract video information and add to results
|
||
const pageVideos = videosData.items
|
||
.filter((item: YouTubePlaylistItem) =>
|
||
!!item.snippet?.title &&
|
||
!!item.snippet?.resourceId?.videoId)
|
||
.map((item: YouTubePlaylistItem) => ({
|
||
title: item.snippet?.title ?? '',
|
||
url: `https://www.youtube.com/watch?v=${item.snippet?.resourceId?.videoId ?? ''}`
|
||
}));
|
||
|
||
videoResults = videoResults.concat(pageVideos);
|
||
videosCount += pageVideos.length;
|
||
|
||
// Get next page token if available
|
||
nextPageToken = videosData.nextPageToken || null;
|
||
|
||
// If we've reached our limit or safety limit, stop paginating
|
||
if ((limit > 0 && videosCount >= limit) ||
|
||
(limit === 0 && videosCount >= SAFETY_LIMIT)) {
|
||
// If we hit the safety limit, show a notice
|
||
if (limit === 0 && videosCount >= SAFETY_LIMIT) {
|
||
this.showNotice(`Reached safety limit of ${SAFETY_LIMIT} videos. Processing the first ${SAFETY_LIMIT} videos.`, 5000);
|
||
}
|
||
break;
|
||
}
|
||
|
||
} while (nextPageToken);
|
||
} else {
|
||
throw new Error('Could not extract playlist ID from URL');
|
||
}
|
||
} else {
|
||
throw new Error('Invalid playlist URL format');
|
||
}
|
||
} else {
|
||
// Handle channel URL
|
||
sourceId = await this.getChannelIdFromInput(sourceUrl, API_KEY);
|
||
|
||
if (!sourceId) {
|
||
throw new Error('Could not extract channel ID from URL');
|
||
}
|
||
|
||
// Get channel details
|
||
const channelResponse = await obsidianFetch(
|
||
`https://www.googleapis.com/youtube/v3/channels?part=contentDetails,snippet&id=${sourceId}&key=${API_KEY}`
|
||
);
|
||
|
||
if (!channelResponse.ok) {
|
||
throw new Error(`Failed to fetch channel data: HTTP status ${channelResponse.status}`);
|
||
}
|
||
|
||
const channelData = await channelResponse.json() as ChannelResponse;
|
||
|
||
if (!channelData.items || channelData.items.length === 0) {
|
||
throw new Error('Channel not found');
|
||
}
|
||
|
||
// Get channel name for display
|
||
sourceTitle = channelData.items[0].snippet?.title ?? '';
|
||
|
||
// Get the uploads playlist ID
|
||
const uploadsPlaylistId = channelData.items[0].contentDetails?.relatedPlaylists?.uploads ?? '';
|
||
if (!uploadsPlaylistId) {
|
||
throw new Error('Channel uploads playlist not found');
|
||
}
|
||
|
||
// Get videos from the uploads playlist with pagination
|
||
let nextPageToken: string | null = null;
|
||
let videosCount = 0;
|
||
const MAX_RESULTS_PER_PAGE = 50; // YouTube API limit
|
||
const SAFETY_LIMIT = 50; // Safety limit to prevent excessive API calls
|
||
|
||
do {
|
||
// Build URL with page token if we have one
|
||
let videosUrl = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=${MAX_RESULTS_PER_PAGE}&playlistId=${uploadsPlaylistId}&key=${API_KEY}`;
|
||
if (nextPageToken) {
|
||
videosUrl += `&pageToken=${nextPageToken}`;
|
||
}
|
||
|
||
this.showNotice(`Fetching channel videos${nextPageToken ? ' (continued)' : ''}...`, 3000);
|
||
|
||
const videosResponse = await obsidianFetch(videosUrl);
|
||
|
||
if (!videosResponse.ok) {
|
||
throw new Error(`Failed to fetch videos: HTTP status ${videosResponse.status}`);
|
||
}
|
||
|
||
const videosData = await videosResponse.json() as PlaylistItemsResponse;
|
||
|
||
if (!videosData.items || videosData.items.length === 0) {
|
||
break;
|
||
}
|
||
|
||
// Extract video information and add to results
|
||
const pageVideos = videosData.items
|
||
.filter((item: YouTubePlaylistItem) =>
|
||
!!item.snippet?.title &&
|
||
!!item.snippet?.resourceId?.videoId)
|
||
.map((item: YouTubePlaylistItem) => ({
|
||
title: item.snippet?.title ?? '',
|
||
url: `https://www.youtube.com/watch?v=${item.snippet?.resourceId?.videoId ?? ''}`
|
||
}));
|
||
|
||
videoResults = videoResults.concat(pageVideos);
|
||
videosCount += pageVideos.length;
|
||
|
||
// Get next page token if available
|
||
nextPageToken = videosData.nextPageToken || null;
|
||
|
||
// If we've reached our limit or safety limit, stop paginating
|
||
if ((limit > 0 && videosCount >= limit) ||
|
||
(limit === 0 && videosCount >= SAFETY_LIMIT)) {
|
||
// If we hit the safety limit, show a notice
|
||
if (limit === 0 && videosCount >= SAFETY_LIMIT) {
|
||
this.showNotice(`Reached safety limit of ${SAFETY_LIMIT} videos. Processing the first ${SAFETY_LIMIT} videos.`, 5000);
|
||
}
|
||
break;
|
||
}
|
||
|
||
} while (nextPageToken);
|
||
}
|
||
|
||
// Show what we found
|
||
this.showNotice(`Found ${sourceTitle}: ${videoResults.length} videos`, 3000);
|
||
|
||
// Limit if needed (for specific requested limits)
|
||
if (limit > 0 && videoResults.length > limit) {
|
||
return videoResults.slice(0, limit);
|
||
}
|
||
|
||
return videoResults;
|
||
} catch (error) {
|
||
logger.error('Error fetching collection videos:', error);
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
throw new Error(`Failed to fetch videos: ${errorMessage || 'Unknown error'}`);
|
||
}
|
||
}
|
||
|
||
// Helper method to get channel ID from URL
|
||
private async getChannelIdFromInput(channelUrl: string, apiKey: string): Promise<string> {
|
||
// If it's already a channel ID format (UC...)
|
||
if (channelUrl.includes('/channel/')) {
|
||
const match = channelUrl.match(/\/channel\/(UC[a-zA-Z0-9_-]+)/);
|
||
if (match && match[1]) {
|
||
return match[1];
|
||
}
|
||
}
|
||
|
||
// Handle @handle format
|
||
if (channelUrl.includes('/@')) {
|
||
const match = channelUrl.match(/\/@([a-zA-Z0-9_-]+)/);
|
||
if (match && match[1]) {
|
||
return await this.getChannelIdFromHandle(match[1], apiKey);
|
||
}
|
||
}
|
||
|
||
// Handle /c/ format
|
||
if (channelUrl.includes('/c/')) {
|
||
const match = channelUrl.match(/\/c\/([a-zA-Z0-9_-]+)/);
|
||
if (match && match[1]) {
|
||
throw new Error('Custom URL slugs (/c/) cannot be directly converted to channel IDs. Please use a channel URL with /channel/ or @handle format.');
|
||
}
|
||
}
|
||
|
||
throw new Error('Could not extract channel identifier from URL');
|
||
}
|
||
|
||
// Helper method to get channel ID from handle
|
||
private async getChannelIdFromHandle(handle: string, apiKey: string): Promise<string> {
|
||
const url = `https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=${handle}&key=${apiKey}`;
|
||
|
||
const response = await obsidianFetch(url);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch channel ID: HTTP status ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json() as ChannelIdResponse;
|
||
|
||
if (!data.items || data.items.length === 0) {
|
||
throw new Error(`No channel found for handle: ${handle}`);
|
||
}
|
||
|
||
const channelId = data.items[0].id;
|
||
if (!channelId) {
|
||
throw new Error(`No channel ID found for handle: ${handle}`);
|
||
}
|
||
return channelId;
|
||
}
|
||
|
||
// Add timestamp links to section headings in an existing note using LLM
|
||
async addSectionLinksToNote(filePath: string, videoUrl: string): Promise<void> {
|
||
try {
|
||
// Extract video ID from URL
|
||
const videoId = YouTubeTranscriptExtractor.extractVideoId(videoUrl);
|
||
if (!videoId) {
|
||
logger.error(`Could not extract video ID from URL: ${videoUrl}`);
|
||
throw new Error(`Invalid Youtube URL: '${videoUrl}'. Please ensure the URL is properly formatted without extra characters like quotes.`);
|
||
}
|
||
|
||
// Read the note content
|
||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||
if (!(file instanceof TFile)) {
|
||
logger.error(`Could not find note file: ${filePath}`);
|
||
throw new Error('Could not find note file');
|
||
}
|
||
|
||
// Log file info but don't do instanceof checks
|
||
logger.debug(`File found: ${filePath}`);
|
||
|
||
// Read the content using our custom interface
|
||
const content = await this.app.vault.read(file);
|
||
|
||
// Extract headings from the content
|
||
const headings: string[] = [];
|
||
const headingPositions: number[] = [];
|
||
|
||
// Use a regex to find all headings (lines starting with #)
|
||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||
let match;
|
||
while ((match = headingRegex.exec(content)) !== null) {
|
||
headings.push(match[2].trim());
|
||
headingPositions.push(match.index);
|
||
}
|
||
|
||
if (headings.length === 0) {
|
||
logger.debug('[addSectionLinksToNote] No headings found in note');
|
||
return;
|
||
}
|
||
|
||
logger.debug(`[addSectionLinksToNote] Found ${headings.length} headings`);
|
||
|
||
// Check if translation is needed
|
||
const needsTranslation = this.settings.translateLanguage !== 'en' || this.settings.translateCountry !== 'US';
|
||
|
||
// If we have more than 5 headings, use chunked processing
|
||
let contentWithLinks;
|
||
if (headings.length > 5) {
|
||
contentWithLinks = await this.addTimestampLinksInChunks(
|
||
filePath,
|
||
videoId,
|
||
content,
|
||
headings,
|
||
headingPositions
|
||
);
|
||
} else {
|
||
contentWithLinks = await this.addTimestampLinksSinglePass(
|
||
filePath,
|
||
videoId,
|
||
content,
|
||
headings
|
||
);
|
||
}
|
||
|
||
// If translation is needed and we have content with links
|
||
if (needsTranslation && contentWithLinks) {
|
||
// Do a second pass for translation
|
||
await this.translateContent(
|
||
filePath,
|
||
contentWithLinks,
|
||
this.settings.translateLanguage,
|
||
this.settings.translateCountry
|
||
);
|
||
}
|
||
} catch (error) {
|
||
logger.error('[addSectionLinksToNote] Error:', error);
|
||
throw error;
|
||
} finally {
|
||
// Stop the Anthropic proxy server if it was used
|
||
if (this.settings.selectedLLM === 'anthropic') {
|
||
logger.info('[addSectionLinksToNote] Completed Anthropic processing');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Process document in a single pass to add timestamp links
|
||
private async addTimestampLinksSinglePass(
|
||
filePath: string,
|
||
videoId: string,
|
||
originalContent: string,
|
||
headings: string[]
|
||
): Promise<string | null> {
|
||
try {
|
||
// Extract document components using the utility
|
||
const { frontmatter, contentWithoutFrontmatter, transcript } =
|
||
extractDocumentComponents(originalContent);
|
||
|
||
// Get the timestamp link configuration
|
||
const timestampConfig = getTimestampLinkConfig(this.settings, videoId, this.getEffectiveMaxTokens());
|
||
|
||
// Get the safely calculated max tokens from our updated method
|
||
const maxTokens = this.getMaxTokensForTimestampPass();
|
||
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug("[addTimestampLinksSinglePass] Using max tokens: " + maxTokens);
|
||
}
|
||
|
||
// Truncate transcript for very large transcripts to avoid token overflow
|
||
let processedTranscript = transcript;
|
||
if (transcript && transcript.length > 10000) {
|
||
// For very large transcripts, truncate to a reasonable size
|
||
processedTranscript = transcript.substring(0, 10000) +
|
||
"\n[Transcript truncated to prevent token overflow]";
|
||
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`[addTimestampLinksSinglePass] Truncated transcript from ${transcript.length} to 10000 characters`);
|
||
}
|
||
}
|
||
|
||
// Debug the timestamp config being passed
|
||
const timestampModel = this.getModelForProvider(this.settings.selectedLLM);
|
||
llmLogger.debug(`[TIMESTAMP DEBUG] Model: ${timestampModel}`);
|
||
llmLogger.debug(`[TIMESTAMP DEBUG] Temperature: ${timestampConfig.temperature}`);
|
||
llmLogger.debug(`[TIMESTAMP DEBUG] MaxTokens: ${maxTokens}`);
|
||
llmLogger.debug(`[TIMESTAMP DEBUG] SystemPrompt length: ${timestampConfig.systemPrompt?.length || 'undefined'}`);
|
||
llmLogger.debug(`[TIMESTAMP DEBUG] UserPrompt length: ${timestampConfig.userPrompt?.length || 'undefined'}`);
|
||
llmLogger.debug(`[TIMESTAMP DEBUG] SelectedLLM: ${this.settings.selectedLLM}`);
|
||
|
||
// Create specialized summarizer for timestamp linking
|
||
const timestampLinkSummarizer = new TranscriptSummarizer({
|
||
model: timestampModel,
|
||
temperature: timestampConfig.temperature,
|
||
maxTokens: maxTokens,
|
||
systemPrompt: timestampConfig.systemPrompt,
|
||
userPrompt: timestampConfig.userPrompt
|
||
}, this.settings.apiKeys);
|
||
|
||
// Add debug logging
|
||
if (this.settings.debugLogging) {
|
||
const maxTokens = this.getMaxTokensForTimestampPass();
|
||
llmLogger.debug(`[addTimestampLinksSinglePass] Using ${maxTokens} tokens for timestamp linking`);
|
||
}
|
||
|
||
// Restructure the prompt with clear section labels for all providers
|
||
const restructuredPrompt =
|
||
"INSTRUCTIONS:\n" + timestampConfig.userPrompt + "\n\n" +
|
||
"INSTRUCTION INPUT DATA - TIMESTAMPS TRANSCRIPT:\n" +
|
||
(processedTranscript ? processedTranscript : "No transcript available") + "\n\n" +
|
||
"INPUT NOTE TO BE MODIFIED WITH TIMESTAMPS:\n" + contentWithoutFrontmatter;
|
||
|
||
// Send to LLM for processing
|
||
this.showNotice("Adding timestamp links with LLM...", 5000);
|
||
|
||
// Log detailed information when debug logging is enabled
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug("==================== TIMESTAMP LINKING DEBUG ====================");
|
||
llmLogger.debug(`Processing file: ${filePath}`);
|
||
llmLogger.debug(`Video ID: ${videoId}`);
|
||
llmLogger.debug(`System Prompt: ${timestampConfig.systemPrompt}`);
|
||
llmLogger.debug(`User Prompt (first 500 chars): ${timestampConfig.userPrompt.substring(0, 500)}...`);
|
||
llmLogger.debug(`Restructured Prompt (first 500 chars): ${restructuredPrompt.substring(0, 500)}...`);
|
||
|
||
// Check if transcript contains TimeIndex markers
|
||
if (processedTranscript) {
|
||
const timeIndexInTranscript = processedTranscript.match(/\[TimeIndex:\d+\]/g);
|
||
if (timeIndexInTranscript) {
|
||
llmLogger.debug(`✅ Transcript contains ${timeIndexInTranscript.length} TimeIndex markers`);
|
||
llmLogger.debug(`First few TimeIndex markers: ${timeIndexInTranscript.slice(0, 5).join(', ')}`);
|
||
} else {
|
||
llmLogger.debug("❌ No TimeIndex markers found in transcript");
|
||
}
|
||
} else {
|
||
llmLogger.debug("❌ No transcript provided to LLM");
|
||
}
|
||
llmLogger.debug(`Number of headings found: ${headings.length}`);
|
||
llmLogger.debug(`First few headings: ${headings.slice(0, 3).join(', ')}${headings.length > 3 ? '...' : ''}`);
|
||
llmLogger.debug(`LLM Provider: ${this.settings.selectedLLM}`);
|
||
llmLogger.debug(`Model: ${this.settings.selectedModels[this.settings.selectedLLM]}`);
|
||
llmLogger.debug(`Max tokens: ${maxTokens}`);
|
||
llmLogger.debug(`Temperature: ${timestampConfig.temperature}`);
|
||
|
||
// Estimate tokens for log
|
||
const contentTokens = Math.ceil(contentWithoutFrontmatter.length / 4);
|
||
const transcriptTokens = Math.ceil((processedTranscript?.length || 0) / 4);
|
||
llmLogger.debug(`Estimated content tokens: ${contentTokens}`);
|
||
llmLogger.debug(`Estimated transcript tokens: ${transcriptTokens}`);
|
||
llmLogger.debug(`Estimated total input tokens: ${contentTokens + transcriptTokens}`);
|
||
|
||
// Log the system prompt
|
||
llmLogger.debug("SYSTEM PROMPT:");
|
||
llmLogger.debug("----------------------------------------");
|
||
llmLogger.debug(truncateForLogs(timestampConfig.systemPrompt, 200));
|
||
llmLogger.debug("----------------------------------------");
|
||
|
||
// Log the user prompt
|
||
llmLogger.debug("USER PROMPT:");
|
||
llmLogger.debug("----------------------------------------");
|
||
llmLogger.debug(truncateForLogs(timestampConfig.userPrompt, 200));
|
||
llmLogger.debug("----------------------------------------");
|
||
|
||
// Log content being processed
|
||
llmLogger.debug("CONTENT BEING PROCESSED:");
|
||
llmLogger.debug("----------------------------------------");
|
||
llmLogger.debug(truncateForLogs(contentWithoutFrontmatter, 500));
|
||
llmLogger.debug("----------------------------------------");
|
||
|
||
// Log transcript excerpt
|
||
if (processedTranscript) {
|
||
llmLogger.debug("TRANSCRIPT EXCERPT:");
|
||
llmLogger.debug("----------------------------------------");
|
||
llmLogger.debug(truncateForLogs(processedTranscript, 300));
|
||
llmLogger.debug("----------------------------------------");
|
||
} else {
|
||
llmLogger.debug("NO TRANSCRIPT FOUND IN FRONTMATTER");
|
||
}
|
||
|
||
// Log the complete formatted prompt
|
||
llmLogger.debug("COMPLETE FORMATTED PROMPT BEING SENT TO LLM:");
|
||
llmLogger.debug("========================================");
|
||
llmLogger.debug(truncateForLogs(restructuredPrompt, 400));
|
||
llmLogger.debug("========================================");
|
||
}
|
||
|
||
logger.debug("[addTimestampLinksSinglePass] Sending content to LLM (without frontmatter)...");
|
||
|
||
let enhancedContent;
|
||
try {
|
||
llmLogger.debug(`[TIMESTAMP DEBUG] About to call summarize with provider: '${this.settings.selectedLLM}'`);
|
||
llmLogger.debug(`[TIMESTAMP DEBUG] Full settings.selectedLLM value:`, this.settings.selectedLLM);
|
||
enhancedContent = await timestampLinkSummarizer.summarize(
|
||
restructuredPrompt,
|
||
this.settings.selectedLLM
|
||
);
|
||
|
||
// Debug log the entire response if debug logging is enabled
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug("LLM RESPONSE:");
|
||
llmLogger.debug("========================================");
|
||
llmLogger.debug(truncateForLogs(enhancedContent || "Empty response from LLM", 400));
|
||
llmLogger.debug("========================================");
|
||
|
||
// Check for TimeIndex markers in the response
|
||
const timeIndexMatches = enhancedContent ? enhancedContent.match(/\[TimeIndex:\d+\]/g) : null;
|
||
if (timeIndexMatches) {
|
||
llmLogger.debug(`✅ Found ${timeIndexMatches.length} TimeIndex markers: ${timeIndexMatches.join(', ')}`);
|
||
} else {
|
||
llmLogger.debug("❌ No TimeIndex markers found in LLM response");
|
||
}
|
||
|
||
// Check for Watch URLs in the response (shouldn't be there with new prompt)
|
||
const watchMatches = enhancedContent ? enhancedContent.match(/\[Watch\]\(https:\/\/www\.youtube\.com\/watch\?v=[^)]+\)/g) : null;
|
||
if (watchMatches) {
|
||
llmLogger.debug(`⚠️ Found ${watchMatches.length} Watch URLs (old format): ${watchMatches.slice(0, 3).join(', ')}`);
|
||
}
|
||
}
|
||
|
||
logger.debug("[addTimestampLinksSinglePass] Received LLM response, length:", enhancedContent ? enhancedContent.length : 0);
|
||
} catch (e) {
|
||
logger.error("[addTimestampLinksSinglePass] Error during LLM call:", e);
|
||
const errorMessage = getSafeErrorMessage(e);
|
||
|
||
// In case of token limit errors, reduce the maxTokens and try again
|
||
if (errorMessage.includes("max_tokens") || errorMessage.includes("token limit")) {
|
||
logger.debug("[addTimestampLinksSinglePass] Token limit error detected, retrying with reduced token limit");
|
||
this.showNotice("Retrying with reduced token limit...", 5000);
|
||
|
||
// Significantly reduce token limit for retry (50% of previous)
|
||
const reducedTokens = Math.floor(maxTokens * 0.5);
|
||
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`Retrying with reduced token limit: ${reducedTokens}`);
|
||
}
|
||
|
||
// Create a new summarizer with reduced tokens but same config otherwise
|
||
const reducedTokensSummarizer = new TranscriptSummarizer({
|
||
model: this.getModelForProvider(this.settings.selectedLLM),
|
||
temperature: timestampConfig.temperature,
|
||
maxTokens: reducedTokens,
|
||
systemPrompt: timestampConfig.systemPrompt,
|
||
userPrompt: timestampConfig.userPrompt
|
||
}, this.settings.apiKeys);
|
||
|
||
try {
|
||
enhancedContent = await reducedTokensSummarizer.summarize(
|
||
restructuredPrompt,
|
||
this.settings.selectedLLM
|
||
);
|
||
|
||
// Debug log the entire response after retry if debug logging is enabled
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug("LLM RESPONSE AFTER RETRY:");
|
||
llmLogger.debug("========================================");
|
||
llmLogger.debug(truncateForLogs(enhancedContent || "Empty response from LLM", 800));
|
||
llmLogger.debug("========================================");
|
||
}
|
||
|
||
logger.debug("[addTimestampLinksSinglePass] Second attempt successful with reduced tokens:", reducedTokens);
|
||
} catch (retryError) {
|
||
logger.error("[addTimestampLinksSinglePass] Error on second attempt:", retryError);
|
||
const retryMessage = getSafeErrorMessage(retryError);
|
||
this.showNotice(`Failed to add timestamp links: ${retryMessage}`, 5000);
|
||
return null;
|
||
}
|
||
} else {
|
||
this.showNotice(`Error adding timestamp links: ${errorMessage}`, 5000);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
if (!enhancedContent) {
|
||
logger.error("[addTimestampLinksSinglePass] Failed to add timestamp links (empty response from LLM)");
|
||
this.showNotice("Failed to add timestamp links (empty response from LLM)", 5000);
|
||
return null;
|
||
}
|
||
|
||
// First validate that we received TimeIndex markers from the LLM
|
||
if (!enhancedContent.includes('[TimeIndex:')) {
|
||
logger.warn("[addTimestampLinksSinglePass] No TimeIndex markers found in LLM response");
|
||
this.showNotice("LLM did not add TimeIndex markers to headings", 5000);
|
||
return null;
|
||
}
|
||
|
||
// Reconstruct the document with original frontmatter and enhanced content
|
||
let enhancedNote = reconstructDocument(frontmatter, enhancedContent);
|
||
|
||
// Convert TimeIndex markers to Watch URLs ONLY in content, preserving frontmatter transcript
|
||
const { frontmatter: extractedFrontmatter, contentWithoutFrontmatter: extractedContent } = extractDocumentComponents(enhancedNote);
|
||
const convertedContent = convertTimeIndexToWatchUrls(extractedContent, videoId);
|
||
enhancedNote = reconstructDocument(extractedFrontmatter, convertedContent);
|
||
|
||
// Validate the final enhanced note with Watch URLs
|
||
if (validateEnhancedContent(enhancedNote, contentWithoutFrontmatter, headings, videoId)) {
|
||
// Update the note file with the LLM-enhanced content
|
||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||
if (file instanceof TFile) {
|
||
await this.app.vault.modify(file, enhancedNote);
|
||
} else {
|
||
logger.error(`[addTimestampLinksSinglePass] File not found: ${filePath}`);
|
||
this.showNotice(`Error: File not found: ${filePath}`, 5000);
|
||
return null;
|
||
}
|
||
|
||
// Count number of section headings with links (use final converted note)
|
||
const linkCount = countTimestampLinks(enhancedNote);
|
||
this.showNotice(`Added timestamp links to ${linkCount} section headings`, 5000);
|
||
|
||
// Log the final output if debug logging is enabled
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug("TIMESTAMP LINKING SUCCESSFUL");
|
||
llmLogger.debug(`Added ${linkCount} timestamp links`);
|
||
llmLogger.debug("========================================");
|
||
}
|
||
|
||
// Return the enhanced content for potential translation
|
||
return enhancedContent;
|
||
}
|
||
|
||
// Log validation failure if debug logging is enabled
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug("TIMESTAMP LINKING VALIDATION FAILED");
|
||
llmLogger.debug("Validation of enhanced content against original content failed");
|
||
llmLogger.debug("========================================");
|
||
}
|
||
|
||
return null;
|
||
} catch (error) {
|
||
logger.error("[addTimestampLinksSinglePass] Error:", error);
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
this.showNotice(`Error adding timestamp links: ${errorMessage}`, 5000);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// New method to translate content
|
||
private async translateContent(
|
||
filePath: string,
|
||
contentToTranslate: string,
|
||
targetLang: string,
|
||
targetCountry: string
|
||
): Promise<void> {
|
||
try {
|
||
this.showNotice(`Translating content to ${targetLang.toUpperCase()}-${targetCountry}...`, 5000);
|
||
logger.debug("[translateContent] Starting translation process");
|
||
|
||
// Extract document components using the utility
|
||
const noteFile = this.app.vault.getAbstractFileByPath(filePath);
|
||
if (!(noteFile instanceof TFile)) {
|
||
logger.error(`[translateContent] File not found: ${filePath}`);
|
||
this.showNotice(`Error: File not found: ${filePath}`, 5000);
|
||
return;
|
||
}
|
||
const fileContent = await this.app.vault.read(noteFile);
|
||
const { frontmatter } = extractDocumentComponents(fileContent);
|
||
|
||
// Create a specialized summarizer for translation
|
||
const translationSummarizer = new TranscriptSummarizer({
|
||
model: this.getModelForProvider(this.settings.selectedLLM),
|
||
temperature: 0.3, // Lower temperature for more accurate translations
|
||
maxTokens: this.getMaxTokensForTimestampPass(),
|
||
systemPrompt: "You are a highly accurate translator who preserves all formatting, links, and structure when translating content.",
|
||
userPrompt: "Translate the following content while preserving all Markdown formatting, links, and structure:"
|
||
}, this.settings.apiKeys);
|
||
|
||
// Create the translation prompt
|
||
const translationPrompt = `
|
||
TRANSLATION TASK: Translate the following content into ${targetLang.toUpperCase()}-${targetCountry}.
|
||
|
||
RULES:
|
||
1. Preserve all Markdown formatting, especially section headings with # syntax
|
||
2. Keep all links intact, especially YouTube timestamp [Watch] links
|
||
3. Maintain the same overall structure and organization
|
||
4. Translate everything else, including headings, paragraphs, and lists
|
||
5. Keep technical terms, proper names, and specific terminology in their original form when appropriate
|
||
6. Ensure the translation sounds natural in the target language
|
||
|
||
CONTENT TO TRANSLATE:
|
||
|
||
${contentToTranslate}
|
||
`;
|
||
|
||
// Send to LLM for translation
|
||
let translatedContent;
|
||
try {
|
||
translatedContent = await translationSummarizer.summarize(translationPrompt, this.settings.selectedLLM);
|
||
logger.debug("[translateContent] Received translated content, length:", translatedContent ? translatedContent.length : 0);
|
||
} catch (e) {
|
||
logger.error("[translateContent] Error during translation:", e);
|
||
const errorMessage = getSafeErrorMessage(e);
|
||
this.showNotice(`Translation error: ${errorMessage}`, 5000);
|
||
return;
|
||
}
|
||
|
||
if (!translatedContent) {
|
||
logger.error("[translateContent] Failed to translate content (empty response)");
|
||
this.showNotice("Failed to translate content (empty response from LLM)", 5000);
|
||
return;
|
||
}
|
||
|
||
// Reconstruct the document with original frontmatter and translated content
|
||
const translatedNote = reconstructDocument(frontmatter, translatedContent);
|
||
|
||
// Update the note file with the translated content
|
||
await this.app.vault.modify(noteFile, translatedNote);
|
||
this.showNotice(`Successfully translated content to ${targetLang.toUpperCase()}-${targetCountry}`, 5000);
|
||
|
||
} catch (error) {
|
||
logger.error("[translateContent] Error:", error);
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
this.showNotice(`Error translating content: ${errorMessage}`, 5000);
|
||
}
|
||
}
|
||
|
||
// Validate just the enhanced content without checking frontmatter
|
||
private validateEnhancedContentOnly(enhancedContent: string, originalContent: string, headings: string[]): boolean {
|
||
// This method has been replaced by the validateEnhancedContent utility
|
||
return validateEnhancedContent(enhancedContent, originalContent, headings, "");
|
||
}
|
||
|
||
// Process document in chunks based on section headings
|
||
private async addTimestampLinksInChunks(
|
||
filePath: string,
|
||
videoId: string,
|
||
originalContent: string,
|
||
headings: string[],
|
||
_headingPositions: number[]
|
||
): Promise<string | null> {
|
||
try {
|
||
logger.debug("[addTimestampLinksInChunks] Processing document in chunks");
|
||
|
||
// Extract document components using the utility
|
||
const { frontmatter, contentWithoutFrontmatter, transcript } =
|
||
extractDocumentComponents(originalContent);
|
||
|
||
// Log debug info about chunking if enabled
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug("==================== CHUNKED TIMESTAMP LINKING DEBUG ====================");
|
||
llmLogger.debug(`Processing file: ${filePath} in chunks`);
|
||
llmLogger.debug(`Video ID: ${videoId}`);
|
||
llmLogger.debug(`Number of headings found: ${headings.length}`);
|
||
llmLogger.debug(`Original content length: ${contentWithoutFrontmatter.length} characters`);
|
||
llmLogger.debug(`Transcript length: ${transcript ? transcript.length : 0} characters`);
|
||
}
|
||
|
||
// Create optimized chunks based on heading positions
|
||
const maxTokenLimit = this.getMaxTokensForTimestampPass();
|
||
const chunks = createOptimizedChunks(contentWithoutFrontmatter, maxTokenLimit);
|
||
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`Split content into ${chunks.length} optimized chunks`);
|
||
llmLogger.debug(`Chunk sizes: ${chunks.map(c => c.length).join(', ')} characters`);
|
||
}
|
||
|
||
logger.debug(`[addTimestampLinksInChunks] Split content into ${chunks.length} optimized chunks`);
|
||
|
||
// Process each chunk separately
|
||
let processedChunks: string[] = [];
|
||
|
||
// We're still in the second LLM pass, just breaking it into smaller chunks
|
||
// The first LLM pass already created the note content, now we're adding timestamp links
|
||
|
||
for (let i = 0; i < chunks.length; i++) {
|
||
const chunk = chunks[i];
|
||
|
||
// Skip chunks without proper heading (including template header)
|
||
if (!hasProperHeading(chunk)) {
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`Chunk ${i+1}: No proper headings found, preserving unchanged`);
|
||
}
|
||
|
||
logger.debug(`[addTimestampLinksInChunks] Preserving non-section chunk ${i+1} unchanged`);
|
||
// Ensure chunk ends with newline
|
||
processedChunks.push(ensureTrailingNewline(chunk));
|
||
continue;
|
||
}
|
||
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`\n===== PROCESSING CHUNK ${i+1} of ${chunks.length} =====`);
|
||
llmLogger.debug(`Chunk size: ${chunk.length} characters`);
|
||
|
||
// Extract and show headings in this chunk
|
||
const chunkHeadings: string[] = [];
|
||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||
let match;
|
||
while ((match = headingRegex.exec(chunk)) !== null) {
|
||
chunkHeadings.push(match[2].trim());
|
||
}
|
||
|
||
llmLogger.debug(`Headings in chunk ${i+1}: ${chunkHeadings.join(', ')}`);
|
||
}
|
||
|
||
logger.debug(`[addTimestampLinksInChunks] Processing chunk ${i+1} of ${chunks.length}, length: ${chunk.length}`);
|
||
this.showNotice(`Processing section ${i+1} of ${chunks.length}...`, 2000);
|
||
|
||
// Get timestamp link configuration
|
||
const timestampConfig = getTimestampLinkConfig(this.settings, videoId, this.getEffectiveMaxTokens());
|
||
|
||
// Construct reference section with clear instructions not to include in output
|
||
// Reduce transcript size on mobile to prevent token overflow
|
||
let transcriptContent = "";
|
||
if (Platform.isMobile && transcript.length > 5000) {
|
||
// On mobile with large transcripts, keep only a portion to save tokens
|
||
transcriptContent = transcript.substring(0, 5000) + "\n[Transcript truncated for mobile processing]";
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`Truncated transcript on mobile from ${transcript.length} to 5000 characters`);
|
||
}
|
||
} else {
|
||
transcriptContent = transcript.length > 0 ?
|
||
transcript :
|
||
"No transcript available, use default timestamps starting at 0 seconds.";
|
||
}
|
||
|
||
// Restructure the prompt with clear section labels for all providers
|
||
const restructuredPrompt =
|
||
"INSTRUCTIONS:\n" + timestampConfig.userPrompt + "\n\n" +
|
||
"INSTRUCTION INPUT DATA - TIMESTAMPS TRANSCRIPT:\n" + transcriptContent + "\n\n" +
|
||
"INPUT NOTE TO BE MODIFIED WITH TIMESTAMPS:\n" + chunk;
|
||
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`CHUNK ${i+1} PROMPT:`);
|
||
llmLogger.debug("----------------------------------------");
|
||
llmLogger.debug(truncateForLogs(timestampConfig.userPrompt, 400));
|
||
llmLogger.debug("----------------------------------------");
|
||
llmLogger.debug(`CHUNK ${i+1} CONTENT:`);
|
||
llmLogger.debug("----------------------------------------");
|
||
llmLogger.debug(truncateForLogs(chunk, 400));
|
||
llmLogger.debug("----------------------------------------");
|
||
llmLogger.debug(`CHUNK ${i+1} COMPLETE FORMATTED PROMPT:`);
|
||
llmLogger.debug("========================================");
|
||
llmLogger.debug(truncateForLogs(restructuredPrompt, 400));
|
||
llmLogger.debug("========================================");
|
||
}
|
||
|
||
try {
|
||
// Get tokens from our simplified method
|
||
const maxTokens = this.getMaxTokensForTimestampPass();
|
||
|
||
// For debugging, log content and transcript info
|
||
if (this.settings.debugLogging) {
|
||
// Estimate token counts for logs only
|
||
const contentLength = chunk.length;
|
||
const transcriptLength = transcriptContent.length;
|
||
const estimatedContentTokens = Math.ceil(contentLength / 4);
|
||
const estimatedTranscriptTokens = Math.ceil(transcriptLength / 4);
|
||
|
||
llmLogger.debug(`[addTimestampLinksInChunks] Content length: ${contentLength} chars (est. ${estimatedContentTokens} tokens)`);
|
||
llmLogger.debug(`[addTimestampLinksInChunks] Transcript length: ${transcriptLength} chars (est. ${estimatedTranscriptTokens} tokens)`);
|
||
llmLogger.debug(`[addTimestampLinksInChunks] Using max tokens: ${maxTokens}`);
|
||
}
|
||
|
||
logger.debug(`[addTimestampLinksInChunks] Using ${maxTokens} tokens for chunk ${i+1}`);
|
||
|
||
// Safety check - if tokens are invalid, skip processing this chunk
|
||
if (maxTokens <= 0) {
|
||
logger.warn(`[addTimestampLinksInChunks] Invalid token value for chunk ${i+1}, skipping processing`);
|
||
processedChunks.push(ensureTrailingNewline(chunk));
|
||
continue;
|
||
}
|
||
|
||
const chunkSummarizer = new TranscriptSummarizer({
|
||
model: this.getModelForProvider(this.settings.selectedLLM),
|
||
temperature: timestampConfig.temperature,
|
||
maxTokens: maxTokens,
|
||
systemPrompt: timestampConfig.systemPrompt,
|
||
userPrompt: timestampConfig.userPrompt // Use the base prompt, not the complete chunk prompt
|
||
}, this.settings.apiKeys);
|
||
|
||
// Process the chunk
|
||
const processedChunk = await chunkSummarizer.summarize(
|
||
restructuredPrompt,
|
||
this.settings.selectedLLM
|
||
);
|
||
|
||
// Log LLM response for this chunk if debug is enabled
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`CHUNK ${i+1} LLM RESPONSE:`);
|
||
llmLogger.debug("========================================");
|
||
llmLogger.debug(truncateForLogs(processedChunk || "Empty response from LLM", 400));
|
||
llmLogger.debug("========================================");
|
||
}
|
||
|
||
if (processedChunk) {
|
||
// Validate processed chunk has timestamp link
|
||
const hasLink = hasTimestampLinks(processedChunk, videoId);
|
||
|
||
// Log link validation result if debug is enabled
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`CHUNK ${i+1} has timestamp links: ${hasLink}`);
|
||
|
||
if (hasLink) {
|
||
const linkCount = countTimestampLinks(processedChunk);
|
||
llmLogger.debug(`CHUNK ${i+1} contains ${linkCount} timestamp links`);
|
||
}
|
||
}
|
||
|
||
// Ensure chunk ends with a newline to prevent wrapping
|
||
let finalChunk = ensureTrailingNewline(processedChunk);
|
||
|
||
if (hasLink) {
|
||
processedChunks.push(finalChunk);
|
||
} else {
|
||
logger.warn("[addTimestampLinksInChunks] No timestamp link added to chunk", i+1);
|
||
// Push original chunk with newline
|
||
processedChunks.push(ensureTrailingNewline(chunk));
|
||
}
|
||
} else {
|
||
logger.warn("[addTimestampLinksInChunks] Empty response for chunk", i+1);
|
||
// Push original chunk with newline
|
||
processedChunks.push(ensureTrailingNewline(chunk));
|
||
}
|
||
} catch (e) {
|
||
logger.error(`[addTimestampLinksInChunks] Error processing chunk ${i+1}:`, e);
|
||
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug(`ERROR PROCESSING CHUNK ${i+1}:`);
|
||
llmLogger.debug(getSafeErrorMessage(e));
|
||
llmLogger.debug(`Using original chunk content instead`);
|
||
}
|
||
|
||
// Push original chunk with newline
|
||
processedChunks.push(ensureTrailingNewline(chunk));
|
||
}
|
||
}
|
||
|
||
// Reconstruct document: frontmatter + processed content
|
||
const combinedContent = processedChunks.join("");
|
||
let combinedNote = reconstructDocument(frontmatter, combinedContent);
|
||
|
||
// Convert TimeIndex markers to Watch URLs ONLY in content, preserving frontmatter transcript
|
||
const { frontmatter: extractedFrontmatter, contentWithoutFrontmatter: extractedContent } = extractDocumentComponents(combinedNote);
|
||
const convertedContent = convertTimeIndexToWatchUrls(extractedContent, videoId);
|
||
combinedNote = reconstructDocument(extractedFrontmatter, convertedContent);
|
||
|
||
// Verify we have some timestamp links (count from the converted note)
|
||
const linkCount = countTimestampLinks(combinedNote);
|
||
|
||
if (this.settings.debugLogging) {
|
||
llmLogger.debug("CHUNKED PROCESSING COMPLETE");
|
||
llmLogger.debug(`Total timestamp links found: ${linkCount}`);
|
||
|
||
if (linkCount > 0) {
|
||
llmLogger.debug("CHUNKED PROCESSING SUCCESSFUL");
|
||
} else {
|
||
llmLogger.debug("CHUNKED PROCESSING FAILED - No timestamp links added");
|
||
}
|
||
|
||
llmLogger.debug("========================================");
|
||
}
|
||
|
||
if (linkCount > 0) {
|
||
// Update the note file with the combined content
|
||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||
if (file instanceof TFile) {
|
||
await this.app.vault.modify(file, combinedNote);
|
||
} else {
|
||
logger.error(`[addTimestampLinksInChunks] File not found: ${filePath}`);
|
||
this.showNotice(`Error: File not found: ${filePath}`, 5000);
|
||
return null;
|
||
}
|
||
|
||
this.showNotice(`Added ${linkCount} timestamp links using chunked processing`, 5000);
|
||
|
||
// Return the combined content for potential translation
|
||
return combinedContent;
|
||
} else {
|
||
logger.error("[addTimestampLinksInChunks] No timestamp links were added in any chunk");
|
||
this.showNotice("Failed to add any timestamp links", 5000);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
logger.error("[addTimestampLinksInChunks] Error:", error);
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
this.showNotice(`Error in chunked processing: ${errorMessage}`, 5000);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Validate the enhanced note meets all quality checks
|
||
private validateEnhancedNote(enhancedNote: string, originalContent: string, headings: string[]): boolean {
|
||
// This method has been replaced by the validateEnhancedContent utility
|
||
return validateEnhancedContent(enhancedNote, originalContent, headings, "");
|
||
}
|
||
|
||
// Method to get appropriate max tokens for the timestamp linking pass based on the model
|
||
private getMaxTokensForTimestampPass(): number {
|
||
// Get the user's configured maxTokens setting
|
||
const configuredMaxTokens = this.settings.maxTokens;
|
||
|
||
// Get the selected LLM provider and model
|
||
const selectedProvider = this.settings.selectedLLM as Provider;
|
||
const selectedModel = this.settings.selectedModels[selectedProvider] || 'gpt-4o'; // fallback to gpt-4o if not set
|
||
|
||
try {
|
||
// Use effective max tokens calculation (same as main summarization, respects custom model params)
|
||
const effectiveMaxTokens = this.getEffectiveMaxTokens();
|
||
|
||
// Apply 85% multiplier for timestamp linking (conservative approach)
|
||
let tokensToUse = Math.floor(effectiveMaxTokens * 0.85);
|
||
|
||
if (this.settings.debugLogging) {
|
||
logger.debug(`[getMaxTokensForTimestampPass] Using ${tokensToUse} tokens (85% of effective limit ${effectiveMaxTokens}, configured: ${configuredMaxTokens}, model: ${selectedProvider}:${selectedModel}, platform: ${Platform.isMobile ? 'mobile' : 'desktop'})`);
|
||
}
|
||
|
||
return tokensToUse;
|
||
|
||
} catch (error) {
|
||
// Fallback to legacy calculation if dynamic calculation fails
|
||
if (this.settings.debugLogging) {
|
||
logger.debug(`[getMaxTokensForTimestampPass] Dynamic calculation failed, using legacy fallback: ${error}`);
|
||
}
|
||
|
||
// Legacy hard limits for fallback
|
||
const LEGACY_LIMITS: Record<string, number> = {
|
||
'openai': 4096,
|
||
'anthropic': 4096,
|
||
'google': 8192,
|
||
'ollama': 4096,
|
||
'default': 4096
|
||
};
|
||
|
||
const providerHardLimit = LEGACY_LIMITS[selectedProvider] || LEGACY_LIMITS.default;
|
||
let tokensToUse = Math.floor(configuredMaxTokens * 0.85);
|
||
tokensToUse = Math.min(tokensToUse, providerHardLimit - 100);
|
||
|
||
return tokensToUse;
|
||
}
|
||
}
|
||
|
||
private sanitizePathComponent(text: string): string {
|
||
// Use the utility function instead of duplicating code
|
||
return sanitizePathComponent(text);
|
||
}
|
||
|
||
/**
|
||
* Get the effective maxTokens for the current provider and model
|
||
* Uses registry values for known models, custom model limits for user-defined models,
|
||
* or falls back to current setting
|
||
*/
|
||
public getEffectiveMaxTokens(): number {
|
||
const provider = this.settings.selectedLLM as Provider;
|
||
const model = this.settings.selectedModels[provider];
|
||
const defaultReserve = provider === 'ollama' ? 0.15 : 0.10;
|
||
|
||
try {
|
||
// Check if user has defined custom limits for this model — checked
|
||
// first so that a fetched-but-unknown model auto-populated by
|
||
// getCurrentCustomLimits picks up the panel's values rather than
|
||
// falling back to settings.maxTokens (which would be stale from
|
||
// the previous model's effective computation).
|
||
const customKey = `${provider}:${model}`;
|
||
const customLimits = this.settings.customModelLimits[customKey];
|
||
if (customLimits) {
|
||
const reserve = customLimits.reservePct ?? defaultReserve;
|
||
const maxOutput = customLimits.maxOutputK * 1000; // K → tokens
|
||
return Math.floor(maxOutput * (1 - reserve));
|
||
}
|
||
|
||
// Then check the registry for known models.
|
||
if (isModelSupported(provider, model)) {
|
||
const limits = getEffectiveLimits(provider, model);
|
||
return limits.maxOutputEff;
|
||
}
|
||
|
||
// No override and not in registry — compute from the same
|
||
// conservative defaults the panel uses (16k maxOutput * 0.90 reserve).
|
||
// This makes the "Max tokens updated to N" notice consistent with
|
||
// the panel's displayed values, instead of returning whatever was
|
||
// last saved in settings.maxTokens (which is the *previous*
|
||
// model's effective value and confuses the user).
|
||
return Math.floor(16_000 * (1 - defaultReserve));
|
||
} catch {
|
||
// Same conservative fallback if anything goes wrong.
|
||
return Math.floor(16_000 * (1 - defaultReserve));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Register a custom model with the dynamic registry
|
||
*/
|
||
public registerCustomModel(provider: Provider, modelId: string, limits: {
|
||
contextK: number;
|
||
maxOutputK: number;
|
||
inputMaxK?: number;
|
||
reservePct?: number;
|
||
}): void {
|
||
// Convert K values to actual tokens and register with the dynamic registry
|
||
upsertModel(provider, modelId, {
|
||
context: limits.contextK * 1000,
|
||
maxOutput: limits.maxOutputK * 1000,
|
||
inputMax: limits.inputMaxK ? limits.inputMaxK * 1000 : undefined,
|
||
reserveOutputPct: limits.reservePct ?? (provider === 'ollama' ? 0.15 : 0.10)
|
||
});
|
||
}
|
||
|
||
async fetchOpenAIModels(apiKey: string): Promise<FetchedModelInfo[]> {
|
||
if (!apiKey || apiKey.trim() === "") {
|
||
this.showNotice("OpenAI API key is missing. Cannot fetch models.", 5000);
|
||
logger.warn("[fetchOpenAIModels] OpenAI API key is missing.");
|
||
return [];
|
||
}
|
||
|
||
const url = "https://api.openai.com/v1/models";
|
||
try {
|
||
this.showNotice("Fetching OpenAI models...", 3000);
|
||
const response = await obsidianFetch(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ message: response.statusText })) as ApiErrorResponse;
|
||
const errorMessage = errorData.error?.message || errorData.message || `HTTP error ${response.status}`;
|
||
logger.error(`[fetchOpenAIModels] Failed to fetch OpenAI models: ${errorMessage}`);
|
||
this.showNotice(`Failed to fetch OpenAI models: ${errorMessage}`, 5000);
|
||
return [];
|
||
}
|
||
|
||
const data = await response.json() as OpenAIModelsResponse;
|
||
if (data && Array.isArray(data.data)) {
|
||
// OpenAI API does not return context/output limits — limits stay in the hardcoded registry
|
||
const models: FetchedModelInfo[] = data.data
|
||
.map((model: OpenAIModel) => model.id)
|
||
.filter((id: string) => id.includes('gpt') || id.includes('text-davinci'))
|
||
.sort()
|
||
.map(id => ({ id }));
|
||
|
||
// Persist discovered IDs so the dropdown survives this.display().
|
||
// OpenAI's API returns no token limits, so customModelLimits is
|
||
// never written to here — fetchedModels is the only signal that
|
||
// these models exist for this provider.
|
||
this.settings.fetchedModels = {
|
||
...this.settings.fetchedModels,
|
||
openai: models.map(m => m.id),
|
||
};
|
||
await this.saveSettings();
|
||
|
||
logger.info(`[fetchOpenAIModels] Successfully fetched ${models.length} OpenAI models.`);
|
||
this.showNotice("OpenAI models updated!", 3000);
|
||
return models;
|
||
} else {
|
||
logger.warn("[fetchOpenAIModels] Unexpected response structure from OpenAI API.");
|
||
this.showNotice("Could not parse OpenAI models from API response.", 5000);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
logger.error("[fetchOpenAIModels] Error fetching or parsing OpenAI models:", errorMessage);
|
||
this.showNotice(`Error fetching OpenAI models: ${errorMessage}`, 5000);
|
||
return []; // Or a default list
|
||
}
|
||
}
|
||
|
||
async fetchGoogleModels(apiKey: string): Promise<FetchedModelInfo[]> {
|
||
if (!apiKey || apiKey.trim() === "") {
|
||
this.showNotice("Google API key is missing. Cannot fetch models.", 5000);
|
||
logger.warn("[fetchGoogleModels] Google API key is missing.");
|
||
return [];
|
||
}
|
||
|
||
const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`;
|
||
try {
|
||
this.showNotice("Fetching Google models...", 3000);
|
||
const response = await obsidianFetch(url, { method: 'GET' });
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ message: response.statusText })) as ApiErrorResponse;
|
||
const errorMessage = errorData.error?.message || errorData.message || `HTTP error ${response.status}`;
|
||
logger.error(`[fetchGoogleModels] Failed to fetch Google models: ${errorMessage}`);
|
||
this.showNotice(`Failed to fetch Google models: ${errorMessage}`, 5000);
|
||
return [];
|
||
}
|
||
|
||
const data = await response.json() as GoogleModelsResponse;
|
||
if (data && Array.isArray(data.models)) {
|
||
const models: FetchedModelInfo[] = data.models
|
||
.filter((model: GoogleModel) =>
|
||
model.name &&
|
||
!model.name.includes('embed') &&
|
||
model.supportedGenerationMethods?.includes('generateContent')
|
||
)
|
||
.map((model: GoogleModel) => {
|
||
const id = model.name.startsWith('models/') ? model.name.substring('models/'.length) : model.name;
|
||
const contextK = model.inputTokenLimit ? Math.round(model.inputTokenLimit / 1000) : undefined;
|
||
const maxOutputK = model.outputTokenLimit ? Math.round(model.outputTokenLimit / 1000) : undefined;
|
||
return { id, contextK, maxOutputK };
|
||
})
|
||
.sort((a, b) => a.id.localeCompare(b.id));
|
||
|
||
// Upsert token limits into customModelLimits for any model that has them
|
||
let updatedCount = 0;
|
||
for (const m of models) {
|
||
if (m.contextK && m.maxOutputK) {
|
||
const key = `google:${m.id}`;
|
||
this.settings.customModelLimits[key] = {
|
||
contextK: m.contextK,
|
||
maxOutputK: m.maxOutputK,
|
||
reservePct: 0.10
|
||
};
|
||
updatedCount++;
|
||
}
|
||
}
|
||
if (updatedCount > 0) {
|
||
logger.info(`[fetchGoogleModels] Stored token limits for ${updatedCount} Google models.`);
|
||
}
|
||
|
||
// Persist discovered IDs (independent of whether limits were
|
||
// captured) so the dropdown survives this.display().
|
||
this.settings.fetchedModels = {
|
||
...this.settings.fetchedModels,
|
||
google: models.map(m => m.id),
|
||
};
|
||
await this.saveSettings();
|
||
|
||
logger.info(`[fetchGoogleModels] Successfully fetched ${models.length} Google models.`);
|
||
this.showNotice("Google models updated!", 3000);
|
||
return models;
|
||
} else {
|
||
logger.warn("[fetchGoogleModels] Unexpected response structure from Google API.");
|
||
this.showNotice("Could not parse Google models from API response.", 5000);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
logger.error("[fetchGoogleModels] Error fetching or parsing Google models:", errorMessage);
|
||
this.showNotice(`Error fetching Google models: ${errorMessage}`, 5000);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async fetchAnthropicModels(apiKey: string): Promise<FetchedModelInfo[]> {
|
||
if (!apiKey || apiKey.trim() === "") {
|
||
this.showNotice("Anthropic API key is missing. Cannot fetch models.", 5000);
|
||
logger.warn("[fetchAnthropicModels] Anthropic API key is missing.");
|
||
return [];
|
||
}
|
||
|
||
const url = "https://api.anthropic.com/v1/models";
|
||
try {
|
||
this.showNotice("Fetching Anthropic models...", 3000);
|
||
const response = await obsidianFetch(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
'x-api-key': apiKey,
|
||
'anthropic-version': '2023-06-01'
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ message: response.statusText })) as ApiErrorResponse;
|
||
const errorMessage = errorData.error?.message || errorData.message || `HTTP error ${response.status}`;
|
||
logger.error(`[fetchAnthropicModels] Failed to fetch Anthropic models: ${errorMessage}`);
|
||
this.showNotice(`Failed to fetch Anthropic models: ${errorMessage}`, 5000);
|
||
return [];
|
||
}
|
||
|
||
const data = await response.json() as {
|
||
data?: Array<{
|
||
id: string;
|
||
type?: string;
|
||
max_input_tokens?: number;
|
||
max_tokens?: number;
|
||
}>
|
||
};
|
||
if (data && Array.isArray(data.data)) {
|
||
const models: FetchedModelInfo[] = data.data
|
||
.filter(model => model.type === 'model' || !model.type)
|
||
.map(model => ({
|
||
id: model.id,
|
||
contextK: model.max_input_tokens ? Math.round(model.max_input_tokens / 1000) : undefined,
|
||
maxOutputK: model.max_tokens ? Math.round(model.max_tokens / 1000) : undefined
|
||
}))
|
||
.sort((a, b) => b.id.localeCompare(a.id)); // Most recent first
|
||
|
||
// Upsert token limits into customModelLimits for any model that has them
|
||
let updatedCount = 0;
|
||
for (const m of models) {
|
||
if (m.contextK && m.maxOutputK) {
|
||
const key = `anthropic:${m.id}`;
|
||
this.settings.customModelLimits[key] = {
|
||
contextK: m.contextK,
|
||
maxOutputK: m.maxOutputK,
|
||
reservePct: 0.10
|
||
};
|
||
updatedCount++;
|
||
}
|
||
}
|
||
if (updatedCount > 0) {
|
||
logger.info(`[fetchAnthropicModels] Stored token limits for ${updatedCount} Anthropic models.`);
|
||
}
|
||
|
||
// Persist discovered IDs (independent of whether limits were
|
||
// captured) so the dropdown survives this.display().
|
||
this.settings.fetchedModels = {
|
||
...this.settings.fetchedModels,
|
||
anthropic: models.map(m => m.id),
|
||
};
|
||
await this.saveSettings();
|
||
|
||
logger.info(`[fetchAnthropicModels] Successfully fetched ${models.length} Anthropic models.`);
|
||
this.showNotice("Anthropic models updated!", 3000);
|
||
return models;
|
||
} else {
|
||
logger.warn("[fetchAnthropicModels] Unexpected response structure from Anthropic API.");
|
||
this.showNotice("Could not parse Anthropic models from API response.", 5000);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
logger.error("[fetchAnthropicModels] Error fetching or parsing Anthropic models:", errorMessage);
|
||
this.showNotice(`Error fetching Anthropic models: ${errorMessage}`, 5000);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async fetchOpenRouterModels(): Promise<FetchedModelInfo[]> {
|
||
const url = 'https://openrouter.ai/api/v1/models';
|
||
try {
|
||
this.showNotice("Fetching OpenRouter models...", 3000);
|
||
const response = await obsidianFetch(url, { method: 'GET' });
|
||
|
||
if (!response.ok) {
|
||
const errorMessage = `HTTP error ${response.status}`;
|
||
logger.error(`[fetchOpenRouterModels] Failed to fetch OpenRouter models: ${errorMessage}`);
|
||
this.showNotice(`Failed to fetch OpenRouter models: ${errorMessage}`, 5000);
|
||
return [];
|
||
}
|
||
|
||
const data = await response.json() as {
|
||
data?: Array<{
|
||
id?: string;
|
||
context_length?: number;
|
||
top_provider?: { max_completion_tokens?: number | null };
|
||
}>;
|
||
};
|
||
|
||
if (data && Array.isArray(data.data)) {
|
||
const models: FetchedModelInfo[] = data.data
|
||
.filter((m) => typeof m.id === 'string' && m.id.length > 0)
|
||
.map((m) => {
|
||
const id = m.id as string;
|
||
const contextK = m.context_length ? Math.round(m.context_length / 1000) : undefined;
|
||
const maxOut = m.top_provider?.max_completion_tokens;
|
||
const maxOutputK = maxOut ? Math.round(maxOut / 1000) : undefined;
|
||
return { id, contextK, maxOutputK };
|
||
})
|
||
.sort((a, b) => a.id.localeCompare(b.id));
|
||
|
||
let updatedCount = 0;
|
||
for (const m of models) {
|
||
if (m.contextK && m.maxOutputK) {
|
||
this.settings.customModelLimits[`openrouter:${m.id}`] = {
|
||
contextK: m.contextK,
|
||
maxOutputK: m.maxOutputK,
|
||
reservePct: 0.10
|
||
};
|
||
updatedCount++;
|
||
}
|
||
}
|
||
if (updatedCount > 0) {
|
||
logger.info(`[fetchOpenRouterModels] Stored token limits for ${updatedCount} OpenRouter models.`);
|
||
}
|
||
|
||
this.settings.fetchedModels = {
|
||
...this.settings.fetchedModels,
|
||
openrouter: models.map((m) => m.id),
|
||
};
|
||
await this.saveSettings();
|
||
|
||
logger.info(`[fetchOpenRouterModels] Successfully fetched ${models.length} OpenRouter models.`);
|
||
this.showNotice("OpenRouter models updated!", 3000);
|
||
return models;
|
||
} else {
|
||
logger.warn("[fetchOpenRouterModels] Unexpected response structure from OpenRouter API.");
|
||
this.showNotice("Could not parse OpenRouter models from API response.", 5000);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = getSafeErrorMessage(error);
|
||
logger.error("[fetchOpenRouterModels] Error fetching or parsing OpenRouter models:", errorMessage);
|
||
this.showNotice(`Error fetching OpenRouter models: ${errorMessage}`, 5000);
|
||
return [];
|
||
}
|
||
}
|
||
}
|
||
|
||
class YouTubeTranscriptModal extends Modal {
|
||
plugin: YouTubeTranscriptPlugin;
|
||
private titleInputEl: HTMLInputElement;
|
||
private urlInputEl: HTMLInputElement;
|
||
private errorEl: HTMLElement;
|
||
private isProcessing: boolean = false;
|
||
private selectedFolder: string = '';
|
||
constructor(app: App, plugin: YouTubeTranscriptPlugin) {
|
||
super(app);
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
// Create a wrapper for Notice that uses the shared utility
|
||
showNotice(message: string, timeout: number = 5000): void {
|
||
showNotice(message, timeout);
|
||
}
|
||
|
||
// Use imported utility method
|
||
private isYoutubeUrl(this: void, url: string): boolean {
|
||
return isYoutubeUrl(url);
|
||
}
|
||
|
||
// Use imported utility method
|
||
private isYoutubeChannelOrPlaylistUrl(this: void, url: string): boolean {
|
||
return isYoutubeChannelOrPlaylistUrl(url);
|
||
}
|
||
|
||
// Use imported utility method
|
||
private extractChannelName(url: string): string {
|
||
return extractChannelName(url);
|
||
}
|
||
|
||
// Helper method to show error message
|
||
private showError(message: string): void {
|
||
if (this.errorEl) {
|
||
displayValidationResult(
|
||
{ isValid: false, message: message },
|
||
{ element: this.errorEl }
|
||
);
|
||
}
|
||
}
|
||
|
||
// Helper method to hide error message
|
||
private hideError(): void {
|
||
if (this.errorEl) {
|
||
// Use class toggling for consistency with displayValidationResult
|
||
this.errorEl.addClass('tubesage-error-hidden');
|
||
this.errorEl.removeClass('tubesage-error-visible');
|
||
}
|
||
}
|
||
|
||
onOpen() {
|
||
// Initialize
|
||
this.showNotice('Youtube transcript extractor ready', 3000);
|
||
|
||
// Clear content and create container
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
|
||
// Add header
|
||
contentEl.createEl('h2', { text: 'Tubesage: create note from YouTube transcript' });
|
||
|
||
// Build the input stage UI
|
||
this.buildInputStage();
|
||
}
|
||
|
||
private buildInputStage() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
|
||
// Add header
|
||
contentEl.createEl('h2', { text: 'Tubesage: create note from YouTube transcript' });
|
||
|
||
// Check if we're on mobile
|
||
const isMobile = Platform.isMobile;
|
||
|
||
// Create the form container - revert to original appearance
|
||
const formEl = contentEl.createDiv({ cls: 'tubesage-transcript-form' });
|
||
|
||
// URL input group - first input
|
||
const urlGroup = formEl.createDiv({ cls: 'form-group' });
|
||
urlGroup.createEl('label', { text: 'YouTube URL', attr: { for: 'url' } });
|
||
const urlText = new TextComponent(urlGroup);
|
||
urlText.setPlaceholder(YOUTUBE_URL_PLACEHOLDER);
|
||
urlText.inputEl.id = 'url';
|
||
this.urlInputEl = urlText.inputEl;
|
||
|
||
// Create a URL validation message element
|
||
const urlValidationEl = urlGroup.createDiv({
|
||
cls: ['validation-message', 'tubesage-validation-hidden']
|
||
});
|
||
|
||
// Add channel selection container (initially hidden)
|
||
const channelOptionsContainer = formEl.createDiv({
|
||
cls: ['channel-options', 'tubesage-display-none']
|
||
});
|
||
|
||
// Add channel message
|
||
channelOptionsContainer.createDiv({
|
||
text: 'How many videos would you like to process? Max hard limit is 50.',
|
||
cls: 'channel-message'
|
||
});
|
||
|
||
// Create a container for controls with different layout based on device
|
||
const controlsContainer = channelOptionsContainer.createDiv({
|
||
cls: ['tubesage-modal-controls-container', isMobile ? 'tubesage-modal-controls-container-mobile' : 'tubesage-modal-controls-container-desktop'],
|
||
});
|
||
|
||
// Radio button for "All Videos"
|
||
const allVideosContainer = controlsContainer.createDiv({
|
||
cls: ['tubesage-modal-radio-option', isMobile ? 'tubesage-modal-radio-option-mobile' : '' ],
|
||
});
|
||
|
||
// Create label first
|
||
allVideosContainer.createEl('label', {
|
||
text: 'All videos',
|
||
cls: 'tubesage-modal-radio-label',
|
||
attr: { for: 'all-videos-radio' }
|
||
});
|
||
|
||
// Then add the radio button
|
||
const allVideosRadio = allVideosContainer.createEl('input', {
|
||
type: 'radio',
|
||
attr: {
|
||
id: 'all-videos-radio',
|
||
name: 'video-count-option',
|
||
checked: 'checked'
|
||
}
|
||
});
|
||
|
||
// Create container for limited videos option (radio + dropdown together)
|
||
const limitedOptionContainer = controlsContainer.createDiv({
|
||
cls: ['tubesage-modal-limited-option-container', isMobile ? 'tubesage-modal-limited-option-container-mobile' : ''],
|
||
});
|
||
|
||
// Radio button for "Limited Number"
|
||
const limitedVideosContainer = limitedOptionContainer.createDiv({
|
||
cls: 'tubesage-modal-radio-option',
|
||
});
|
||
|
||
// Create label first
|
||
limitedVideosContainer.createEl('label', {
|
||
text: 'Limited number:',
|
||
cls: 'tubesage-modal-radio-label',
|
||
attr: { for: 'limited-videos-radio' }
|
||
});
|
||
|
||
// Then add the radio button
|
||
limitedVideosContainer.createEl('input', {
|
||
type: 'radio',
|
||
attr: {
|
||
id: 'limited-videos-radio',
|
||
name: 'video-count-option'
|
||
}
|
||
});
|
||
|
||
// Dropdown for selecting number of videos - add to the limitedOptionContainer
|
||
const videoCountDropdown = new DropdownComponent(limitedOptionContainer);
|
||
for (let i = 1; i <= 50; i++) {
|
||
videoCountDropdown.addOption(String(i), String(i));
|
||
}
|
||
videoCountDropdown.setValue('1');
|
||
videoCountDropdown.selectEl.addClass('video-count-dropdown');
|
||
|
||
// Process button in its own container for mobile layout
|
||
const processBtnContainer = controlsContainer.createDiv({
|
||
cls: ['tubesage-modal-process-btn-container', isMobile ? 'tubesage-modal-process-btn-container-mobile' : ''],
|
||
});
|
||
|
||
const processBtn = new ButtonComponent(processBtnContainer);
|
||
processBtn.setButtonText('Process');
|
||
processBtn.setCta();
|
||
if (isMobile) {
|
||
processBtn.buttonEl.addClass('tubesage-process-btn-mobile');
|
||
}
|
||
|
||
// Add event listener for process button
|
||
processBtn.onClick(() => {
|
||
const url = this.urlInputEl.value.trim();
|
||
|
||
if (allVideosRadio.checked) {
|
||
// Process all videos
|
||
this.processCollectionVideos(url, 0);
|
||
} else {
|
||
// Process limited number of videos
|
||
const count = parseInt(videoCountDropdown.getValue()) || 10;
|
||
this.processCollectionVideos(url, count);
|
||
}
|
||
});
|
||
|
||
// Title input group - second input (for single video mode)
|
||
const titleGroup = formEl.createDiv({ cls: 'form-group' });
|
||
titleGroup.createEl('label', { text: 'Custom note title (optional)', attr: { for: 'title' } });
|
||
const titleText = new TextComponent(titleGroup);
|
||
titleText.setPlaceholder('Leave empty to use YouTube title');
|
||
titleText.inputEl.id = 'title';
|
||
this.titleInputEl = titleText.inputEl;
|
||
|
||
// Add toggle switch for summary mode
|
||
const toggleContainer = formEl.createDiv({ cls: 'toggle-container' });
|
||
|
||
// Label for the toggle
|
||
const toggleLabel = toggleContainer.createDiv({ cls: 'toggle-label' });
|
||
toggleLabel.createDiv({ text: 'Fast summary mode' });
|
||
toggleLabel.createDiv({
|
||
text: 'Enable for shorter, quicker summaries (skips timestamp links)',
|
||
cls: 'summary-info'
|
||
});
|
||
|
||
// Create the native Obsidian toggle
|
||
const fastToggle = new ToggleComponent(toggleContainer);
|
||
fastToggle.setValue(this.plugin.settings.useFastSummary);
|
||
fastToggle.onChange(value => {
|
||
this.plugin.settings.useFastSummary = value;
|
||
void this.plugin.saveSettings();
|
||
});
|
||
|
||
// Error message container
|
||
this.errorEl = formEl.createDiv({ cls: ['tubesage-error', 'tubesage-error-hidden'] });
|
||
|
||
// Add real-time validation on URL change
|
||
this.urlInputEl.addEventListener('input', () => {
|
||
const url = this.urlInputEl.value.trim();
|
||
|
||
// First check if it's a valid URL
|
||
if (url && !this.isYoutubeUrl(url)) {
|
||
urlValidationEl.setText('Not a valid YouTube URL. Only video, playlist, and channel urls are supported.');
|
||
urlValidationEl.removeClass('tubesage-validation-success', 'tubesage-validation-accent');
|
||
urlValidationEl.addClass('tubesage-validation-error', 'tubesage-validation-visible');
|
||
urlValidationEl.removeClass('tubesage-validation-hidden');
|
||
return;
|
||
}
|
||
|
||
// Check if it's a channel URL
|
||
if (url && this.isYoutubeChannelOrPlaylistUrl(url)) {
|
||
urlValidationEl.setText('YouTube channel or playlist URL detected');
|
||
urlValidationEl.removeClass('tubesage-validation-error', 'tubesage-validation-success');
|
||
urlValidationEl.addClass('tubesage-validation-accent', 'tubesage-validation-visible');
|
||
urlValidationEl.removeClass('tubesage-validation-hidden');
|
||
|
||
// Show channel options, hide title input
|
||
channelOptionsContainer.addClass('tubesage-display-block');
|
||
channelOptionsContainer.removeClass('tubesage-display-none');
|
||
titleGroup.addClass('tubesage-display-none');
|
||
titleGroup.removeClass('tubesage-display-block');
|
||
} else if (url) {
|
||
urlValidationEl.setText('YouTube video URL detected');
|
||
urlValidationEl.removeClass('tubesage-validation-error', 'tubesage-validation-accent');
|
||
urlValidationEl.addClass('tubesage-validation-success', 'tubesage-validation-visible');
|
||
urlValidationEl.removeClass('tubesage-validation-hidden');
|
||
|
||
// Hide channel options, show title input
|
||
channelOptionsContainer.addClass('tubesage-display-none');
|
||
channelOptionsContainer.removeClass('tubesage-display-block');
|
||
titleGroup.addClass('tubesage-display-block');
|
||
titleGroup.removeClass('tubesage-display-none');
|
||
} else {
|
||
urlValidationEl.addClass('tubesage-validation-hidden');
|
||
urlValidationEl.removeClass('tubesage-validation-visible');
|
||
channelOptionsContainer.addClass('tubesage-display-none');
|
||
channelOptionsContainer.removeClass('tubesage-display-block');
|
||
titleGroup.addClass('tubesage-display-block');
|
||
titleGroup.removeClass('tubesage-display-none');
|
||
}
|
||
});
|
||
|
||
// Add event listeners for Enter key
|
||
this.urlInputEl.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const url = this.urlInputEl.value.trim();
|
||
|
||
if (url && this.isYoutubeUrl(url)) {
|
||
if (this.isYoutubeChannelOrPlaylistUrl(url)) {
|
||
// If it's a channel or playlist URL, show channel options and hide the title input
|
||
channelOptionsContainer.addClass('tubesage-display-block');
|
||
channelOptionsContainer.removeClass('tubesage-display-none');
|
||
titleGroup.addClass('tubesage-display-none');
|
||
titleGroup.removeClass('tubesage-display-block');
|
||
} else if (!titleGroup.hasClass('tubesage-display-none')) {
|
||
// Only focus title if it's visible (single video mode)
|
||
this.titleInputEl.focus();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
this.titleInputEl.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
this.handleInputSubmit();
|
||
}
|
||
});
|
||
|
||
// Focus the URL input by default
|
||
this.urlInputEl.focus();
|
||
}
|
||
|
||
private handleInputSubmit = () => {
|
||
// Clear any previous errors
|
||
this.hideError();
|
||
|
||
// Get values from inputs
|
||
const url = this.urlInputEl.value.trim();
|
||
|
||
// Create validation rules
|
||
const validations: ValidationResult[] = [
|
||
// URL is required
|
||
validateRequired(url, 'Youtube URL'),
|
||
|
||
// URL must be a valid YouTube URL
|
||
validateYouTubeUrl(url, this.isYoutubeUrl)
|
||
];
|
||
|
||
// Check if any validation fails
|
||
for (const validation of validations) {
|
||
if (!validation.isValid) {
|
||
displayValidationResult(validation, { element: this.errorEl });
|
||
this.urlInputEl.focus();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Check if it's a channel URL
|
||
if (this.isYoutubeChannelOrPlaylistUrl(url)) {
|
||
// Check if Youtube API key is set
|
||
if (!this.plugin.settings.youtubeApiKey || this.plugin.settings.youtubeApiKey.trim() === '') {
|
||
this.showError(
|
||
'Youtube API key is required to process channels or playlists. ' +
|
||
'Please set your Youtube data API key in the plugin settings first. ' +
|
||
'See the README section "Creating a Youtube API key" for instructions.'
|
||
);
|
||
return;
|
||
}
|
||
|
||
// For channels, we handle this through the channel options UI
|
||
this.showError('Please use the channel options to process this URL');
|
||
return;
|
||
}
|
||
|
||
// Title is now optional
|
||
// Show folder picker directly, we'll get title later if needed
|
||
this.showFolderPicker();
|
||
}
|
||
|
||
// Method to handle processing Youtube channel or playlist videos
|
||
private processCollectionVideos(sourceUrl: string, videoCount: number) {
|
||
// Validate the URL once more
|
||
if (!sourceUrl || !this.isYoutubeUrl(sourceUrl) || !this.isYoutubeChannelOrPlaylistUrl(sourceUrl)) {
|
||
this.showError('Invalid Youtube channel or playlist URL');
|
||
return;
|
||
}
|
||
|
||
// Check if Youtube API key is set
|
||
if (!this.plugin.settings.youtubeApiKey || this.plugin.settings.youtubeApiKey.trim() === '') {
|
||
this.showError(
|
||
'Youtube API key is required to process channels or playlists. ' +
|
||
'Please set your Youtube data API key in the plugin settings first. ' +
|
||
'See the README section "Creating a Youtube API key" for instructions.'
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Show folder picker - we'll process videos after folder selection
|
||
const folderSelectionModal = new FolderPickerModal(
|
||
this.app,
|
||
this.plugin, // Revert to this.plugin
|
||
(folderPath) => {
|
||
this.selectedFolder = folderPath;
|
||
void this.beginCollectionProcessing(sourceUrl, videoCount);
|
||
}
|
||
);
|
||
void folderSelectionModal.open();
|
||
}
|
||
|
||
// Method to start the collection processing workflow
|
||
private async beginCollectionProcessing(sourceUrl: string, videoCount: number) {
|
||
if (this.isProcessing) return;
|
||
|
||
let spinner: ProcessingSpinner | undefined;
|
||
try {
|
||
this.isProcessing = true;
|
||
|
||
// Processing UI. On mobile the modal stays open and hosts the
|
||
// spinner; on desktop the status bar is the spinner surface, so the
|
||
// modal is closed — no blank popup. ProcessingSpinner routes itself
|
||
// (status bar on desktop, in-modal on mobile).
|
||
const { contentEl } = this;
|
||
if (Platform.isMobile) {
|
||
contentEl.empty();
|
||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||
modalEl.addClass('tubesage-processing-modal');
|
||
}
|
||
} else {
|
||
this.close();
|
||
}
|
||
|
||
spinner = new ProcessingSpinner(this.plugin, 'Processing collection', contentEl);
|
||
spinner.start();
|
||
|
||
// Determine if this is a playlist or channel
|
||
const isPlaylist = sourceUrl.includes('/playlist') || sourceUrl.includes('list=');
|
||
const contentType = isPlaylist ? 'Playlist' : 'Channel';
|
||
|
||
// Extract name for folder creation (different method depending on type)
|
||
let sourceName = '';
|
||
|
||
if (isPlaylist) {
|
||
// For playlists, we'll extract the name from the API
|
||
if (sourceUrl.includes('list=')) {
|
||
const match = sourceUrl.match(/list=([^&]+)/);
|
||
if (match && match[1]) {
|
||
const playlistId = match[1];
|
||
this.showNotice(`Extracting playlist name...`, 5000);
|
||
|
||
try {
|
||
// Get the playlist name from the API
|
||
const API_KEY = this.plugin.settings.youtubeApiKey;
|
||
const response = await obsidianFetch(
|
||
`https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=${playlistId}&key=${API_KEY}`
|
||
);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json() as PlaylistResponse;
|
||
if (data.items && data.items.length > 0) {
|
||
sourceName = data.items[0].snippet?.title ?? '';
|
||
this.showNotice(`Found playlist: ${sourceName}`, 5000);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error("Error getting playlist name:", error);
|
||
// Use a generic name if we can't get the real name
|
||
sourceName = `Playlist-${playlistId}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we still don't have a name, use a fallback
|
||
if (!sourceName) {
|
||
sourceName = "Youtube-Playlist";
|
||
}
|
||
} else {
|
||
// For channels, use the existing method
|
||
sourceName = this.extractChannelName(sourceUrl);
|
||
}
|
||
|
||
const sanitizedName = this.sanitizePathComponent(sourceName);
|
||
|
||
// Show starting notice
|
||
this.showNotice(`Starting to process YouTube ${contentType.toLowerCase()}: ${sourceName}`, 5000);
|
||
|
||
// Create the subfolder with content type prefix
|
||
const formattedSourceName = `${contentType} - ${sanitizedName}`;
|
||
const sourceSubfolder = this.selectedFolder
|
||
? joinPaths(this.selectedFolder, formattedSourceName)
|
||
: formattedSourceName;
|
||
|
||
// Ensure the subfolder exists
|
||
await ensureFolder(this.app.vault, sourceSubfolder);
|
||
this.showNotice(`Created ${contentType.toLowerCase()} folder: ${formattedSourceName}`, 5000);
|
||
|
||
// Fetch videos from the source using the source URL
|
||
this.showNotice(`Fetching videos from ${contentType.toLowerCase()}: ${sourceName}`, 5000);
|
||
|
||
// Use the plugin's fetchCollectionVideos method
|
||
// @ts-ignore - This is a mistake in our code structure
|
||
const collectionVideos = await this.plugin.fetchCollectionVideos(sourceUrl, videoCount);
|
||
|
||
if (!collectionVideos || collectionVideos.length === 0) {
|
||
throw new Error(`No videos found in this ${contentType.toLowerCase()}`);
|
||
}
|
||
|
||
this.showNotice(`Found ${collectionVideos.length} videos to process`, 5000);
|
||
|
||
// Get the actual number of videos to process - respect ALL vs Limited options
|
||
const videosToProcess = videoCount === 0 ? collectionVideos : collectionVideos.slice(0, videoCount);
|
||
|
||
// Process each video
|
||
let processedCount = 0;
|
||
let skippedCount = 0;
|
||
let errorCount = 0;
|
||
|
||
for (const video of videosToProcess) {
|
||
try {
|
||
clearLogs(); // Clear logs for each video processed in the collection
|
||
|
||
// Update processing message
|
||
this.showNotice(`Processing video ${processedCount + skippedCount + errorCount + 1}/${videosToProcess.length}: ${video.title}`, 5000);
|
||
|
||
// Extract and summarize transcript
|
||
try {
|
||
const transcript = await this.plugin.extractTranscript(video.url);
|
||
|
||
if (!transcript) {
|
||
this.showNotice(`Skipping video ${video.title} - No transcript available`, 5000);
|
||
skippedCount++;
|
||
continue;
|
||
}
|
||
|
||
const summary = await this.plugin.summarizeTranscript(transcript);
|
||
|
||
// Create note with video title as the note title
|
||
await this.plugin.applyTemplate(
|
||
video.title,
|
||
video.url,
|
||
transcript,
|
||
summary,
|
||
sourceSubfolder,
|
||
contentType // Pass the content type (Channel or Playlist)
|
||
);
|
||
|
||
// Add timestamp links if enabled and not in fast summary mode
|
||
if (this.plugin.settings.addTimestampLinks && !this.plugin.settings.useFastSummary) {
|
||
// Calculate datePrefix for filename (should match logic in applyTemplate)
|
||
let datePrefix = '';
|
||
if (this.plugin.settings.prependDate) {
|
||
const now = new Date();
|
||
// Format date based on selected format
|
||
switch (this.plugin.settings.dateFormat) {
|
||
case 'YYYY-MM-DD':
|
||
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
|
||
break;
|
||
case 'MM-DD-YYYY':
|
||
datePrefix = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${now.getFullYear()} `;
|
||
break;
|
||
case 'DD-MM-YYYY':
|
||
datePrefix = `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()} `;
|
||
break;
|
||
default:
|
||
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
|
||
}
|
||
}
|
||
|
||
// Calculate the path of the created note
|
||
const notePath = joinPaths(sourceSubfolder, `${datePrefix}${sanitizeFilename(video.title)}.md`);
|
||
|
||
// Add timestamp links to the note - with specific notification for channel vs playlist
|
||
this.showNotice(`Adding timestamp links to ${isPlaylist ? 'playlist' : 'channel'} video: ${video.title}`, 3000);
|
||
try {
|
||
// Simple, small delay to allow file creation to complete
|
||
await new Promise(resolve => window.setTimeout(resolve, 300));
|
||
logger.debug(`Adding timestamps to file: ${notePath}`);
|
||
|
||
await this.plugin.addSectionLinksToNote(notePath, video.url);
|
||
this.showNotice(`✓ Timestamp links added to ${video.title}`, 2000);
|
||
} catch (timestampError) {
|
||
logger.error(`Error adding timestamp links to ${isPlaylist ? 'playlist' : 'channel'} video (${video.title}):`, timestampError);
|
||
this.showNotice(`Note created but timestamps could not be added to "${video.title}"`, 3000);
|
||
}
|
||
}
|
||
|
||
processedCount++;
|
||
this.showNotice(`✓ Processed video ${processedCount + skippedCount + errorCount}/${videosToProcess.length}`, 3000);
|
||
|
||
// === NEW LOGGING LOGIC START (for collection items) ===
|
||
if (this.plugin.settings.debugLogging) {
|
||
const finalLogs = getLogsForCallout();
|
||
if (finalLogs && finalLogs.trim() !== "") { // Only append if there are non-empty logs
|
||
// Simplest approach to create debug section
|
||
const debugHeader = "\n\n> [!info]- Debug Information (hidden)\n> ```";
|
||
const debugFooter = "\n> ```";
|
||
|
||
const debugSection = debugHeader + "\n" + finalLogs + debugFooter;
|
||
|
||
try {
|
||
// Recalculate notePath here as it's needed for appending
|
||
let datePrefix = '';
|
||
if (this.plugin.settings.prependDate) {
|
||
const now = new Date();
|
||
// Format date based on selected format
|
||
switch (this.plugin.settings.dateFormat) {
|
||
case 'YYYY-MM-DD':
|
||
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
|
||
break;
|
||
case 'MM-DD-YYYY':
|
||
datePrefix = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${now.getFullYear()} `;
|
||
break;
|
||
case 'DD-MM-YYYY':
|
||
datePrefix = `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()} `;
|
||
break;
|
||
default:
|
||
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
|
||
}
|
||
}
|
||
const notePathForLog = joinPaths(sourceSubfolder, `${datePrefix}${sanitizeFilename(video.title)}.md`);
|
||
|
||
const file = this.app.vault.getAbstractFileByPath(notePathForLog);
|
||
if (file instanceof TFile) {
|
||
const currentContent = await this.app.vault.read(file);
|
||
await this.app.vault.modify(file, currentContent + debugSection);
|
||
logger.debug("Appended debug logs to note:", notePathForLog);
|
||
} else {
|
||
logger.warn("Could not find file to append debug logs:", notePathForLog);
|
||
}
|
||
} catch (logAppendError) {
|
||
logger.error("Error appending debug logs to note:", logAppendError);
|
||
}
|
||
}
|
||
}
|
||
// === NEW LOGGING LOGIC END ===
|
||
|
||
} catch (transcriptError) {
|
||
const transcriptErrorMessage = getSafeErrorMessage(transcriptError);
|
||
this.showNotice(`⚠️ Skipping video "${video.title}" - ${transcriptErrorMessage}`, 5000);
|
||
skippedCount++;
|
||
// Clear logs even on skip, so next video starts fresh
|
||
clearLogs();
|
||
}
|
||
} catch (videoError) {
|
||
logger.error('Error processing video:', video, videoError);
|
||
const videoErrorMessage = getSafeErrorMessage(videoError);
|
||
this.showNotice(`❌ Error processing video "${video.title}": ${videoErrorMessage}`, 5000);
|
||
errorCount++;
|
||
// Clear logs on error, so next video starts fresh
|
||
clearLogs();
|
||
}
|
||
}
|
||
|
||
// Final success notice
|
||
this.showNotice(`Channel processing complete: ${processedCount} processed, ${skippedCount} skipped, ${errorCount} errors`, 7000);
|
||
|
||
// Close the modal
|
||
this.close();
|
||
|
||
} catch (err) {
|
||
logger.error('Error in channel processing workflow:', err);
|
||
|
||
// Use the getSafeErrorMessage utility instead of duplicating error handling logic
|
||
const errorMessage = getSafeErrorMessage(err);
|
||
|
||
// Show error notice
|
||
this.showNotice(`Error: ${errorMessage}`, 5000);
|
||
|
||
// Close the modal on error
|
||
this.close();
|
||
} finally {
|
||
spinner?.stop();
|
||
// Only stop the proxy server if currently using Anthropic
|
||
if (this.plugin.settings.selectedLLM === 'anthropic') {
|
||
try {
|
||
logger.info('[beginCollectionProcessing] Completed Anthropic processing');
|
||
} catch (error) {
|
||
logger.error('Error during Anthropic processing:', error);
|
||
}
|
||
}
|
||
this.isProcessing = false;
|
||
}
|
||
}
|
||
|
||
// Helper method to sanitize file/folder names
|
||
private sanitizePathComponent(text: string): string {
|
||
// Use the utility function instead of duplicating code
|
||
return sanitizePathComponent(text);
|
||
}
|
||
|
||
private showFolderPicker() {
|
||
// Open folder picker modal
|
||
const folderSelectionModal = new FolderPickerModal(
|
||
this.app,
|
||
this.plugin, // Use plugin instance
|
||
(folderPath) => {
|
||
this.selectedFolder = folderPath;
|
||
void this.processTranscript();
|
||
}
|
||
);
|
||
void folderSelectionModal.open();
|
||
}
|
||
|
||
private processTranscript = async () => {
|
||
if (this.isProcessing) return;
|
||
|
||
// Get URL from the input
|
||
const url = this.urlInputEl.value.trim();
|
||
|
||
// Validate URL using form utilities
|
||
const urlValidations: ValidationResult[] = [
|
||
validateRequired(url, 'Youtube URL'),
|
||
validateYouTubeUrl(url, this.isYoutubeUrl)
|
||
];
|
||
|
||
// Check URL validations
|
||
for (const validation of urlValidations) {
|
||
if (!validation.isValid) {
|
||
displayValidationResult(validation, { element: this.errorEl });
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Check if it's a channel URL - redirect to channel processing
|
||
if (this.isYoutubeChannelOrPlaylistUrl(url)) {
|
||
this.showError('This is a channel or playlist URL. Please use the channel options to process it.');
|
||
return;
|
||
}
|
||
|
||
// Ensure a folder has been selected
|
||
if (!this.selectedFolder) {
|
||
this.showError('Please select a folder for the note');
|
||
// Go back to folder selection
|
||
this.handleInputSubmit();
|
||
return;
|
||
}
|
||
|
||
let spinner: ProcessingSpinner | undefined;
|
||
try {
|
||
this.isProcessing = true;
|
||
|
||
// Processing UI. On mobile the modal stays open and hosts the
|
||
// spinner; on desktop the status bar is the spinner surface, so the
|
||
// modal is closed — no blank popup. ProcessingSpinner routes itself
|
||
// (status bar on desktop, in-modal on mobile).
|
||
const { contentEl } = this;
|
||
if (Platform.isMobile) {
|
||
contentEl.empty();
|
||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||
modalEl.addClass('tubesage-processing-modal');
|
||
}
|
||
} else {
|
||
this.close();
|
||
}
|
||
|
||
spinner = new ProcessingSpinner(this.plugin, 'Processing video', contentEl);
|
||
spinner.start();
|
||
|
||
// Get custom title if provided
|
||
let title = this.titleInputEl.value.trim();
|
||
|
||
// Show starting notice
|
||
this.showNotice('Starting transcript extraction workflow...', 5000);
|
||
|
||
// Extract video ID
|
||
const videoId = YouTubeTranscriptExtractor.extractVideoId(url);
|
||
if (!videoId) {
|
||
throw new Error(`Invalid Youtube URL: '${url}'. Please ensure the URL is properly formatted without extra characters like quotes.`);
|
||
}
|
||
|
||
// Extract transcript and metadata in one request
|
||
this.showNotice('Extracting transcript from Youtube...', 5000);
|
||
let transcript: string;
|
||
let transcriptFailed = false;
|
||
let extractedMetadata: {title?: string, author?: string} = {};
|
||
|
||
try {
|
||
const result = await this.plugin.extractTranscriptWithMetadata(url);
|
||
transcript = result.transcript;
|
||
extractedMetadata = result.metadata;
|
||
|
||
// If no custom title was provided, use the extracted title
|
||
if (!title && extractedMetadata.title) {
|
||
title = sanitizeFilename(extractedMetadata.title);
|
||
this.showNotice(`Using YouTube title: ${title}`, 3000);
|
||
} else if (!title) {
|
||
title = `YouTube Video ${videoId}`;
|
||
this.showNotice('Could not retrieve Youtube title, using fallback', 3000);
|
||
}
|
||
|
||
if (!transcript) {
|
||
transcriptFailed = true;
|
||
transcript = '[TRANSCRIPT EXTRACTION FAILED: Empty result returned]';
|
||
this.showNotice('Transcript extraction failed (empty result), continuing with debug note creation...', 5000);
|
||
} else if (transcript.includes('[TRANSCRIPT EXTRACTION FAILED')) {
|
||
// Check if transcript contains failure message from error handling
|
||
transcriptFailed = true;
|
||
this.showNotice('Transcript extraction failed, continuing with debug note creation...', 5000);
|
||
} else {
|
||
this.showNotice('Transcript extracted successfully', 5000);
|
||
}
|
||
} catch (transcriptError) {
|
||
transcriptFailed = true;
|
||
transcript = `[TRANSCRIPT EXTRACTION FAILED: ${getSafeErrorMessage(transcriptError)}]`;
|
||
this.showNotice('Transcript extraction failed, continuing with debug note creation...', 5000);
|
||
logger.error('Transcript extraction failed:', transcriptError);
|
||
|
||
// Still set fallback title if not provided
|
||
if (!title) {
|
||
title = `YouTube Video ${videoId}`;
|
||
this.showNotice('Using fallback title due to extraction failure', 3000);
|
||
}
|
||
}
|
||
|
||
// Summarize transcript (skip if extraction failed)
|
||
let summary: string;
|
||
let summaryFailed = false;
|
||
if (transcriptFailed) {
|
||
summary = '[SUMMARY SKIPPED: Transcript extraction failed - see debug information below]';
|
||
summaryFailed = true;
|
||
this.showNotice('Skipping AI summarization due to transcript failure...', 3000);
|
||
} else {
|
||
this.showNotice('Summarizing transcript with AI...', 5000);
|
||
try {
|
||
summary = await this.plugin.summarizeTranscript(transcript);
|
||
if (!summary) {
|
||
summary = '[SUMMARY FAILED: Empty result returned from AI]';
|
||
summaryFailed = true;
|
||
this.showNotice('AI summarization failed (empty result), continuing with note creation...', 5000);
|
||
} else {
|
||
this.showNotice('Summary generated successfully', 5000);
|
||
}
|
||
} catch (summaryError) {
|
||
summary = `[SUMMARY FAILED: ${getSafeErrorMessage(summaryError)}]`;
|
||
summaryFailed = true;
|
||
this.showNotice('AI summarization failed, continuing with note creation...', 5000);
|
||
logger.error('Summary generation failed:', summaryError);
|
||
}
|
||
}
|
||
|
||
// Create note
|
||
this.showNotice('Creating note with template...', 5000);
|
||
await this.plugin.applyTemplate(
|
||
title,
|
||
url,
|
||
transcript,
|
||
summary,
|
||
this.selectedFolder
|
||
);
|
||
|
||
// Calculate datePrefix for filename
|
||
let datePrefix = '';
|
||
if (this.plugin.settings.prependDate) {
|
||
const now = new Date();
|
||
// Format date based on selected format
|
||
switch (this.plugin.settings.dateFormat) {
|
||
case 'YYYY-MM-DD':
|
||
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
|
||
break;
|
||
case 'MM-DD-YYYY':
|
||
datePrefix = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${now.getFullYear()} `;
|
||
break;
|
||
case 'DD-MM-YYYY':
|
||
datePrefix = `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()} `;
|
||
break;
|
||
default:
|
||
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
|
||
}
|
||
}
|
||
|
||
// Get the path of the created note
|
||
const notePath = this.selectedFolder
|
||
? joinPaths(this.selectedFolder, `${datePrefix}${sanitizeFilename(title)}.md`)
|
||
: `${datePrefix}${sanitizeFilename(title)}.md`;
|
||
|
||
// Add section links in a second pass if enabled and not in fast summary mode
|
||
// and BOTH transcript extraction AND summary succeeded. The timestamp pass uses
|
||
// the same LLM as the summary, so if summary failed, timestamp linking will
|
||
// almost certainly fail the same way — skip it to avoid wasted tokens / latency.
|
||
if (this.plugin.settings.addTimestampLinks && !this.plugin.settings.useFastSummary && !transcriptFailed && !summaryFailed) {
|
||
this.showNotice('Adding section timestamp links...', 3000);
|
||
try {
|
||
// Simple, small delay to allow file creation to complete
|
||
await new Promise(resolve => window.setTimeout(resolve, 300));
|
||
logger.debug(`Adding timestamps to file: ${notePath}`);
|
||
|
||
await this.plugin.addSectionLinksToNote(notePath, url);
|
||
this.showNotice('Timestamp links added successfully', 3000);
|
||
} catch (timestampError) {
|
||
const timestampErrorMessage = getSafeErrorMessage(timestampError);
|
||
logger.error(`Error adding timestamp links: ${timestampErrorMessage}`, timestampError);
|
||
this.showNotice(`Note created but timestamps could not be added: ${timestampErrorMessage}`, 5000);
|
||
}
|
||
} else if (transcriptFailed) {
|
||
this.showNotice('Timestamp links skipped - no transcript available', 3000);
|
||
} else if (summaryFailed) {
|
||
this.showNotice('Timestamp links skipped - summary failed', 5000);
|
||
logger.info('[pipeline] Skipping timestamp pass because summary failed; the same LLM is used for both.');
|
||
}
|
||
|
||
// === NEW LOGGING LOGIC START ===
|
||
// Append debug logs if enabled, AFTER all processing is done
|
||
if (this.plugin.settings.debugLogging) {
|
||
const finalLogs = getLogsForCallout();
|
||
if (finalLogs && finalLogs.trim() !== "") { // Only append if there are non-empty logs
|
||
// Simplest approach to create debug section
|
||
const debugHeader = "\n\n> [!info]- Debug Information (hidden)\n> ```";
|
||
const debugFooter = "\n> ```";
|
||
|
||
const debugSection = debugHeader + "\n" + finalLogs + debugFooter;
|
||
|
||
try {
|
||
const file = this.app.vault.getAbstractFileByPath(notePath);
|
||
if (file instanceof TFile) {
|
||
const currentContent = await this.app.vault.read(file);
|
||
await this.app.vault.modify(file, currentContent + debugSection);
|
||
logger.debug("Appended debug logs to note:", notePath);
|
||
} else {
|
||
logger.warn("Could not find file to append debug logs:", notePath);
|
||
}
|
||
} catch (logAppendError) {
|
||
logger.error("Error appending debug logs to note:", logAppendError);
|
||
}
|
||
}
|
||
}
|
||
// === NEW LOGGING LOGIC END ===
|
||
|
||
// Final success notice
|
||
this.showNotice('Transcript note created successfully!', 5000);
|
||
|
||
// Success - just close the modal
|
||
this.close();
|
||
|
||
} catch (err) {
|
||
logger.error('Error in transcript workflow:', err);
|
||
|
||
// Use the getSafeErrorMessage utility instead of duplicating error handling logic
|
||
const errorMessage = getSafeErrorMessage(err);
|
||
|
||
// Create an error note with debug information if debug logging is enabled
|
||
if (this.plugin.settings.debugLogging) {
|
||
try {
|
||
const url = this.urlInputEl.value.trim();
|
||
const noteTitle = this.titleInputEl?.value.trim() || 'Failed Youtube transcript';
|
||
|
||
// Create error content with debug logs
|
||
const finalLogs = getLogsForCallout();
|
||
let errorContent = `# ${noteTitle}\n\n`;
|
||
errorContent += `**Error occurred during processing:**\n\n`;
|
||
errorContent += `> [!error] Processing Failed\n`;
|
||
errorContent += `> ${errorMessage}\n\n`;
|
||
errorContent += `**URL:** ${url}\n\n`;
|
||
|
||
if (finalLogs && finalLogs.trim() !== "") {
|
||
// Use the exact same format as successful notes
|
||
const debugHeader = "\n\n> [!info]- Debug Information (hidden)\n> ```";
|
||
const debugFooter = "\n> ```";
|
||
|
||
const debugSection = debugHeader + "\n" + finalLogs + debugFooter;
|
||
errorContent += debugSection;
|
||
}
|
||
|
||
// Create the error note
|
||
const fileName = sanitizeFilename(noteTitle) + '.md';
|
||
const notePath = normalizePath(joinPaths(this.selectedFolder, fileName));
|
||
|
||
await this.app.vault.create(notePath, errorContent);
|
||
this.showNotice(`Error note created with debug information: ${fileName}`, 8000);
|
||
|
||
} catch (noteError) {
|
||
logger.error('Failed to create error note:', noteError);
|
||
this.showNotice(`Error: ${errorMessage} (Also failed to create debug note)`, 5000);
|
||
}
|
||
} else {
|
||
// Show error notice
|
||
this.showNotice(`Error: ${errorMessage}`, 5000);
|
||
}
|
||
|
||
// Close the modal on error
|
||
this.close();
|
||
} finally {
|
||
spinner?.stop();
|
||
// Only stop the proxy server if it was started (only for Anthropic provider)
|
||
if (this.plugin.settings.selectedLLM === 'anthropic') {
|
||
try {
|
||
logger.info('[processTranscript] Completed Anthropic processing');
|
||
} catch (error) {
|
||
logger.error('Error during Anthropic processing:', error);
|
||
}
|
||
}
|
||
this.isProcessing = false;
|
||
}
|
||
}
|
||
|
||
onClose() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
}
|
||
|
||
}
|
||
|
||
class YouTubeTranscriptSettingTab extends PluginSettingTab {
|
||
plugin: YouTubeTranscriptPlugin;
|
||
|
||
constructor(app: App, plugin: YouTubeTranscriptPlugin) {
|
||
super(app, plugin);
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
display(): void {
|
||
const { containerEl } = this;
|
||
containerEl.empty();
|
||
|
||
// Buy Me a Coffee section at the top
|
||
const supportContainer = containerEl.createDiv({
|
||
cls: 'tubesage-settings-support-container' // Apply new class
|
||
});
|
||
|
||
// Support section
|
||
const supportHeading = new Setting(supportContainer)
|
||
.setName('Support development')
|
||
.setHeading();
|
||
supportHeading.settingEl.addClass('tubesage-heading');
|
||
|
||
// Support message in appearance format
|
||
supportContainer.createDiv({
|
||
text: 'If you find this plugin useful, consider supporting its development:',
|
||
cls: 'tubesage-settings-support-desc' // Apply new class
|
||
});
|
||
|
||
// Add italicized mission statement
|
||
supportContainer.createDiv({
|
||
text: '…and help seed a bigger vision: technology that serves people and planet..',
|
||
cls: ['tubesage-settings-support-desc', 'tubesage-mission-italic'],
|
||
});
|
||
|
||
// Buy Me a Coffee button in a container
|
||
const bmcContainer = supportContainer.createDiv({
|
||
cls: 'tubesage-settings-bmc-container' // Apply new class
|
||
});
|
||
|
||
// Create the link
|
||
const bmcLink = bmcContainer.createEl('a', {
|
||
href: 'https://www.buymeacoffee.com/RMcCorkle',
|
||
attr: {
|
||
target: '_blank',
|
||
rel: 'noopener'
|
||
}
|
||
});
|
||
|
||
// Add the image (bundled as base64 to avoid external dependencies)
|
||
bmcLink.createEl('img', {
|
||
cls: 'tubesage-settings-bmc-img', // Apply existing class
|
||
attr: {
|
||
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAiEAAACZCAMAAADOzxqEAAAAhFBMVEX/3QD//////////e//+9//+c//97//9r//9K//8p//8I//7oD/7n//7HD/6mD/6FD/5kD/4zD/4SD/3xDw0ALhwwTStgfStgbDqQmznAukjw2Vgg+GdRKGdRF3ZxR3ZxNoWhZoWhVZTRhZTRdKQBo6MxwrJh8rJh4cGSEcGSANDCMNDCJzVeEVAAAAAnRSTlP/AOW3MEoAABB4SURBVHja7Jzreps4EIa1m6RNs22aJsgeScgYezhJ939/WzBEBnEwTZ/Gjeb955gQ8um1DiMM+2fI7cPjD0YEyMvTt/sbzwfWf3nz8ExJBc33L3OG3D5SQsTzl0lDvr5QPETtyOdRQ+5o9kF0fLvxDbmnDoRw/LgdGvKFQiHOebnrDCFBiElFnCH3FAgx5PnGGXJHcxDC54czhFYxxBhfO0O+UhbEKHcnQ25pjCHGeToZQqV2YorPtSE3lAMxxVNtyAPlQExy+9MQ2u4npvn2D7ulFIi5mgijQYaY45bRSoaY455RPZWYg8qpxCw0xhALPFEEBBlCkCEEGUL87YZEAEKfIwG2lC8ZwhgX+oiVnQCPWnCKOVxDRFrYZYpURhR1gIZEurKXUqXkSHCGbCu7hkpQ2mEZwiu7EqC4gzIktedkmGqtwCG11og9iwqKOyhD0LZUGiI2Cag0sy20+g3TEItaTjkCQm0Zi7RtUJR3SIZo26dCxFR3JIhY2IbkdUhKKe+QDIkyeyGcsa2tQco7JENYdFyxhrENlHdgNVVIK3sB/HXWQvX38PZlRIJ2gYT9JKGKSMB7u1uhU0Trg5hqsT2ZZGs0BR6iIQ4Oju3gLVrMkCHzVM2KmLbvgjFkq7UGvrrAdqS5ahiGQOFuEJKwXR59ZFLZEwXNRQIwRNohGWKitZYw2LvTiOgdS0PNRzeEV/ZNJBT7Bzcktb8OFc5CMMRaS50IGTIN2JpKgkoyuwJMtEJbk1HuH9oQZWtEq4vURyzsHBWiVt3KuKAdvI9vSNo0O+sDALoGO9L6lQDgA71of+bjG1K85VYPuPRms80edzSj7eAqBtZDxjK6VkPeNNmEC/dneGmMKZsMoCZiIbOp09ixMzJjTBZdpyHwGwxBtkRsakDuc3Mikyxc0NQwhzQ16joN2dqa4hf9PV5oyM54ZBsWKrmp8T5A8ppHGVskCjhbBQdV2AtHGTQ+ZbCKmAYvHrhSQ47WUWCqJQCbZQtSJ5hZh1xjiKPkLEg2pgb/GkNgouiBia5RACDcVyKqsYPZIpkZY8+CBDxDTMPH3ZcRbBHTku0kAKhOGM5CRJmaw9Ub4kjsm5AXj7w73g/JKBYisamJh8NOeb2GuK9BrKdKOVsmGmbC9q6nDY69S8Mbdq7NEEcEKsHCruTI14y8yp+tBVwOEewVefWGOHizTplTpUBMdHcTYsQuA/y5evCGwHDY2V+3IT5QI3SLgBrWIdZ9cVf4hvzRxUwEwHwihcaUB2C/AV5Xi/N4+n3mMOOGeL+8ac654yOX/D6GcKX5xZGvMyT2Vi6iV0WUsYrOwsY995p2mQ3vgi3rpYJkHTJrbOSsjyzNiR2bJJIHRFT80ppx5l1UjD+pLyCXcwUz35Bo5yqL/iW/13PMqu263Zx1hvg9LXdTt6y3yZe3HpUuoAU2ebv3FZsWjE5JY5e09NacLYJNILoWiSO2gWmPMtORi15MOFL+4fMlVf+cZeTvXYh3MCRrpp6rDNHrZu/eD7LziZrovSXcBkbeT25vcsl8yvZjuDevZJFL2msDYRwlG6N/roNTbOw4hxM6OhiH6yXA+5v5iCEHXy1pHPk7GLLqdjG17iFE2J+WcjTng8yu18mW7SvXin51FibrlDtzBg6SziPXeqU5Qyw2vFPMR4xo0Lo5gE+WVJ1anQ2ewbx3yfDnDcEFQ54fHz7V/Pf99YlF8EuGiC75fGz1B6ZBuCY6+MntJmpypTA9JJNe6421fjw9NC4c57qAUpoGNS6Iu+54oejuC2w2I5f8XoZwNsrL492/r3xa/XSI/NTSG5Ax+p9c08B77RK57kT5ne9hauenDbbE1sEu6bx0TroSnsl4PBn3xvgI5tNJuBkMFmh8cvcf7ry/NHJO1WoGbvpyiOJ3MWS+W3h+uGnUcIbgqnIIMz5ubgq9xju4V+DUGbSsnL8DZR+xTXnKszue93ty1Qrk5i8TyplyByr/v73zbYsbhaI4GW3Xav1ThSwhmKZTgrH7/b/fPu4kHOCGTFx9MjPKeVXrSAj8crkcCDMWzFnyg3oEWoaJcLurgm7dvTTTlqqlZTa+T6BR5YMSUjGi24sNFBDCXkMIlZUTC1kWuZmmLVfTnqJTE4OfAZQJ0OrC/9fJyNAJhy2YDiTcfAOhAMOEYmZ3PxxX3G+6l67M0hFiUcCBCFGT05O7y/NNrCvGlq3644YTUmSZQniDuaGumk32lPQmh4g2gE/Tq/SOFZVMLoQfoJr0Sm3r7jIYLNVwF3K4kE5bqoaW6W4LVXZVU6sTggns2cXV7d2Lbq++YnSZIMQs3xCRknTPswwHdUQeRQtqZq9Rx3mAIIQo1wMclwtU4eIoS86uskif3n5gE4SYseZ23lJFzKpGViyq7FgpD+CY/TMYIpt9umbiDYSYF/VI3cJMvsVPJZ0EurSCSiAbjBKTlnZD43pAJfyQbVBWj1oRuXGPD1AAsJ6PhFRDBQyZ25LEBNdz/LWBTaRR5VUJQVz4stmju9caZnqEQ0seuY0qfPSYRcRVpFvEnBcQTwM1Qgi6XEeOLu/BEEtmxAL5DZFA9ZvdsATQW/QxIYREoTou0zjSKvchjiofipCOsa/vT4i7T2qrhomqIL5BN2Fh8TlCeIxlOBjgGe1JTgBVQVkqtiCkHx9Rfe0WlGxkY/QgZIHpLsdGKF1F2R9S5fUJ6Yb5ycVmjx6GeY9iC9VMxUVud50QxNiaZLBbEkJmoz0+L5ENkwXVAR1hUolvg7JApoSd1ZdhUqmnQpD1PlDvgphGugIhEYvKlPCXOaoM7NclBDPYq80evcFSpZFFBs/EFn1BZ4VbGClpQlTshXOfrtI3ILbBoFWqMhX4+7EkhBSbTDNxgdafN4kOhExaqoJMbjqsFSSrvBoh2NEslxDy6x0J6bz24QgSIITku8YtjBm0EI9HII0PgxeUBDWuI9vpLRwlUmAEmHlCaNJjR+ZUgpCk3y9nq7weIQgMNxuiNxpmyK0mCPHbR6EvyGY9bkEI9gzE/HRB8YgDOkmIQWRHpA9oa6Lxy+KvEjmW8gGTQW9XHeghpjvuFFK0yq1X5XJ1QhS7209I9z8sVT3tVgfD8hadBhObrLUZLyqUESE6JqT0S+7oYmzr14aj06g9qv1fNskYgr+QdCHf0oW32t0UdrpAvQJzcZUtqrwCIbDM9hLybZj2PL2BEHSTPywLH4FoqVRFT32JXvAJkQEhyI+DBG9LN/xIND0KC2O+8lEtk4QAZoWYQFTHe0gMiIEwjLbJKq9OyE9imb2TpSrZ1HPWY7aIvtDoU7igkAEgXXrFZhs0fE9yxz/GNAoBbQq3cFuGZPhrO5mURjA3JCpAEtsKd9rGu9J6Y3TpSnBVrkiVVyMERuk+QmC/LpOc2WTXSnDA/V2Y0p+48HhjkOqnQ0aX2vzpcAN7ZHGlI0YpKoprtSgIfWcJIMF/cjtJiLLRfo+yDe4S6miVNaq8EiGIDOebWd2+1jCr6BuYcoRBgBD45MZv05pJ3Y+47H7tgNGxg9HGjVrFhIC9Mk4WEVHwcdGhU4F7j1CFdCm5mRTBEVtYOK+aPtwRVNa4VtT5TbLK6xGC0/z3mqp3rPo/lir3+DDo/tLF2BKNyN3wHMgOTpQFSSRkJFwGEOLYU2jtPo4DZozqcerA7dQqGw4QkjY+/wKA9KbRFf+zVBNRqSJVXo8QWGb7CcFWktcQ0jdy935DY4OsfNfAwz3PqhMs/JknjUlibmOO6dhr0drkeawTO55EF4UQzNCVELi1usdMRJogOyXlkvs2LXBAZVJVXp8Qzq7e01JFn1Bpb5282QuI4Uymjx8JwzCsy2guI7zKlGS6mdgn2tldHVXTT7gflvZ5xbTLN22Ui8Z3VSJOjVRVkTEICVLlFQnB5tO9hPxe/oXM6J2pdqT8tF7XVCbAKWosCxoQMuL/aBHHps/J4spGvNHPd7ydO/ZE0ViHBJdGvHDcVFHz9Jo7f7jljAndk6JR5XUJQWi43szpHMPRawkhTUEauOP4sfaJaAVjZRdGFGjqJRTV+xSVUWAneYZKVrrlTJCu9tWGZdc7QhFagvryMLGKm0f5T01vOpRKq1yxtQlRsMze1VJt6Ghraz4V0HsRmSKytS8NVQuMvegGX/VE2JVlAtOOzj4VrbW/mVYTQBKI2BF8xk2EDVCG2piQnuNDYc6bqPKqhMilhCw/zd15ToaLxgyNuK1LGqVxPKLc0peXI0BaPj2j3s6bMujjkgx4VEJpreFpIhZQye2umDYoR3XjBWse1aOrh7sRboty4BA2dNyiVT4UIb/Yw2ZOlzBOlorjaRYsEozFvuF7TqlFe1HpHh41NAFir3FNDGF7VXW72McSmjxGWEitaykoqe2YqDRY2m0xUIJI1DiqMj/I6RCLTNUrR9K7iVdaa9LAUx7sW7bPyNYY06rdZTCpsY1Y/NWhJXuzKufF9QMQu1rEi9kVzibmLFHltQnB6HE2u48ZluqK2gau+7tIvHCpysMcYub+VeKsHRGt5tR4bkiVD0TI0xLL7I6pdQmhC57shKWj1yxrEMK6hXd3OELMIkJgmK0krK+q7qMQ4oBoyl3kwHNwvITACbvczOgehKw9xtTMfhRCXDZqpDe3P3pCHpeYqjglYDVJZ1vjeTtVaRj+OiZE4HdHTUi1jJDVDw/sR29Tnz4hjBACR14eMSEVLLM5w+x5TULgmNd4Be0jEcJPiRC5hBDYJuueYWwRoj8UISzOQ8TREoJ9iPebtC4YX5sQHEVk0LynKok4YWJC1LFnqogOCyzVnyu3Kd6uNx+DEJdU2fAl8P74CXlaRMjjynF5S465O01xeKr+IUWdg+dIHbPlltkNTspcSSY630l+gC+5s24lUTiHhGOn5LETwkHIwS1VcIFdQicsnIoxvrbtbms8dac8YkLwxva3OUJ+HoQQjnPNTlv0fN6eHKh5hIQsN1URaNb+OpYWz9hJy8ZbyrRPiDpqQlyKcX08lio2bWJh9+MEER3vw+zYURPiJrL3M3YI63BS5ipS5ADrE1eLnWLkpE9xAoTMn2V2t76lyvvwofs4iLTYh4k3PI6bEP7PsGz7cJbcpFphD+L6YbllH0KyNX/sVgbfPWV7U3N27ISw57H7H6aiyNm120SiVm1Q6yJI1oEJ+Y2v0P1xdXXx1dPl1c2De6nmmbNVVbXGmEbkDj4sIa7/A0/9b+aJP+5+/ys39SclhD0NiDz9lDTY1+M3Nz/z3NSfjhCc7g4ZX/4vZG7pz0kIxpl5qdzQn5gQJp/38PGcI8gnJQTZ6PMcH485B/m0hEDVr26aj98q85EJwYvM6tFXLbMfkQnJyoRkZUKysjIhWZmQrExI1iF0y37kRsia0Xd2kxsha0YX7DI3QtaMztl5boSstH4UrLjPzZCV1HXBijzMZKV1XrDiLDdDVkp3RcGKIs9mslL66z9Czh9yS2SlQsgLIcX33BRZk/oyEFJkXzVrSt+LkZAveZzJmvJCHCHFRW6OrFj3Zx4hxbfcIFmhHr4UAyEZkaw0ICCkuMi5SBb047wAIYO+5BlN1qjrswKEQN9zGMl60f1fBcQKT+fZgM9i99+KIiYEOrvMmwE+t27BBwgJdX55kzOST6mHu+uLM8LDv3oR6iw5DvC5AAAAAElFTkSuQmCC',
|
||
alt: 'Buy Me A Coffee'
|
||
}
|
||
});
|
||
|
||
// Create a horizontal container for the remaining buttons
|
||
const buttonsContainer = supportContainer.createDiv({
|
||
cls: 'tubesage-settings-action-buttons-container' // Apply new class
|
||
});
|
||
|
||
// License button - in the middle
|
||
const licenseButtonContainer = buttonsContainer.createDiv({
|
||
cls: 'tubesage-settings-action-button-item-container' // Apply new class (reused)
|
||
});
|
||
|
||
// License text
|
||
licenseButtonContainer.createSpan({
|
||
text: 'License & disclaimer',
|
||
cls: 'tubesage-settings-action-button-label' // Apply new class (reused)
|
||
});
|
||
|
||
// Eye icon button for viewing license
|
||
new ExtraButtonComponent(licenseButtonContainer)
|
||
.setIcon('eye')
|
||
.setTooltip('View license & disclaimer')
|
||
.onClick(() => {
|
||
new LicenseModal(this.app).open();
|
||
});
|
||
|
||
// License acceptance toggle - on the right
|
||
const toggleContainer = buttonsContainer.createDiv({
|
||
cls: 'tubesage-settings-action-button-item-container' // Reuse class
|
||
});
|
||
|
||
toggleContainer.createSpan({
|
||
text: 'Accept license & disclaimer',
|
||
cls: 'tubesage-settings-action-button-label' // Reuse class
|
||
});
|
||
|
||
// Create native Obsidian toggle
|
||
const licenseToggle = new ToggleComponent(toggleContainer);
|
||
licenseToggle.setValue(this.plugin.settings.licenseAccepted);
|
||
|
||
// README button - after the license toggle
|
||
const readmeButtonContainer = buttonsContainer.createDiv({
|
||
cls: 'tubesage-settings-action-button-item-container' // Reuse class
|
||
});
|
||
|
||
// README text
|
||
readmeButtonContainer.createSpan({
|
||
text: 'README',
|
||
cls: 'tubesage-settings-action-button-label' // Reuse class
|
||
});
|
||
|
||
// Eye icon button for viewing README
|
||
new ExtraButtonComponent(readmeButtonContainer)
|
||
.setIcon('eye')
|
||
.setTooltip('View readme')
|
||
.onClick(() => {
|
||
new READMEModal(this.app).open();
|
||
});
|
||
|
||
// Create remaining settings container that will be disabled if license not accepted
|
||
const settingsContainer = containerEl.createDiv({
|
||
cls: 'settings-container'
|
||
});
|
||
|
||
// Function to update settings container based on license acceptance
|
||
const updateSettingsState = () => {
|
||
if (this.plugin.settings.licenseAccepted) {
|
||
settingsContainer.removeClass('tubesage-settings-container-disabled');
|
||
} else {
|
||
settingsContainer.addClass('tubesage-settings-container-disabled');
|
||
}
|
||
};
|
||
|
||
// Initial state
|
||
updateSettingsState();
|
||
|
||
// Add change listener to native toggle
|
||
licenseToggle.onChange((value) => {
|
||
this.plugin.settings.licenseAccepted = value;
|
||
void this.plugin.saveSettings().then(updateSettingsState);
|
||
});
|
||
|
||
// ALL SETTINGS SECTIONS GO IN settingsContainer FROM HERE ON
|
||
|
||
// Template section
|
||
const templatesHeading = new Setting(settingsContainer)
|
||
.setName('Templates')
|
||
.setHeading();
|
||
templatesHeading.settingEl.addClass('tubesage-heading');
|
||
|
||
const templaterSetting = new Setting(settingsContainer)
|
||
.setName('Templater plugin template file')
|
||
.setDesc('Path to the templater plugin template file to use')
|
||
.addText(text => text
|
||
.setPlaceholder('templates/YouTubeTranscript.md')
|
||
.setValue(this.plugin.settings.templaterTemplateFile)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.templaterTemplateFile = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}));
|
||
|
||
// Add Browse button
|
||
templaterSetting.addExtraButton(button => {
|
||
button
|
||
.setIcon('folder')
|
||
.setTooltip('Browse for template file')
|
||
.onClick(() => {
|
||
// Show a file picker modal
|
||
const filePickerModal = new TemplateFilePickerModal(this.app, (selectedPath) => {
|
||
if (selectedPath) {
|
||
this.plugin.settings.templaterTemplateFile = selectedPath;
|
||
void this.plugin.saveSettings();
|
||
this.display();
|
||
}
|
||
});
|
||
void filePickerModal.open();
|
||
});
|
||
});
|
||
|
||
// Create a special container for the Example button with text
|
||
const exampleContainer = createDiv();
|
||
settingsContainer.appendChild(exampleContainer);
|
||
|
||
// Add a setting with empty name
|
||
const exampleSetting = new Setting(exampleContainer)
|
||
.setName('');
|
||
|
||
// Get the control element using DOM query after setting is created
|
||
// The setting-item-control is the right side where buttons go
|
||
window.setTimeout(() => {
|
||
const controlEl = exampleContainer.querySelector('.setting-item-control');
|
||
if (controlEl) {
|
||
// Add the text right before the button
|
||
const textSpan = createSpan({ text: "Example " });
|
||
controlEl.prepend(textSpan);
|
||
}
|
||
}, 0);
|
||
|
||
// Add the eye button
|
||
exampleSetting.addExtraButton(button => {
|
||
button
|
||
.setIcon('eye')
|
||
.setTooltip('View example template')
|
||
.onClick(() => {
|
||
new TemplateViewModal(this.app).open();
|
||
});
|
||
});
|
||
|
||
// Transcript section with info icon
|
||
const transcriptHeading = new Setting(settingsContainer)
|
||
.setName('Transcripts')
|
||
.setHeading();
|
||
transcriptHeading.settingEl.addClass('tubesage-heading');
|
||
|
||
// Add info icon directly to the heading name element (adjacent to text)
|
||
const transcriptHeadingNameEl = transcriptHeading.settingEl.querySelector('.setting-item-name');
|
||
if (transcriptHeadingNameEl && transcriptHeadingNameEl.instanceOf(HTMLElement)) {
|
||
this.createInfoIcon(
|
||
transcriptHeadingNameEl,
|
||
'Transcript settings control how and where your extracted notes are saved, which youtube data API key to use (required for channel/playlist processing), and optional language-translation parameters.'
|
||
);
|
||
}
|
||
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('Transcript root folder')
|
||
.setDesc('The root folder where transcript subfolders will be organized (e.g., inbox, notes, etc.)')
|
||
.addText(text => text
|
||
.setPlaceholder('Inbox')
|
||
.setValue(this.plugin.settings.transcriptRootFolder)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.transcriptRootFolder = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}));
|
||
|
||
// Removed "Use YouTube Data API v3" setting and CORS issue notice
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('YouTube data API key')
|
||
.setDesc('Your Google cloud console API key for accessing public YouTube transcripts (not an OAUTH token). Required for downloading channels and playlists.')
|
||
.addText(text => {
|
||
const textComponent = text
|
||
.setPlaceholder('Enter API key (starts with aiza)')
|
||
.setValue(this.plugin.settings.youtubeApiKey)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.youtubeApiKey = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
|
||
// Access the DOM element directly to change its type
|
||
const inputEl = textComponent.inputEl;
|
||
if (inputEl) {
|
||
// Set to password type to hide with dots
|
||
inputEl.type = 'password';
|
||
|
||
// Show text when focused
|
||
inputEl.addEventListener('focus', () => {
|
||
inputEl.type = 'text';
|
||
});
|
||
|
||
// Hide again when focus is lost
|
||
inputEl.addEventListener('blur', () => {
|
||
inputEl.type = 'password';
|
||
});
|
||
}
|
||
|
||
return textComponent;
|
||
});
|
||
|
||
const scSetting = new Setting(settingsContainer)
|
||
.setName('Scrape creators API key')
|
||
.setDesc('Optional key for scrapecreators transcript service. When set, used as the primary transcript method. Get a free key at app.scrapecreators.com (100 requests free).')
|
||
.addText(text => {
|
||
const textComponent = text
|
||
.setPlaceholder('Enter scrapecreators API key')
|
||
.setValue(this.plugin.settings.scrapcreatorsApiKey)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.scrapcreatorsApiKey = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
|
||
const inputEl = textComponent.inputEl;
|
||
if (inputEl) {
|
||
inputEl.type = 'password';
|
||
inputEl.addEventListener('focus', () => {
|
||
inputEl.type = 'text';
|
||
});
|
||
inputEl.addEventListener('blur', () => {
|
||
inputEl.type = 'password';
|
||
});
|
||
}
|
||
|
||
return textComponent;
|
||
});
|
||
const scNameEl = scSetting.settingEl.querySelector('.setting-item-name');
|
||
if (scNameEl && scNameEl.instanceOf(HTMLElement)) {
|
||
this.createInfoIcon(scNameEl, 'https://scrapecreators.com/');
|
||
}
|
||
|
||
const sdSetting = new Setting(settingsContainer)
|
||
.setName('Supa data API key')
|
||
.setDesc('Optional key for supadata transcript service. When set, used as the primary transcript method (if no scrapecreators key). Get a key at supadata.ai.')
|
||
.addText(text => {
|
||
const textComponent = text
|
||
.setPlaceholder('Enter supadata key')
|
||
.setValue(this.plugin.settings.supadataApiKey)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.supadataApiKey = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
|
||
const inputEl = textComponent.inputEl;
|
||
if (inputEl) {
|
||
inputEl.type = 'password';
|
||
inputEl.addEventListener('focus', () => {
|
||
inputEl.type = 'text';
|
||
});
|
||
inputEl.addEventListener('blur', () => {
|
||
inputEl.type = 'password';
|
||
});
|
||
}
|
||
|
||
return textComponent;
|
||
});
|
||
const sdNameEl = sdSetting.settingEl.querySelector('.setting-item-name');
|
||
if (sdNameEl && sdNameEl.instanceOf(HTMLElement)) {
|
||
this.createInfoIcon(sdNameEl, 'https://supadata.ai/');
|
||
}
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('Translate language')
|
||
.setDesc('Target language code for translation (e.g., en, es, fr, de). Use "en" to keep content in english.')
|
||
.addText(text => text
|
||
.setPlaceholder('Enter language code')
|
||
.setValue(this.plugin.settings.translateLanguage)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.translateLanguage = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}));
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('Translate country')
|
||
.setDesc('Target country/region code for translation (e.g., us, gb, ca). Used for region-specific language variants.')
|
||
.addText(text => text
|
||
.setPlaceholder('US')
|
||
.setValue(this.plugin.settings.translateCountry)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.translateCountry = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}));
|
||
|
||
// LLM section with info icon
|
||
// Reference + refresh helper for the conditionally-rendered "Max tokens"
|
||
// field that appears further down (only for unknown / custom models).
|
||
// Held at this scope so the model-dropdown's change listener inside
|
||
// createProviderConfigBlock can re-apply the value when the user picks
|
||
// a different model — without rebuilding the whole settings panel.
|
||
let maxTokensField: TextComponent | null = null;
|
||
const refreshMaxTokensField = (): void => {
|
||
// Field only exists in the DOM when the current model is unknown.
|
||
// If it isn't rendered, this is a no-op.
|
||
if (!maxTokensField) return;
|
||
const effective = this.plugin.getEffectiveMaxTokens();
|
||
this.plugin.settings.maxTokens = effective;
|
||
maxTokensField.setValue(String(effective));
|
||
};
|
||
|
||
const llmHeading = new Setting(settingsContainer)
|
||
.setName('Language model')
|
||
.setHeading();
|
||
llmHeading.settingEl.addClass('tubesage-heading');
|
||
|
||
// Add info icon directly to the heading name element (adjacent to text)
|
||
const llmHeadingNameEl = llmHeading.settingEl.querySelector('.setting-item-name');
|
||
if (llmHeadingNameEl && llmHeadingNameEl.instanceOf(HTMLElement)) {
|
||
this.createInfoIcon(
|
||
llmHeadingNameEl,
|
||
'Choose an AI provider, enter its API key, and pick a model. Temperature controls creativity; max tokens caps output length. Suggested for most users: Google provider with the gemini-2.5-flash model — fast, inexpensive, and high-quality. If you hit rate limits or reliability issues, OpenRouter is a solid fallback.'
|
||
);
|
||
}
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('Provider')
|
||
.setDesc('Provider used for summarisation.')
|
||
.addDropdown(dropdown => {
|
||
// Add OpenAI option
|
||
dropdown.addOption('openai', 'OpenAI');
|
||
|
||
// Always add Anthropic, Google, Ollama, and OpenRouter options since they all work on any platform now
|
||
dropdown.addOption('anthropic', 'Anthropic');
|
||
dropdown.addOption('google', 'Google');
|
||
dropdown.addOption('ollama', 'Ollama');
|
||
dropdown.addOption('openrouter', 'OpenRouter');
|
||
|
||
// Set the current value
|
||
let currentValue = this.plugin.settings.selectedLLM;
|
||
|
||
// Set the current value
|
||
dropdown.setValue(currentValue);
|
||
|
||
// Add change handler
|
||
dropdown.onChange((value: string) => {
|
||
void (async () => {
|
||
// Update provider first
|
||
this.plugin.settings.selectedLLM = value;
|
||
|
||
// Set appropriate max token value using registry for known models
|
||
const effectiveMaxTokens = this.plugin.getEffectiveMaxTokens();
|
||
this.plugin.settings.maxTokens = effectiveMaxTokens;
|
||
|
||
// Show notice with effective token limit
|
||
this.plugin.showNotice(`Max tokens set to ${effectiveMaxTokens} for ${value} provider`, 3000);
|
||
|
||
// Update settings
|
||
await this.plugin.saveSettings();
|
||
|
||
// Refresh the display to update the max tokens field
|
||
this.display();
|
||
})();
|
||
});
|
||
|
||
return dropdown;
|
||
});
|
||
|
||
// Generic function for creating LLM provider settings
|
||
// API key row for a provider — always rendered for every provider so a
|
||
// user can populate keys independently of which LLM is currently
|
||
// selected. The model dropdown / refresh / custom-name / custom-params
|
||
// are split out into createProviderConfigBlock and only shown for the
|
||
// selected provider (so the LLM section reflects the active provider's
|
||
// context rather than rendering all four providers' full settings at once).
|
||
const createProviderApiKeyRow = (
|
||
provider: string,
|
||
displayName: string,
|
||
placeholder: string,
|
||
): void => {
|
||
const apiKeySetting = new Setting(settingsContainer)
|
||
.setName(`${displayName} api key`)
|
||
.setDesc(
|
||
provider === 'ollama'
|
||
? 'Server URL, stored in plugin data.'
|
||
: 'Stored in Obsidian secret storage, not in plugin data.'
|
||
)
|
||
.addText(text => {
|
||
const textComponent = text
|
||
.setPlaceholder(placeholder)
|
||
.setValue(this.plugin.settings.apiKeys[provider])
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.apiKeys[provider] = value;
|
||
if (CLOUD_PROVIDERS.includes(provider as typeof CLOUD_PROVIDERS[number])) {
|
||
this.plugin.app.secretStorage.setSecret(SECRET_IDS[provider], value);
|
||
}
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
const inputEl = textComponent.inputEl;
|
||
if (inputEl) {
|
||
inputEl.type = 'password';
|
||
inputEl.addEventListener('focus', () => { inputEl.type = 'text'; });
|
||
inputEl.addEventListener('blur', () => { inputEl.type = 'password'; });
|
||
}
|
||
return textComponent;
|
||
});
|
||
if (provider === 'openrouter') {
|
||
const nameEl = apiKeySetting.settingEl.querySelector('.setting-item-name');
|
||
if (nameEl && nameEl.instanceOf(HTMLElement)) {
|
||
this.createInfoIcon(nameEl, 'https://openrouter.ai/');
|
||
}
|
||
}
|
||
};
|
||
|
||
// Full provider config block — only rendered for the selected provider.
|
||
// Includes header, model dropdown, refresh button, custom-model-name field,
|
||
// and the custom-model-parameters section (which has its own visibility
|
||
// gating based on whether a custom model is selected).
|
||
const createProviderConfigBlock = (
|
||
provider: string,
|
||
displayName: string,
|
||
modelOptions: string[],
|
||
defaultModelValue: string,
|
||
) => {
|
||
// Header row
|
||
new Setting(settingsContainer)
|
||
.setName(`${displayName} model`)
|
||
.setDesc('Select a preset model or type a custom model name below.')
|
||
.settingEl.addClass('tubesage-provider-header');
|
||
|
||
// Controls row: model dropdown, refresh, custom model name
|
||
const setting = new Setting(settingsContainer)
|
||
.setClass('tubesage-provider-controls');
|
||
|
||
// Reference for dropdown to update later
|
||
let modelDropdown: DropdownComponent | null = null;
|
||
|
||
// Build the dropdown's option list from the hardcoded presets plus
|
||
// any models the user has previously discovered via the per-provider
|
||
// refresh button. Discovered IDs are persisted in
|
||
// settings.fetchedModels[provider] by the three fetch handlers,
|
||
// independent of whether the API exposed token limits — so OpenAI
|
||
// (whose API doesn't return limits) survives this.display() the
|
||
// same way Google and Anthropic do. Deduplicated and sorted.
|
||
const fetched = this.plugin.settings.fetchedModels ?? {};
|
||
const fetchedForProvider = fetched[provider] ?? [];
|
||
const mergedOptions = Array.from(new Set([...modelOptions, ...fetchedForProvider])).sort();
|
||
|
||
// Add dropdown for preset + fetched models
|
||
setting.addDropdown(dropdown => {
|
||
modelDropdown = dropdown;
|
||
if (provider === 'openrouter') {
|
||
let currentVendor = '';
|
||
let group: HTMLElement | null = null;
|
||
for (const model of mergedOptions) {
|
||
const vendor = model.includes('/') ? model.slice(0, model.indexOf('/')) : 'other';
|
||
if (vendor !== currentVendor) {
|
||
currentVendor = vendor;
|
||
group = dropdown.selectEl.createEl('optgroup', { attr: { label: vendor } });
|
||
}
|
||
(group ?? dropdown.selectEl).createEl('option', { value: model, text: model });
|
||
}
|
||
} else {
|
||
mergedOptions.forEach((model) => {
|
||
dropdown.addOption(model, model);
|
||
});
|
||
}
|
||
dropdown.addOption('custom', 'Use custom model');
|
||
const currentModel = this.plugin.settings.selectedModels[provider];
|
||
let validSelection = mergedOptions.includes(currentModel) ? currentModel : 'custom';
|
||
dropdown.setValue(validSelection)
|
||
.onChange((value: string) => {
|
||
// Synchronous part of the write must run before the
|
||
// selectEl 'change' DOM listener fires (see the wiring
|
||
// below addCustomModelParametersSection). The listener
|
||
// calls paramsHandle.refresh() which reads
|
||
// selectedModels[provider] via getCurrentCustomLimits;
|
||
// if the write is inside the async IIFE the listener
|
||
// sees the previous model and the params panel keeps
|
||
// showing stale values.
|
||
if (value !== 'custom') {
|
||
this.plugin.settings.selectedModels[provider] = value;
|
||
customField?.setValue(''); // Clear custom if a preset is chosen
|
||
this.plugin.settings.maxTokens = this.plugin.getEffectiveMaxTokens();
|
||
}
|
||
|
||
void (async () => {
|
||
if (value !== 'custom') {
|
||
await this.plugin.saveSettings();
|
||
this.plugin.showNotice(`Model changed to ${value}. Max tokens updated to ${this.plugin.settings.maxTokens}`, 3000);
|
||
} else {
|
||
// Custom model selected - initialize custom model limits if they don't exist
|
||
const currentModel = this.plugin.settings.selectedModels[provider];
|
||
if (currentModel && currentModel !== '') {
|
||
const customKey = `${provider}:${currentModel}`;
|
||
|
||
// Initialize custom limits if they don't exist
|
||
if (!this.plugin.settings.customModelLimits[customKey]) {
|
||
this.plugin.settings.customModelLimits[customKey] = {
|
||
contextK: 128,
|
||
maxOutputK: 16,
|
||
inputMaxK: undefined,
|
||
reservePct: 0.1
|
||
};
|
||
|
||
// Update maxTokens setting based on new custom limits
|
||
this.plugin.settings.maxTokens = this.plugin.getEffectiveMaxTokens();
|
||
await this.plugin.saveSettings();
|
||
|
||
// Refresh display to show custom parameters
|
||
this.display();
|
||
}
|
||
}
|
||
}
|
||
})();
|
||
});
|
||
return dropdown;
|
||
});
|
||
|
||
// Add refresh button for OpenAI, Google, Anthropic, and OpenRouter providers
|
||
if (provider === 'openai' || provider === 'google' || provider === 'anthropic' || provider === 'openrouter') {
|
||
setting.addExtraButton(button => {
|
||
button
|
||
.setIcon('refresh-cw') // Refresh icon
|
||
.setTooltip(`Refresh ${displayName} model list`)
|
||
.onClick(() => {
|
||
void (async () => {
|
||
const apiKey = this.plugin.settings.apiKeys[provider];
|
||
if (provider !== 'openrouter' && (!apiKey || apiKey.trim() === '')) {
|
||
this.plugin.showNotice(`${displayName} API key is required to refresh models.`, 5000);
|
||
return;
|
||
}
|
||
|
||
let fetchedModels: FetchedModelInfo[] = [];
|
||
if (provider === 'openai') {
|
||
fetchedModels = await this.plugin.fetchOpenAIModels(apiKey);
|
||
} else if (provider === 'google') {
|
||
fetchedModels = await this.plugin.fetchGoogleModels(apiKey);
|
||
} else if (provider === 'anthropic') {
|
||
fetchedModels = await this.plugin.fetchAnthropicModels(apiKey);
|
||
} else if (provider === 'openrouter') {
|
||
fetchedModels = await this.plugin.fetchOpenRouterModels();
|
||
}
|
||
|
||
const fetchedIds = fetchedModels.map(m => m.id);
|
||
|
||
if (fetchedIds.length > 0) {
|
||
// Pick the selected model after refresh:
|
||
// 1. keep current if still in fetched list
|
||
// 2. fall back to the hardcoded preset default if it's now fetched
|
||
// 3. otherwise pick the first fetched id
|
||
// No 'custom' fallback path here — fetchedIds.length > 0 guarantees
|
||
// we have at least one real model id to point at.
|
||
const currentSelectedModel = this.plugin.settings.selectedModels[provider];
|
||
if (fetchedIds.includes(currentSelectedModel)) {
|
||
// No change needed.
|
||
} else if (fetchedIds.includes(defaultModelValue)) {
|
||
this.plugin.settings.selectedModels[provider] = defaultModelValue;
|
||
} else {
|
||
this.plugin.settings.selectedModels[provider] = fetchedIds[0];
|
||
}
|
||
this.plugin.settings.maxTokens = this.plugin.getEffectiveMaxTokens();
|
||
await this.plugin.saveSettings();
|
||
|
||
const limitedCount = fetchedModels.filter(m => m.contextK).length;
|
||
const limitMsg = limitedCount > 0 ? ` Token limits auto-populated for ${limitedCount} models.` : '';
|
||
this.plugin.showNotice(`${displayName} model list refreshed.${limitMsg}`, 4000);
|
||
|
||
// Re-render the whole settings panel. The fetch
|
||
// handlers have already upserted limits into
|
||
// settings.customModelLimits and discovered IDs
|
||
// into settings.fetchedModels — so the rebuilt
|
||
// dropdown picks up the fresh model list automatically.
|
||
this.display();
|
||
} else {
|
||
this.plugin.showNotice(`Could not refresh ${displayName} models. Using existing list.`, 4000);
|
||
}
|
||
})();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Reference to store custom field
|
||
let customField: TextComponent | null = null;
|
||
|
||
// Add custom model field — commits on blur, NOT on every keystroke.
|
||
// The previous per-keystroke onChange wrote one customModelLimits
|
||
// entry per character, polluting settings.json with junk like
|
||
// `openai:b`, `openai:bd`, `openai:bdd` for someone typing "bdd".
|
||
// Blur-only commit makes typing transient and only the final value
|
||
// becomes a real custom model entry.
|
||
setting.addText(text => {
|
||
customField = text;
|
||
text.setPlaceholder('Custom model name')
|
||
.setValue(
|
||
// Show current model in custom field only when it's not
|
||
// in the merged dropdown options (preset + previously-
|
||
// fetched). A fetched-but-not-preset model is a normal
|
||
// dropdown selection, not a custom one.
|
||
!mergedOptions.includes(this.plugin.settings.selectedModels[provider])
|
||
? this.plugin.settings.selectedModels[provider]
|
||
: ''
|
||
);
|
||
|
||
text.inputEl?.addEventListener('blur', () => {
|
||
void (async () => {
|
||
const value = text.getValue();
|
||
if (value && value.trim() !== '') {
|
||
// Commit the typed model as the active custom model.
|
||
this.plugin.settings.selectedModels[provider] = value;
|
||
if (modelDropdown && modelDropdown.getValue() !== 'custom') {
|
||
modelDropdown.setValue('custom');
|
||
}
|
||
|
||
// Initialise customModelLimits with conservative
|
||
// defaults if this provider:model is brand-new.
|
||
const customKey = `${provider}:${value}`;
|
||
if (!this.plugin.settings.customModelLimits[customKey]) {
|
||
this.plugin.settings.customModelLimits[customKey] = {
|
||
contextK: 128,
|
||
maxOutputK: 16,
|
||
inputMaxK: undefined,
|
||
reservePct: provider === 'ollama' ? 0.15 : 0.10,
|
||
};
|
||
this.plugin.settings.maxTokens = this.plugin.getEffectiveMaxTokens();
|
||
}
|
||
|
||
await this.plugin.saveSettings();
|
||
} else if (modelDropdown && modelDropdown.getValue() === 'custom') {
|
||
// Custom field cleared while dropdown was on 'custom':
|
||
// revert to the provider's preset default.
|
||
modelDropdown.setValue(defaultModelValue);
|
||
this.plugin.settings.selectedModels[provider] = defaultModelValue;
|
||
await this.plugin.saveSettings();
|
||
}
|
||
})();
|
||
});
|
||
|
||
return text;
|
||
});
|
||
|
||
// Add the always-visible model parameters section. Captures a
|
||
// refresh handle we wire into the dropdown's onChange below so a
|
||
// model switch immediately repopulates the override fields with
|
||
// the new model's resolved limits (registry default or saved
|
||
// override). Without this the panel would keep showing the
|
||
// previous model's values until the user touched another control.
|
||
//
|
||
// Note on the local aliases: `modelDropdown` and `customField` are
|
||
// mutated inside addDropdown / addText callbacks; TypeScript's
|
||
// closure-narrowing leaves them typed as `null` here. The aliases
|
||
// assert non-null after the runtime guard so we can use them
|
||
// without re-narrowing on every line.
|
||
const dropdownRef = modelDropdown as DropdownComponent | null;
|
||
const fieldRef = customField as TextComponent | null;
|
||
if (fieldRef && dropdownRef) {
|
||
const paramsHandle = this.addCustomModelParametersSection(settingsContainer, provider, fieldRef, dropdownRef);
|
||
dropdownRef.selectEl?.addEventListener('change', () => {
|
||
paramsHandle.refresh();
|
||
refreshMaxTokensField();
|
||
});
|
||
}
|
||
|
||
return setting;
|
||
};
|
||
|
||
// Provider catalog — keys, display names, placeholders, model lists, defaults.
|
||
// Defined once so both the always-rendered API key rows and the
|
||
// selected-provider-only config block draw from the same source.
|
||
interface ProviderCatalogEntry {
|
||
provider: string;
|
||
displayName: string;
|
||
placeholder: string;
|
||
modelOptions: string[];
|
||
defaultModelValue: string;
|
||
}
|
||
const providerCatalog: ProviderCatalogEntry[] = [
|
||
{
|
||
provider: 'openai',
|
||
displayName: 'OpenAI',
|
||
placeholder: 'sk-...',
|
||
modelOptions: [
|
||
'gpt-5',
|
||
'gpt-4.5',
|
||
'gpt-4o',
|
||
'gpt-4o-mini',
|
||
'gpt-4-turbo',
|
||
'gpt-4',
|
||
'gpt-3.5-turbo',
|
||
'o1',
|
||
'o1-pro',
|
||
'o3-mini',
|
||
'o3-mini-high',
|
||
],
|
||
defaultModelValue: 'gpt-4-turbo',
|
||
},
|
||
{
|
||
provider: 'anthropic',
|
||
displayName: 'Anthropic',
|
||
placeholder: 'sk-ant-...',
|
||
modelOptions: [
|
||
'claude-opus-4-0',
|
||
'claude-opus-4-1',
|
||
'claude-sonnet-4-0',
|
||
'claude-3-7-sonnet-20250219',
|
||
'claude-3-5-sonnet-20241022',
|
||
'claude-3-5-haiku-20241022',
|
||
'claude-3-opus-20240229',
|
||
'claude-3-sonnet-20240229',
|
||
'claude-3-haiku-20240307',
|
||
],
|
||
defaultModelValue: 'claude-3-sonnet-20240229',
|
||
},
|
||
{
|
||
provider: 'google',
|
||
displayName: 'Google',
|
||
placeholder: 'AIza...',
|
||
modelOptions: [
|
||
'gemini-2.5-flash',
|
||
'gemini-2.5-pro-exp-03-25',
|
||
'gemini-2.0-flash-exp',
|
||
'gemini-2.0-pro-exp-02-05',
|
||
'gemini-2.0-flash',
|
||
'gemini-2.0-flash-lite',
|
||
'gemini-1.5-pro',
|
||
'gemini-1.5-flash',
|
||
'gemini-1.5-flash-8b',
|
||
'gemini-nano',
|
||
'gemini-ultra (beta)',
|
||
'gemini-pro',
|
||
'gemini-pro-vision',
|
||
],
|
||
defaultModelValue: 'gemini-1.5-pro',
|
||
},
|
||
{
|
||
provider: 'ollama',
|
||
displayName: 'Ollama',
|
||
placeholder: 'http://localhost:11434',
|
||
modelOptions: [
|
||
'llama3.1',
|
||
'llama3.1:8b',
|
||
'llama3.1:70b',
|
||
'mistral',
|
||
'mixtral',
|
||
'gemma',
|
||
'codellama',
|
||
'phi',
|
||
'wizardcoder',
|
||
'solar',
|
||
],
|
||
defaultModelValue: 'llama3.1',
|
||
},
|
||
{
|
||
provider: 'openrouter',
|
||
displayName: 'OpenRouter',
|
||
placeholder: 'sk-or-v1-...',
|
||
modelOptions: [
|
||
'openai/gpt-4o',
|
||
'openai/gpt-4o-mini',
|
||
'anthropic/claude-3.5-sonnet',
|
||
'anthropic/claude-3.5-haiku',
|
||
'google/gemini-2.5-flash',
|
||
'meta-llama/llama-3.1-70b-instruct',
|
||
'mistralai/mistral-large',
|
||
],
|
||
defaultModelValue: 'openai/gpt-4o',
|
||
},
|
||
];
|
||
|
||
// Always render an API key row for every provider — users can
|
||
// populate or update any key irrespective of which LLM is currently
|
||
// selected.
|
||
for (const entry of providerCatalog) {
|
||
createProviderApiKeyRow(entry.provider, entry.displayName, entry.placeholder);
|
||
}
|
||
|
||
// Render the model config block for the currently-selected provider only.
|
||
// Switching the provider re-runs display() (the Select-LLM dropdown's
|
||
// onChange calls this.display()), which re-enters this code path and
|
||
// renders the new provider's block.
|
||
const selectedEntry = providerCatalog.find(e => e.provider === this.plugin.settings.selectedLLM);
|
||
if (selectedEntry) {
|
||
createProviderConfigBlock(
|
||
selectedEntry.provider,
|
||
selectedEntry.displayName,
|
||
selectedEntry.modelOptions,
|
||
selectedEntry.defaultModelValue,
|
||
);
|
||
}
|
||
|
||
// LLM Parameters
|
||
new Setting(settingsContainer)
|
||
.setName('Temperature')
|
||
.setDesc(`Controls randomness of output (0-1). Lower is more focused, higher is more creative. Current value: ${this.plugin.settings.temperature}`)
|
||
.addSlider(slider => slider
|
||
.setLimits(0, 1, 0.1)
|
||
.setValue(this.plugin.settings.temperature)
|
||
.setDynamicTooltip()
|
||
.onChange((value: number) => {
|
||
void (async () => {
|
||
this.plugin.settings.temperature = value;
|
||
// Update the description text to show the current value
|
||
this.display();
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}));
|
||
|
||
// Only show Max Tokens setting for custom models - known models use dynamic calculation
|
||
const provider = this.plugin.settings.selectedLLM as Provider;
|
||
const model = this.plugin.settings.selectedModels[provider];
|
||
const isCustomModel = !isModelSupported(provider, model);
|
||
|
||
if (isCustomModel) {
|
||
new Setting(settingsContainer)
|
||
.setName('Max tokens')
|
||
.setDesc('Maximum length of summary output for custom model')
|
||
.addText(text => {
|
||
// Capture so refreshMaxTokensField (called from the model
|
||
// dropdown's change listener) can repopulate this field
|
||
// when the user picks a different unknown model — without
|
||
// rebuilding the whole settings panel.
|
||
maxTokensField = text;
|
||
text.setPlaceholder('1000')
|
||
.setValue(String(this.plugin.settings.maxTokens));
|
||
|
||
// Commit on blur, not per keystroke. Avoids the
|
||
// type-1-then-12-then-123 thrash that the override fields
|
||
// had, and lets the user blank the field as a "reset"
|
||
// gesture: blanking + blurring re-applies the
|
||
// model-derived value (registry / override / fallback).
|
||
text.inputEl?.addEventListener('blur', () => {
|
||
void (async () => {
|
||
const value = text.getValue().trim();
|
||
if (value === '') {
|
||
// Reset gesture: re-derive from the current
|
||
// model's effective max tokens.
|
||
const effective = this.plugin.getEffectiveMaxTokens();
|
||
this.plugin.settings.maxTokens = effective;
|
||
text.setValue(String(effective));
|
||
await this.plugin.saveSettings();
|
||
return;
|
||
}
|
||
const numValue = parseInt(value, 10);
|
||
if (!isNaN(numValue) && numValue > 0) {
|
||
this.plugin.settings.maxTokens = numValue;
|
||
await this.plugin.saveSettings();
|
||
} else {
|
||
// Bad input: silently revert to the persisted
|
||
// value so the field doesn't show garbage.
|
||
text.setValue(String(this.plugin.settings.maxTokens));
|
||
}
|
||
})();
|
||
});
|
||
|
||
return text;
|
||
})
|
||
.addExtraButton(button => {
|
||
button
|
||
.setIcon('alert-triangle')
|
||
.setTooltip('Max tokens should not be confused with the context window size. This setting caps the maximum output returned by the model and is sensitive — exceeding this limit will cause the model to fail. For custom models, ensure this parameter is aligned with the model\'s capabilities.');
|
||
});
|
||
}
|
||
// Note: Known models automatically calculate optimal max tokens using getEffectiveMaxTokens()
|
||
|
||
// Note format section
|
||
const noteFormatHeading = new Setting(settingsContainer)
|
||
.setName('Note format')
|
||
.setHeading();
|
||
noteFormatHeading.settingEl.addClass('tubesage-heading');
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('Prepend date to note title')
|
||
.setDesc('Automatically add date to the beginning of note filenames')
|
||
.addDropdown((dropdown) => dropdown
|
||
.addOption('true', 'Enabled')
|
||
.addOption('false', 'Disabled')
|
||
.setValue(this.plugin.settings.prependDate ? 'true' : 'false')
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.prependDate = value === 'true';
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}));
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('Date format')
|
||
.setDesc('Format for date prepended to note titles')
|
||
.addDropdown((dropdown) => dropdown
|
||
.addOption('YYYY-MM-DD', 'Yyyy-mm-dd')
|
||
.addOption('MM-DD-YYYY', 'Mm-dd-yyyy')
|
||
.addOption('DD-MM-YYYY', 'Dd-mm-yyyy')
|
||
.setValue(this.plugin.settings.dateFormat)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.dateFormat = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}));
|
||
|
||
|
||
// Prompt section
|
||
const promptsHeading = new Setting(settingsContainer)
|
||
.setName('Prompts')
|
||
.setHeading();
|
||
promptsHeading.settingEl.addClass('tubesage-heading');
|
||
|
||
// Create a sub-heading for Fast Summary prompts
|
||
const fastSummaryHeading = new Setting(settingsContainer)
|
||
.setName('Fast summary prompts')
|
||
.setHeading();
|
||
fastSummaryHeading.settingEl.addClass('tubesage-settings-prompt-subheader');
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('System prompt (fast summary)')
|
||
.setDesc('Instructions for the llms behavior when generating fast summaries')
|
||
.addTextArea(text => {
|
||
const textComponent = text
|
||
.setPlaceholder('You are a helpful assistant...')
|
||
.setValue(this.plugin.settings.systemPrompt)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.systemPrompt = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
|
||
// Access the DOM element and set its appearance
|
||
// @ts-ignore - inputEl exists but TypeScript doesn't know about it
|
||
const inputEl = textComponent.inputEl;
|
||
inputEl.addClass('tubesage-prompt-textarea');
|
||
// Tag the parent setting-item so CSS can target it without :has()
|
||
inputEl.closest('.setting-item')?.classList.add('tubesage-prompt-setting');
|
||
|
||
return textComponent;
|
||
})
|
||
.addExtraButton(button => {
|
||
button
|
||
.setIcon('reset')
|
||
.setTooltip('Reset to default')
|
||
.onClick(() => {
|
||
void (async () => {
|
||
this.plugin.settings.systemPrompt = DEFAULT_SETTINGS.systemPrompt;
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
})();
|
||
});
|
||
});
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('User prompt (fast summary)')
|
||
.setDesc('Specific instructions for summarizing the transcript quickly and concisely')
|
||
.addTextArea(text => {
|
||
const textComponent = text
|
||
.setPlaceholder('Please summarize the following YouTube transcript...')
|
||
.setValue(this.plugin.settings.userPrompt)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.userPrompt = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
|
||
// Access the DOM element and set its appearance
|
||
// @ts-ignore - inputEl exists but TypeScript doesn't know about it
|
||
const inputEl = textComponent.inputEl;
|
||
inputEl.addClass('tubesage-prompt-textarea');
|
||
// Tag the parent setting-item so CSS can target it without :has()
|
||
inputEl.closest('.setting-item')?.classList.add('tubesage-prompt-setting');
|
||
|
||
return textComponent;
|
||
})
|
||
.addExtraButton(button => {
|
||
button
|
||
.setIcon('reset')
|
||
.setTooltip('Reset to default')
|
||
.onClick(async () => {
|
||
this.plugin.settings.userPrompt = DEFAULT_SETTINGS.userPrompt;
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
});
|
||
});
|
||
|
||
// Create a sub-heading for Extensive Summary prompts
|
||
const extensiveSummaryHeading = new Setting(settingsContainer)
|
||
.setName('Extensive summary prompts')
|
||
.setHeading();
|
||
extensiveSummaryHeading.settingEl.addClass('tubesage-settings-prompt-subheader');
|
||
extensiveSummaryHeading.settingEl.addClass('tubesage-settings-prompt-subheader-extensive');
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('System prompt (extensive summary)')
|
||
.setDesc('Instructions for the llms behavior when generating detailed, comprehensive summaries')
|
||
.addTextArea(text => {
|
||
const textComponent = text
|
||
.setPlaceholder('You are an analytical assistant...')
|
||
.setValue(this.plugin.settings.extensiveSystemPrompt)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.extensiveSystemPrompt = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
|
||
// Access the DOM element and set its appearance
|
||
// @ts-ignore - inputEl exists but TypeScript doesn't know about it
|
||
const inputEl = textComponent.inputEl;
|
||
inputEl.addClass('tubesage-prompt-textarea');
|
||
// Tag the parent setting-item so CSS can target it without :has()
|
||
inputEl.closest('.setting-item')?.classList.add('tubesage-prompt-setting');
|
||
|
||
return textComponent;
|
||
})
|
||
.addExtraButton(button => {
|
||
button
|
||
.setIcon('reset')
|
||
.setTooltip('Reset to default')
|
||
.onClick(() => {
|
||
void (async () => {
|
||
this.plugin.settings.extensiveSystemPrompt = DEFAULT_SETTINGS.extensiveSystemPrompt;
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
})();
|
||
});
|
||
});
|
||
|
||
new Setting(settingsContainer)
|
||
.setName('User prompt (extensive summary)')
|
||
.setDesc('Specific instructions for creating detailed and structured notes from the transcript')
|
||
.addTextArea(text => {
|
||
const textComponent = text
|
||
.setPlaceholder('From the transcript below, create detailed and structured notes...')
|
||
.setValue(this.plugin.settings.extensiveUserPrompt)
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.extensiveUserPrompt = value;
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
|
||
// Access the DOM element and set its appearance
|
||
// @ts-ignore - inputEl exists but TypeScript doesn't know about it
|
||
const inputEl = textComponent.inputEl;
|
||
inputEl.addClass('tubesage-prompt-textarea');
|
||
// Tag the parent setting-item so CSS can target it without :has()
|
||
inputEl.closest('.setting-item')?.classList.add('tubesage-prompt-setting');
|
||
|
||
return textComponent;
|
||
})
|
||
.addExtraButton(button => {
|
||
button
|
||
.setIcon('reset')
|
||
.setTooltip('Reset to default')
|
||
.onClick(() => {
|
||
void (async () => {
|
||
this.plugin.settings.extensiveUserPrompt = DEFAULT_SETTINGS.extensiveUserPrompt;
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
})();
|
||
});
|
||
});
|
||
|
||
// Default Summary Mode Setting
|
||
new Setting(settingsContainer)
|
||
.setName('Default summary mode')
|
||
.setDesc('Choose the default summary mode to use when the plugin starts. Fast summary mode skips timestamp links for quicker processing.')
|
||
.addDropdown((dropdown) => dropdown
|
||
.addOption('false', 'Extensive summary (detailed)')
|
||
.addOption('true', 'Fast summary (brief)')
|
||
.setValue(this.plugin.settings.useFastSummary ? 'true' : 'false')
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.useFastSummary = value === 'true';
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}));
|
||
|
||
// Add timestamp links setting
|
||
new Setting(settingsContainer)
|
||
.setName('Add YouTube timestamp links')
|
||
.setDesc('Add links to each numbered section heading that jump to the corresponding timestamp in the YouTube video (note: disabled in fast summary mode)')
|
||
.addDropdown(dropdown => dropdown
|
||
.addOption('true', 'Enabled')
|
||
.addOption('false', 'Disabled')
|
||
.setValue(this.plugin.settings.addTimestampLinks ? 'true' : 'false')
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.addTimestampLinks = value === 'true';
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}))
|
||
.addExtraButton(button => {
|
||
button
|
||
.setIcon('info')
|
||
.setTooltip('When enabled, reduces first pass tokens by 12% to make room for links. Automatically disabled in fast summary mode.');
|
||
});
|
||
|
||
// Advanced section with info icon
|
||
const advancedHeading = new Setting(settingsContainer)
|
||
.setName('Advanced')
|
||
.setHeading();
|
||
advancedHeading.settingEl.addClass('tubesage-heading');
|
||
|
||
// Add info icon directly to the heading name element (adjacent to text)
|
||
const advancedHeadingNameEl = advancedHeading.settingEl.querySelector('.setting-item-name');
|
||
if (advancedHeadingNameEl && advancedHeadingNameEl.instanceOf(HTMLElement)) {
|
||
this.createInfoIcon(
|
||
advancedHeadingNameEl,
|
||
'Advanced settings for debugging and troubleshooting. Enable debug logging to get detailed information appended to each note for technical support.'
|
||
);
|
||
}
|
||
|
||
// Debug logging toggle
|
||
new Setting(settingsContainer)
|
||
.setName('Enable debug logging')
|
||
.setDesc('Enable detailed debug logs. When enabled, debug information will be appended to each note as a hidden callout for troubleshooting.')
|
||
.addDropdown(dropdown => dropdown
|
||
.addOption('true', 'Enabled')
|
||
.addOption('false', 'Disabled')
|
||
.setValue(this.plugin.settings.debugLogging ? 'true' : 'false')
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
this.plugin.settings.debugLogging = value === 'true';
|
||
// Update log level immediately
|
||
if (this.plugin.settings.debugLogging) {
|
||
setGlobalLogLevel(LogLevel.DEBUG);
|
||
logger.debug('Debug logging enabled from settings');
|
||
} else {
|
||
setGlobalLogLevel(LogLevel.INFO);
|
||
logger.info('Debug logging disabled from settings');
|
||
}
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
}))
|
||
.addExtraButton((button: ExtraButtonComponent) => {
|
||
button
|
||
.setIcon('info')
|
||
.setTooltip('When enabled, debug information will be collected and added to notes as a hidden callout instead of being logged to the console.');
|
||
});
|
||
}
|
||
|
||
// Helper function to create info icons with tooltips
|
||
private createInfoIcon(container: HTMLElement, tooltipText: string): HTMLElement {
|
||
// Add info icon
|
||
const infoIcon = container.createSpan({
|
||
cls: 'tubesage-settings-info-icon', // Apply new class
|
||
attr: { 'aria-label': 'Information' } // Keep aria-label
|
||
});
|
||
|
||
setIcon(infoIcon, 'info');
|
||
|
||
// Native Obsidian tooltip — renders reliably, positioned correctly,
|
||
// and works on mobile (unlike the prior hover-only CSS tooltip).
|
||
setTooltip(infoIcon, tooltipText, { placement: 'bottom' });
|
||
|
||
return infoIcon;
|
||
}
|
||
|
||
/**
|
||
* Add custom model parameters section that appears when custom model is selected
|
||
*/
|
||
private addCustomModelParametersSection(container: HTMLElement, provider: string, customField: TextComponent, modelDropdown: DropdownComponent): { refresh: () => void } {
|
||
// Create container — always visible for the selected provider.
|
||
const customParamsContainer = container.createDiv({
|
||
cls: 'tubesage-custom-model-params'
|
||
});
|
||
|
||
// Add header (text updated by updateCustomParamsLabel below).
|
||
const customParamsHeading = new Setting(customParamsContainer)
|
||
.setName(`Model parameters (${provider.toUpperCase()})`)
|
||
.setHeading();
|
||
customParamsHeading.settingEl.addClass('tubesage-custom-params-header');
|
||
|
||
// Optional sub-description shown under the heading. Updated alongside
|
||
// the heading text by updateCustomParamsLabel.
|
||
const subDescEl = customParamsContainer.createDiv({
|
||
cls: 'setting-item-description tubesage-custom-params-subdesc',
|
||
});
|
||
|
||
// Resolve which limits to display in the override fields.
|
||
//
|
||
// Priority:
|
||
// 1. User override stored in customModelLimits[provider:model]
|
||
// 2. Registry defaults from getEffectiveLimits (registry stores raw
|
||
// tokens; the override panel uses k-tokens — divide by 1000)
|
||
// 3. Conservative fallback for genuinely-unknown models
|
||
//
|
||
// This makes the panel populate with the model's actual capabilities
|
||
// when the user picks a registry-known model from the dropdown (or
|
||
// refreshes and the registry gets enriched with vendor-published
|
||
// limits) so the user can see the model's parameters before deciding
|
||
// whether to override them.
|
||
const getCurrentCustomLimits = () => {
|
||
const model = this.plugin.settings.selectedModels[provider];
|
||
const customKey = `${provider}:${model}`;
|
||
const userOverride = this.plugin.settings.customModelLimits[customKey];
|
||
if (userOverride) return userOverride;
|
||
|
||
// Try the registry. isModelSupported guards getEffectiveLimits
|
||
// (which throws on unknown model). Registry units are raw tokens;
|
||
// the panel speaks k-tokens.
|
||
const providerKey = provider as Parameters<typeof isModelSupported>[0];
|
||
if (model && isModelSupported(providerKey, model)) {
|
||
try {
|
||
const eff = getEffectiveLimits(providerKey, model);
|
||
return {
|
||
contextK: Math.round(eff.context / 1000),
|
||
maxOutputK: Math.round(eff.maxOutput / 1000),
|
||
inputMaxK: eff.inputMax != null ? Math.round(eff.inputMax / 1000) : undefined,
|
||
reservePct: eff.reserveOutputPct ?? (provider === 'ollama' ? 0.15 : 0.10),
|
||
};
|
||
} catch {
|
||
// Fall through to fallback.
|
||
}
|
||
}
|
||
|
||
// Conservative fallback: model is unknown to both the user override
|
||
// map and the registry. Returned for *display* only — never
|
||
// persisted, because the user might still be typing in the custom
|
||
// model field, and persisting a fallback per keystroke leads to
|
||
// junk entries (`openai:b`, `openai:bd`, ...). The user explicitly
|
||
// editing the override fields below is what writes to settings.
|
||
return {
|
||
contextK: 128,
|
||
maxOutputK: 16,
|
||
inputMaxK: undefined,
|
||
reservePct: provider === 'ollama' ? 0.15 : 0.10,
|
||
};
|
||
};
|
||
|
||
// Hold field references so we can refresh their displayed values when
|
||
// the model selection changes (or a refresh repopulates the registry).
|
||
// setValue is called once at construction; without these refs the
|
||
// panel keeps showing the previous model's values.
|
||
let contextKField: TextComponent | null = null;
|
||
let maxOutputKField: TextComponent | null = null;
|
||
let inputMaxKField: TextComponent | null = null;
|
||
let reservePctField: TextComponent | null = null;
|
||
|
||
// Context window field
|
||
new Setting(customParamsContainer)
|
||
.setName('Context window (k tokens)')
|
||
.setDesc('Total context window in thousands of tokens (e.g., 400 for 400k tokens)')
|
||
.addText(text => {
|
||
contextKField = text;
|
||
text.setValue(getCurrentCustomLimits().contextK.toString())
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
const numValue = parseInt(value) || 128;
|
||
const model = this.plugin.settings.selectedModels[provider];
|
||
const customKey = `${provider}:${model}`;
|
||
|
||
if (!this.plugin.settings.customModelLimits[customKey]) {
|
||
this.plugin.settings.customModelLimits[customKey] = getCurrentCustomLimits();
|
||
}
|
||
|
||
this.plugin.settings.customModelLimits[customKey].contextK = numValue;
|
||
await this.plugin.saveSettings();
|
||
|
||
// Update maxTokens setting based on new context window
|
||
this.plugin.settings.maxTokens = this.plugin.getEffectiveMaxTokens();
|
||
await this.plugin.saveSettings();
|
||
})();
|
||
});
|
||
});
|
||
|
||
// Max output field
|
||
new Setting(customParamsContainer)
|
||
.setName('Max output (k tokens)')
|
||
.setDesc('Maximum output tokens in thousands (e.g., 128 for 128k tokens)')
|
||
.addText(text => {
|
||
maxOutputKField = text;
|
||
text.setValue(getCurrentCustomLimits().maxOutputK.toString())
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
const numValue = parseInt(value) || 16;
|
||
const model = this.plugin.settings.selectedModels[provider];
|
||
const customKey = `${provider}:${model}`;
|
||
|
||
if (!this.plugin.settings.customModelLimits[customKey]) {
|
||
this.plugin.settings.customModelLimits[customKey] = getCurrentCustomLimits();
|
||
}
|
||
|
||
this.plugin.settings.customModelLimits[customKey].maxOutputK = numValue;
|
||
await this.plugin.saveSettings();
|
||
|
||
// Update maxTokens setting
|
||
this.plugin.settings.maxTokens = this.plugin.getEffectiveMaxTokens();
|
||
await this.plugin.saveSettings();
|
||
|
||
})();
|
||
});
|
||
});
|
||
|
||
// Input max field (optional)
|
||
new Setting(customParamsContainer)
|
||
.setName('Input max (k tokens) - optional')
|
||
.setDesc('Explicit input cap if vendor publishes one (leave empty to auto calculate)')
|
||
.addText(text => {
|
||
inputMaxKField = text;
|
||
const currentLimits = getCurrentCustomLimits();
|
||
text.setValue(currentLimits.inputMaxK ? currentLimits.inputMaxK.toString() : '')
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
const numValue = value.trim() ? parseInt(value) || undefined : undefined;
|
||
const model = this.plugin.settings.selectedModels[provider];
|
||
const customKey = `${provider}:${model}`;
|
||
|
||
if (!this.plugin.settings.customModelLimits[customKey]) {
|
||
this.plugin.settings.customModelLimits[customKey] = getCurrentCustomLimits();
|
||
}
|
||
|
||
this.plugin.settings.customModelLimits[customKey].inputMaxK = numValue;
|
||
await this.plugin.saveSettings();
|
||
|
||
// Update maxTokens setting based on new input max
|
||
this.plugin.settings.maxTokens = this.plugin.getEffectiveMaxTokens();
|
||
await this.plugin.saveSettings();
|
||
|
||
})();
|
||
});
|
||
});
|
||
|
||
// Reserve percentage field
|
||
new Setting(customParamsContainer)
|
||
.setName('Reserve percentage')
|
||
.setDesc(`Safety reserve for output tokens (0.10 = 10%, default: ${provider === 'ollama' ? '15%' : '10%'})`)
|
||
.addText(text => {
|
||
reservePctField = text;
|
||
text.setValue(getCurrentCustomLimits().reservePct?.toString() || (provider === 'ollama' ? '0.15' : '0.10'))
|
||
.onChange((value: string) => {
|
||
void (async () => {
|
||
const numValue = parseFloat(value) || (provider === 'ollama' ? 0.15 : 0.10);
|
||
const model = this.plugin.settings.selectedModels[provider];
|
||
const customKey = `${provider}:${model}`;
|
||
|
||
if (!this.plugin.settings.customModelLimits[customKey]) {
|
||
this.plugin.settings.customModelLimits[customKey] = getCurrentCustomLimits();
|
||
}
|
||
|
||
this.plugin.settings.customModelLimits[customKey].reservePct = Math.max(0, Math.min(1, numValue));
|
||
await this.plugin.saveSettings();
|
||
|
||
// Update maxTokens setting
|
||
this.plugin.settings.maxTokens = this.plugin.getEffectiveMaxTokens();
|
||
await this.plugin.saveSettings();
|
||
|
||
})();
|
||
});
|
||
});
|
||
|
||
// The container is always visible. We update the heading text and
|
||
// sub-description based on whether 'custom' is the selected dropdown
|
||
// value, so the same panel signals "required input" for custom models
|
||
// and "optional override" for preset models.
|
||
const headingNameEl = customParamsHeading.settingEl.querySelector('.setting-item-name');
|
||
const updateCustomParamsLabel = (): void => {
|
||
const isCustomSelected = modelDropdown.getValue() === 'custom';
|
||
if (headingNameEl && headingNameEl.instanceOf(HTMLElement)) {
|
||
headingNameEl.setText(
|
||
isCustomSelected
|
||
? `Custom model parameters (${provider.toUpperCase()})`
|
||
: `Model parameters (${provider.toUpperCase()}) — override`,
|
||
);
|
||
}
|
||
subDescEl.setText(
|
||
isCustomSelected
|
||
? `Required: this model isn't in the registry — set context window, max output, and reserve % so token budgeting can size requests safely.`
|
||
: `Optional: registry defaults apply when these are blank. Set values here to override the registry for this provider+model.`,
|
||
);
|
||
};
|
||
|
||
// Repopulate the four input fields from the resolved limits for the
|
||
// currently-selected model. Called when the model changes (preset →
|
||
// preset, preset → custom, or after a refresh enriches the registry)
|
||
// so the user sees the model's actual capabilities, not the previous
|
||
// model's values stuck in the inputs.
|
||
const refreshFieldValues = (): void => {
|
||
const limits = getCurrentCustomLimits();
|
||
contextKField?.setValue(limits.contextK.toString());
|
||
maxOutputKField?.setValue(limits.maxOutputK.toString());
|
||
inputMaxKField?.setValue(limits.inputMaxK ? limits.inputMaxK.toString() : '');
|
||
reservePctField?.setValue(
|
||
limits.reservePct?.toString() ?? (provider === 'ollama' ? '0.15' : '0.10'),
|
||
);
|
||
};
|
||
|
||
// Combined refresh: re-apply both label and field values. Returned
|
||
// to the caller so the dropdown's onChange handler can invoke it
|
||
// synchronously (after writing selectedModels) — that's strictly
|
||
// ordered, unlike a separate DOM-event listener which runs in a
|
||
// race against Obsidian's async onChange wrapper.
|
||
const refreshAll = (): void => {
|
||
updateCustomParamsLabel();
|
||
refreshFieldValues();
|
||
};
|
||
|
||
// Custom-name field blur is a secondary path: typing into the
|
||
// custom field can switch the dropdown to 'custom' implicitly.
|
||
customField.inputEl?.addEventListener('blur', () => {
|
||
window.setTimeout(refreshAll, 100);
|
||
});
|
||
|
||
// Initial label + field check.
|
||
refreshAll();
|
||
|
||
return { refresh: refreshAll };
|
||
}
|
||
}
|
||
|
||
class TemplateFilePickerModal extends Modal {
|
||
private result: (path: string) => void;
|
||
private templates: { path: string }[] = [];
|
||
private templatesFolder: string = "Templates"; // Default fallback
|
||
|
||
constructor(app: App, callback: (path: string) => void) {
|
||
super(app);
|
||
this.result = callback;
|
||
|
||
// Try to get template folder from Templater plugin settings if available
|
||
const templaterSettings = getTemplaterSettings(this.app);
|
||
if (templaterSettings?.templates_folder) {
|
||
this.templatesFolder = normalizePath(templaterSettings.templates_folder);
|
||
}
|
||
|
||
logger.debug("Template picker initialized with templates folder:", this.templatesFolder);
|
||
}
|
||
|
||
onOpen() {
|
||
const { contentEl } = this;
|
||
|
||
contentEl.createEl('h2', { text: 'Select template file' });
|
||
|
||
// Collect template files by walking only the configured templates
|
||
// folder subtree — no whole-vault enumeration.
|
||
this.templates = collectUnder(this.app.vault, this.templatesFolder, 'markdown')
|
||
.map(file => ({ path: file.path }));
|
||
logger.debug(`Found ${this.templates.length} template files in "${this.templatesFolder}"`);
|
||
|
||
// Display a message if no template files were found
|
||
if (this.templates.length === 0) {
|
||
contentEl.createDiv({
|
||
text: `No template files found in "${this.templatesFolder}" folder. ` +
|
||
`Make sure your template folder is correctly configured in Templater settings.`,
|
||
cls: 'setting-item-description'
|
||
});
|
||
}
|
||
|
||
// Create search input
|
||
const searchEl = contentEl.createEl('input', {
|
||
type: 'text',
|
||
placeholder: 'Type to search templates...'
|
||
});
|
||
searchEl.addEventListener('input', () => {
|
||
const query = searchEl.value.toLowerCase();
|
||
this.updateTemplateList(query);
|
||
});
|
||
|
||
// Create template list container – reuse folder-list styling for consistency
|
||
const templateListEl = contentEl.createDiv({ cls: ['template-list', 'folder-list'] });
|
||
|
||
// Populate initial list
|
||
this.updateTemplateList('', templateListEl);
|
||
}
|
||
|
||
updateTemplateList(query: string, listEl?: HTMLElement) {
|
||
const templateListEl = listEl || activeDocument.querySelector('.template-list') as HTMLElement;
|
||
if (!templateListEl) return;
|
||
|
||
templateListEl.empty();
|
||
|
||
const filteredTemplates = this.templates.filter(t =>
|
||
t.path.toLowerCase().includes(query)
|
||
);
|
||
|
||
for (const template of filteredTemplates) {
|
||
const item = templateListEl.createDiv({ cls: ['template-item', 'folder-item'] });
|
||
|
||
// Reuse icon appearance
|
||
const iconEl = item.createSpan({ cls: 'folder-icon' });
|
||
iconEl.setText('📄');
|
||
|
||
// Path span – mimic folder picker
|
||
item.createSpan({ text: template.path, cls: 'folder-path' });
|
||
|
||
item.addEventListener('click', () => {
|
||
logger.debug("Selected template file:", template.path);
|
||
this.result(template.path);
|
||
this.close();
|
||
});
|
||
}
|
||
|
||
if (filteredTemplates.length === 0) {
|
||
templateListEl.createDiv({ text: 'No matching templates found.' });
|
||
}
|
||
}
|
||
|
||
onClose() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
}
|
||
}
|
||
|
||
// Simple folder picker modal using Obsidian's Modal class
|
||
class FolderPickerModal extends Modal {
|
||
private onSelectFolder: (folderPath: string) => void;
|
||
private folders: FolderItem[] = [];
|
||
private plugin: YouTubeTranscriptPlugin;
|
||
|
||
constructor(app: App, plugin: YouTubeTranscriptPlugin, onSelectFolder: (folderPath: string) => void) {
|
||
super(app);
|
||
this.onSelectFolder = onSelectFolder;
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
onOpen() {
|
||
// Run async setup without returning a promise to Modal
|
||
void (async () => {
|
||
await this.loadFolders();
|
||
this.renderFolderPicker();
|
||
})();
|
||
}
|
||
|
||
private async loadFolders() {
|
||
try {
|
||
// Reset folders array
|
||
this.folders = [];
|
||
|
||
// Track unique paths to prevent duplicates
|
||
const uniquePaths = new Set<string>();
|
||
|
||
// Get the root folder from settings
|
||
const rootFolder = this.plugin.settings.transcriptRootFolder || 'Inbox';
|
||
const normalizedRootFolder = normalizePath(rootFolder, false); // Keep leading slash
|
||
|
||
// Ensure root folder exists
|
||
try {
|
||
// Create root folder from settings if it doesn't exist
|
||
const rootFolderPath = normalizePath(rootFolder);
|
||
if (!this.app.vault.getAbstractFileByPath(rootFolderPath)) {
|
||
await ensureFolder(this.app.vault, rootFolderPath);
|
||
logger.debug(`[DEBUG] Created ${rootFolderPath} folder`);
|
||
}
|
||
} catch (e) {
|
||
logger.error('Error ensuring root folder exists:', e);
|
||
}
|
||
|
||
// Add root folder first — explicit, so the picker always has it
|
||
// even if the subtree walk below returns nothing.
|
||
this.folders.push({
|
||
path: normalizedRootFolder,
|
||
name: rootFolder
|
||
});
|
||
uniquePaths.add(normalizedRootFolder);
|
||
|
||
// Collection of folders for summarized logging
|
||
const foundFolders: string[] = [];
|
||
|
||
// Walk only the configured root-folder subtree — no whole-vault
|
||
// enumeration. transcriptRootFolder is a free-text setting, so
|
||
// resolve it to the canonical (no leading/trailing slash) form that
|
||
// getAbstractFileByPath expects. collectUnder includes the root
|
||
// folder itself, which is skipped here since it was already added.
|
||
const canonicalRoot = normalizePath(rootFolder);
|
||
for (const folder of collectUnder(this.app.vault, canonicalRoot, 'folder')) {
|
||
const path = folder.path;
|
||
if (path === canonicalRoot) continue;
|
||
const normalizedPath = normalizePath(path, false); // Keep leading slash for display
|
||
if (!uniquePaths.has(normalizedPath)) {
|
||
this.folders.push({
|
||
path: normalizedPath,
|
||
name: path
|
||
});
|
||
uniquePaths.add(normalizedPath);
|
||
foundFolders.push(path);
|
||
}
|
||
}
|
||
|
||
// Sort folders by path for hierarchical order
|
||
this.folders.sort((a, b) => {
|
||
if (a.path === normalizedRootFolder) return -1;
|
||
if (b.path === normalizedRootFolder) return 1;
|
||
return a.path.localeCompare(b.path);
|
||
});
|
||
|
||
// Log a summary of folders found - only show first MAX_FOLDERS_TO_LOG folders
|
||
const MAX_FOLDERS_TO_LOG = 5;
|
||
if (foundFolders.length > 0) {
|
||
if (foundFolders.length <= MAX_FOLDERS_TO_LOG) {
|
||
logger.debug(`[DEBUG] Found folders (${foundFolders.length} total): ${foundFolders.join(', ')}`);
|
||
} else {
|
||
const shownFolders = foundFolders.slice(0, MAX_FOLDERS_TO_LOG);
|
||
logger.debug(`[DEBUG] Found folders (${foundFolders.length} total): ${shownFolders.join(', ')}... and ${foundFolders.length - MAX_FOLDERS_TO_LOG} more`);
|
||
}
|
||
}
|
||
|
||
logger.debug(`[DEBUG] Found ${this.folders.length} folders total, ${this.folders.length - 1} subfolders`);
|
||
} catch (err) {
|
||
logger.error('Error loading folders:', err);
|
||
}
|
||
}
|
||
|
||
private renderFolderPicker() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
|
||
// Get the root folder name for display
|
||
const rootFolder = this.plugin.settings.transcriptRootFolder || 'Inbox';
|
||
const normalizedRootFolder = normalizePath(rootFolder, false); // Keep leading slash for display
|
||
|
||
// Simple title only
|
||
contentEl.createEl('h2', { text: 'Select folder location' });
|
||
|
||
// Debug info about folder count
|
||
const rootSubfolderCount = this.folders.length - 1; // Subtract root folder itself
|
||
if (rootSubfolderCount <= 0) {
|
||
contentEl.createDiv({
|
||
text: `No subfolders found under ${rootFolder}`,
|
||
cls: 'tubesage-folder-picker-status-error' // Apply new class
|
||
});
|
||
} else {
|
||
contentEl.createDiv({
|
||
text: `Found ${rootSubfolderCount} subfolder${rootSubfolderCount === 1 ? '' : 's'}`,
|
||
cls: 'tubesage-folder-picker-status-info' // Apply new class
|
||
});
|
||
}
|
||
|
||
// Create search input
|
||
const searchEl = contentEl.createEl('input', {
|
||
type: 'text',
|
||
placeholder: 'Filter folders...',
|
||
cls: 'folder-search-input'
|
||
});
|
||
searchEl.focus();
|
||
|
||
// Create folder list container
|
||
const folderListEl = contentEl.createDiv({ cls: 'folder-list' });
|
||
|
||
// Track the currently selected folder item
|
||
let selectedItem: HTMLElement | null = null;
|
||
|
||
// Function to select a folder item
|
||
const selectFolderItem = (item: HTMLElement) => {
|
||
if (selectedItem) {
|
||
selectedItem.classList.remove('selected');
|
||
}
|
||
item.classList.add('selected');
|
||
selectedItem = item;
|
||
};
|
||
|
||
// Function to render folders with filter
|
||
const renderFolders = (filter: string = '') => {
|
||
folderListEl.empty();
|
||
|
||
// Use native JS filter to work directly with folder objects
|
||
const lowerFilter = filter.toLowerCase().trim();
|
||
|
||
// Filter if needed, otherwise show all
|
||
const foldersToShow = !lowerFilter ? this.folders :
|
||
this.folders.filter(f => f.path.toLowerCase().includes(lowerFilter));
|
||
|
||
logger.debug(`Displaying ${foldersToShow.length} folders`);
|
||
|
||
if (foldersToShow.length === 0) {
|
||
folderListEl.createDiv({
|
||
text: 'No matching folders',
|
||
cls: 'empty-state'
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Create HTML elements for each folder
|
||
foldersToShow.forEach((folder, index) => {
|
||
const folderEl = folderListEl.createDiv({
|
||
cls: 'folder-item',
|
||
attr: {
|
||
'data-path': folder.path,
|
||
'tabindex': '0'
|
||
}
|
||
});
|
||
|
||
// Auto-select the first item
|
||
if (index === 0) {
|
||
selectFolderItem(folderEl);
|
||
}
|
||
|
||
// Add folder icon
|
||
const iconEl = folderEl.createSpan({ cls: 'folder-icon' });
|
||
iconEl.setText('📁');
|
||
|
||
// For the root folder
|
||
if (folder.path === normalizedRootFolder) {
|
||
folderEl.createSpan({
|
||
text: rootFolder,
|
||
cls: 'folder-path'
|
||
});
|
||
}
|
||
// For subfolders under root folder
|
||
else {
|
||
// Get the full path without the leading slash using our utility
|
||
const displayPath = normalizePath(folder.path);
|
||
|
||
// Create the display span with the full path
|
||
const textSpan = folderEl.createSpan({
|
||
cls: 'folder-path'
|
||
});
|
||
|
||
// Set the text with the full path
|
||
textSpan.textContent = displayPath;
|
||
}
|
||
|
||
// Add click handler
|
||
folderEl.addEventListener('click', () => {
|
||
selectFolderItem(folderEl);
|
||
this.selectFolder(folder.path);
|
||
});
|
||
});
|
||
};
|
||
|
||
// Initial render
|
||
renderFolders('');
|
||
|
||
// Filter as user types
|
||
searchEl.addEventListener('input', () => {
|
||
renderFolders(searchEl.value);
|
||
});
|
||
|
||
// Handle keyboard navigation
|
||
searchEl.addEventListener('keydown', (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
this.close();
|
||
} else if (e.key === 'Enter') {
|
||
if (selectedItem) {
|
||
selectedItem.click();
|
||
} else {
|
||
const firstFolder = folderListEl.querySelector('.folder-item');
|
||
if (firstFolder) {
|
||
(firstFolder as HTMLElement).click();
|
||
} else {
|
||
// Close the modal if no folders are found
|
||
this.close();
|
||
}
|
||
}
|
||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
|
||
const items = Array.from(folderListEl.querySelectorAll('.folder-item'));
|
||
if (items.length === 0) return;
|
||
|
||
const curIndex = selectedItem ? items.indexOf(selectedItem) : -1;
|
||
|
||
let newIndex;
|
||
if (e.key === 'ArrowDown') {
|
||
newIndex = curIndex < items.length - 1 ? curIndex + 1 : 0;
|
||
} else {
|
||
newIndex = curIndex > 0 ? curIndex - 1 : items.length - 1;
|
||
}
|
||
|
||
selectFolderItem(items[newIndex] as HTMLElement);
|
||
items[newIndex].scrollIntoView({ block: 'nearest' });
|
||
}
|
||
});
|
||
}
|
||
|
||
private selectFolder(folderPath: string) {
|
||
// Normalize the folder path - store WITHOUT leading slash for consistency
|
||
const normalizedPath = normalizePath(folderPath);
|
||
logger.debug("[DEBUG] Selected folder path:", folderPath);
|
||
logger.debug("[DEBUG] Normalized folder path:", normalizedPath);
|
||
this.onSelectFolder(normalizedPath);
|
||
this.close();
|
||
}
|
||
}
|
||
|
||
// Add the LicenseModal class
|
||
class LicenseModal extends Modal {
|
||
constructor(app: App) {
|
||
super(app);
|
||
}
|
||
|
||
onOpen() {
|
||
const { contentEl } = this;
|
||
|
||
// Add CSS class for proper styling
|
||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||
modalEl.addClass('tubesage-license-modal-size');
|
||
}
|
||
|
||
contentEl.createEl('h2', { text: 'Tubesage YouTube transcript plugin license' });
|
||
|
||
// Run async work without returning a promise to Modal
|
||
void (async () => {
|
||
try {
|
||
// Get the plugin folder path
|
||
const pluginId = getPluginIdFromManifest(this.app, 'tubesage');
|
||
|
||
// Try to read the license file - from multiple possible locations
|
||
let licenseContent = '';
|
||
let licenseFound = false;
|
||
|
||
// List of possible file paths to try - use platform-independent paths with forward slashes
|
||
const possiblePaths = [
|
||
// Plugin directory paths
|
||
`${this.app.vault.configDir}/plugins/${pluginId}/MIT-license-tubesage.md`,
|
||
`${this.app.vault.configDir}/plugins/${pluginId}/LICENSE.md`,
|
||
`${this.app.vault.configDir}/plugins/${pluginId}/license.md`,
|
||
|
||
// Root directory paths
|
||
`MIT-license-tubesage.md`,
|
||
`LICENSE.md`,
|
||
`license.md`
|
||
];
|
||
|
||
// Try each path in sequence
|
||
for (const filePath of possiblePaths) {
|
||
try {
|
||
// Always normalize path before reading to ensure consistent slashes
|
||
const normalizedPath = normalizePath(filePath);
|
||
logger.debug(`Trying to find license file at: ${normalizedPath}`);
|
||
licenseContent = await this.app.vault.adapter.read(normalizedPath);
|
||
logger.debug(`License file found at: ${normalizedPath}`);
|
||
licenseFound = true;
|
||
break;
|
||
} catch (e) {
|
||
logger.debug(`Failed to read license file at ${filePath}:`, e);
|
||
// Continue to next path
|
||
}
|
||
}
|
||
|
||
if (!licenseFound) {
|
||
throw new Error('Could not find license file in any of the expected locations.');
|
||
}
|
||
|
||
// Create a div for the license content with scrollable style
|
||
const licenseContainer = contentEl.createDiv({
|
||
cls: 'tubesage-license-container'
|
||
});
|
||
|
||
// Process the license markdown content
|
||
const lines = licenseContent.split('\n');
|
||
let inList = false;
|
||
|
||
for (const line of lines) {
|
||
// Handle headers
|
||
if (line.startsWith('# ')) {
|
||
inList = false;
|
||
licenseContainer.createEl('h3', { text: line.substring(2), cls: 'tubesage-license-h3' });
|
||
}
|
||
// Handle list items
|
||
else if (line.match(/^\d+\.\s+\*\*.*\*\*/)) {
|
||
inList = true;
|
||
const listItem = licenseContainer.createDiv({ cls: 'tubesage-license-list-item' });
|
||
|
||
// Extract and format the list item
|
||
const match = line.match(/^(\d+)\.\s+\*\*(.*?)\*\*:\s+(.*)/);
|
||
if (match) {
|
||
const [, number, title, content] = match;
|
||
|
||
listItem.createSpan({ text: `${number}. `, cls: 'tubesage-license-list-item-title-segment' });
|
||
|
||
listItem.createSpan({ text: `${title}: `, cls: 'tubesage-license-list-item-title-segment' });
|
||
|
||
listItem.createSpan({ text: content });
|
||
} else {
|
||
listItem.setText(line);
|
||
}
|
||
}
|
||
// Handle list sub-items
|
||
else if (inList && line.match(/^\s+-\s+/)) {
|
||
const subItem = licenseContainer.createDiv({ cls: 'tubesage-license-sub-item' });
|
||
subItem.setText(line.replace(/^\s+-\s+/, '• '));
|
||
}
|
||
// Handle normal paragraphs
|
||
else if (line.trim() !== '') {
|
||
inList = false;
|
||
licenseContainer.createEl('p', { text: line, cls: 'tubesage-license-paragraph' });
|
||
}
|
||
// Handle empty lines
|
||
else {
|
||
licenseContainer.createDiv({ cls: 'tubesage-license-spacer' });
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Handle error if license file can't be read
|
||
logger.error('Error loading license file:', error);
|
||
contentEl.createEl('p', {
|
||
text: 'Could not load license file. Please check that a license file exists in your plugin directory.',
|
||
cls: 'tubesage-license-load-error' // Apply new class
|
||
});
|
||
}
|
||
|
||
// Add close button
|
||
const footerEl = contentEl.createDiv({
|
||
cls: 'tubesage-license-footer' // Apply new class
|
||
});
|
||
|
||
const closeButton = new ButtonComponent(footerEl);
|
||
closeButton.setButtonText('Close');
|
||
closeButton.buttonEl.addClass('tubesage-license-close-button');
|
||
closeButton.onClick(() => {
|
||
this.close();
|
||
});
|
||
})();
|
||
}
|
||
|
||
onClose() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
}
|
||
}
|
||
|
||
// Add the LicenseRequiredModal class
|
||
class LicenseRequiredModal extends Modal {
|
||
constructor(app: App) {
|
||
super(app);
|
||
}
|
||
|
||
onOpen() {
|
||
const { contentEl } = this;
|
||
|
||
// Add CSS class for proper styling
|
||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||
modalEl.addClass('tubesage-license-required-modal-size');
|
||
}
|
||
|
||
// Add title
|
||
contentEl.createEl('h2', {
|
||
text: 'License acceptance required',
|
||
cls: 'tubesage-license-required-title' // Apply new class
|
||
});
|
||
|
||
// Add warning icon
|
||
const iconContainer = contentEl.createDiv({
|
||
cls: 'tubesage-license-required-icon-container' // Apply new class
|
||
});
|
||
|
||
iconContainer.createSpan({
|
||
cls: 'tubesage-license-required-icon', // Apply new class
|
||
attr: { 'aria-hidden': 'true' },
|
||
text: '⚠️'
|
||
});
|
||
|
||
// Add message
|
||
const messageDiv = contentEl.createDiv({
|
||
cls: 'tubesage-license-required-message-container' // Apply new class
|
||
});
|
||
|
||
messageDiv.createEl('p', {
|
||
text: 'You must accept the plugin license before using this feature.',
|
||
cls: 'tubesage-license-required-message-bold' // Apply new class
|
||
});
|
||
|
||
messageDiv.createEl('p', {
|
||
text: 'Please go to the plugin settings and accept the license terms to continue.'
|
||
});
|
||
|
||
// Add instructions with steps
|
||
const stepsDiv = contentEl.createDiv({
|
||
cls: 'tubesage-license-required-steps-container' // Apply new class
|
||
});
|
||
|
||
stepsDiv.createEl('p', {
|
||
text: 'How to accept the license:',
|
||
cls: 'tubesage-license-required-steps-title' // Apply new class
|
||
});
|
||
|
||
const steps = [
|
||
'Open Obsidian Settings',
|
||
'Scroll down to the "Community Plugins" section in the sidebar',
|
||
'Find "Tubesage" in the Community Plugins list',
|
||
'Click the "Tubesage" plugin settings',
|
||
'Toggle "Accept License" to enable the plugin'
|
||
];
|
||
|
||
const stepsList = stepsDiv.createEl('ol', {
|
||
cls: 'tubesage-license-required-steps-list' // Apply new class
|
||
});
|
||
|
||
steps.forEach(step => {
|
||
stepsList.createEl('li', {
|
||
text: step,
|
||
cls: 'tubesage-license-required-step-item' // Apply new class
|
||
});
|
||
});
|
||
|
||
// Add buttons
|
||
const buttonContainer = contentEl.createDiv({
|
||
cls: 'tubesage-license-required-button-container' // Apply new class
|
||
});
|
||
|
||
// Open settings button
|
||
const openSettingsButton = new ButtonComponent(buttonContainer);
|
||
openSettingsButton.setButtonText('Open plugin settings');
|
||
openSettingsButton.buttonEl.addClass('tubesage-license-required-button-primary');
|
||
openSettingsButton.onClick(() => {
|
||
this.close();
|
||
const appWithSettings = this.app as App & { setting?: { open?: (id: string) => void } };
|
||
appWithSettings.setting?.open?.('tubesage');
|
||
});
|
||
|
||
// Close button
|
||
const closeButton = new ButtonComponent(buttonContainer);
|
||
closeButton.setButtonText('Close');
|
||
closeButton.buttonEl.addClass('tubesage-license-required-button-secondary');
|
||
closeButton.onClick(() => {
|
||
this.close();
|
||
});
|
||
}
|
||
|
||
onClose() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
}
|
||
}
|
||
|
||
// Add the READMEModal class
|
||
class READMEModal extends Modal {
|
||
constructor(app: App) {
|
||
super(app);
|
||
}
|
||
|
||
onOpen() {
|
||
const { contentEl } = this;
|
||
|
||
// Add CSS class for proper styling
|
||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||
modalEl.addClass('tubesage-readme-modal-size');
|
||
}
|
||
|
||
contentEl.createEl('h2', { text: 'Tubesage YouTube transcript plugin documentation' });
|
||
|
||
// Run async work without returning a promise to Modal
|
||
void (async () => {
|
||
try {
|
||
// Get the plugin folder path
|
||
const pluginId = getPluginIdFromManifest(this.app, 'tubesage');
|
||
|
||
// Try to read the README file from multiple possible locations
|
||
let readmeContent = '';
|
||
let readmeFound = false;
|
||
|
||
// List of possible file paths to try - use platform-independent paths with forward slashes
|
||
const possiblePaths = [
|
||
// Plugin directory paths
|
||
`${this.app.vault.configDir}/plugins/${pluginId}/README.md`,
|
||
`${this.app.vault.configDir}/plugins/${pluginId}/readme.md`,
|
||
|
||
// Root directory paths
|
||
`README.md`,
|
||
`readme.md`
|
||
];
|
||
|
||
// Try each path in sequence
|
||
for (const filePath of possiblePaths) {
|
||
try {
|
||
// Always normalize path before reading to ensure consistent slashes
|
||
const normalizedPath = normalizePath(filePath);
|
||
logger.debug(`Trying to find README file at: ${normalizedPath}`);
|
||
readmeContent = await this.app.vault.adapter.read(normalizedPath);
|
||
logger.debug(`README file found at: ${normalizedPath}`);
|
||
readmeFound = true;
|
||
break;
|
||
} catch (e) {
|
||
logger.debug(`Failed to read README file at ${filePath}:`, e);
|
||
// Continue to next path
|
||
}
|
||
}
|
||
|
||
if (!readmeFound) {
|
||
throw new Error('Could not find readme file in any of the expected locations.');
|
||
}
|
||
|
||
// Create a div for the README content with scrollable style
|
||
const readmeContainer = contentEl.createDiv({
|
||
cls: 'tubesage-readme-container'
|
||
});
|
||
|
||
// Process the README markdown content
|
||
const lines = readmeContent.split('\n');
|
||
let inCodeBlock = false;
|
||
let codeLanguage = '';
|
||
|
||
for (const line of lines) {
|
||
// Handle code blocks
|
||
if (line.startsWith('```')) {
|
||
if (!inCodeBlock) {
|
||
// Start of code block
|
||
inCodeBlock = true;
|
||
codeLanguage = line.substring(3).trim();
|
||
|
||
// Create code block container
|
||
const codeContainer = readmeContainer.createDiv({
|
||
cls: 'code-block-container' // Style now in CSS
|
||
});
|
||
|
||
// Add language tag if specified
|
||
if (codeLanguage) {
|
||
codeContainer.createDiv({
|
||
text: codeLanguage,
|
||
cls: 'tubesage-readme-code-lang' // Apply new class
|
||
});
|
||
}
|
||
|
||
// Create pre>code element for the code
|
||
const pre = codeContainer.createEl('pre', {
|
||
cls: 'tubesage-readme-code-pre' // Apply new class
|
||
});
|
||
pre.createEl('code', {
|
||
cls: `tubesage-readme-code-inline ${codeLanguage ? 'language-' + codeLanguage : ''}`.trim() // Apply new class and existing language class
|
||
});
|
||
} else {
|
||
// End of code block
|
||
inCodeBlock = false;
|
||
codeLanguage = '';
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Add lines to code block
|
||
if (inCodeBlock) {
|
||
const codeContainer = readmeContainer.querySelector('.code-block-container:last-child');
|
||
if (codeContainer) {
|
||
const code = codeContainer.querySelector('code');
|
||
if (code) {
|
||
const textNode = activeDocument.createTextNode(line + '\n');
|
||
code.appendChild(textNode);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Handle headers
|
||
if (line.startsWith('# ')) {
|
||
readmeContainer.createEl('h1', { text: line.substring(2), cls: 'tubesage-readme-h1' });
|
||
} else if (line.startsWith('## ')) {
|
||
readmeContainer.createEl('h2', { text: line.substring(3), cls: 'tubesage-readme-h2' });
|
||
} else if (line.startsWith('### ')) {
|
||
readmeContainer.createEl('h3', { text: line.substring(4), cls: 'tubesage-readme-h3' });
|
||
} else if (line.startsWith('#### ')) {
|
||
readmeContainer.createEl('h4', { text: line.substring(5), cls: 'tubesage-readme-h4' });
|
||
}
|
||
// Handle list items
|
||
else if (line.match(/^[*+-]\s/)) {
|
||
const listItem = readmeContainer.createDiv({ cls: 'tubesage-readme-list-item' });
|
||
|
||
// Bullet
|
||
listItem.createSpan({ text: '• ', cls: 'tubesage-readme-list-bullet' });
|
||
|
||
// Content
|
||
const content = line.replace(/^[*+-]\s/, '');
|
||
if (content.includes('[') && content.includes('](')) {
|
||
// Handle links in list items
|
||
const parts = this.splitMarkdownLink(content);
|
||
const contentSpan = listItem.createSpan();
|
||
|
||
parts.forEach(part => {
|
||
if (part.isLink) {
|
||
contentSpan.createEl('a', {
|
||
text: part.text,
|
||
attr: {
|
||
href: part.url || '#',
|
||
cls: 'tubesage-readme-link' // Apply new class
|
||
}
|
||
});
|
||
} else {
|
||
// Process bold text in list items
|
||
this.renderTextWithBold(contentSpan, part.text);
|
||
}
|
||
});
|
||
} else {
|
||
// Process bold text in list items
|
||
this.renderTextWithBold(listItem, content);
|
||
}
|
||
}
|
||
// Handle normal paragraphs
|
||
else if (line.trim() !== '') {
|
||
const para = readmeContainer.createEl('p', { cls: 'tubesage-readme-paragraph' });
|
||
|
||
// Check for links
|
||
if (line.includes('[') && line.includes('](')) {
|
||
const parts = this.splitMarkdownLink(line);
|
||
|
||
parts.forEach(part => {
|
||
if (part.isLink) {
|
||
para.createEl('a', {
|
||
text: part.text,
|
||
attr: {
|
||
href: part.url || '#',
|
||
cls: 'tubesage-readme-link' // Apply new class
|
||
}
|
||
});
|
||
} else {
|
||
// Process bold text in paragraphs
|
||
this.renderTextWithBold(para, part.text);
|
||
}
|
||
});
|
||
} else {
|
||
// Process bold text in paragraphs
|
||
this.renderTextWithBold(para, line);
|
||
}
|
||
}
|
||
// Handle empty lines with more spacing between sections
|
||
else {
|
||
readmeContainer.createDiv({ cls: 'tubesage-readme-spacer' });
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Handle error if README file can't be read
|
||
logger.error('Error loading README file:', error);
|
||
contentEl.createEl('p', {
|
||
text: 'Could not load readme file. Please check that readme.md exists in your plugin directory.',
|
||
cls: 'tubesage-license-load-error' // Assumes this class is defined and appropriate
|
||
});
|
||
}
|
||
|
||
// Add close button
|
||
const footerEl = contentEl.createDiv({
|
||
cls: 'tubesage-license-footer' // Reuse existing class
|
||
});
|
||
|
||
const closeButton = new ButtonComponent(footerEl);
|
||
closeButton.setButtonText('Close');
|
||
closeButton.buttonEl.addClass('tubesage-readme-close-button');
|
||
closeButton.onClick(() => {
|
||
this.close();
|
||
});
|
||
})();
|
||
}
|
||
|
||
// Helper to handle markdown links
|
||
private splitMarkdownLink(text: string): Array<{text: string, url?: string, isLink: boolean}> {
|
||
const parts: Array<{text: string, url?: string, isLink: boolean}> = [];
|
||
let remaining = text;
|
||
let currentIndex = 0;
|
||
|
||
// First check for image links inside regular links - pattern: [](link-url)
|
||
const imageInsideLinkRegex = /\[!\[(.*?)\]\((.*?)\)\]\((.*?)\)/g;
|
||
let match;
|
||
|
||
// Process all image+link combinations first
|
||
while ((match = imageInsideLinkRegex.exec(remaining)) !== null) {
|
||
// Add text before the image+link
|
||
if (match.index > currentIndex) {
|
||
parts.push({
|
||
text: remaining.substring(currentIndex, match.index),
|
||
isLink: false
|
||
});
|
||
}
|
||
|
||
// Extract the link components: alt text, image URL, and target URL
|
||
const [fullMatch, altText, , targetUrl] = match;
|
||
|
||
// Add as a link (we'll ignore the image and just use the alt text)
|
||
parts.push({
|
||
text: altText || "Link",
|
||
url: targetUrl,
|
||
isLink: true
|
||
});
|
||
|
||
// Update the current index
|
||
currentIndex = match.index + fullMatch.length;
|
||
}
|
||
|
||
// If we processed any image+link combinations, update remaining text
|
||
if (currentIndex > 0) {
|
||
remaining = remaining.substring(currentIndex);
|
||
currentIndex = 0;
|
||
}
|
||
|
||
// Now process regular links as before
|
||
while (currentIndex < remaining.length) {
|
||
// Skip image links - they're not clickable by themselves
|
||
const imageMatch = remaining.indexOf('![', currentIndex);
|
||
if (imageMatch !== -1 && (imageMatch === currentIndex || remaining.charAt(imageMatch-1) !== '\\')) {
|
||
// Find the closing parenthesis for the image
|
||
const closingParen = remaining.indexOf(')', imageMatch);
|
||
if (closingParen !== -1) {
|
||
// Add text before the image
|
||
if (imageMatch > currentIndex) {
|
||
parts.push({
|
||
text: remaining.substring(currentIndex, imageMatch),
|
||
isLink: false
|
||
});
|
||
}
|
||
|
||
// Skip the image and continue parsing after it
|
||
currentIndex = closingParen + 1;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Find opening bracket for regular links
|
||
const openBracketIndex = remaining.indexOf('[', currentIndex);
|
||
|
||
// No more links, add the rest as plain text
|
||
if (openBracketIndex === -1) {
|
||
parts.push({
|
||
text: remaining.substring(currentIndex),
|
||
isLink: false
|
||
});
|
||
break;
|
||
}
|
||
|
||
// Make sure this is not an escaped bracket or part of an image
|
||
const isEscaped = openBracketIndex > 0 && remaining.charAt(openBracketIndex - 1) === '\\';
|
||
const isImage = openBracketIndex > 0 && remaining.charAt(openBracketIndex - 1) === '!';
|
||
|
||
if (isEscaped || isImage) {
|
||
// Add text up to and including this bracket, then continue
|
||
parts.push({
|
||
text: remaining.substring(currentIndex, openBracketIndex + 1),
|
||
isLink: false
|
||
});
|
||
currentIndex = openBracketIndex + 1;
|
||
continue;
|
||
}
|
||
|
||
// Add text before the link
|
||
if (openBracketIndex > currentIndex) {
|
||
parts.push({
|
||
text: remaining.substring(currentIndex, openBracketIndex),
|
||
isLink: false
|
||
});
|
||
}
|
||
|
||
// Find closing bracket and opening parenthesis
|
||
const closeBracketIndex = remaining.indexOf(']', openBracketIndex);
|
||
if (closeBracketIndex === -1) {
|
||
// Malformed link, treat as text
|
||
parts.push({
|
||
text: remaining.substring(openBracketIndex),
|
||
isLink: false
|
||
});
|
||
break;
|
||
}
|
||
|
||
// Check for opening parenthesis immediately after closing bracket
|
||
if (remaining.charAt(closeBracketIndex + 1) !== '(') {
|
||
// Not a link, just brackets
|
||
parts.push({
|
||
text: remaining.substring(openBracketIndex, closeBracketIndex + 1),
|
||
isLink: false
|
||
});
|
||
currentIndex = closeBracketIndex + 1;
|
||
continue;
|
||
}
|
||
|
||
// Find closing parenthesis
|
||
const closeParenIndex = remaining.indexOf(')', closeBracketIndex);
|
||
if (closeParenIndex === -1) {
|
||
// Malformed link, treat as text
|
||
parts.push({
|
||
text: remaining.substring(openBracketIndex),
|
||
isLink: false
|
||
});
|
||
break;
|
||
}
|
||
|
||
// Extract link text and URL
|
||
const linkText = remaining.substring(openBracketIndex + 1, closeBracketIndex);
|
||
const linkUrl = remaining.substring(closeBracketIndex + 2, closeParenIndex);
|
||
|
||
parts.push({
|
||
text: linkText,
|
||
url: linkUrl,
|
||
isLink: true
|
||
});
|
||
|
||
currentIndex = closeParenIndex + 1;
|
||
}
|
||
|
||
return parts;
|
||
}
|
||
|
||
// Helper to render text with bold formatting
|
||
private renderTextWithBold(container: HTMLElement, text: string): void {
|
||
// Split by bold markers
|
||
const parts = text.split(/(\*\*.*?\*\*)/g);
|
||
|
||
for (const part of parts) {
|
||
// Check if this part is bold (surrounded by **)
|
||
if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
|
||
// Extract the text between ** markers and create a bold element
|
||
const boldText = part.substring(2, part.length - 2);
|
||
container.createEl('strong', { text: boldText });
|
||
} else if (part.trim() !== '') {
|
||
// Regular text
|
||
container.createSpan({ text: part });
|
||
}
|
||
}
|
||
}
|
||
|
||
onClose() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
}
|
||
}
|
||
|
||
// Add the TemplateViewModal class
|
||
class TemplateViewModal extends Modal {
|
||
constructor(app: App) {
|
||
super(app);
|
||
}
|
||
|
||
onOpen() {
|
||
const { contentEl } = this;
|
||
|
||
// Add CSS class for proper styling
|
||
const modalEl = (this as unknown as { modalEl?: HTMLElement }).modalEl;
|
||
if (modalEl && modalEl.instanceOf(HTMLElement)) {
|
||
modalEl.addClass('tubesage-template-view-modal-size');
|
||
}
|
||
|
||
contentEl.createEl('h2', {
|
||
text: 'Example template: copy and place in your templater plugin specified template directory',
|
||
cls: 'tubesage-template-view-title'
|
||
});
|
||
|
||
// Run async work without returning a promise to Modal
|
||
void (async () => {
|
||
try {
|
||
// Get the plugin folder path
|
||
const pluginId = getPluginIdFromManifest(this.app, 'tubesage');
|
||
|
||
// Try to read the template file from multiple possible locations
|
||
let templateContent = '';
|
||
let templateFound = false;
|
||
|
||
// List of possible file paths to try
|
||
const possiblePaths = [
|
||
// Plugin directory paths
|
||
normalizePath(`${this.app.vault.configDir}/plugins/${pluginId}/templates/YouTubeTranscript.md`),
|
||
normalizePath(`${this.app.vault.configDir}/plugins/${pluginId}/templates/youtubeTranscript.md`),
|
||
|
||
// Standard templates directory paths
|
||
normalizePath('templates/YouTubeTranscript.md'),
|
||
normalizePath('templates/youtubeTranscript.md'),
|
||
normalizePath('Templates/YouTubeTranscript.md'),
|
||
normalizePath('Templates/youtubeTranscript.md')
|
||
];
|
||
|
||
// Try each path in sequence
|
||
for (const filePath of possiblePaths) {
|
||
try {
|
||
logger.debug(`Trying to find template file at: ${filePath}`);
|
||
templateContent = await this.app.vault.adapter.read(filePath);
|
||
logger.debug(`Template file found at: ${filePath}`);
|
||
templateFound = true;
|
||
break;
|
||
} catch (error) {
|
||
logger.debug(`Failed to read template file at ${filePath}:`, error);
|
||
// Continue to next path
|
||
}
|
||
}
|
||
|
||
if (!templateFound) {
|
||
throw new Error('Could not find template file in any of the expected locations.');
|
||
}
|
||
|
||
// Create a div for the template content with scrollable style
|
||
const templateContainer = contentEl.createDiv({
|
||
cls: ['tubesage-template-view-container', 'tubesage-template-view-container-short']
|
||
});
|
||
|
||
// Add a subtle separator line for spacing
|
||
contentEl.createDiv({
|
||
cls: 'tubesage-divider'
|
||
});
|
||
|
||
// Create a container for the copy button
|
||
const copyContainer = contentEl.createDiv({
|
||
cls: ['tubesage-template-view-copy-container', 'tubesage-row-end']
|
||
});
|
||
|
||
// Add copy text
|
||
copyContainer.createSpan({
|
||
text: 'Copy template',
|
||
cls: 'tubesage-template-view-copy-text'
|
||
});
|
||
|
||
// Make the text also clickable
|
||
const copyTextElement = copyContainer.querySelector('span');
|
||
|
||
// Function to handle copy
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(templateContent)
|
||
.then(() => {
|
||
// Show success state
|
||
if (copyTextElement) {
|
||
const originalText = copyTextElement.textContent;
|
||
copyTextElement.textContent = 'Copied';
|
||
window.setTimeout(() => {
|
||
copyTextElement.textContent = originalText;
|
||
}, 2000);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
logger.error('Failed to copy template:', err);
|
||
// Show error state
|
||
if (copyTextElement) {
|
||
copyTextElement.textContent = 'Failed to copy';
|
||
window.setTimeout(() => {
|
||
copyTextElement.textContent = 'Copy template';
|
||
}, 2000);
|
||
}
|
||
});
|
||
};
|
||
|
||
// Copy icon button
|
||
new ExtraButtonComponent(copyContainer)
|
||
.setIcon('copy')
|
||
.setTooltip('Copy template')
|
||
.onClick(handleCopy);
|
||
|
||
if (copyTextElement) {
|
||
copyTextElement.addEventListener('click', handleCopy);
|
||
}
|
||
|
||
// Display the content with syntax highlighting
|
||
templateContainer.createEl('pre', {
|
||
cls: 'language-markdown',
|
||
text: templateContent
|
||
});
|
||
|
||
// Add explanation
|
||
contentEl.createDiv({
|
||
text: 'This is the example templater plugin template used for YouTube transcript notes. You can customize this template for your own needs.',
|
||
cls: 'tubesage-template-view-explanation'
|
||
});
|
||
|
||
// Add Templater variables explanation
|
||
const variablesContainer = contentEl.createDiv({
|
||
cls: 'tubesage-template-view-variables-container'
|
||
});
|
||
|
||
variablesContainer.createEl('h3', {text: 'Available template variables:'});
|
||
|
||
const variables = [
|
||
{name: 'tp.user.title', desc: 'The title of the Youtube video'},
|
||
{name: 'tp.user.videoUrl', desc: 'The URL of the Youtube video'},
|
||
{name: 'tp.user.transcript', desc: 'The full transcript with timestamps'},
|
||
{name: 'tp.user.summary', desc: 'The LLM-generated summary of the video'},
|
||
{name: 'tp.user.llmProvider', desc: 'The LLM provider used (e.g., openai, anthropic)'},
|
||
{name: 'tp.user.llmModel', desc: 'The specific model used (e.g., gpt-4, claude-3-opus)'},
|
||
{name: 'tp.user.llmTags', desc: 'Tags generated from the LLM provider and model'}
|
||
];
|
||
|
||
const varList = variablesContainer.createEl('ul');
|
||
variables.forEach(v => {
|
||
const item = varList.createEl('li');
|
||
item.createEl('code', {text: v.name});
|
||
item.createSpan({text: ` - ${v.desc}`});
|
||
});
|
||
} catch (error) {
|
||
// Handle error if template file can't be read
|
||
logger.error('Error loading template file:', error);
|
||
contentEl.createEl('p', {
|
||
text: 'Could not load template file. Please check that the template exists in your plugin directory or vault templates folder.',
|
||
cls: 'tubesage-license-load-error' // Reuse existing class for error messages
|
||
});
|
||
}
|
||
|
||
// Add close button
|
||
const footerEl = contentEl.createDiv({
|
||
cls: ['tubesage-license-footer', 'tubesage-row-end']
|
||
});
|
||
|
||
const closeButton = new ButtonComponent(footerEl);
|
||
closeButton.setButtonText('Close');
|
||
closeButton.buttonEl.addClass('tubesage-license-close-button');
|
||
closeButton.onClick(() => {
|
||
this.close();
|
||
});
|
||
})();
|
||
}
|
||
|
||
onClose() {
|
||
const { contentEl } = this;
|
||
contentEl.empty();
|
||
}
|
||
}
|
||
|
||
// Add this interface at the top of the file after the existing imports
|