diff --git a/manifest.json b/manifest.json index c277ea5..f626aa6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "ai-toolbox", "name": "AI Toolbox", - "version": "1.0.6", + "version": "1.0.7", "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 index 6d52c87..6e079c4 100644 --- a/src/components/collapsible-section.ts +++ b/src/components/collapsible-section.ts @@ -18,14 +18,18 @@ export interface CollapsibleSectionConfig { startExpanded: boolean; /** Whether to show as a heading (bold) - defaults to true */ isHeading?: boolean; - /** Optional icon to display before the title */ - icon?: string; + /** Optional icon(s) to display after the title - can be a single icon or array of icons */ + icons?: 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; + /** Callback when the move up button is clicked (if provided, move up button is shown) */ + onMoveUp?: () => void | Promise; + /** Callback when the move down button is clicked (if provided, move down button is shown) */ + onMoveDown?: () => void | Promise; } /** @@ -57,9 +61,11 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll headerClass, startExpanded, isHeading = true, - icon, + icons, secondaryText, onDelete, + onMoveUp, + onMoveDown, } = config; const container = containerEl.createDiv(containerClass); @@ -71,11 +77,11 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll // Track current title for updates let currentTitle = title; - let iconElement: HTMLElement | null = null; + let iconElements: HTMLElement[] = []; // 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 + // Arrow only, icons will be inserted separately return `${expanded ? '▾' : '▸'} ${name || 'Unnamed'}`; }; @@ -87,11 +93,14 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll 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 icons if provided (at the end, after title text) + if (icons && icons.length > 0) { + for (const iconName of icons) { + const iconElement = headerSetting.nameEl.createSpan({ cls: 'workflow-header-icon' }); + setIcon(iconElement, iconName); + headerSetting.nameEl.appendChild(iconElement); + iconElements.push(iconElement); + } } // Add secondary text if provided (displayed before delete button) @@ -100,6 +109,22 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll secondaryEl.textContent = secondaryText; } + // Add move up button if callback provided + if (onMoveUp) { + headerSetting.addButton(button => button + .setIcon('chevron-up') + .setTooltip('Move up') + .onClick(() => { void onMoveUp(); })); + } + + // Add move down button if callback provided + if (onMoveDown) { + headerSetting.addButton(button => button + .setIcon('chevron-down') + .setTooltip('Move down') + .onClick(() => { void onMoveDown(); })); + } + // Add delete button if callback provided if (onDelete) { headerSetting.addButton(button => button @@ -117,8 +142,8 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll contentContainer.classList.toggle('is-expanded', isCollapsed); headerSetting.setName(getFormattedTitle(currentTitle, isCollapsed)); - // Re-add icon at the end - if (icon && iconElement) { + // Re-add icons at the end + for (const iconElement of iconElements) { headerSetting.nameEl.appendChild(iconElement); } }; @@ -139,8 +164,8 @@ export function createCollapsibleSection(config: CollapsibleSectionConfig): Coll const isExpanded = contentContainer.classList.contains('is-expanded'); headerSetting.setName(getFormattedTitle(newTitle, isExpanded)); - // Re-add icon at the end - if (icon && iconElement) { + // Re-add icons at the end + for (const iconElement of iconElements) { headerSetting.nameEl.appendChild(iconElement); } }; diff --git a/src/components/delete-mode-manager.ts b/src/components/delete-mode-manager.ts new file mode 100644 index 0000000..9458030 --- /dev/null +++ b/src/components/delete-mode-manager.ts @@ -0,0 +1,59 @@ +/** + * Manages delete mode state for a collection of entities. + * Provides a centralized way to track whether delete mode is enabled + * for different entity types (workflows, actions, providers, models). + */ +export class DeleteModeManager { + private deleteStates: Map = new Map(); + + /** + * Get delete mode state for an entity + * @param entityId - The unique identifier for the entity (e.g., workflow ID for actions) + * @returns Whether delete mode is enabled + */ + get(entityId: string): boolean { + return this.deleteStates.get(entityId) ?? false; + } + + /** + * Set delete mode state for an entity + * @param entityId - The unique identifier for the entity + * @param enabled - Whether delete mode should be enabled + */ + set(entityId: string, enabled: boolean): void { + this.deleteStates.set(entityId, enabled); + } + + /** + * Toggle delete mode state for an entity + * @param entityId - The unique identifier for the entity + * @returns The new delete mode state + */ + toggle(entityId: string): boolean { + const newState = !this.get(entityId); + this.set(entityId, newState); + return newState; + } + + /** + * Clear delete mode state for an entity + * @param entityId - The unique identifier for the entity + */ + clear(entityId: string): void { + this.deleteStates.delete(entityId); + } + + /** + * Clear all delete mode states + */ + clearAll(): void { + this.deleteStates.clear(); + } +} + +// Global manager for top-level entity delete modes (workflows, providers) +export const globalDeleteModeManager = new DeleteModeManager(); + +// Global manager for nested entity delete modes (actions within workflows, models within providers) +export const nestedDeleteModeManager = new DeleteModeManager(); + diff --git a/src/components/entity-list-header.ts b/src/components/entity-list-header.ts new file mode 100644 index 0000000..07fc05a --- /dev/null +++ b/src/components/entity-list-header.ts @@ -0,0 +1,103 @@ +import { Setting, setIcon } from "obsidian"; + +/** + * Configuration for creating an entity list header with delete mode toggle and add button + */ +export interface EntityListHeaderConfig { + /** Container element where the header will be created */ + containerEl: HTMLElement; + /** Label for the entities (e.g., 'Workflows', 'Actions', 'Models') */ + label?: string; + /** Description for the setting */ + description?: string; + /** Current delete mode state */ + isDeleteMode: boolean; + /** Callback when delete mode is toggled */ + onDeleteModeChange: (enabled: boolean) => void | Promise; + /** Text for the add button (e.g., 'Add workflow', 'Add model') */ + addButtonText?: string; + /** Whether the add button should be CTA styled */ + addButtonCta?: boolean; + /** Callback when add button is clicked (if not provided, no add button shown) */ + onAdd?: () => void | Promise; + /** Optional dropdown options for add (alternative to button) */ + addDropdownOptions?: Record; + /** Callback when a dropdown option is selected */ + onDropdownSelect?: (value: string) => void | Promise; +} + +/** + * Creates a header for an entity list with delete mode toggle and add button. + * This provides a consistent UI pattern for managing collections of entities. + */ +export function createEntityListHeader(config: EntityListHeaderConfig): Setting { + const { + containerEl, + label, + description, + isDeleteMode, + onDeleteModeChange, + addButtonText, + addButtonCta = false, + onAdd, + addDropdownOptions, + onDropdownSelect, + } = config; + + const setting = new Setting(containerEl); + + if (label) { + setting.setName(label); + } + if (description) { + setting.setDesc(description); + } + + // Add delete mode toggle with trash icon + setting.addToggle(toggle => { + toggle + .setValue(isDeleteMode) + .setTooltip(isDeleteMode ? 'Exit delete mode' : 'Enter delete mode') + .onChange(async (value) => { + await onDeleteModeChange(value); + }); + + // Create a custom label with trash icon before the toggle + const toggleContainer = toggle.toggleEl.parentElement; + if (toggleContainer) { + const labelContainer = toggleContainer.createDiv({ cls: 'delete-mode-toggle-label' }); + const iconSpan = labelContainer.createSpan({ cls: 'delete-mode-toggle-icon' }); + setIcon(iconSpan, 'trash'); + // Move the label before the toggle element + toggleContainer.insertBefore(labelContainer, toggle.toggleEl); + } + }); + + // Add dropdown if options provided + if (addDropdownOptions && onDropdownSelect) { + setting.addDropdown(dropdown => dropdown + .addOption('', 'Add...') + .addOptions(addDropdownOptions) + .onChange(async (value) => { + if (value) { + await onDropdownSelect(value); + } + })); + } + + // Add button if callback provided + if (onAdd && addButtonText) { + setting.addButton(button => { + button.setButtonText(addButtonText); + if (addButtonCta) { + button.setCta(); + } + button.onClick(async () => { + await onAdd(); + }); + }); + } + + return setting; +} + diff --git a/src/components/ordered-list-utils.ts b/src/components/ordered-list-utils.ts new file mode 100644 index 0000000..4e74e56 --- /dev/null +++ b/src/components/ordered-list-utils.ts @@ -0,0 +1,133 @@ +/** + * Options for move operations + */ +export interface MoveOptions { + /** The array of items */ + items: T[]; + /** Current index of the item to move */ + index: number; +} + +/** + * Result of a move operation + */ +export interface MoveResult { + /** Whether the move was successful */ + success: boolean; + /** The new index after moving (same as original if not successful) */ + newIndex: number; +} + +/** + * Move an item up in an array (toward index 0) + * @param options - Move options containing the items array and current index + * @returns Result indicating success and new index + */ +export function moveItemUp(options: MoveOptions): MoveResult { + const { items, index } = options; + + if (index <= 0 || index >= items.length) { + return { success: false, newIndex: index }; + } + + const temp = items[index - 1]; + const current = items[index]; + if (temp !== undefined && current !== undefined) { + items[index - 1] = current; + items[index] = temp; + return { success: true, newIndex: index - 1 }; + } + + return { success: false, newIndex: index }; +} + +/** + * Move an item down in an array (toward higher index) + * @param options - Move options containing the items array and current index + * @returns Result indicating success and new index + */ +export function moveItemDown(options: MoveOptions): MoveResult { + const { items, index } = options; + + if (index < 0 || index >= items.length - 1) { + return { success: false, newIndex: index }; + } + + const temp = items[index + 1]; + const current = items[index]; + if (temp !== undefined && current !== undefined) { + items[index + 1] = current; + items[index] = temp; + return { success: true, newIndex: index + 1 }; + } + + return { success: false, newIndex: index }; +} + +/** + * Check if an item can be moved up + * @param index - Current index of the item + * @returns Whether the item can be moved up + */ +export function canMoveUp(index: number): boolean { + return index > 0; +} + +/** + * Check if an item can be moved down + * @param index - Current index of the item + * @param arrayLength - Total length of the array + * @returns Whether the item can be moved down + */ +export function canMoveDown(index: number, arrayLength: number): boolean { + return index < arrayLength - 1; +} + +/** + * Configuration for creating move handlers for collapsible sections + */ +export interface CreateMoveHandlersConfig { + /** The array of items */ + items: T[]; + /** Current index of the item */ + index: number; + /** Whether delete mode is currently active (hides move buttons when true) */ + isDeleteMode: boolean; + /** Callback to save settings after move */ + saveSettings: () => Promise; + /** Callback to preserve expand state before refresh */ + preserveExpandState: () => void; + /** Callback to refresh the UI */ + refresh: () => void; +} + +/** + * Create move up/down handlers for a collapsible section + * Returns undefined for handlers that should not be shown + */ +export function createMoveHandlers( + config: CreateMoveHandlersConfig +): { onMoveUp?: () => Promise; onMoveDown?: () => Promise } { + const { items, index, isDeleteMode, saveSettings, preserveExpandState, refresh } = config; + + if (isDeleteMode) { + return {}; + } + + const onMoveUp = canMoveUp(index) ? async () => { + moveItemUp({ items, index }); + await saveSettings(); + preserveExpandState(); + refresh(); + } : undefined; + + const onMoveDown = canMoveDown(index, items.length) ? async () => { + moveItemDown({ items, index }); + await saveSettings(); + preserveExpandState(); + refresh(); + } : undefined; + + return { onMoveUp, onMoveDown }; +} + diff --git a/src/handlers/input/clipboard-url-input-handler.ts b/src/handlers/input/clipboard-url-input-handler.ts index 3acdcae..0ff9521 100644 --- a/src/handlers/input/clipboard-url-input-handler.ts +++ b/src/handlers/input/clipboard-url-input-handler.ts @@ -1,16 +1,37 @@ import { Notice } from 'obsidian'; import { InputHandler, InputContext, InputResult } from './types'; -import { extractAudioFromClipboard } from '../../processing'; +import { extractAudioFromUrl } 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. + * + * Note: This is a legacy handler that uses default browser settings. + * For per-action browser/cookie settings, use TokenUrlInputHandler instead. */ export class ClipboardUrlInputHandler implements InputHandler { async getInput(context: InputContext): Promise { new Notice('Checking clipboard for video URL...'); - const result = await extractAudioFromClipboard(context.settings); + let clipboardText: string; + try { + clipboardText = await navigator.clipboard.readText(); + } catch { + new Notice('Failed to read clipboard'); + return null; + } + + if (!clipboardText || !clipboardText.trim()) { + new Notice('Clipboard is empty'); + return null; + } + + const defaultExtractionSettings = { + impersonateBrowser: 'chrome', + useBrowserCookies: false + }; + + const result = await extractAudioFromUrl(clipboardText.trim(), context.settings, defaultExtractionSettings); if (!result) { return null; diff --git a/src/handlers/input/selection-url-input-handler.ts b/src/handlers/input/selection-url-input-handler.ts index 4cea098..be13c9c 100644 --- a/src/handlers/input/selection-url-input-handler.ts +++ b/src/handlers/input/selection-url-input-handler.ts @@ -5,6 +5,9 @@ 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. + * + * Note: This is a legacy handler that uses default browser settings. + * For per-action browser/cookie settings, use TokenUrlInputHandler instead. */ export class SelectionUrlInputHandler implements InputHandler { async getInput(context: InputContext): Promise { @@ -27,7 +30,12 @@ export class SelectionUrlInputHandler implements InputHandler { new Notice('Processing URL from selection...'); - const result = await extractAudioFromUrl(url, context.settings); + const defaultExtractionSettings = { + impersonateBrowser: 'chrome', + useBrowserCookies: false + }; + + const result = await extractAudioFromUrl(url, context.settings, defaultExtractionSettings); if (!result) { return null; diff --git a/src/handlers/input/token-url-input-handler.ts b/src/handlers/input/token-url-input-handler.ts index 7d92a53..44a26ed 100644 --- a/src/handlers/input/token-url-input-handler.ts +++ b/src/handlers/input/token-url-input-handler.ts @@ -1,6 +1,6 @@ import { Notice } from 'obsidian'; import { InputHandler, InputContext, InputResult } from './types'; -import { extractAudioFromUrl } from '../../processing'; +import { extractAudioFromUrl, VideoExtractionSettings } from '../../processing'; /** * Input handler that extracts audio from a URL resolved from a token value. @@ -8,9 +8,11 @@ import { extractAudioFromUrl } from '../../processing'; */ export class TokenUrlInputHandler implements InputHandler { private url: string; + private extractionSettings: VideoExtractionSettings; - constructor(url: string) { + constructor(url: string, extractionSettings: VideoExtractionSettings) { this.url = url; + this.extractionSettings = extractionSettings; } async getInput(context: InputContext): Promise { @@ -21,7 +23,7 @@ export class TokenUrlInputHandler implements InputHandler { new Notice('Processing URL from token...'); - const result = await extractAudioFromUrl(this.url.trim(), context.settings); + const result = await extractAudioFromUrl(this.url.trim(), context.settings, this.extractionSettings); if (!result) { return null; diff --git a/src/main.ts b/src/main.ts index f1d6132..be38f54 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,16 @@ import { Plugin } from 'obsidian'; -import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab } from "./settings/index"; +import { DEFAULT_SETTINGS, AIToolboxSettings, AIToolboxSettingTab, WorkflowConfig } from "./settings/index"; import { WorkflowSuggesterModal } from "./components/workflow-suggester"; import { executeWorkflow } from "./processing/workflow-executor"; import { VIEW_TYPE_LOG, LogPaneView, logInfo, logNotice, LogCategory } from "./logging"; +// Command ID prefix for workflow commands +const WORKFLOW_COMMAND_PREFIX = 'execute-workflow-'; + export default class AIToolboxPlugin extends Plugin { settings: AIToolboxSettings; + // Track registered workflow command IDs for cleanup + private registeredWorkflowCommandIds: Set = new Set(); async onload() { await this.loadSettings(); @@ -34,6 +39,9 @@ export default class AIToolboxPlugin extends Plugin { } }); + // Register individual workflow commands + this.registerWorkflowCommands(); + // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new AIToolboxSettingTab(this.app, this)); @@ -42,6 +50,7 @@ export default class AIToolboxPlugin extends Plugin { onunload() { // Log views are cleaned up automatically by Obsidian when the plugin unloads + // Workflow commands are also cleaned up automatically when the plugin unloads } /** @@ -87,5 +96,68 @@ export default class AIToolboxPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); + // Re-register workflow commands to pick up any changes + this.registerWorkflowCommands(); + } + + /** + * Get the command ID for a workflow. + */ + private getWorkflowCommandId(workflow: WorkflowConfig): string { + return `${WORKFLOW_COMMAND_PREFIX}${workflow.id}`; + } + + /** + * Register individual workflow commands for workflows with showInCommandPalette enabled. + * This method handles both initial registration and updates when settings change. + */ + private registerWorkflowCommands(): void { + // Build the set of command IDs that should be registered + const shouldBeRegistered = new Set(); + for (const workflow of this.settings.workflows) { + if (workflow.showInCommandPalette) { + shouldBeRegistered.add(this.getWorkflowCommandId(workflow)); + } + } + + // Remove commands that should no longer be registered + for (const commandId of this.registeredWorkflowCommandIds) { + if (!shouldBeRegistered.has(commandId)) { + this.unregisterWorkflowCommand(commandId); + this.registeredWorkflowCommandIds.delete(commandId); + } + } + + // Register new commands + for (const workflow of this.settings.workflows) { + if (!workflow.showInCommandPalette) { + continue; + } + + const commandId = this.getWorkflowCommandId(workflow); + if (!this.registeredWorkflowCommandIds.has(commandId)) { + this.addCommand({ + id: commandId, + name: `Run workflow: ${workflow.name}`, + callback: () => { + void executeWorkflow(this.app, this.settings, workflow); + } + }); + this.registeredWorkflowCommandIds.add(commandId); + } + } + } + + /** + * Unregister a workflow command by its ID. + */ + private unregisterWorkflowCommand(commandId: string): void { + // Access the internal commands API to remove commands + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + const commands = (this.app as any).commands; + if (commands && typeof commands.removeCommand === 'function') { + commands.removeCommand(`${this.manifest.id}:${commandId}`); + } + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ } } diff --git a/src/processing/action-executor.ts b/src/processing/action-executor.ts index f442a59..46f79fc 100644 --- a/src/processing/action-executor.ts +++ b/src/processing/action-executor.ts @@ -1,5 +1,5 @@ -import { App, TFile } from 'obsidian'; -import { AIToolboxSettings, ChatAction, TranscriptionAction, WorkflowAction } from '../settings'; +import { App, TFile, requestUrl } from 'obsidian'; +import { AIToolboxSettings, ChatAction, TranscriptionAction, HttpRequestAction, WorkflowAction } from '../settings'; import { createActionProvider, ChatMessage, TranscriptionOptions } from '../providers'; import { InputContext, @@ -9,6 +9,7 @@ import { import { createChatWorkflowTokens, createTranscriptionWorkflowTokens, + createHttpRequestWorkflowTokens, ContextTokenValues, replaceWorkflowContextTokens } from './workflow-chaining'; @@ -21,7 +22,7 @@ export interface ActionExecutionResult { /** The action that was executed */ actionId: string; /** The action type */ - actionType: 'chat' | 'transcription'; + actionType: 'chat' | 'transcription' | 'http-request'; /** Whether execution succeeded */ success: boolean; /** Error message if failed */ @@ -256,7 +257,13 @@ export async function executeTranscriptionAction( return { ...baseResult, error: `No URL found in token {{${sourceUrlToken}}}` }; } - const inputHandler = new TokenUrlInputHandler(sourceUrl); + // Get per-action browser/cookie settings (required, with defaults) + const extractionSettings = { + impersonateBrowser: action.transcriptionContext?.impersonateBrowser ?? 'chrome', + useBrowserCookies: action.transcriptionContext?.useBrowserCookies ?? false + }; + + const inputHandler = new TokenUrlInputHandler(sourceUrl, extractionSettings); // Create a minimal workflow-like object for InputContext compatibility const inputContext: InputContext = { @@ -267,7 +274,8 @@ export async function executeTranscriptionAction( name: action.name, actions: [], outputType: 'popup', - outputFolder: '' + outputFolder: '', + showInCommandPalette: false } }; @@ -309,6 +317,49 @@ export async function executeTranscriptionAction( } } +/** + * Execute an HTTP request action and return the result. + */ +export async function executeHttpRequestAction( + action: HttpRequestAction, + context: ActionExecutionContext +): Promise { + const baseResult: ActionExecutionResult = { + actionId: action.id, + actionType: 'http-request', + success: false, + tokens: {} + }; + + // Resolve the URL from the configured token + const sourceUrlToken = action.sourceUrlToken ?? 'workflow.clipboard'; + const url = resolveTokenValue(sourceUrlToken, context); + + if (!url || !url.trim()) { + return { ...baseResult, error: `No URL found in token {{${sourceUrlToken}}}` }; + } + + try { + logDebug(LogCategory.WORKFLOW, `Executing HTTP request action: ${action.name} -> ${url}`); + + const response = await requestUrl({ + url: url.trim(), + method: 'GET' + }); + + logInfo(LogCategory.WORKFLOW, `HTTP request completed: ${action.name} (status: ${response.status})`); + + return { + ...baseResult, + success: true, + tokens: createHttpRequestWorkflowTokens(url.trim(), response.status, response.text) + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { ...baseResult, error: errorMessage }; + } +} + /** * Execute any action type and return the result. */ @@ -318,8 +369,10 @@ export async function executeAction( ): Promise { if (action.type === 'chat') { return executeChatAction(action, context); - } else { + } else if (action.type === 'transcription') { return executeTranscriptionAction(action, context); + } else { + return executeHttpRequestAction(action, context); } } diff --git a/src/processing/index.ts b/src/processing/index.ts index b488143..46889d8 100644 --- a/src/processing/index.ts +++ b/src/processing/index.ts @@ -26,11 +26,11 @@ export { getOutputDirectory, runYtDlp, extractAudioFromUrl, - extractAudioFromClipboard, } from './video-processor'; export type { VideoProcessorConfig, + VideoExtractionSettings, YtDlpResult, ExtractAudioResult, } from './video-processor'; diff --git a/src/processing/video-processor.ts b/src/processing/video-processor.ts index decbe80..7ee82a9 100644 --- a/src/processing/video-processor.ts +++ b/src/processing/video-processor.ts @@ -14,7 +14,7 @@ export interface VideoProcessorConfig { ytdlpLocation?: string; ffmpegLocation?: string; impersonateBrowser: string; - cookiesFromBrowser?: string; + useBrowserCookies: boolean; keepVideo: boolean; outputDirectory?: string; } @@ -109,8 +109,8 @@ function spawnYtDlp( args.push('--ffmpeg-location', config.ffmpegLocation); } - if (config.cookiesFromBrowser) { - args.push('--cookies-from-browser', config.cookiesFromBrowser); + if (config.useBrowserCookies) { + args.push('--cookies-from-browser', config.impersonateBrowser); } args.push( @@ -203,14 +203,23 @@ export interface ExtractAudioResult { } /** - * Converts AIToolboxSettings to VideoProcessorConfig. + * Browser and cookie settings for video extraction. + * These are required per-action settings for transcription workflows. */ -function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorConfig { +export interface VideoExtractionSettings { + impersonateBrowser: string; + useBrowserCookies: boolean; +} + +/** + * Converts AIToolboxSettings and extraction settings to VideoProcessorConfig. + */ +function settingsToProcessorConfig(settings: AIToolboxSettings, extractionSettings: VideoExtractionSettings): VideoProcessorConfig { return { ytdlpLocation: settings.ytdlpLocation, ffmpegLocation: settings.ffmpegLocation, - impersonateBrowser: settings.impersonateBrowser, - cookiesFromBrowser: settings.cookiesFromBrowser, + impersonateBrowser: extractionSettings.impersonateBrowser, + useBrowserCookies: extractionSettings.useBrowserCookies, keepVideo: settings.keepVideo, outputDirectory: settings.outputDirectory, }; @@ -221,8 +230,16 @@ function settingsToProcessorConfig(settings: AIToolboxSettings): VideoProcessorC * 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. + * + * @param url - The video URL to extract audio from + * @param settings - Plugin settings for yt-dlp/ffmpeg paths + * @param extractionSettings - Browser and cookie settings for video extraction */ -export async function extractAudioFromUrl(url: string, settings: AIToolboxSettings): Promise { +export async function extractAudioFromUrl( + url: string, + settings: AIToolboxSettings, + extractionSettings: VideoExtractionSettings +): Promise { try { if (!url || !url.trim()) { logNotice(LogCategory.TRANSCRIPTION, 'URL is empty'); @@ -243,7 +260,7 @@ export async function extractAudioFromUrl(url: string, settings: AIToolboxSettin ? handler.getYtDlpArgs().outputConfig.filenameTemplate : '%(title)s_%(id)s'; - const config = settingsToProcessorConfig(settings); + const config = settingsToProcessorConfig(settings, extractionSettings); const outputDir = getOutputDirectory(config); const outputTemplate = path.join(outputDir, `${filenameTemplate}.%(ext)s`); @@ -268,25 +285,4 @@ export async function extractAudioFromUrl(url: string, settings: AIToolboxSettin } } -/** - * 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) { - logNotice(LogCategory.TRANSCRIPTION, 'Clipboard is empty'); - return null; - } - - return extractAudioFromUrl(clipboardText, settings); - } catch (error) { - logNotice(LogCategory.TRANSCRIPTION, `Failed to read clipboard: ${error instanceof Error ? error.message : String(error)}`, error); - return null; - } -} diff --git a/src/processing/workflow-chaining.ts b/src/processing/workflow-chaining.ts index 5b28d93..fdd2c9c 100644 --- a/src/processing/workflow-chaining.ts +++ b/src/processing/workflow-chaining.ts @@ -9,7 +9,7 @@ export interface WorkflowExecutionResult { /** The workflow that was executed */ workflowId: string; /** The workflow type */ - workflowType: 'chat' | 'transcription'; + workflowType: 'chat' | 'transcription' | 'http-request'; /** Whether execution succeeded */ success: boolean; /** Error message if failed */ @@ -150,6 +150,21 @@ export function createTranscriptionWorkflowTokens( return tokens; } +/** + * Create HTTP request workflow tokens from execution data + */ +export function createHttpRequestWorkflowTokens( + url: string, + statusCode: number, + responseBody: string +): Record { + return { + url, + statusCode: String(statusCode), + responseBody + }; +} + /** * Get ordered list of dependency workflow IDs for a workflow. * Currently workflows don't have cross-workflow dependencies. diff --git a/src/processing/workflow-executor.ts b/src/processing/workflow-executor.ts index 40481ab..ca1a67c 100644 --- a/src/processing/workflow-executor.ts +++ b/src/processing/workflow-executor.ts @@ -76,13 +76,16 @@ function generateNoteTitle(result: ActionExecutionResult, workflowName: string): function getFinalOutputText(lastResult: ActionExecutionResult): string { if (lastResult.actionType === 'chat') { return lastResult.tokens['response'] || ''; - } else { + } else if (lastResult.actionType === 'transcription') { // For transcription, prefer timestamped version if available const timestamped = lastResult.tokens['transcriptionWithTimestamps']; if (timestamped) { return timestamped; } return lastResult.tokens['transcription'] || ''; + } else { + // For http-request, return the response body + return lastResult.tokens['responseBody'] || ''; } } diff --git a/src/settings/additional-settings.ts b/src/settings/additional-settings.ts index 18ead84..42dfc50 100644 --- a/src/settings/additional-settings.ts +++ b/src/settings/additional-settings.ts @@ -25,41 +25,6 @@ export function displayAdditionalSettings( .setHeading(); ytdlpHeading.settingEl.addClass('additional-settings-heading'); - new Setting(containerEl) - .setName('Browser to impersonate') - .setDesc('Browser to impersonate when extracting audio for transcription') - .addDropdown(dropdown => dropdown - .addOption('chrome', 'Chrome') - .addOption('edge', 'Edge') - .addOption('safari', 'Safari') - .addOption('firefox', 'Firefox') - .addOption('brave', 'Brave') - .addOption('chromium', 'Chromium') - .setValue(plugin.settings.impersonateBrowser) - .onChange(async (value) => { - plugin.settings.impersonateBrowser = value; - await plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Use cookies from browser') - .setDesc('Extract cookies from a browser for authentication (required for some age-restricted or private content)') - .addDropdown(dropdown => dropdown - .addOption('', 'None') - .addOption('chrome', 'Chrome') - .addOption('edge', 'Edge') - .addOption('safari', 'Safari') - .addOption('firefox', 'Firefox') - .addOption('brave', 'Brave') - .addOption('chromium', 'Chromium') - .addOption('opera', 'Opera') - .addOption('vivaldi', 'Vivaldi') - .setValue(plugin.settings.cookiesFromBrowser) - .onChange(async (value) => { - plugin.settings.cookiesFromBrowser = value; - await plugin.saveSettings(); - })); - const ytdlpPathSetting = new Setting(containerEl) .setName('yt-dlp path') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun .setDesc('Path to yt-dlp binary directory (leave empty to use system PATH)') // eslint-disable-line obsidianmd/ui/sentence-case -- proper noun diff --git a/src/settings/index.ts b/src/settings/index.ts index 290131a..8dde1ea 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -12,7 +12,8 @@ export { DEFAULT_SETTINGS, DEFAULT_WORKFLOW_CONFIG, DEFAULT_CHAT_ACTION, - DEFAULT_TRANSCRIPTION_ACTION + DEFAULT_TRANSCRIPTION_ACTION, + DEFAULT_HTTP_REQUEST_ACTION } from "./types"; export type { AIProviderType, @@ -32,6 +33,7 @@ export type { BaseAction, ChatAction, TranscriptionAction, + HttpRequestAction, WorkflowAction, AIToolboxSettings, SettingsTabType, diff --git a/src/settings/providers.ts b/src/settings/providers.ts index 76b2233..26dd584 100644 --- a/src/settings/providers.ts +++ b/src/settings/providers.ts @@ -11,6 +11,9 @@ import { import { createModelProvider, ModelProviderConfig } from "../providers"; import { createCollapsibleSection } from "../components/collapsible-section"; import { createTestAudioBuffer } from "../processing/audio-processor"; +import { globalDeleteModeManager, nestedDeleteModeManager } from "../components/delete-mode-manager"; +import { createEntityListHeader } from "../components/entity-list-header"; +import { createMoveHandlers } from "../components/ordered-list-utils"; /** * Callbacks for the provider settings tab to communicate with the main settings tab @@ -151,6 +154,9 @@ async function testModel(provider: AIProviderConfig, model: AIModelConfig): Prom } } +// Key for provider-level delete mode in the global manager +const PROVIDERS_DELETE_MODE_KEY = '__providers__'; + /** * Display the providers settings tab content */ @@ -159,30 +165,44 @@ export function displayProvidersSettings( plugin: AIToolboxPlugin, callbacks: ProviderSettingsCallbacks ): void { - // Add provider button - new Setting(containerEl) - .setName('AI providers') - .setDesc('Configure AI providers for transcription and other features') - .addButton(button => button - .setButtonText('Add provider') - .setCta() - .onClick(async () => { - const newProvider: AIProviderConfig = { - id: generateId(), - name: 'New provider', - type: 'azure-openai', - endpoint: '', - apiKey: '', - models: [] - }; - plugin.settings.providers.push(newProvider); - await plugin.saveSettings(); - callbacks.refresh(); - })); + const isProviderDeleteMode = globalDeleteModeManager.get(PROVIDERS_DELETE_MODE_KEY); + + // Add provider header with delete mode toggle and add button + createEntityListHeader({ + containerEl, + label: 'AI providers', + description: 'Configure AI providers for transcription and other features', + isDeleteMode: isProviderDeleteMode, + onDeleteModeChange: (value) => { + globalDeleteModeManager.set(PROVIDERS_DELETE_MODE_KEY, value); + callbacks.refresh(); + }, + addButtonText: 'Add provider', + addButtonCta: true, + onAdd: async () => { + const newProvider: AIProviderConfig = { + id: generateId(), + name: 'New provider', + type: 'azure-openai', + endpoint: '', + apiKey: '', + models: [] + }; + plugin.settings.providers.push(newProvider); + callbacks.setExpandState({ providerId: newProvider.id }); + await plugin.saveSettings(); + callbacks.refresh(); + } + }); + + // Add horizontal rule separator + containerEl.createEl('hr', { cls: 'entity-list-separator' }); // Display each provider - for (const provider of plugin.settings.providers) { - displayProviderSettings(containerEl, plugin, provider, callbacks); + for (let i = 0; i < plugin.settings.providers.length; i++) { + const provider = plugin.settings.providers[i]; + if (!provider) continue; + displayProviderSettings(containerEl, plugin, provider, i, callbacks, isProviderDeleteMode); } } @@ -190,12 +210,24 @@ function displayProviderSettings( containerEl: HTMLElement, plugin: AIToolboxPlugin, provider: AIProviderConfig, - callbacks: ProviderSettingsCallbacks + index: number, + callbacks: ProviderSettingsCallbacks, + isProviderDeleteMode: boolean ): void { const expandState = callbacks.getExpandState(); const shouldExpand = expandState.providerId === provider.id; - const { contentContainer, updateTitle } = createCollapsibleSection({ + // Create move handlers using utility + const moveHandlers = createMoveHandlers({ + items: plugin.settings.providers, + index, + isDeleteMode: isProviderDeleteMode, + saveSettings: () => plugin.saveSettings(), + preserveExpandState: () => callbacks.setExpandState({ providerId: provider.id }), + refresh: callbacks.refresh + }); + + const { contentContainer, updateTitle, isExpanded } = createCollapsibleSection({ containerEl, title: provider.name || 'Unnamed provider', containerClass: 'provider-container', @@ -203,14 +235,14 @@ function displayProviderSettings( headerClass: 'provider-header', startExpanded: shouldExpand, isHeading: true, - onDelete: async () => { - const index = plugin.settings.providers.findIndex(p => p.id === provider.id); - if (index !== -1) { - plugin.settings.providers.splice(index, 1); - await plugin.saveSettings(); - callbacks.refresh(); - } - }, + onMoveUp: moveHandlers.onMoveUp, + onMoveDown: moveHandlers.onMoveDown, + // Show delete button only when in delete mode + onDelete: isProviderDeleteMode ? async () => { + plugin.settings.providers.splice(index, 1); + await plugin.saveSettings(); + callbacks.refresh(); + } : undefined, }); // Provider name @@ -273,29 +305,43 @@ function displayProviderSettings( await plugin.saveSettings(); })); - // Models section - new Setting(contentContainer) - .setName('Models') - .setDesc('Configure available models for this provider') - .addButton(button => button - .setButtonText('Add model') - .onClick(async () => { - const newModel: AIModelConfig = { - id: generateId(), - name: 'New model', - deploymentName: '', - modelId: '' - }; - provider.models.push(newModel); - // Keep provider expanded and expand the new model - callbacks.setExpandState({ providerId: provider.id, modelId: newModel.id }); - await plugin.saveSettings(); - callbacks.refresh(); - })); + // Get model delete mode state for this provider from the nested manager + const isModelDeleteMode = nestedDeleteModeManager.get(provider.id); + + // Models section header with delete mode toggle and add button + createEntityListHeader({ + containerEl: contentContainer, + label: 'Models', + description: 'Configure available models for this provider', + isDeleteMode: isModelDeleteMode, + onDeleteModeChange: (value) => { + nestedDeleteModeManager.set(provider.id, value); + if (isExpanded()) { + callbacks.setExpandState({ providerId: provider.id }); + } + callbacks.refresh(); + }, + addButtonText: 'Add model', + onAdd: async () => { + const newModel: AIModelConfig = { + id: generateId(), + name: 'New model', + deploymentName: '', + modelId: '' + }; + provider.models.push(newModel); + // Keep provider expanded and expand the new model + callbacks.setExpandState({ providerId: provider.id, modelId: newModel.id }); + await plugin.saveSettings(); + callbacks.refresh(); + } + }); // Display models - for (const model of provider.models) { - displayModelSettings(contentContainer, plugin, provider, model, callbacks); + for (let i = 0; i < provider.models.length; i++) { + const model = provider.models[i]; + if (!model) continue; + displayModelSettings(contentContainer, plugin, provider, model, i, callbacks, isExpanded, isModelDeleteMode); } // Clear the expand state after rendering all models for this provider @@ -309,12 +355,37 @@ function displayModelSettings( plugin: AIToolboxPlugin, provider: AIProviderConfig, model: AIModelConfig, - callbacks: ProviderSettingsCallbacks + index: number, + callbacks: ProviderSettingsCallbacks, + isProviderExpanded: () => boolean, + isModelDeleteMode: boolean ): void { const expandState = callbacks.getExpandState(); const shouldExpand = expandState.modelId === model.id; - const { contentContainer, updateTitle } = createCollapsibleSection({ + // We need isModelExpanded before creating move handlers, so we track it via a ref + let modelExpandedRef = { current: shouldExpand }; + + // Helper to preserve model expand state on refresh + const preserveModelExpandState = () => { + if (isProviderExpanded() && modelExpandedRef.current) { + callbacks.setExpandState({ providerId: provider.id, modelId: model.id }); + } else if (isProviderExpanded()) { + callbacks.setExpandState({ providerId: provider.id }); + } + }; + + // Create move handlers using utility + const moveHandlers = createMoveHandlers({ + items: provider.models, + index, + isDeleteMode: isModelDeleteMode, + saveSettings: () => plugin.saveSettings(), + preserveExpandState: preserveModelExpandState, + refresh: callbacks.refresh + }); + + const { contentContainer, updateTitle, isExpanded: isModelExpanded } = createCollapsibleSection({ containerEl, title: model.name || 'Unnamed model', containerClass: 'model-container', @@ -322,17 +393,22 @@ function displayModelSettings( headerClass: 'model-header', startExpanded: shouldExpand, isHeading: false, - onDelete: async () => { - const index = provider.models.findIndex(m => m.id === model.id); - if (index !== -1) { - provider.models.splice(index, 1); + onMoveUp: moveHandlers.onMoveUp, + onMoveDown: moveHandlers.onMoveDown, + // Show delete button only when in delete mode + onDelete: isModelDeleteMode ? async () => { + provider.models.splice(index, 1); + await plugin.saveSettings(); + if (isProviderExpanded()) { callbacks.setExpandState({ providerId: provider.id }); - await plugin.saveSettings(); - callbacks.refresh(); } - }, + callbacks.refresh(); + } : undefined, }); + // Update the ref to use the actual isExpanded function + modelExpandedRef = { get current() { return isModelExpanded(); } }; + // Model name new Setting(contentContainer) .setName('Display name') diff --git a/src/settings/types.ts b/src/settings/types.ts index ecd9cd2..313b070 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -57,8 +57,10 @@ export type WorkflowType = 'chat' | 'transcription'; /** * Media type for transcription context + * - 'video-url': Video from a URL (requires yt-dlp to extract audio) + * - 'audio-file': Local audio file from the vault */ -export type TranscriptionMediaType = 'video' | 'audio'; +export type TranscriptionMediaType = 'video-url' | 'audio-file'; /** * Timestamp granularity for transcription output. @@ -73,8 +75,12 @@ export type TimestampGranularity = 'disabled' | 'segment' | 'word'; */ export interface TranscriptionContextConfig { mediaType: TranscriptionMediaType; - /** Token name to resolve for the source URL (e.g., 'workflow.clipboard', 'chat1.response') */ + /** Token name to resolve for the source URL/path (e.g., 'workflow.clipboard', 'chat1.response') */ sourceUrlToken?: string; + /** Browser to impersonate when extracting audio (video-url only) */ + impersonateBrowser?: string; + /** Whether to use browser cookies for authentication (video-url only) */ + useBrowserCookies?: boolean; } /** @@ -97,7 +103,7 @@ export interface ChatContextConfig { /** * Action type options - determines what kind of operation an action performs */ -export type ActionType = 'chat' | 'transcription'; +export type ActionType = 'chat' | 'transcription' | 'http-request'; /** * Base action interface with common properties for all action types @@ -141,10 +147,19 @@ export interface TranscriptionAction extends BaseAction { timestampGranularity?: TimestampGranularity; } +/** + * HTTP request action configuration - makes a GET request to a URL + */ +export interface HttpRequestAction extends BaseAction { + type: 'http-request'; + /** Token name to resolve for the source URL (e.g., 'workflow.clipboard', 'action1.response') */ + sourceUrlToken: string; +} + /** * Union type for all action types */ -export type WorkflowAction = ChatAction | TranscriptionAction; +export type WorkflowAction = ChatAction | TranscriptionAction | HttpRequestAction; /** * Default configuration for a new chat action @@ -166,11 +181,26 @@ export const DEFAULT_TRANSCRIPTION_ACTION: Omit = { type: 'transcription', name: 'Transcription', provider: null, - transcriptionContext: { mediaType: 'video', sourceUrlToken: 'workflow.clipboard' }, + transcriptionContext: { + mediaType: 'video-url', + sourceUrlToken: 'workflow.clipboard', + impersonateBrowser: 'chrome', + useBrowserCookies: false + }, language: '', timestampGranularity: 'disabled' }; +/** + * Default configuration for a new HTTP request action + */ +export const DEFAULT_HTTP_REQUEST_ACTION: Omit = { + type: 'http-request', + name: 'HTTP request', + provider: null, + sourceUrlToken: 'workflow.clipboard' +}; + /** * Configuration for a custom workflow - a container for sequential actions */ @@ -185,6 +215,8 @@ export interface WorkflowConfig { outputType: WorkflowOutputType; /** Folder for output (when outputType is 'new-note') */ outputFolder: string; + /** Whether to show this workflow as a command in the command palette */ + showInCommandPalette: boolean; } /** @@ -194,12 +226,11 @@ export const DEFAULT_WORKFLOW_CONFIG: Omit = { name: 'New workflow', actions: [], outputType: 'popup', - outputFolder: '' + outputFolder: '', + showInCommandPalette: false }; export interface AIToolboxSettings { - impersonateBrowser: string; - cookiesFromBrowser: string; ytdlpLocation: string; ffmpegLocation: string; outputDirectory: string; @@ -218,8 +249,6 @@ export function generateId(): string { } export const DEFAULT_SETTINGS: AIToolboxSettings = { - impersonateBrowser: 'chrome', - cookiesFromBrowser: '', ytdlpLocation: '', ffmpegLocation: '', outputDirectory: '', diff --git a/src/settings/workflows.ts b/src/settings/workflows.ts index ae16192..6f2d330 100644 --- a/src/settings/workflows.ts +++ b/src/settings/workflows.ts @@ -1,4 +1,4 @@ -import { Setting } from "obsidian"; +import { Setting, Notice, setIcon } from "obsidian"; import AIToolboxPlugin from "../main"; import { WorkflowConfig, @@ -6,6 +6,7 @@ import { WorkflowAction, ChatAction, TranscriptionAction, + HttpRequestAction, ActionType, PromptSourceType, TranscriptionMediaType, @@ -14,17 +15,20 @@ import { generateId, DEFAULT_WORKFLOW_CONFIG, DEFAULT_CHAT_ACTION, - DEFAULT_TRANSCRIPTION_ACTION + DEFAULT_TRANSCRIPTION_ACTION, + DEFAULT_HTTP_REQUEST_ACTION } from "./types"; import { createCollapsibleSection } from "../components/collapsible-section"; import { createPathPicker } from "../components/path-picker"; +import { globalDeleteModeManager, nestedDeleteModeManager } from "../components/delete-mode-manager"; +import { createEntityListHeader } from "../components/entity-list-header"; +import { createMoveHandlers } from "../components/ordered-list-utils"; import { getAvailableTokensForAction, TokenGroup, TokenDefinition, generateGroupTemplate } from "../tokens"; -import { setIcon, Notice } from "obsidian"; /** * Callbacks for the workflows settings tab to communicate with the main settings tab @@ -53,6 +57,9 @@ const PROMPT_SOURCE_OPTIONS: Record = { 'from-file': 'From file' }; +// Key for workflow-level delete mode in the global manager +const WORKFLOWS_DELETE_MODE_KEY = '__workflows__'; + /** * Display the workflows settings tab content */ @@ -61,28 +68,41 @@ export function displayWorkflowsSettings( plugin: AIToolboxPlugin, callbacks: WorkflowSettingsCallbacks ): void { - // Add workflow button - new Setting(containerEl) - .setName('Workflows') - .setDesc('Configure custom workflows with actions') - .addButton(button => button - .setButtonText('Add workflow') - .setCta() - .onClick(async () => { - const newWorkflow: WorkflowConfig = { - id: generateId(), - ...DEFAULT_WORKFLOW_CONFIG, - actions: [] // Create new array to avoid shared reference - }; - plugin.settings.workflows.push(newWorkflow); - callbacks.setExpandState({ workflowId: newWorkflow.id }); - await plugin.saveSettings(); - callbacks.refresh(); - })); + const isWorkflowDeleteMode = globalDeleteModeManager.get(WORKFLOWS_DELETE_MODE_KEY); + + // Add workflow header with delete mode toggle and add button + createEntityListHeader({ + containerEl, + label: 'Workflows', + description: 'Configure custom workflows with actions', + isDeleteMode: isWorkflowDeleteMode, + onDeleteModeChange: (value) => { + globalDeleteModeManager.set(WORKFLOWS_DELETE_MODE_KEY, value); + callbacks.refresh(); + }, + addButtonText: 'Add workflow', + addButtonCta: true, + onAdd: async () => { + const newWorkflow: WorkflowConfig = { + id: generateId(), + ...DEFAULT_WORKFLOW_CONFIG, + actions: [] // Create new array to avoid shared reference + }; + plugin.settings.workflows.push(newWorkflow); + callbacks.setExpandState({ workflowId: newWorkflow.id }); + await plugin.saveSettings(); + callbacks.refresh(); + } + }); + + // Add horizontal rule separator + containerEl.createEl('hr', { cls: 'entity-list-separator' }); // Display each workflow - for (const workflow of plugin.settings.workflows) { - displayWorkflowSettings(containerEl, plugin, workflow, callbacks); + for (let i = 0; i < plugin.settings.workflows.length; i++) { + const workflow = plugin.settings.workflows[i]; + if (!workflow) continue; + displayWorkflowSettings(containerEl, plugin, workflow, i, callbacks, isWorkflowDeleteMode); } } @@ -90,7 +110,9 @@ function displayWorkflowSettings( containerEl: HTMLElement, plugin: AIToolboxPlugin, workflow: WorkflowConfig, - callbacks: WorkflowSettingsCallbacks + index: number, + callbacks: WorkflowSettingsCallbacks, + isWorkflowDeleteMode: boolean ): void { const expandState = callbacks.getExpandState(); const shouldExpand = expandState.workflowId === workflow.id; @@ -100,12 +122,31 @@ function displayWorkflowSettings( workflow.actions = []; } - // Determine icon based on first action type (or default) - const firstAction = workflow.actions[0]; - const icon = firstAction?.type === 'transcription' ? 'audio-lines' : 'message-circle'; + // Determine icons based on all action types present in the workflow + const actionTypes = new Set(workflow.actions.map(a => a.type)); + const icons: string[] = []; + if (actionTypes.has('chat')) { + icons.push('message-circle'); + } + if (actionTypes.has('transcription')) { + icons.push('audio-lines'); + } + if (actionTypes.has('http-request')) { + icons.push('globe'); + } const showAdvanced = callbacks.isAdvancedVisible(); + // Create move handlers using utility + const moveHandlers = createMoveHandlers({ + items: plugin.settings.workflows, + index, + isDeleteMode: isWorkflowDeleteMode, + saveSettings: () => plugin.saveSettings(), + preserveExpandState: () => callbacks.setExpandState({ workflowId: workflow.id }), + refresh: callbacks.refresh + }); + const { contentContainer, updateTitle, isExpanded } = createCollapsibleSection({ containerEl, title: workflow.name || 'Unnamed workflow', @@ -114,16 +155,16 @@ function displayWorkflowSettings( headerClass: 'workflow-header', startExpanded: shouldExpand, isHeading: true, - icon, + icons, secondaryText: showAdvanced ? workflow.id : undefined, - onDelete: async () => { - const index = plugin.settings.workflows.findIndex(w => w.id === workflow.id); - if (index !== -1) { - plugin.settings.workflows.splice(index, 1); - await plugin.saveSettings(); - callbacks.refresh(); - } - }, + onMoveUp: moveHandlers.onMoveUp, + onMoveDown: moveHandlers.onMoveDown, + // Show delete button only when in delete mode + onDelete: isWorkflowDeleteMode ? async () => { + plugin.settings.workflows.splice(index, 1); + await plugin.saveSettings(); + callbacks.refresh(); + } : undefined, }); // Workflow name @@ -138,9 +179,23 @@ function displayWorkflowSettings( await plugin.saveSettings(); })); + // Add separator between workflow name and actions + contentContainer.createEl('hr', { cls: 'workflow-actions-separator' }); + // Actions section displayActionsSection(contentContainer, plugin, workflow, callbacks, isExpanded); + // Show in command palette toggle + new Setting(contentContainer) + .setName('Show in command palette') + .setDesc('Register this workflow as a command in Obsidian\'s command palette') + .addToggle(toggle => toggle + .setValue(workflow.showInCommandPalette ?? false) + .onChange(async (value) => { + workflow.showInCommandPalette = value; + await plugin.saveSettings(); + })); + // Output type new Setting(contentContainer) .setName('Output type') @@ -184,7 +239,8 @@ function displayWorkflowSettings( */ const ACTION_TYPE_OPTIONS: Record = { 'chat': 'Chat', - 'transcription': 'Transcription' + 'transcription': 'Transcription', + 'http-request': 'HTTP request' }; /** @@ -198,50 +254,74 @@ function displayActionsSection( isExpanded: () => boolean ): void { const actionsContainer = containerEl.createDiv('actions-section'); - actionsContainer.createDiv({ text: 'Actions', cls: 'actions-section-title' }); - // Add action button - new Setting(actionsContainer) - .addDropdown(dropdown => dropdown - .addOption('', 'Add action...') - .addOptions(ACTION_TYPE_OPTIONS) - .onChange(async (value) => { - if (!value) return; - - const actionType = value as ActionType; - let newAction: WorkflowAction; - - if (actionType === 'chat') { - newAction = { - ...DEFAULT_CHAT_ACTION, - id: generateId(), - name: `Chat ${workflow.actions.length + 1}`, - contexts: [] // Create new array to avoid shared reference - }; - } else { - newAction = { - ...DEFAULT_TRANSCRIPTION_ACTION, - id: generateId(), - name: `Transcription ${workflow.actions.length + 1}`, - // Create new object to avoid shared reference - transcriptionContext: { mediaType: 'video', sourceUrlToken: 'workflow.clipboard' } - }; - } + // Get action delete mode state for this workflow from the nested manager + const isActionDeleteMode = nestedDeleteModeManager.get(workflow.id); + + // Add action controls header with delete mode toggle and add dropdown + createEntityListHeader({ + containerEl: actionsContainer, + label: 'Actions', + description: 'Execute actions sequentially. Tokens from previous actions are available to prompts and templates.', + isDeleteMode: isActionDeleteMode, + onDeleteModeChange: (value) => { + nestedDeleteModeManager.set(workflow.id, value); + if (isExpanded()) { + callbacks.setExpandState({ workflowId: workflow.id }); + } + callbacks.refresh(); + }, + addDropdownOptions: ACTION_TYPE_OPTIONS, + onDropdownSelect: async (value) => { + const actionType = value as ActionType; + let newAction: WorkflowAction; + + if (actionType === 'chat') { + newAction = { + ...DEFAULT_CHAT_ACTION, + id: generateId(), + name: `Chat ${workflow.actions.length + 1}`, + contexts: [] // Create new array to avoid shared reference + }; + } else if (actionType === 'transcription') { + newAction = { + ...DEFAULT_TRANSCRIPTION_ACTION, + id: generateId(), + name: `Transcription ${workflow.actions.length + 1}`, + // Create new object to avoid shared reference + transcriptionContext: { + mediaType: 'video-url', + sourceUrlToken: 'workflow.clipboard', + impersonateBrowser: 'chrome', + useBrowserCookies: false + } + }; + } else { + newAction = { + ...DEFAULT_HTTP_REQUEST_ACTION, + id: generateId(), + name: `HTTP request ${workflow.actions.length + 1}` + }; + } - workflow.actions.push(newAction); - await plugin.saveSettings(); + workflow.actions.push(newAction); + await plugin.saveSettings(); - if (isExpanded()) { - callbacks.setExpandState({ workflowId: workflow.id }); - } - callbacks.refresh(); - })); + if (isExpanded()) { + callbacks.setExpandState({ workflowId: workflow.id }); + } + callbacks.refresh(); + } + }); - // Display each action - for (let i = 0; i < workflow.actions.length; i++) { - const action = workflow.actions[i]; - if (!action) continue; - displayActionSettings(actionsContainer, plugin, workflow, action, i, callbacks, isExpanded); + // Display each action in a grouped list container + if (workflow.actions.length > 0) { + const actionsList = actionsContainer.createDiv('actions-list'); + for (let i = 0; i < workflow.actions.length; i++) { + const action = workflow.actions[i]; + if (!action) continue; + displayActionSettings(actionsList, plugin, workflow, action, i, callbacks, isExpanded, isActionDeleteMode); + } } } @@ -255,12 +335,40 @@ function displayActionSettings( action: WorkflowAction, index: number, callbacks: WorkflowSettingsCallbacks, - isExpanded: () => boolean + isExpanded: () => boolean, + isDeleteMode: boolean ): void { - const actionIcon = action.type === 'transcription' ? 'audio-lines' : 'message-circle'; + let actionIcon = 'message-circle'; + if (action.type === 'transcription') { + actionIcon = 'audio-lines'; + } else if (action.type === 'http-request') { + actionIcon = 'globe'; + } const expandState = callbacks.getExpandState(); const shouldExpandAction = expandState.workflowId === workflow.id && expandState.actionId === action.id; + // We need isActionExpanded before creating move handlers, so we track it via a ref + let actionExpandedRef = { current: shouldExpandAction }; + + // Helper to preserve action expand state on refresh + const preserveActionExpandState = () => { + if (isExpanded() && actionExpandedRef.current) { + callbacks.setExpandState({ workflowId: workflow.id, actionId: action.id }); + } else if (isExpanded()) { + callbacks.setExpandState({ workflowId: workflow.id }); + } + }; + + // Create move handlers using utility + const moveHandlers = createMoveHandlers({ + items: workflow.actions, + index, + isDeleteMode, + saveSettings: () => plugin.saveSettings(), + preserveExpandState: preserveActionExpandState, + refresh: callbacks.refresh + }); + const { contentContainer, updateTitle, isExpanded: isActionExpanded } = createCollapsibleSection({ containerEl, title: action.name || `Action ${index + 1}`, @@ -269,25 +377,22 @@ function displayActionSettings( headerClass: 'action-header', startExpanded: shouldExpandAction, isHeading: false, - icon: actionIcon, - onDelete: async () => { + icons: [actionIcon], + onMoveUp: moveHandlers.onMoveUp, + onMoveDown: moveHandlers.onMoveDown, + // Show delete button only when in delete mode + onDelete: isDeleteMode ? async () => { workflow.actions.splice(index, 1); await plugin.saveSettings(); if (isExpanded()) { callbacks.setExpandState({ workflowId: workflow.id }); } callbacks.refresh(); - }, + } : undefined, }); - // Helper to preserve action expand state on refresh - const preserveActionExpandState = () => { - if (isExpanded() && isActionExpanded()) { - callbacks.setExpandState({ workflowId: workflow.id, actionId: action.id }); - } else if (isExpanded()) { - callbacks.setExpandState({ workflowId: workflow.id }); - } - }; + // Update the ref to use the actual isExpanded function + actionExpandedRef = { get current() { return isActionExpanded(); } }; // Clear the action expand state after applying it if (shouldExpandAction) { @@ -314,8 +419,10 @@ function displayActionSettings( // Route to type-specific settings if (action.type === 'transcription') { displayTranscriptionActionSettings(contentContainer, plugin, action, callbacks, workflow, preserveActionExpandState); - } else { + } else if (action.type === 'chat') { displayChatActionSettings(contentContainer, plugin, action, callbacks, preserveActionExpandState); + } else { + displayHttpRequestActionSettings(contentContainer, plugin, action, callbacks, workflow, preserveActionExpandState); } } @@ -469,6 +576,20 @@ function displayChatActionSettings( } } +/** + * Browser options for yt-dlp + */ +const BROWSER_OPTIONS: Record = { + 'chrome': 'Chrome', + 'edge': 'Edge', + 'safari': 'Safari', + 'firefox': 'Firefox', + 'brave': 'Brave', + 'chromium': 'Chromium', + 'opera': 'Opera', + 'vivaldi': 'Vivaldi' +}; + /** * Display transcription action settings */ @@ -485,29 +606,32 @@ function displayTranscriptionActionSettings( // Ensure transcriptionContext exists if (!action.transcriptionContext) { - action.transcriptionContext = { mediaType: 'video', sourceUrlToken: 'workflow.clipboard' }; + action.transcriptionContext = { mediaType: 'video-url', sourceUrlToken: 'workflow.clipboard' }; } // Media type dropdown const mediaTypeOptions: Record = { - 'video': 'Video', - 'audio': 'Audio' + 'video-url': 'Video URL', + 'audio-file': 'Audio file' }; + const currentMediaType = action.transcriptionContext.mediaType; new Setting(containerEl) .setName('Media type') .setDesc('The type of media to transcribe') .addDropdown(dropdown => dropdown .addOptions(mediaTypeOptions) - .setValue(action.transcriptionContext?.mediaType ?? 'video') + .setValue(currentMediaType ?? 'video-url') .onChange(async (value) => { if (!action.transcriptionContext) { - action.transcriptionContext = { mediaType: 'video', sourceUrlToken: 'workflow.clipboard' }; + action.transcriptionContext = { mediaType: 'video-url', sourceUrlToken: 'workflow.clipboard' }; } action.transcriptionContext.mediaType = value as TranscriptionMediaType; await plugin.saveSettings(); + preserveActionExpandState(); + callbacks.refresh(); })); - // Source URL token picker + // Source token picker - label changes based on media type const actionIndex = workflow.actions.findIndex(a => a.id === action.id); const tokenGroups = getAvailableTokensForAction(workflow, actionIndex); @@ -519,20 +643,57 @@ function displayTranscriptionActionSettings( } } + const sourceLabel = currentMediaType === 'audio-file' ? 'File path source' : 'Source URL'; + const sourceDesc = currentMediaType === 'audio-file' + ? 'Select a token containing the audio file path' + : 'Select a token containing the video URL'; + new Setting(containerEl) - .setName('Source URL') - .setDesc('Select a token containing the video/audio URL') + .setName(sourceLabel) + .setDesc(sourceDesc) .addDropdown(dropdown => dropdown .addOptions(tokenOptions) .setValue(action.transcriptionContext?.sourceUrlToken ?? 'workflow.clipboard') .onChange(async (value) => { if (!action.transcriptionContext) { - action.transcriptionContext = { mediaType: 'video', sourceUrlToken: 'workflow.clipboard' }; + action.transcriptionContext = { mediaType: 'video-url', sourceUrlToken: 'workflow.clipboard' }; } action.transcriptionContext.sourceUrlToken = value; await plugin.saveSettings(); })); + // Show yt-dlp browser/cookie settings only for video-url media type + if (currentMediaType === 'video-url') { + // Browser selection for yt-dlp + new Setting(containerEl) + .setName('Browser') + .setDesc('Browser to impersonate when extracting audio') + .addDropdown(dropdown => dropdown + .addOptions(BROWSER_OPTIONS) + .setValue(action.transcriptionContext?.impersonateBrowser ?? 'chrome') + .onChange(async (value) => { + if (!action.transcriptionContext) { + action.transcriptionContext = { mediaType: 'video-url', sourceUrlToken: 'workflow.clipboard' }; + } + action.transcriptionContext.impersonateBrowser = value; + await plugin.saveSettings(); + })); + + // Use browser cookies toggle + new Setting(containerEl) + .setName('Use browser cookies') + .setDesc('Extract cookies from the selected browser for authentication (required for some age-restricted or private content)') + .addToggle(toggle => toggle + .setValue(action.transcriptionContext?.useBrowserCookies ?? false) + .onChange(async (value) => { + if (!action.transcriptionContext) { + action.transcriptionContext = { mediaType: 'video-url', sourceUrlToken: 'workflow.clipboard' }; + } + action.transcriptionContext.useBrowserCookies = value; + await plugin.saveSettings(); + })); + } + // Language setting (advanced) const showAdvanced = callbacks.isAdvancedVisible(); const languageSetting = new Setting(containerEl) @@ -623,3 +784,38 @@ function displayActionProviderSelection( await plugin.saveSettings(); })); } + +/** + * Display HTTP request action settings + */ +function displayHttpRequestActionSettings( + containerEl: HTMLElement, + plugin: AIToolboxPlugin, + action: HttpRequestAction, + _callbacks: WorkflowSettingsCallbacks, + workflow: WorkflowConfig, + _preserveActionExpandState: () => void +): void { + // Source token picker - uses same pattern as transcription action + const actionIndex = workflow.actions.findIndex(a => a.id === action.id); + const tokenGroups = getAvailableTokensForAction(workflow, actionIndex); + + // Build dropdown options from available tokens + const tokenOptions: Record = {}; + for (const group of tokenGroups) { + for (const token of group.tokens) { + tokenOptions[token.name] = token.name; + } + } + + new Setting(containerEl) + .setName('Source URL') + .setDesc('Select a token containing the URL to fetch') + .addDropdown(dropdown => dropdown + .addOptions(tokenOptions) + .setValue(action.sourceUrlToken ?? 'workflow.clipboard') + .onChange(async (value) => { + action.sourceUrlToken = value; + await plugin.saveSettings(); + })); +} diff --git a/src/tokens/types.ts b/src/tokens/types.ts index 962cd64..ddae6ae 100644 --- a/src/tokens/types.ts +++ b/src/tokens/types.ts @@ -79,6 +79,15 @@ export const TRANSCRIPTION_WORKFLOW_TOKENS: TokenDefinition[] = [ { name: 'transcriptionWithTimestamps', description: 'The transcription with [MM:SS] timestamps' } ]; +/** + * Token definitions for HTTP request workflows (ordered for template generation) + */ +export const HTTP_REQUEST_WORKFLOW_TOKENS: TokenDefinition[] = [ + { name: 'url', description: 'The URL that was requested' }, + { name: 'statusCode', description: 'The HTTP response status code' }, + { name: 'responseBody', description: 'The full response body as text' } +]; + /** * Options for getting token definitions */ @@ -105,6 +114,9 @@ export function getTokenDefinitionsForActionType( } return TRANSCRIPTION_WORKFLOW_TOKENS; } + if (type === 'http-request') { + return HTTP_REQUEST_WORKFLOW_TOKENS; + } return CHAT_WORKFLOW_TOKENS; } diff --git a/styles.css b/styles.css index 8905461..e616b7b 100644 --- a/styles.css +++ b/styles.css @@ -73,57 +73,81 @@ If your plugin does not need CSS, delete this file. display: block; } -/* Provider container styles */ -.provider-container { - margin-bottom: 16px; - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - padding: 8px; -} - -.provider-header { +/* ==================== Collapsible Entity Container System ==================== */ +/* + * Shared styles for collapsible entity containers used throughout the plugin. + * Entity types: providers, models, workflows, actions + * Each entity has: container, header, and content + */ + +/* Base collapsible header styles - shared by all entity types */ +.provider-header, +.model-header, +.workflow-header, +.action-header { cursor: pointer; user-select: none; } -.provider-header:hover .setting-item-name { +.provider-header:hover .setting-item-name, +.model-header:hover .setting-item-name, +.workflow-header:hover .setting-item-name, +.action-header:hover .setting-item-name { color: var(--text-accent); } -.provider-content.is-collapsed { +/* Base collapsible content states - shared by all entity types */ +.provider-content.is-collapsed, +.model-content.is-collapsed, +.workflow-content.is-collapsed, +.action-content.is-collapsed { display: none; } -.provider-content.is-expanded { +.provider-content.is-expanded, +.model-content.is-expanded, +.workflow-content.is-expanded, +.action-content.is-expanded { display: block; +} + +/* Top-level entity containers (providers, workflows) */ +.provider-container, +.workflow-container { + margin-bottom: 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 8px; +} + +.provider-content.is-expanded, +.workflow-content.is-expanded { padding-top: 8px; } -/* Model container styles */ -.model-container { - margin-left: 16px; +/* Nested entity containers (models, actions) */ +.model-container, +.action-container { margin-bottom: 8px; border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 8px; + background: var(--background-secondary); } -.model-header { - cursor: pointer; - user-select: none; -} - -.model-header:hover .setting-item-name { - color: var(--text-accent); +.model-content.is-expanded, +.action-content.is-expanded { + padding-top: 8px; } -.model-content.is-collapsed { - display: none; +/* Provider-specific adjustments */ +.provider-header { + padding: 2px 4px; } -.model-content.is-expanded { - display: block; - padding-top: 8px; +/* Model-specific adjustments - nested under providers */ +.model-container { + margin-left: 16px; } /* Model capabilities section */ @@ -159,57 +183,124 @@ If your plugin does not need CSS, delete this file. height: 16px; } -/* Workflow container styles */ -.workflow-container { - margin-bottom: 16px; - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - padding: 8px; +/* Workflow-specific adjustments */ +.workflow-header { + padding: 2px 4px; } -.workflow-header { - cursor: pointer; - user-select: none; +/* Entity list separator (used below main headers like Workflows, AI Providers) */ +.entity-list-separator { + margin: 0px 0 12px 0; + border: none; + border-top: 1px solid var(--background-modifier-border); } -.workflow-header:hover .setting-item-name { - color: var(--text-accent); +/* Separator between workflow name and actions section */ +.workflow-actions-separator { + margin: 8px 0; + border: none; + border-top: 1px solid var(--background-modifier-border); } -.workflow-content.is-collapsed { - display: none; +/* Action-specific adjustments */ +.action-header { + background: transparent; + border-radius: 4px; + padding: 4px 8px; } -.workflow-content.is-expanded { - display: block; - padding-top: 8px; +.action-content.is-expanded { + padding-left: 12px; + padding-right: 8px; } -/* Action container styles */ -.action-container { - margin-left: 20px; +/* ==================== Entity List Sections ==================== */ +/* Used for nested entity lists (actions within workflows, models within providers) */ + +.actions-section, +.models-section { + margin-top: 12px; + margin-bottom: 12px; +} + +.actions-section-title, +.models-section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-muted); margin-bottom: 8px; } -.action-header { - cursor: pointer; - background: var(--background-secondary); - border-radius: 4px; - padding: 4px 8px; +/* Entity list wrappers - vertical spacing between items */ +.actions-list, +.models-list { + display: flex; + flex-direction: column; + gap: 8px; } -.action-header:hover .setting-item-name { - color: var(--text-accent); +/* ==================== Delete Mode Toggle Styles ==================== */ +/* Shared styles for the delete mode toggle used in entity list headers */ + +.delete-mode-toggle-label { + display: inline-flex; + align-items: center; + gap: 4px; } -.action-content.is-collapsed { - display: none; +.delete-mode-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; } -.action-content.is-expanded { - display: block; - padding-top: 8px; - padding-left: 12px; +.delete-mode-toggle-icon svg { + width: 16px; + height: 16px; + color: var(--text-muted); +} + +/* ==================== Collapsible Header Button Styles ==================== */ +/* Shared styles for move up/down and delete buttons in entity headers */ + +/* Delete button - red styling when visible (in delete mode) */ +.provider-header button[aria-label="Delete"], +.model-header button[aria-label="Delete"], +.workflow-header button[aria-label="Delete"], +.action-header button[aria-label="Delete"] { + color: var(--color-red); +} + +.provider-header button[aria-label="Delete"]:hover, +.model-header button[aria-label="Delete"]:hover, +.workflow-header button[aria-label="Delete"]:hover, +.action-header button[aria-label="Delete"]:hover { + background: var(--color-red); + color: var(--text-on-accent); +} + +/* Move up/down buttons - subtle styling */ +.provider-header button[aria-label="Move up"], +.provider-header button[aria-label="Move down"], +.model-header button[aria-label="Move up"], +.model-header button[aria-label="Move down"], +.workflow-header button[aria-label="Move up"], +.workflow-header button[aria-label="Move down"], +.action-header button[aria-label="Move up"], +.action-header button[aria-label="Move down"] { + color: var(--text-muted); +} + +.provider-header button[aria-label="Move up"]:hover, +.provider-header button[aria-label="Move down"]:hover, +.model-header button[aria-label="Move up"]:hover, +.model-header button[aria-label="Move down"]:hover, +.workflow-header button[aria-label="Move up"]:hover, +.workflow-header button[aria-label="Move down"]:hover, +.action-header button[aria-label="Move up"]:hover, +.action-header button[aria-label="Move down"]:hover { + color: var(--text-accent); + background: var(--background-modifier-hover); } .workflow-textarea { diff --git a/versions.json b/versions.json index 14e1913..324e332 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,6 @@ { "1.0.0": "0.15.0", "1.0.3": "0.15.0", - "1.0.6": "0.15.0" + "1.0.6": "0.15.0", + "1.0.7": "0.15.0" }