diff --git a/AGENTS.md b/AGENTS.md index 3f4274a..b72af5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,26 @@ -# Obsidian community plugin +# AI Toolbox Obsidian plugin + +## Agent instructions + +- Run builds (`npm run build`) to verify your changes compile correctly. +- Only run linting (`eslint ./src/`) when preparing for a pull request. ## Project overview +- **Plugin**: AI Toolbox - A personal collection of AI tools to enhance Obsidian workflows. +- **Core features**: AI-powered transcription and configurable chat workflows with multiple AI providers. - Target: Obsidian Community Plugin (TypeScript → bundled JavaScript). -- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian. +- Entry point: `src/main.ts` compiled to `main.js` and loaded by Obsidian. - Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`. +- Desktop only (`isDesktopOnly: true`) due to external tool dependencies (yt-dlp, ffmpeg). ## Environment & tooling - Node.js: use current LTS (Node 18+ recommended). -- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies). -- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`. +- **Package manager: npm** (`package.json` defines npm scripts and dependencies). +- **Bundler: esbuild** (`esbuild.config.mjs` and build scripts depend on it). - Types: `obsidian` type definitions. -**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly. - ### Install ```bash @@ -47,18 +53,35 @@ npm run build - **Example file structure**: ``` src/ - main.ts # Plugin entry point, lifecycle management - settings.ts # Settings interface and defaults - commands/ # Command implementations - command1.ts - command2.ts - ui/ # UI components, modals, views - modal.ts - view.ts - utils/ # Utility functions, helpers - helpers.ts - constants.ts - types.ts # TypeScript interfaces and types + main.ts # Plugin entry point, lifecycle management + settings/ # Settings interfaces, types, and UI + index.ts # Settings tab (Providers, Workflows, Settings tabs) + types.ts # All type definitions and defaults + providers.ts # Provider settings UI + workflows.ts # Workflow settings UI + additional-settings.ts # Additional settings UI + providers/ # AI model provider implementations + index.ts # Re-exports + types.ts # Provider interfaces (ModelProvider, etc.) + provider-factory.ts # Factory for creating providers + base-provider.ts # Base provider class + openai-provider.ts # OpenAI implementation + azure-openai-provider.ts # Azure OpenAI implementation + handlers/ # Input and output handlers + input/ # Input handlers for acquiring media + output/ # Output handlers for presenting results + context/ # Context handling utilities + processing/ # Audio/video processing and workflow execution + workflow-executor.ts # Main workflow execution logic + audio-processor.ts # Audio file processing + video-processor.ts # Video extraction (yt-dlp) + workflow-chaining.ts # Workflow chaining logic + components/ # UI components + collapsible-section.ts + workflow-suggester.ts + workflow-type-modal.ts + tokens/ # Token/template processing + utils/ # Utility functions ``` - **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control. - Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages. @@ -151,6 +174,7 @@ Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particula - Provide defaults and validation in settings. - Write idempotent code paths so reload/unload doesn't leak listeners or intervals. - Use `this.register*` helpers for everything that needs cleanup. +- When refreshing collapsible settings sections, preserve the expand state by setting `callbacks.setExpandState({ workflowId: workflow.id })` before calling `callbacks.refresh()` if the section is currently expanded. **Don't** - Introduce network calls without an obvious user-facing reason and documentation. @@ -234,6 +258,80 @@ this.registerDomEvent(window, "resize", () => { /* ... */ }); this.registerInterval(window.setInterval(() => { /* ... */ }, 1000)); ``` +## Handler architecture + +The plugin uses a handler-based architecture to separate concerns for workflow execution: + +### Input handlers (`src/handlers/input/`) + +Input handlers acquire media for transcription workflows. Each handler implements the `InputHandler` interface: + +```ts +interface InputHandler { + getInput(context: InputContext): Promise; +} +``` + +**Available input handlers:** +- `VaultFileInputHandler` - Prompts user to select an audio file from the vault +- `ClipboardUrlInputHandler` - Extracts audio from a video URL in the clipboard (uses yt-dlp) +- `SelectionUrlInputHandler` - Extracts audio from a video URL in the current text selection + +**InputResult structure:** +```ts +interface InputResult { + audioFilePath: string; // Absolute path to the audio file + sourceUrl?: string; // Source URL if extracted from video + metadata?: VideoMetadata; // Title, uploader, description, tags +} +``` + +### Output handlers (`src/handlers/output/`) + +Output handlers present workflow results to the user. Each handler implements the `OutputHandler` interface: + +```ts +interface OutputHandler { + handleOutput(responseText: string, context: OutputContext): Promise; +} +``` + +**Available output handlers:** +- `PopupOutputHandler` - Displays result in a modal popup +- `NewNoteOutputHandler` - Creates a new note with the result +- `AtCursorOutputHandler` - Inserts result at the current cursor position + +### Naming conventions + +- Input handler classes end with `InputHandler` (e.g., `VaultFileInputHandler`) +- Output handler classes end with `OutputHandler` (e.g., `PopupOutputHandler`) +- Handler files use kebab-case matching the class name (e.g., `vault-file-input-handler.ts`) + +### Extending the handler system + +**To add a new input handler:** +1. Create a new file in `src/handlers/input/` (e.g., `my-custom-input-handler.ts`) +2. Implement the `InputHandler` interface +3. Export from `src/handlers/input/index.ts` +4. Add the new source type to `TranscriptionSourceType` in `src/settings/types.ts` +5. Update `createInputHandler()` in `src/processing/workflow-executor.ts` + +**To add a new output handler:** +1. Create a new file in `src/handlers/output/` (e.g., `my-custom-output-handler.ts`) +2. Implement the `OutputHandler` interface +3. Export from `src/handlers/output/index.ts` +4. Add the new output type to `WorkflowOutputType` in `src/settings/types.ts` +5. Update `createOutputHandler()` in `src/processing/workflow-executor.ts` + +### Workflow execution flow + +``` +┌─────────────────┐ ┌───────────────┐ ┌────────────────┐ ┌────────────────┐ +│ User triggers │ ──▶ │ Input Handler │ ──▶ │ AI Provider │ ──▶ │ Output Handler │ +│ workflow │ │ (get media) │ │ (transcribe) │ │ (show result) │ +└─────────────────┘ └───────────────┘ └────────────────┘ └────────────────┘ +``` + ## Troubleshooting - Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `/.obsidian/plugins//`. diff --git a/manifest.json b/manifest.json index 45409a8..de98265 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "ai-toolbox", "name": "AI Toolbox", - "version": "1.0.2", + "version": "1.0.3", "minAppVersion": "0.15.0", "description": "Personal collection of AI tools to enhance the Obsidian.md workflow.", "author": "dalinicus", diff --git a/src/components/collapsible-section.ts b/src/components/collapsible-section.ts new file mode 100644 index 0000000..6d52c87 --- /dev/null +++ b/src/components/collapsible-section.ts @@ -0,0 +1,159 @@ +import { Setting, setIcon } from "obsidian"; + +/** + * Configuration options for creating a collapsible section + */ +export interface CollapsibleSectionConfig { + /** Container element where the section will be created */ + containerEl: HTMLElement; + /** Display name for the section header */ + title: string; + /** CSS class for the main container (e.g., 'provider-container', 'workflow-container') */ + containerClass: string; + /** CSS class for the content area (e.g., 'provider-content', 'workflow-content') */ + contentClass: string; + /** CSS class for the header (e.g., 'provider-header', 'workflow-header') */ + headerClass: string; + /** Whether this section should start expanded */ + startExpanded: boolean; + /** Whether to show as a heading (bold) - defaults to true */ + isHeading?: boolean; + /** Optional icon to display before the title */ + icon?: string; + /** Optional secondary text to display in the header (e.g., ID) */ + secondaryText?: string; + /** Callback when the delete button is clicked (if provided, delete button is shown) */ + onDelete?: () => void | Promise; + /** Callback when the title changes (for dynamic name updates) */ + onTitleChange?: (newTitle: string) => void; +} + +/** + * Result from creating a collapsible section + */ +export interface CollapsibleSectionResult { + /** The main container element */ + container: HTMLElement; + /** The collapsible content container (add your content here) */ + contentContainer: HTMLElement; + /** The header setting element */ + headerSetting: Setting; + /** Update the displayed title (with arrow indicator) */ + updateTitle: (newTitle: string) => void; + /** Check if the section is currently expanded */ + isExpanded: () => boolean; +} + +/** + * Creates a collapsible section with consistent styling and behavior. + * Handles expand/collapse toggle, arrow icons, and optional delete button. + */ +export function createCollapsibleSection(config: CollapsibleSectionConfig): CollapsibleSectionResult { + const { + containerEl, + title, + containerClass, + contentClass, + headerClass, + startExpanded, + isHeading = true, + icon, + secondaryText, + onDelete, + } = config; + + const container = containerEl.createDiv(containerClass); + + // Create content container first (will be moved after header) + const contentContainer = container.createDiv( + `${contentClass} ${startExpanded ? 'is-expanded' : 'is-collapsed'}` + ); + + // Track current title for updates + let currentTitle = title; + let iconElement: HTMLElement | null = null; + + // Helper to get the formatted title with arrow and optional icon + const getFormattedTitle = (name: string, expanded: boolean): string => { + // Arrow only, icon will be inserted separately + return `${expanded ? '▾' : '▸'} ${name || 'Unnamed'}`; + }; + + // Create header with collapse toggle + const headerSetting = new Setting(container) + .setName(getFormattedTitle(title, startExpanded)); + + if (isHeading) { + headerSetting.setHeading(); + } + + // Add icon if provided (at the end, after title text) + if (icon) { + iconElement = headerSetting.nameEl.createSpan({ cls: 'workflow-header-icon' }); + setIcon(iconElement, icon); + headerSetting.nameEl.appendChild(iconElement); + } + + // Add secondary text if provided (displayed before delete button) + if (secondaryText) { + const secondaryEl = headerSetting.controlEl.createSpan({ cls: 'collapsible-section-secondary-text' }); + secondaryEl.textContent = secondaryText; + } + + // Add delete button if callback provided + if (onDelete) { + headerSetting.addButton(button => button + .setIcon('trash') + .setTooltip('Delete') + .onClick(() => { void onDelete(); })); + } + + headerSetting.settingEl.addClass(headerClass); + + // Toggle function + const toggleCollapse = () => { + const isCollapsed = contentContainer.classList.contains('is-collapsed'); + contentContainer.classList.toggle('is-collapsed', !isCollapsed); + contentContainer.classList.toggle('is-expanded', isCollapsed); + headerSetting.setName(getFormattedTitle(currentTitle, isCollapsed)); + + // Re-add icon at the end + if (icon && iconElement) { + headerSetting.nameEl.appendChild(iconElement); + } + }; + + // Add click handler to header (excluding buttons) + headerSetting.settingEl.addEventListener('click', (e) => { + if (!(e.target as HTMLElement).closest('button')) { + toggleCollapse(); + } + }); + + // Move content container after header + container.appendChild(contentContainer); + + // Update title function + const updateTitle = (newTitle: string) => { + currentTitle = newTitle; + const isExpanded = contentContainer.classList.contains('is-expanded'); + headerSetting.setName(getFormattedTitle(newTitle, isExpanded)); + + // Re-add icon at the end + if (icon && iconElement) { + headerSetting.nameEl.appendChild(iconElement); + } + }; + + // Check if expanded + const isExpanded = () => contentContainer.classList.contains('is-expanded'); + + return { + container, + contentContainer, + headerSetting, + updateTitle, + isExpanded, + }; +} + diff --git a/src/components/path-picker.ts b/src/components/path-picker.ts new file mode 100644 index 0000000..715adc4 --- /dev/null +++ b/src/components/path-picker.ts @@ -0,0 +1,328 @@ +import { App, Setting, AbstractInputSuggest, TFolder, TFile } from "obsidian"; + +/** + * Internal folder suggestion component. + */ +class FolderSuggestInternal extends AbstractInputSuggest { + private textInputEl: HTMLInputElement; + + constructor(app: App, inputEl: HTMLInputElement) { + super(app, inputEl); + this.textInputEl = inputEl; + } + + getSuggestions(inputStr: string): TFolder[] { + const inputLower = inputStr.toLowerCase().trim(); + const allFolders = this.getAllFolders(); + + if (inputLower === "") { + return allFolders; + } + + return allFolders.filter(folder => + folder.path.toLowerCase().includes(inputLower) + ); + } + + renderSuggestion(folder: TFolder, el: HTMLElement): void { + const displayPath = folder.path === "" ? "/" : folder.path; + el.createEl("div", { text: displayPath, cls: "folder-suggest-item" }); + } + + selectSuggestion(folder: TFolder): void { + this.textInputEl.value = folder.path; + this.textInputEl.dispatchEvent(new Event("input", { bubbles: true })); + this.close(); + } + + private getAllFolders(): TFolder[] { + const folders: TFolder[] = []; + + const rootFolder = this.app.vault.getRoot(); + if (rootFolder) { + folders.push(rootFolder); + } + + const allFolders = this.app.vault.getAllFolders(); + folders.push(...allFolders); + + folders.sort((a, b) => { + if (a.path === "") return -1; + if (b.path === "") return 1; + return a.path.localeCompare(b.path); + }); + + return folders; + } +} + +/** + * Internal file suggestion component that filters by folder. + */ +class FileSuggestInternal extends AbstractInputSuggest { + private textInputEl: HTMLInputElement; + private folderPath: string = ""; + + constructor(app: App, inputEl: HTMLInputElement) { + super(app, inputEl); + this.textInputEl = inputEl; + } + + setFolderPath(folderPath: string): void { + this.folderPath = folderPath; + } + + getSuggestions(inputStr: string): TFile[] { + const inputLower = inputStr.toLowerCase().trim(); + const files = this.getFilesInFolder(); + + if (inputLower === "") { + return files; + } + + return files.filter(file => { + const displayPath = this.getDisplayPath(file); + return displayPath.toLowerCase().includes(inputLower); + }); + } + + renderSuggestion(file: TFile, el: HTMLElement): void { + const displayPath = this.getDisplayPath(file); + el.createEl("div", { text: displayPath, cls: "file-suggest-item" }); + } + + selectSuggestion(file: TFile): void { + this.textInputEl.value = file.name; + this.textInputEl.dataset.fullPath = file.path; + this.textInputEl.dispatchEvent(new CustomEvent("file-selected", { + bubbles: true, + detail: { path: file.path, name: file.name } + })); + this.close(); + } + + private getDisplayPath(file: TFile): string { + if (this.folderPath && file.path.startsWith(this.folderPath + "/")) { + return file.path.substring(this.folderPath.length + 1); + } + return file.path; + } + + private getFilesInFolder(): TFile[] { + const allFiles = this.app.vault.getFiles(); + + if (!this.folderPath) { + return allFiles.sort((a, b) => a.path.localeCompare(b.path)); + } + + const normalizedFolder = this.folderPath.replace(/\/$/, ""); + const folder = this.app.vault.getAbstractFileByPath(normalizedFolder); + + if (!(folder instanceof TFolder)) { + return []; + } + + const filesInFolder = allFiles.filter(file => { + if (normalizedFolder === "") { + return true; + } + return file.path.startsWith(normalizedFolder + "/"); + }); + + filesInFolder.sort((a, b) => a.path.localeCompare(b.path)); + return filesInFolder; + } +} + +/** + * Mode for the path picker component. + */ +export type PathPickerMode = "folder-only" | "folder-file"; + +/** + * Base options for the path picker. + */ +interface PathPickerBaseOptions { + containerEl: HTMLElement; + app: App; + name: string; + description: string; + folderPlaceholder?: string; + initialFolderPath?: string; + onFolderChange?: (folderPath: string) => void; +} + +/** + * Options for folder-only mode. + */ +export interface FolderOnlyPickerOptions extends PathPickerBaseOptions { + mode: "folder-only"; +} + +/** + * Options for folder-file cascading mode. + */ +export interface FolderFilePickerOptions extends PathPickerBaseOptions { + mode: "folder-file"; + filePlaceholder?: string; + initialFilePath?: string; + onFileChange?: (filePath: string) => void; +} + +export type PathPickerOptions = FolderOnlyPickerOptions | FolderFilePickerOptions; + +/** + * Result for folder-only mode. + */ +export interface FolderOnlyPickerResult { + setting: Setting; + folderInputEl: HTMLInputElement; +} + +/** + * Result for folder-file mode. + */ +export interface FolderFilePickerResult { + setting: Setting; + folderInputEl: HTMLInputElement; + fileInputEl: HTMLInputElement; + fileSuggest: FileSuggestInternal; +} + +export type PathPickerResult = + T extends FolderOnlyPickerOptions ? FolderOnlyPickerResult : FolderFilePickerResult; + +/** + * Create a unified path picker component. + * + * Supports two modes: + * - "folder-only": Shows only a folder selection input + * - "folder-file": Shows folder and file selection inputs side-by-side + */ +export function createPathPicker(options: T): PathPickerResult { + if (options.mode === "folder-only") { + return createFolderOnlyPicker(options) as PathPickerResult; + } else { + return createFolderFilePicker(options) as PathPickerResult; + } +} + +function createFolderOnlyPicker(options: FolderOnlyPickerOptions): FolderOnlyPickerResult { + const { + containerEl, + app, + name, + description, + folderPlaceholder = "Select folder...", + initialFolderPath = "", + onFolderChange + } = options; + + let folderInputEl: HTMLInputElement; + + const setting = new Setting(containerEl) + .setName(name) + .setDesc(description) + .addSearch(folderSearch => { + folderInputEl = folderSearch.inputEl; + folderSearch + .setPlaceholder(folderPlaceholder) + .setValue(initialFolderPath) + .onChange((value) => { + onFolderChange?.(value); + }); + + new FolderSuggestInternal(app, folderSearch.inputEl); + folderSearch.inputEl.addClass("path-picker-folder-input"); + }); + + setting.settingEl.addClass("path-picker"); + + return { + setting, + folderInputEl: folderInputEl! + }; +} + +function createFolderFilePicker(options: FolderFilePickerOptions): FolderFilePickerResult { + const { + containerEl, + app, + name, + description, + folderPlaceholder = "Select folder...", + filePlaceholder = "Select file...", + initialFolderPath = "", + initialFilePath = "", + onFolderChange, + onFileChange + } = options; + + let folderInputEl: HTMLInputElement; + let fileInputEl: HTMLInputElement; + let fileSuggest: FileSuggestInternal; + + const setting = new Setting(containerEl) + .setName(name) + .setDesc(description) + .addSearch(folderSearch => { + folderInputEl = folderSearch.inputEl; + folderSearch + .setPlaceholder(folderPlaceholder) + .setValue(initialFolderPath) + .onChange((value) => { + if (fileSuggest) { + fileSuggest.setFolderPath(value); + } + // Enable/disable file input based on folder selection + if (fileInputEl) { + fileInputEl.disabled = !value; + // Clear file if folder is cleared + if (!value) { + fileInputEl.value = ""; + fileInputEl.dataset.fullPath = ""; + onFileChange?.(""); + } + } + onFolderChange?.(value); + }); + + new FolderSuggestInternal(app, folderSearch.inputEl); + folderSearch.inputEl.addClass("path-picker-folder-input"); + }) + .addSearch(fileSearch => { + fileInputEl = fileSearch.inputEl; + + // Extract just the filename from the initial path for display + const initialFileName = initialFilePath ? initialFilePath.split("/").pop() ?? "" : ""; + + fileSearch + .setPlaceholder(filePlaceholder) + .setValue(initialFileName); + + // Disable file input if no folder is selected + fileSearch.inputEl.disabled = !initialFolderPath; + + // Store the full path in dataset + fileSearch.inputEl.dataset.fullPath = initialFilePath; + + // Listen for file selection (custom event with full path) + fileSearch.inputEl.addEventListener("file-selected", ((e: CustomEvent<{ path: string }>) => { + onFileChange?.(e.detail.path); + }) as EventListener); + + fileSuggest = new FileSuggestInternal(app, fileSearch.inputEl); + fileSuggest.setFolderPath(initialFolderPath); + fileSearch.inputEl.addClass("path-picker-file-input"); + }); + + setting.settingEl.addClass("path-picker", "path-picker-folder-file"); + + return { + setting, + folderInputEl: folderInputEl!, + fileInputEl: fileInputEl!, + fileSuggest: fileSuggest! + }; +} + diff --git a/src/components/workflow-suggester.ts b/src/components/workflow-suggester.ts new file mode 100644 index 0000000..756b318 --- /dev/null +++ b/src/components/workflow-suggester.ts @@ -0,0 +1,31 @@ +import { App, FuzzySuggestModal } from 'obsidian'; +import { WorkflowConfig } from '../settings'; + +/** + * Modal for selecting a workflow from the configured workflows list. + * Uses fuzzy search to filter workflows by name. + */ +export class WorkflowSuggesterModal extends FuzzySuggestModal { + private workflows: WorkflowConfig[]; + private onChoose: (workflow: WorkflowConfig) => void; + + constructor(app: App, workflows: WorkflowConfig[], onChoose: (workflow: WorkflowConfig) => void) { + super(app); + this.workflows = workflows; + this.onChoose = onChoose; + this.setPlaceholder('Select a workflow to execute...'); + } + + getItems(): WorkflowConfig[] { + return this.workflows; + } + + getItemText(workflow: WorkflowConfig): string { + return workflow.name; + } + + onChooseItem(workflow: WorkflowConfig): void { + this.onChoose(workflow); + } +} + diff --git a/src/components/workflow-type-modal.ts b/src/components/workflow-type-modal.ts new file mode 100644 index 0000000..ab0af94 --- /dev/null +++ b/src/components/workflow-type-modal.ts @@ -0,0 +1,66 @@ +import { App, Modal, setIcon } from "obsidian"; +import { WorkflowType } from "../settings"; + +/** + * Modal for selecting the type of workflow to create + */ +export class WorkflowTypeModal extends Modal { + private onSelect: (type: WorkflowType) => void; + + constructor(app: App, onSelect: (type: WorkflowType) => void) { + super(app); + this.onSelect = onSelect; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('workflow-type-modal'); + + // Title + contentEl.createEl('h2', { text: 'Select workflow type' }); + + // Description + contentEl.createEl('p', { + text: 'Choose the type of workflow you want to create:', + cls: 'workflow-type-description' + }); + + // Options container + const optionsContainer = contentEl.createDiv('workflow-type-options'); + + // Chat option + const chatOption = optionsContainer.createDiv('workflow-type-option'); + chatOption.addEventListener('click', () => { + this.onSelect('chat'); + this.close(); + }); + + const chatIcon = chatOption.createDiv('workflow-type-icon'); + setIcon(chatIcon, 'message-circle'); + + const chatContent = chatOption.createDiv('workflow-type-content'); + chatContent.createEl('h3', { text: 'Chat' }); + chatContent.createEl('p', { text: 'Send prompts to AI models and receive text responses' }); + + // Transcription option + const transcriptionOption = optionsContainer.createDiv('workflow-type-option'); + transcriptionOption.addEventListener('click', () => { + this.onSelect('transcription'); + this.close(); + }); + + const transcriptionIcon = transcriptionOption.createDiv('workflow-type-icon'); + setIcon(transcriptionIcon, 'audio-lines'); + + const transcriptionContent = transcriptionOption.createDiv('workflow-type-content'); + transcriptionContent.createEl('h3', { text: 'Transcription' }); + transcriptionContent.createEl('p', { text: 'Transcribe audio or video files using AI models' }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + diff --git a/src/handlers/context/active-tab-context-handler.ts b/src/handlers/context/active-tab-context-handler.ts new file mode 100644 index 0000000..7c410f5 --- /dev/null +++ b/src/handlers/context/active-tab-context-handler.ts @@ -0,0 +1,59 @@ +import { MarkdownView } from 'obsidian'; +import { ContextHandler, ContextHandlerContext, ContextResult, ChatContextType } from './types'; +import { TokenDefinition } from '../../tokens'; + +/** + * Context handler that retrieves the full contents of the currently active file. + */ +export class ActiveTabContextHandler implements ContextHandler { + readonly contextType: ChatContextType = 'active-tab'; + + getAvailableTokens(): TokenDefinition[] { + return [ + { + name: 'activeTabContent', + description: 'The full contents of the currently active file' + }, + { + name: 'activeTabFilename', + description: 'The filename of the currently active file' + } + ]; + } + + async getContent(context: ContextHandlerContext): Promise { + const activeView = context.app.workspace.getActiveViewOfType(MarkdownView); + + if (!activeView) { + return { + content: '', + success: false, + error: 'No active file. Please open a note.' + }; + } + + const file = activeView.file; + if (!file) { + return { + content: '', + success: false, + error: 'No file associated with the active view.' + }; + } + + try { + const content = await context.app.vault.read(file); + return { + content, + success: true + }; + } catch (error) { + return { + content: '', + success: false, + error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}` + }; + } + } +} + diff --git a/src/handlers/context/clipboard-context-handler.ts b/src/handlers/context/clipboard-context-handler.ts new file mode 100644 index 0000000..e31e097 --- /dev/null +++ b/src/handlers/context/clipboard-context-handler.ts @@ -0,0 +1,44 @@ +import { ContextHandler, ContextHandlerContext, ContextResult, ChatContextType } from './types'; +import { TokenDefinition } from '../../tokens'; + +/** + * Context handler that retrieves the current clipboard contents. + */ +export class ClipboardContextHandler implements ContextHandler { + readonly contextType: ChatContextType = 'clipboard'; + + getAvailableTokens(): TokenDefinition[] { + return [ + { + name: 'clipboard', + description: 'The current contents of the system clipboard' + } + ]; + } + + async getContent(_context: ContextHandlerContext): Promise { + try { + const clipboardText = await navigator.clipboard.readText(); + + if (!clipboardText || !clipboardText.trim()) { + return { + content: '', + success: false, + error: 'Clipboard is empty or contains no text.' + }; + } + + return { + content: clipboardText, + success: true + }; + } catch (error) { + return { + content: '', + success: false, + error: `Failed to read clipboard: ${error instanceof Error ? error.message : String(error)}` + }; + } + } +} + diff --git a/src/handlers/context/index.ts b/src/handlers/context/index.ts new file mode 100644 index 0000000..21f18d2 --- /dev/null +++ b/src/handlers/context/index.ts @@ -0,0 +1,49 @@ +// Context handler types +export type { + ChatContextType, + ChatContextConfig, + ContextHandler, + ContextHandlerContext, + ContextResult +} from './types'; + +export { + CHAT_CONTEXT_TYPE_LABELS, + CHAT_CONTEXT_TYPE_DESCRIPTIONS +} from './types'; + +// Context handler implementations +export { SelectionContextHandler } from './selection-context-handler'; +export { ActiveTabContextHandler } from './active-tab-context-handler'; +export { ClipboardContextHandler } from './clipboard-context-handler'; + +import { ChatContextType, ContextHandler } from './types'; +import { SelectionContextHandler } from './selection-context-handler'; +import { ActiveTabContextHandler } from './active-tab-context-handler'; +import { ClipboardContextHandler } from './clipboard-context-handler'; + +/** + * Factory function to create a context handler based on context type. + */ +export function createContextHandler(contextType: ChatContextType): ContextHandler { + switch (contextType) { + case 'selection': + return new SelectionContextHandler(); + case 'active-tab': + return new ActiveTabContextHandler(); + case 'clipboard': + return new ClipboardContextHandler(); + default: { + const exhaustiveCheck: never = contextType; + throw new Error(`Unknown context type: ${exhaustiveCheck as string}`); + } + } +} + +/** + * Get all available context types. + */ +export function getAvailableContextTypes(): ChatContextType[] { + return ['selection', 'active-tab', 'clipboard']; +} + diff --git a/src/handlers/context/selection-context-handler.ts b/src/handlers/context/selection-context-handler.ts new file mode 100644 index 0000000..50acd88 --- /dev/null +++ b/src/handlers/context/selection-context-handler.ts @@ -0,0 +1,48 @@ +import { MarkdownView } from 'obsidian'; +import { ContextHandler, ContextHandlerContext, ContextResult, ChatContextType } from './types'; +import { TokenDefinition } from '../../tokens'; + +/** + * Context handler that retrieves the current text selection in the editor. + */ +export class SelectionContextHandler implements ContextHandler { + readonly contextType: ChatContextType = 'selection'; + + getAvailableTokens(): TokenDefinition[] { + return [ + { + name: 'selection', + description: 'The currently selected text in the editor' + } + ]; + } + + async getContent(context: ContextHandlerContext): Promise { + const activeView = context.app.workspace.getActiveViewOfType(MarkdownView); + + if (!activeView) { + return { + content: '', + success: false, + error: 'No active editor. Please open a note and select some text.' + }; + } + + const editor = activeView.editor; + const selection = editor.getSelection(); + + if (!selection || !selection.trim()) { + return { + content: '', + success: false, + error: 'No text selected. Please select some text in the editor.' + }; + } + + return { + content: selection, + success: true + }; + } +} + diff --git a/src/handlers/context/types.ts b/src/handlers/context/types.ts new file mode 100644 index 0000000..bae8dd7 --- /dev/null +++ b/src/handlers/context/types.ts @@ -0,0 +1,74 @@ +import { App } from 'obsidian'; +import { WorkflowConfig, ChatContextType } from '../../settings'; +import { TokenDefinition } from '../../tokens'; + +// Re-export types from settings for convenience +export type { ChatContextType, ChatContextConfig } from '../../settings'; + +/** + * Display labels for context types + */ +export const CHAT_CONTEXT_TYPE_LABELS: Record = { + 'selection': 'Selection', + 'active-tab': 'Active Tab', + 'clipboard': 'Clipboard' +}; + +/** + * Descriptions for context types + */ +export const CHAT_CONTEXT_TYPE_DESCRIPTIONS: Record = { + 'selection': 'The currently selected text in the editor', + 'active-tab': 'The full contents of the currently active file', + 'clipboard': 'The current contents of the system clipboard' +}; + +/** + * Context provided to context handlers + */ +export interface ContextHandlerContext { + /** The Obsidian App instance */ + app: App; + /** The workflow configuration being executed */ + workflow: WorkflowConfig; +} + +/** + * Result from getting context content + */ +export interface ContextResult { + /** The content retrieved from the context source */ + content: string; + /** Whether the context was successfully retrieved */ + success: boolean; + /** Error message if the context retrieval failed */ + error?: string; +} + +/** + * Interface for context handlers that provide content for chat workflows. + * + * Context handlers retrieve content from various sources (selection, clipboard, etc.) + * and provide token definitions for use in prompts. + */ +export interface ContextHandler { + /** + * Get the type of context this handler provides. + */ + readonly contextType: ChatContextType; + + /** + * Get the token definitions this context handler provides. + * These tokens can be used in prompts to reference the context content. + */ + getAvailableTokens(): TokenDefinition[]; + + /** + * Get the content from this context source. + * + * @param context - Context information about the workflow execution + * @returns Promise that resolves with the context result + */ + getContent(context: ContextHandlerContext): Promise; +} + diff --git a/src/handlers/index.ts b/src/handlers/index.ts new file mode 100644 index 0000000..dac0517 --- /dev/null +++ b/src/handlers/index.ts @@ -0,0 +1,35 @@ +// Input handlers +export type { InputHandler, InputContext, InputResult } from './input'; +export { + VaultFileInputHandler, + ClipboardUrlInputHandler, + SelectionUrlInputHandler +} from './input'; + +// Output handlers +export type { OutputHandler, OutputContext } from './output'; +export { + AtCursorOutputHandler, + PopupOutputHandler, + NewNoteOutputHandler, + WorkflowResultModal +} from './output'; + +// Context handlers +export type { + ChatContextType, + ChatContextConfig, + ContextHandler, + ContextHandlerContext, + ContextResult +} from './context'; +export { + CHAT_CONTEXT_TYPE_LABELS, + CHAT_CONTEXT_TYPE_DESCRIPTIONS, + SelectionContextHandler, + ActiveTabContextHandler, + ClipboardContextHandler, + createContextHandler, + getAvailableContextTypes +} from './context'; + diff --git a/src/handlers/input/clipboard-url-input-handler.ts b/src/handlers/input/clipboard-url-input-handler.ts new file mode 100644 index 0000000..3acdcae --- /dev/null +++ b/src/handlers/input/clipboard-url-input-handler.ts @@ -0,0 +1,26 @@ +import { Notice } from 'obsidian'; +import { InputHandler, InputContext, InputResult } from './types'; +import { extractAudioFromClipboard } from '../../processing'; + +/** + * Input handler that extracts audio from a video URL in the clipboard. + * Uses yt-dlp to download and convert the video to audio. + */ +export class ClipboardUrlInputHandler implements InputHandler { + async getInput(context: InputContext): Promise { + new Notice('Checking clipboard for video URL...'); + + const result = await extractAudioFromClipboard(context.settings); + + if (!result) { + return null; + } + + return { + audioFilePath: result.audioFilePath, + sourceUrl: result.sourceUrl, + metadata: result.metadata + }; + } +} + diff --git a/src/handlers/input/index.ts b/src/handlers/input/index.ts new file mode 100644 index 0000000..9afca07 --- /dev/null +++ b/src/handlers/input/index.ts @@ -0,0 +1,8 @@ +// Input handler types +export type { InputHandler, InputContext, InputResult } from './types'; + +// Input handler implementations +export { VaultFileInputHandler } from './vault-file-input-handler'; +export { ClipboardUrlInputHandler } from './clipboard-url-input-handler'; +export { SelectionUrlInputHandler } from './selection-url-input-handler'; + diff --git a/src/handlers/input/selection-url-input-handler.ts b/src/handlers/input/selection-url-input-handler.ts new file mode 100644 index 0000000..4cea098 --- /dev/null +++ b/src/handlers/input/selection-url-input-handler.ts @@ -0,0 +1,43 @@ +import { MarkdownView, Notice } from 'obsidian'; +import { InputHandler, InputContext, InputResult } from './types'; +import { extractAudioFromUrl } from '../../processing'; + +/** + * Input handler that extracts audio from a video URL in the current text selection. + * Uses yt-dlp to download and convert the video to audio. + */ +export class SelectionUrlInputHandler implements InputHandler { + async getInput(context: InputContext): Promise { + const activeView = context.app.workspace.getActiveViewOfType(MarkdownView); + + if (!activeView) { + new Notice('No active editor. Please open a note and select a URL.'); + return null; + } + + const editor = activeView.editor; + const selection = editor.getSelection(); + + if (!selection || !selection.trim()) { + new Notice('No text selected. Please select a video URL.'); + return null; + } + + const url = selection.trim(); + + new Notice('Processing URL from selection...'); + + const result = await extractAudioFromUrl(url, context.settings); + + if (!result) { + return null; + } + + return { + audioFilePath: result.audioFilePath, + sourceUrl: result.sourceUrl, + metadata: result.metadata + }; + } +} + diff --git a/src/handlers/input/types.ts b/src/handlers/input/types.ts new file mode 100644 index 0000000..6e0d6e7 --- /dev/null +++ b/src/handlers/input/types.ts @@ -0,0 +1,45 @@ +import { App } from 'obsidian'; +import { AIToolboxSettings, WorkflowConfig } from '../../settings'; +import { VideoMetadata } from '../../processing'; + +/** + * Result from acquiring input for transcription. + * Contains the audio file path and optional metadata. + */ +export interface InputResult { + /** Absolute path to the audio file */ + audioFilePath: string; + /** Source URL if the audio was extracted from a video URL */ + sourceUrl?: string; + /** Metadata from the video/audio source */ + metadata?: VideoMetadata; +} + +/** + * Context provided to input handlers for acquiring transcription input. + */ +export interface InputContext { + /** The Obsidian App instance */ + app: App; + /** Plugin settings */ + settings: AIToolboxSettings; + /** The workflow configuration being executed */ + workflow: WorkflowConfig; +} + +/** + * Common interface for input handlers. + * + * Each input handler handles a specific way of acquiring media input + * for transcription (e.g., file selection, clipboard URL, selection URL). + */ +export interface InputHandler { + /** + * Acquire input for transcription. + * + * @param context - Context information about the workflow execution + * @returns Promise that resolves with the input result, or null if cancelled/failed + */ + getInput(context: InputContext): Promise; +} + diff --git a/src/handlers/input/vault-file-input-handler.ts b/src/handlers/input/vault-file-input-handler.ts new file mode 100644 index 0000000..2c22aad --- /dev/null +++ b/src/handlers/input/vault-file-input-handler.ts @@ -0,0 +1,81 @@ +import { TFile, FuzzySuggestModal } from 'obsidian'; +import { InputHandler, InputContext, InputResult } from './types'; +import * as path from 'path'; + +/** + * Supported audio file extensions for transcription. + */ +const SUPPORTED_AUDIO_EXTENSIONS = ['mp3', 'wav', 'm4a', 'webm', 'ogg', 'flac', 'aac']; + +/** + * Modal for selecting an audio file from the vault. + */ +class AudioFileSelectorModal extends FuzzySuggestModal { + private audioFiles: TFile[]; + private onSelect: (file: TFile | null) => void; + private wasSelected = false; + + constructor( + app: import('obsidian').App, + onSelect: (file: TFile | null) => void + ) { + super(app); + this.onSelect = onSelect; + + this.audioFiles = app.vault.getFiles().filter(file => { + const ext = file.extension.toLowerCase(); + return SUPPORTED_AUDIO_EXTENSIONS.includes(ext); + }); + + this.setPlaceholder('Select an audio file to transcribe...'); + } + + getItems(): TFile[] { + return this.audioFiles; + } + + getItemText(file: TFile): string { + return file.path; + } + + onChooseItem(file: TFile): void { + this.wasSelected = true; + this.onSelect(file); + } + + onClose(): void { + if (!this.wasSelected) { + this.onSelect(null); + } + } +} + +/** + * Input handler that prompts the user to select an audio file from the vault. + */ +export class VaultFileInputHandler implements InputHandler { + async getInput(context: InputContext): Promise { + return new Promise((resolve) => { + const modal = new AudioFileSelectorModal(context.app, (file) => { + if (!file) { + resolve(null); + return; + } + + // Get the absolute path to the audio file + const adapter = context.app.vault.adapter as unknown as { basePath: string }; + const audioFilePath = path.join(adapter.basePath, file.path); + + resolve({ + audioFilePath, + metadata: { + title: file.basename, + } + }); + }); + + modal.open(); + }); + } +} + diff --git a/src/handlers/output/at-cursor-output-handler.ts b/src/handlers/output/at-cursor-output-handler.ts new file mode 100644 index 0000000..fbb03fb --- /dev/null +++ b/src/handlers/output/at-cursor-output-handler.ts @@ -0,0 +1,30 @@ +import { MarkdownView, Notice } from 'obsidian'; +import { OutputHandler, OutputContext } from './types'; + +/** + * Output handler that inserts the AI response at the current cursor position + * in the active Obsidian editor. + */ +export class AtCursorOutputHandler implements OutputHandler { + async handleOutput(responseText: string, context: OutputContext): Promise { + const activeView = context.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView) { + new Notice('No active editor. Please open a note first.'); + return; + } + + const editor = activeView.editor; + const cursor = editor.getCursor(); + editor.replaceRange(responseText, cursor); + + // Move cursor to end of inserted text + const lines = responseText.split('\n'); + const lastLine = lines[lines.length - 1] ?? ''; + const newLine = cursor.line + lines.length - 1; + const newCh = lines.length === 1 ? cursor.ch + lastLine.length : lastLine.length; + editor.setCursor({ line: newLine, ch: newCh }); + + new Notice('Response inserted at cursor'); + } +} + diff --git a/src/handlers/output/index.ts b/src/handlers/output/index.ts new file mode 100644 index 0000000..ab98a0e --- /dev/null +++ b/src/handlers/output/index.ts @@ -0,0 +1,8 @@ +// Output handler types +export type { OutputHandler, OutputContext } from './types'; + +// Output handler implementations +export { AtCursorOutputHandler } from './at-cursor-output-handler'; +export { PopupOutputHandler, WorkflowResultModal } from './popup-output-handler'; +export { NewNoteOutputHandler } from './new-note-output-handler'; + diff --git a/src/handlers/output/new-note-output-handler.ts b/src/handlers/output/new-note-output-handler.ts new file mode 100644 index 0000000..6eb33a5 --- /dev/null +++ b/src/handlers/output/new-note-output-handler.ts @@ -0,0 +1,63 @@ +import { Notice, TFile } from 'obsidian'; +import { OutputHandler, OutputContext } from './types'; +import { generateFilenameTimestamp } from '../../utils/date-utils'; + +/** + * Output handler that creates a new note containing the AI response. + * The note contains only the raw response text with no additional formatting. + */ +export class NewNoteOutputHandler implements OutputHandler { + async handleOutput(responseText: string, context: OutputContext): Promise { + const { app, workflow } = context; + + const timestamp = generateFilenameTimestamp(); + const noteTitle = context.noteTitle || `${workflow.name} - ${timestamp}`; + + // Determine folder path: use workflow-specific folder if set, otherwise fall back to Obsidian's default + let folderPath: string | undefined; + if (workflow.outputFolder && workflow.outputFolder.trim() !== '') { + folderPath = workflow.outputFolder.trim().replace(/\/$/, ''); + } else { + // Get the default new note location from Obsidian's vault config + const vault = app.vault as { getConfig?: (key: string) => string | undefined }; + const newFileFolderPath = vault.getConfig?.('newFileFolderPath'); + if (newFileFolderPath && newFileFolderPath.trim() !== '') { + folderPath = newFileFolderPath.replace(/\/$/, ''); + } + } + + // Build the file path + let filePath: string; + if (folderPath) { + // Ensure folder exists + const folder = app.vault.getAbstractFileByPath(folderPath); + if (!folder) { + await app.vault.createFolder(folderPath); + } + filePath = `${folderPath}/${noteTitle}.md`; + } else { + filePath = `${noteTitle}.md`; + } + + // Handle filename conflicts by appending a number + let finalPath = filePath; + let counter = 1; + while (app.vault.getAbstractFileByPath(finalPath)) { + const basePath = filePath.replace('.md', ''); + finalPath = `${basePath} (${counter}).md`; + counter++; + } + + // Create the note with raw response text + const file = await app.vault.create(finalPath, responseText); + + // Open the newly created note + const leaf = app.workspace.getLeaf(false); + if (file instanceof TFile) { + await leaf.openFile(file); + } + + new Notice(`Created note: ${file.name}`); + } +} + diff --git a/src/handlers/output/popup-output-handler.ts b/src/handlers/output/popup-output-handler.ts new file mode 100644 index 0000000..6dfeb3b --- /dev/null +++ b/src/handlers/output/popup-output-handler.ts @@ -0,0 +1,60 @@ +import { App, Modal, Notice } from 'obsidian'; +import { OutputHandler, OutputContext } from './types'; + +/** + * Modal to display the AI response from a workflow execution. + */ +export class WorkflowResultModal extends Modal { + private workflowName: string; + private response: string; + + constructor(app: App, workflowName: string, response: string) { + super(app); + this.workflowName = workflowName; + this.response = response; + } + + onOpen(): void { + const { contentEl } = this; + + contentEl.createEl('h2', { text: this.workflowName }); + + const responseContainer = contentEl.createDiv('workflow-response-container'); + responseContainer.createEl('pre', { + text: this.response, + cls: 'workflow-response-content' + }); + + // Add copy button + const buttonContainer = contentEl.createDiv('workflow-response-buttons'); + const copyButton = buttonContainer.createEl('button', { text: 'Copy to clipboard' }); + copyButton.addEventListener('click', () => { + void navigator.clipboard.writeText(this.response).then(() => { + new Notice('Response copied to clipboard'); + }); + }); + + const closeButton = buttonContainer.createEl('button', { text: 'Close' }); + closeButton.addEventListener('click', () => this.close()); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} + +/** + * Output handler that displays the AI response in a modal popup. + */ +export class PopupOutputHandler implements OutputHandler { + async handleOutput(responseText: string, context: OutputContext): Promise { + const modal = new WorkflowResultModal( + context.app, + context.workflow.name, + responseText + ); + modal.open(); + } +} + diff --git a/src/handlers/output/types.ts b/src/handlers/output/types.ts new file mode 100644 index 0000000..ff94d4c --- /dev/null +++ b/src/handlers/output/types.ts @@ -0,0 +1,34 @@ +import { App } from 'obsidian'; +import { WorkflowConfig } from '../../settings'; + +/** + * Context provided to output handlers for handling workflow results. + */ +export interface OutputContext { + /** The Obsidian App instance */ + app: App; + /** The workflow configuration that was executed */ + workflow: WorkflowConfig; + /** The prompt text that was sent to the AI (for chat workflows) */ + promptText?: string; + /** Custom note title (for new-note output handler) */ + noteTitle?: string; +} + +/** + * Common interface for output handlers. + * + * Each output handler handles a specific way of presenting or storing + * the AI model's response (e.g., popup modal, new note, cursor insertion). + */ +export interface OutputHandler { + /** + * Handle the AI model's response output. + * + * @param responseText - The text response from the AI model + * @param context - Context information about the workflow execution + * @returns Promise that resolves when output handling is complete + */ + handleOutput(responseText: string, context: OutputContext): Promise; +} + diff --git a/src/main.ts b/src/main.ts index c0920c3..4e3b00c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { Notice, Plugin } from 'obsidian'; -import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings"; -import { createTranscriptionProvider } from "./providers"; -import { transcribeFromClipboard } from "./transcriptions/transcription-workflow"; +import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; +import { WorkflowSuggesterModal } from "./components/workflow-suggester"; +import { executeWorkflow } from "./processing/workflow-executor"; export default class AIToolboxPlugin extends Plugin { settings: AIToolboxSettings; @@ -9,9 +9,11 @@ export default class AIToolboxPlugin extends Plugin { async onload() { await this.loadSettings(); - // This creates an icon in the left ribbon for video transcription. - this.addRibbonIcon('captions', 'Transcribe video from clipboard', () => { - void this.transcribeFromClipboard(); + // Add command to execute custom workflows + this.addCommand({ + id: 'execute-workflow', + name: 'Run workflow', + callback: () => this.showWorkflowSuggester() }); // This adds a settings tab so the user can configure various aspects of the plugin @@ -19,24 +21,20 @@ export default class AIToolboxPlugin extends Plugin { } /** - * Complete workflow: Extract audio from clipboard URL, transcribe it, and create a note. + * Show the workflow suggester modal and execute the selected workflow. */ - async transcribeFromClipboard(): Promise { - // Create the transcription provider from settings - const provider = createTranscriptionProvider(this.settings); - if (!provider) { - new Notice('No transcription provider configured. Please configure a provider in settings.'); + private showWorkflowSuggester(): void { + const availableWorkflows = this.settings.workflows.filter(w => w.showInCommand); + + if (availableWorkflows.length === 0) { + new Notice('No workflows available. You can configure workflows from the workflows tab in settings.'); return; } - // Build workflow options from settings - const options = { - includeTimestamps: this.settings.includeTimestamps, - language: this.settings.transcriptionLanguage || undefined, - outputFolder: this.settings.outputFolder, - }; - - await transcribeFromClipboard(this.app, provider, this.settings, options); + const modal = new WorkflowSuggesterModal(this.app, availableWorkflows, (workflow) => { + void executeWorkflow(this.app, this.settings, workflow); + }); + modal.open(); } async loadSettings() { diff --git a/src/processing/audio-processor.ts b/src/processing/audio-processor.ts new file mode 100644 index 0000000..15d7d13 --- /dev/null +++ b/src/processing/audio-processor.ts @@ -0,0 +1,267 @@ +import { Buffer } from 'buffer'; +import * as fs from 'fs'; + +/** + * Interface for transcription API response + */ +export interface TranscriptionApiResponse { + text: string; + segments?: Array<{ text: string; start: number; end: number }>; +} + +/** + * Individual chunk with timestamps from transcription + */ +export interface TranscriptionChunk { + text: string; + timestamp: [number, number | null]; +} + +/** + * Result from audio transcription + */ +export interface TranscriptionResult { + text: string; + chunks?: TranscriptionChunk[]; + audioFilePath: string; +} + +/** + * Interface for additional form fields to include in multipart form data + */ +export interface FormField { + name: string; + value: string; +} + +/** + * Options for building multipart form data for audio transcription + */ +export interface MultipartFormDataOptions { + boundary: string; + audioBuffer: Buffer; + fileName: string; + includeTimestamps: boolean; + language?: string; + additionalFields?: FormField[]; +} + +/** + * Options for preparing audio form data from a file path + */ +export interface PrepareAudioFormDataOptions { + audioFilePath: string; + includeTimestamps: boolean; + language?: string; + additionalFields?: FormField[]; +} + +/** + * Result from preparing audio form data + */ +export interface PreparedAudioFormData { + boundary: string; + formData: ArrayBuffer; + fileName: string; +} + +/** + * Combines string and Buffer parts into a single ArrayBuffer. + * Used internally by buildMultipartFormData. + */ +function combinePartsToArrayBuffer(parts: (string | Buffer)[]): ArrayBuffer { + const encoder = new TextEncoder(); + const buffers: Uint8Array[] = parts.map(part => { + if (typeof part === 'string') { + return encoder.encode(part); + } + return new Uint8Array(part.buffer, part.byteOffset, part.byteLength); + }); + + const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const buf of buffers) { + combined.set(new Uint8Array(buf), offset); + offset += buf.byteLength; + } + + return combined.buffer; +} + +/** + * Builds multipart form data for audio transcription API requests. + * Creates a properly formatted multipart/form-data body with the audio file + * and optional configuration fields. + * + * @param options - Configuration for the multipart form data + * @returns ArrayBuffer containing the complete multipart form data + */ +export function buildMultipartFormData(options: MultipartFormDataOptions): ArrayBuffer { + const { boundary, audioBuffer, fileName, includeTimestamps, language, additionalFields = [] } = options; + const parts: (string | Buffer)[] = []; + + // File field + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`); + parts.push(`Content-Type: application/octet-stream\r\n\r\n`); + parts.push(audioBuffer); + parts.push('\r\n'); + + // Additional provider-specific fields (e.g., model for OpenAI) + for (const field of additionalFields) { + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="${field.name}"\r\n\r\n`); + parts.push(`${field.value}\r\n`); + } + + // Response format + const responseFormat = includeTimestamps ? 'verbose_json' : 'json'; + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="response_format"\r\n\r\n`); + parts.push(`${responseFormat}\r\n`); + + // Language (optional) + if (language) { + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="language"\r\n\r\n`); + parts.push(`${language}\r\n`); + } + + // Timestamp granularities (for verbose_json) + if (includeTimestamps) { + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="timestamp_granularities[]"\r\n\r\n`); + parts.push(`segment\r\n`); + } + + parts.push(`--${boundary}--\r\n`); + + return combinePartsToArrayBuffer(parts); +} + +/** + * Generates a random boundary string for multipart form data. + * @returns A unique boundary string + */ +export function generateFormBoundary(): string { + return '----FormBoundary' + Math.random().toString(36).substring(2); +} + +/** + * Validates that an audio file exists at the given path. + * @param audioFilePath - Path to the audio file + * @throws Error if the file does not exist + */ +export function validateAudioFile(audioFilePath: string): void { + if (!fs.existsSync(audioFilePath)) { + throw new Error(`Audio file not found: ${audioFilePath}`); + } +} + +/** + * Extracts the filename from a file path. + * @param filePath - Full path to the file + * @param defaultName - Default name if extraction fails + * @returns The extracted filename + */ +export function extractFileName(filePath: string, defaultName = 'audio.mp3'): string { + return filePath.split(/[/\\]/).pop() || defaultName; +} + +/** + * Prepares audio form data for transcription API requests. + * Reads the audio file, generates a boundary, and builds the multipart form data. + * + * @param options - Configuration for preparing the form data + * @returns Object containing the boundary and prepared form data + * @throws Error if the audio file cannot be read + */ +export function prepareAudioFormData(options: PrepareAudioFormDataOptions): PreparedAudioFormData { + const { audioFilePath, includeTimestamps, language, additionalFields } = options; + + validateAudioFile(audioFilePath); + + const audioBuffer = fs.readFileSync(audioFilePath); + const fileName = extractFileName(audioFilePath); + const boundary = generateFormBoundary(); + + const formData = buildMultipartFormData({ + boundary, + audioBuffer, + fileName, + includeTimestamps, + language, + additionalFields, + }); + + return { boundary, formData, fileName }; +} + +/** + * Result from creating test audio data + */ +export interface TestAudioData { + audioBuffer: Buffer; + fileName: string; +} + +/** + * Creates a test audio buffer for API testing. + * Generates a 1-second WAV file at 16kHz mono with a simple tone pattern + * that simulates speech-like sound. + * + * @returns Object containing the audio buffer and filename + */ +export function createTestAudioBuffer(): TestAudioData { + const sampleRate = 16000; + const duration = 1; + const numSamples = sampleRate * duration; + const numChannels = 1; + const bitsPerSample = 16; + const bytesPerSample = bitsPerSample / 8; + const blockAlign = numChannels * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = numSamples * blockAlign; + const fileSize = 36 + dataSize; + + const audioBuffer = Buffer.alloc(44 + dataSize); + let offset = 0; + + // RIFF header + audioBuffer.write('RIFF', offset); offset += 4; + audioBuffer.writeUInt32LE(fileSize, offset); offset += 4; + audioBuffer.write('WAVE', offset); offset += 4; + + // fmt chunk + audioBuffer.write('fmt ', offset); offset += 4; + audioBuffer.writeUInt32LE(16, offset); offset += 4; + audioBuffer.writeUInt16LE(1, offset); offset += 2; + audioBuffer.writeUInt16LE(numChannels, offset); offset += 2; + audioBuffer.writeUInt32LE(sampleRate, offset); offset += 4; + audioBuffer.writeUInt32LE(byteRate, offset); offset += 4; + audioBuffer.writeUInt16LE(blockAlign, offset); offset += 2; + audioBuffer.writeUInt16LE(bitsPerSample, offset); offset += 2; + + // data chunk + audioBuffer.write('data', offset); offset += 4; + audioBuffer.writeUInt32LE(dataSize, offset); offset += 4; + + // Generate audio data with speech-like pattern + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + const freq1 = 200 + Math.sin(t * 10) * 50; + const freq2 = 800 + Math.sin(t * 15) * 100; + const freq3 = 2400; + const envelope = Math.sin(t * Math.PI) * 0.3; + const sample = envelope * ( + Math.sin(2 * Math.PI * freq1 * t) * 0.5 + + Math.sin(2 * Math.PI * freq2 * t) * 0.3 + + Math.sin(2 * Math.PI * freq3 * t) * 0.2 + ); + audioBuffer.writeInt16LE(Math.floor(sample * 32767), offset); + offset += 2; + } + + return { audioBuffer, fileName: 'test-audio.wav' }; +} diff --git a/src/processing/index.ts b/src/processing/index.ts new file mode 100644 index 0000000..aa1d7d7 --- /dev/null +++ b/src/processing/index.ts @@ -0,0 +1,51 @@ +// Audio processor - multipart form data and audio file handling +export { + buildMultipartFormData, + generateFormBoundary, + validateAudioFile, + extractFileName, + prepareAudioFormData, + createTestAudioBuffer, +} from './audio-processor'; + +export type { + TranscriptionApiResponse, + TranscriptionChunk, + TranscriptionResult, + FormField, + MultipartFormDataOptions, + PrepareAudioFormDataOptions, + PreparedAudioFormData, + TestAudioData, +} from './audio-processor'; + +// Video processor - yt-dlp related functionality +export { + getOutputDirectory, + runYtDlp, + extractAudioFromUrl, + extractAudioFromClipboard, +} from './video-processor'; + +export type { + VideoProcessorConfig, + YtDlpResult, + ExtractAudioResult, +} from './video-processor'; + +// Video platforms - platform-specific handlers +export type { + VideoPlatformHandler, + VideoMetadata, + YtDlpOutputConfig, + YtDlpPlatformArgs, + EmbedConfig, +} from './video-platforms'; + +export { + YouTubeHandler, + TikTokHandler, + VideoPlatformRegistry, + videoPlatformRegistry, +} from './video-platforms'; + diff --git a/src/transcriptions/video-platforms/index.ts b/src/processing/video-platforms/index.ts similarity index 100% rename from src/transcriptions/video-platforms/index.ts rename to src/processing/video-platforms/index.ts diff --git a/src/transcriptions/video-platforms/tiktok-handler.ts b/src/processing/video-platforms/tiktok-handler.ts similarity index 100% rename from src/transcriptions/video-platforms/tiktok-handler.ts rename to src/processing/video-platforms/tiktok-handler.ts diff --git a/src/transcriptions/video-platforms/video-platform-handler.ts b/src/processing/video-platforms/video-platform-handler.ts similarity index 100% rename from src/transcriptions/video-platforms/video-platform-handler.ts rename to src/processing/video-platforms/video-platform-handler.ts diff --git a/src/transcriptions/video-platforms/video-platform-registry.ts b/src/processing/video-platforms/video-platform-registry.ts similarity index 100% rename from src/transcriptions/video-platforms/video-platform-registry.ts rename to src/processing/video-platforms/video-platform-registry.ts diff --git a/src/transcriptions/video-platforms/youtube-handler.ts b/src/processing/video-platforms/youtube-handler.ts similarity index 100% rename from src/transcriptions/video-platforms/youtube-handler.ts rename to src/processing/video-platforms/youtube-handler.ts diff --git a/src/transcriptions/video-downloader.ts b/src/processing/video-processor.ts similarity index 64% rename from src/transcriptions/video-downloader.ts rename to src/processing/video-processor.ts index ce11e72..7e3e6f7 100644 --- a/src/transcriptions/video-downloader.ts +++ b/src/processing/video-processor.ts @@ -1,100 +1,27 @@ -import {Notice} from 'obsidian'; -import {spawn} from 'child_process'; +import { spawn } from 'child_process'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; import { Buffer } from 'buffer'; -import {AIToolboxSettings} from '../settings'; +import { Notice } from 'obsidian'; +import { AIToolboxSettings } from '../settings'; import { videoPlatformRegistry, VideoMetadata } from './video-platforms'; -// Re-export VideoMetadata for backwards compatibility -export type { VideoMetadata } from './video-platforms'; - -export interface ExtractAudioResult { - audioFilePath: string; - sourceUrl: string; - metadata?: VideoMetadata; -} - /** - * Downloads audio from a video URL in the clipboard for transcription. - * Supports TikTok, YouTube, and other platforms supported by yt-dlp. - * Uses yt-dlp directly via child_process for audio extraction. - * Requires yt-dlp and ffmpeg to be installed and available in PATH. + * Configuration for video processing operations. */ -export async function extractAudioFromClipboard(settings: AIToolboxSettings): Promise { - try { - // Read URL from clipboard - const clipboardText = await navigator.clipboard.readText(); - - if (!clipboardText) { - new Notice('Clipboard is empty'); - return null; - } - - // Validate video URL - if (!videoPlatformRegistry.isValidVideoUrl(clipboardText)) { - new Notice('Clipboard does not contain a valid video URL'); - return null; - } - - const url = clipboardText.trim(); - new Notice('Preparing video for transcription...'); - - // Get platform-specific handler for output template - const handler = videoPlatformRegistry.findHandlerForUrl(url); - const filenameTemplate = handler - ? handler.getYtDlpArgs().outputConfig.filenameTemplate - : '%(title)s_%(id)s'; - - let outputDir: string; - - if (settings.keepVideo) { - // Use custom directory or default Downloads folder when keeping video - const homeDir = os.homedir(); - outputDir = settings.outputDirectory || - path.join(homeDir, 'Videos', 'Obsidian'); - - // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, {recursive: true}); - } - } else { - // Use system temporary directory when not keeping video - outputDir = os.tmpdir(); - } - - // Use platform-specific output template - const outputTemplate = path.join(outputDir, `${filenameTemplate}.%(ext)s`); - - // Run yt-dlp to extract audio for transcription and get metadata - const ytdlpResult = await runYtDlp(url, outputTemplate, settings); - - new Notice(`Audio extracted successfully!\nReady for transcription.\nSaved to: ${path.dirname(ytdlpResult.audioFilePath)}`); - - return { - audioFilePath: ytdlpResult.audioFilePath, - sourceUrl: url, - metadata: { - title: ytdlpResult.title, - uploader: ytdlpResult.uploader, - description: ytdlpResult.description, - tags: ytdlpResult.tags, - }, - }; - - } catch (error) { - console.error('Video audio extraction error:', error); - const errorMessage = error instanceof Error ? error.message : String(error); - new Notice(`Failed to extract audio for transcription: ${errorMessage}`); - return null; - } +export interface VideoProcessorConfig { + ytdlpLocation?: string; + ffmpegLocation?: string; + impersonateBrowser: string; + keepVideo: boolean; + outputDirectory?: string; } /** - * Result from running yt-dlp including audio file path and video metadata + * Result from running yt-dlp including audio file path and video metadata. */ -interface YtDlpResult { +export interface YtDlpResult { audioFilePath: string; title?: string; uploader?: string; @@ -103,7 +30,7 @@ interface YtDlpResult { } /** - * Structure of the yt-dlp info.json file (partial - only fields we use) + * Structure of the yt-dlp info.json file (partial - only fields we use). */ interface YtDlpInfoJson { title?: string; @@ -113,15 +40,34 @@ interface YtDlpInfoJson { [key: string]: unknown; } +/** + * Determines the output directory for downloaded videos/audio. + */ +export function getOutputDirectory(config: VideoProcessorConfig): string { + if (config.keepVideo) { + const homeDir = os.homedir(); + const outputDir = config.outputDirectory || path.join(homeDir, 'Videos', 'Obsidian'); + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + return outputDir; + } + return os.tmpdir(); +} + /** * Runs yt-dlp to extract audio from a video for transcription. * Requires yt-dlp and ffmpeg to be installed and available in PATH. * Returns the actual filepath and video metadata reported by yt-dlp. */ -async function runYtDlp(url: string, outputTemplate: string, settings: AIToolboxSettings): Promise { - const audioFilePath = await spawnYtDlp(url, outputTemplate, settings); +export async function runYtDlp( + url: string, + outputTemplate: string, + config: VideoProcessorConfig +): Promise { + const audioFilePath = await spawnYtDlp(url, outputTemplate, config); - // Read metadata from the .info.json file let metadata: YtDlpInfoJson | null = null; try { metadata = await readAndCleanupInfoJson(audioFilePath); @@ -141,7 +87,11 @@ async function runYtDlp(url: string, outputTemplate: string, settings: AIToolbox /** * Spawns yt-dlp process and returns the audio file path. */ -function spawnYtDlp(url: string, outputTemplate: string, settings: AIToolboxSettings): Promise { +function spawnYtDlp( + url: string, + outputTemplate: string, + config: VideoProcessorConfig +): Promise { return new Promise((resolve, reject) => { const args = [ '-x', // Extract audio @@ -150,30 +100,28 @@ function spawnYtDlp(url: string, outputTemplate: string, settings: AIToolboxSett '--write-info-json', // Write metadata to .info.json file ]; - // Keep video file if setting is enabled - if (settings.keepVideo) { + if (config.keepVideo) { args.push('-k'); } - // Add ffmpeg location if specified in settings - if (settings.ffmpegLocation) { - args.push('--ffmpeg-location', settings.ffmpegLocation); + if (config.ffmpegLocation) { + args.push('--ffmpeg-location', config.ffmpegLocation); } args.push( - '--impersonate', settings.impersonateBrowser, // Impersonate browser from settings - '-o', outputTemplate, // Output template - '--print', 'after_move:filepath', // Print the final filepath after all processing + '--impersonate', config.impersonateBrowser, + '-o', outputTemplate, + '--print', 'after_move:filepath', url ); let ytdlpCommand = 'yt-dlp'; - if (settings.ytdlpLocation) { - ytdlpCommand = path.join(settings.ytdlpLocation, 'yt-dlp'); + if (config.ytdlpLocation) { + ytdlpCommand = path.join(config.ytdlpLocation, 'yt-dlp'); } const proc = spawn(ytdlpCommand, args, { - shell: false, // Don't pass back to the shell, prevents it seeing the templating as interpretable by the shell + shell: false, }); let stdout = ''; @@ -214,8 +162,6 @@ function spawnYtDlp(url: string, outputTemplate: string, settings: AIToolboxSett */ async function readAndCleanupInfoJson(audioFilePath: string): Promise { try { - // The info.json file has the same base name but with .info.json extension - // e.g., "video_123.mp3" -> "video_123.info.json" const dir = path.dirname(audioFilePath); const basename = path.basename(audioFilePath, path.extname(audioFilePath)); const infoJsonPath = path.join(dir, `${basename}.info.json`); @@ -224,7 +170,6 @@ async function readAndCleanupInfoJson(audioFilePath: string): Promise { + try { + if (!url || !url.trim()) { + new Notice('URL is empty'); + return null; + } + + const trimmedUrl = url.trim(); + + if (!videoPlatformRegistry.isValidVideoUrl(trimmedUrl)) { + new Notice('The provided text is not a valid video URL'); + return null; + } + + new Notice('Preparing video for transcription...'); + + const handler = videoPlatformRegistry.findHandlerForUrl(trimmedUrl); + const filenameTemplate = handler + ? handler.getYtDlpArgs().outputConfig.filenameTemplate + : '%(title)s_%(id)s'; + + const config = settingsToProcessorConfig(settings); + const outputDir = getOutputDirectory(config); + const outputTemplate = path.join(outputDir, `${filenameTemplate}.%(ext)s`); + + const ytdlpResult = await runYtDlp(trimmedUrl, outputTemplate, config); + + new Notice(`Audio extracted successfully!\nReady for transcription.\nSaved to: ${path.dirname(ytdlpResult.audioFilePath)}`); + + return { + audioFilePath: ytdlpResult.audioFilePath, + sourceUrl: trimmedUrl, + metadata: { + title: ytdlpResult.title, + uploader: ytdlpResult.uploader, + description: ytdlpResult.description, + tags: ytdlpResult.tags, + }, + }; + + } catch (error) { + console.error('Video audio extraction error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(`Failed to extract audio for transcription: ${errorMessage}`); + return null; + } +} + +/** + * Downloads audio from a video URL in the clipboard for transcription. + * Supports TikTok, YouTube, and other platforms supported by yt-dlp. + * Uses yt-dlp directly via child_process for audio extraction. + * Requires yt-dlp and ffmpeg to be installed and available in PATH. + */ +export async function extractAudioFromClipboard(settings: AIToolboxSettings): Promise { + try { + const clipboardText = await navigator.clipboard.readText(); + if (!clipboardText) { + new Notice('Clipboard is empty'); + return null; + } + + return extractAudioFromUrl(clipboardText, settings); + + } catch (error) { + console.error('Clipboard read error:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + new Notice(`Failed to read clipboard: ${errorMessage}`); + return null; + } +} diff --git a/src/processing/workflow-chaining.ts b/src/processing/workflow-chaining.ts new file mode 100644 index 0000000..fd57e97 --- /dev/null +++ b/src/processing/workflow-chaining.ts @@ -0,0 +1,152 @@ +import { App } from 'obsidian'; +import { WorkflowConfig, AIToolboxSettings } from '../settings'; + +/** + * Result from executing a workflow, used for chaining + */ +export interface WorkflowExecutionResult { + /** The workflow that was executed */ + workflowId: string; + /** The workflow type */ + workflowType: 'chat' | 'transcription'; + /** Whether execution succeeded */ + success: boolean; + /** Error message if failed */ + error?: string; + /** Token values produced by the workflow */ + tokens: Record; +} + +/** + * Map of workflow ID to execution results + */ +export type WorkflowResultsMap = Map; + +/** + * Context for tracking workflow dependency execution + */ +export interface DependencyExecutionContext { + /** The Obsidian App instance */ + app: App; + /** Plugin settings */ + settings: AIToolboxSettings; + /** Results from executed dependencies */ + results: WorkflowResultsMap; + /** Workflow IDs currently in the execution stack (for circular detection) */ + executionStack: Set; +} + +/** + * Detect circular dependencies in a workflow's dependency chain. + * Returns an array of workflow names forming the cycle, or empty if no cycle. + */ +export function detectCircularDependency( + workflow: WorkflowConfig, + settings: AIToolboxSettings, + visited: Set = new Set(), + path: string[] = [] +): string[] { + if (visited.has(workflow.id)) { + // Found a cycle - return the path from the first occurrence + const cycleStart = path.indexOf(workflow.name); + return [...path.slice(cycleStart), workflow.name]; + } + + visited.add(workflow.id); + path.push(workflow.name); + + const workflowContexts = workflow.workflowContexts ?? []; + for (const ctx of workflowContexts) { + const depWorkflow = settings.workflows.find(w => w.id === ctx.workflowId); + if (depWorkflow) { + const cycle = detectCircularDependency(depWorkflow, settings, visited, path); + if (cycle.length > 0) { + return cycle; + } + } + } + + path.pop(); + return []; +} + +/** + * Check if a workflow has any workflow dependencies configured + */ +export function hasWorkflowDependencies(workflow: WorkflowConfig): boolean { + return (workflow.workflowContexts?.length ?? 0) > 0; +} + +/** + * Replace workflow context tokens in a prompt with actual values from executed dependencies. + * Tokens are in the format {{workflowId.tokenName}} + */ +export function replaceWorkflowTokens( + promptText: string, + results: WorkflowResultsMap +): string { + // Match tokens like {{workflowId.tokenName}} + const tokenPattern = /\{\{([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_]+)\}\}/g; + + return promptText.replace(tokenPattern, (match, workflowId: string, tokenName: string) => { + const result = results.get(workflowId); + if (!result) { + // Workflow not executed or not found - leave token as-is + console.warn(`Workflow result not found for token: ${match}`); + return match; + } + + const tokenValue = result.tokens[tokenName]; + if (tokenValue === undefined) { + // Token not found in results - leave as-is + console.warn(`Token "${tokenName}" not found in workflow "${workflowId}" results`); + return match; + } + + return tokenValue; + }); +} + +/** + * Create chat workflow tokens from execution data + */ +export function createChatWorkflowTokens( + promptText: string, + responseText: string +): Record { + return { + prompt: promptText, + response: responseText + }; +} + +/** + * Create transcription workflow tokens from execution data + */ +export function createTranscriptionWorkflowTokens( + transcriptionText: string, + metadata?: { + title?: string; + uploader?: string; + sourceUrl?: string; + description?: string; + tags?: string[]; + } +): Record { + return { + transcription: transcriptionText, + title: metadata?.title ?? '', + author: metadata?.uploader ?? '', + sourceUrl: metadata?.sourceUrl ?? '', + description: metadata?.description ?? '', + tags: metadata?.tags?.join(', ') ?? '' + }; +} + +/** + * Get ordered list of dependency workflow IDs for a workflow + */ +export function getDependencyWorkflowIds(workflow: WorkflowConfig): string[] { + return (workflow.workflowContexts ?? []).map(ctx => ctx.workflowId); +} + diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts new file mode 100644 index 0000000..47234b2 --- /dev/null +++ b/src/processing/workflow-executor.ts @@ -0,0 +1,531 @@ +import { App, Notice, TFile } from 'obsidian'; +import { WorkflowConfig, AIToolboxSettings, TranscriptionSourceType } from '../settings'; +import { createWorkflowProvider, ChatMessage, TranscriptionOptions, TranscriptionResult } from '../providers'; +import { videoPlatformRegistry } from './video-platforms'; +import { generateFilenameTimestamp } from '../utils/date-utils'; +import { + OutputHandler, + OutputContext, + NewNoteOutputHandler, + AtCursorOutputHandler, + PopupOutputHandler, + InputHandler, + InputContext, + InputResult, + VaultFileInputHandler, + ClipboardUrlInputHandler, + SelectionUrlInputHandler +} from '../handlers'; +import { + WorkflowExecutionResult, + WorkflowResultsMap, + detectCircularDependency, + hasWorkflowDependencies, + replaceWorkflowTokens, + createChatWorkflowTokens, + createTranscriptionWorkflowTokens, + getDependencyWorkflowIds +} from './workflow-chaining'; + +/** + * Create an output handler based on the workflow's output type. + */ +function createOutputHandler(outputType: string): OutputHandler { + switch (outputType) { + case 'new-note': + return new NewNoteOutputHandler(); + case 'at-cursor': + return new AtCursorOutputHandler(); + case 'popup': + default: + return new PopupOutputHandler(); + } +} + +/** + * Create an input handler based on the transcription source type. + */ +function createInputHandler(sourceType: TranscriptionSourceType): InputHandler { + switch (sourceType) { + case 'select-file-from-vault': + return new VaultFileInputHandler(); + case 'url-from-clipboard': + return new ClipboardUrlInputHandler(); + case 'url-from-selection': + return new SelectionUrlInputHandler(); + default: + return new VaultFileInputHandler(); + } +} + +/** + * Format a timestamp in seconds to MM:SS or HH:MM:SS format. + */ +function formatTimestamp(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Format a transcription result, optionally including timestamps. + * When timestamps are included and chunks are available, formats each segment + * with its start time prefix. + */ +function formatTranscriptionResult(result: TranscriptionResult, includeTimestamps: boolean): string { + if (!includeTimestamps || !result.chunks || result.chunks.length === 0) { + return result.text; + } + + return result.chunks + .map(chunk => `[${formatTimestamp(chunk.timestamp[0])}] ${chunk.text}`) + .join('\n'); +} + +/** + * Generate a note title for transcription output based on input source. + * - TikTok: "TikTok by - " + * - YouTube: "