mirror of
https://github.com/rodolfo-terriquez/images-to-notes.git
synced 2026-07-22 05:48:18 +00:00
Fix Obsidian review issues and release 1.1.4
This commit is contained in:
parent
a056a6f786
commit
d52c0bd021
18 changed files with 817 additions and 1094 deletions
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
|
|
@ -9,19 +9,30 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18.x"
|
||||
cache: npm
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
npm install
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: |
|
||||
main.js
|
||||
styles.css
|
||||
manifest.json
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Image to notes Plugin
|
||||
# Images to Notes
|
||||
|
||||
Use AI to transcribe photos of handwritten or printed notes into Markdown, creates a new note for each image, then adds the image to the bottom of the Note.
|
||||
|
||||
|
|
|
|||
348
main.ts
348
main.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { Plugin, TAbstractFile, TFile, normalizePath, Editor, MarkdownView, MarkdownFileInfo, Workspace, Platform, TFolder, Menu, Notice } from 'obsidian';
|
||||
import { Plugin, TAbstractFile, TFile, normalizePath, Editor, MarkdownView, MarkdownFileInfo, Platform, TFolder, Menu, Notice } from 'obsidian';
|
||||
import { PluginSettings, DEFAULT_SETTINGS, NoteNamingOption } from 'models/settings';
|
||||
import { TranscriptionSettingTab } from 'ui/settingsTab';
|
||||
import { ProcessingQueue } from './services/processingQueue';
|
||||
|
|
@ -10,6 +10,61 @@ import { NoteCreator } from './services/noteCreator';
|
|||
import { CopilotService } from './services/copilotService';
|
||||
import { ProcessingJob } from './models/processingJob';
|
||||
|
||||
type LoadedPluginData = Partial<PluginSettings> & {
|
||||
useFirstLineAsTitle?: boolean;
|
||||
};
|
||||
|
||||
const CURRENT_OPENAI_MODELS = new Set<Exclude<PluginSettings["openaiModel"], "custom">>([
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
]);
|
||||
|
||||
const CURRENT_ANTHROPIC_MODELS = new Set<Exclude<PluginSettings["anthropicModel"], "custom">>([
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
|
||||
const CURRENT_GOOGLE_MODELS = new Set<Exclude<PluginSettings["googleModel"], "custom">>([
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
]);
|
||||
|
||||
const CURRENT_MISTRAL_MODELS = new Set<Exclude<PluginSettings["mistralModel"], "custom">>([
|
||||
"mistral-medium-3-5",
|
||||
"mistral-small-2603",
|
||||
"mistral-large-2512",
|
||||
"mistral-medium-2508",
|
||||
"ministral-14b-2512",
|
||||
"ministral-8b-2512",
|
||||
"ministral-3b-2512",
|
||||
]);
|
||||
|
||||
function preserveLegacyModelSelection<TModel extends string>(
|
||||
selectedModel: TModel | "custom",
|
||||
customModel: string,
|
||||
availableModels: ReadonlySet<TModel>,
|
||||
): { selectedModel: TModel | "custom"; customModel: string } {
|
||||
if (selectedModel !== "custom" && !availableModels.has(selectedModel)) {
|
||||
return {
|
||||
selectedModel: "custom",
|
||||
customModel: customModel || selectedModel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
selectedModel,
|
||||
customModel,
|
||||
};
|
||||
}
|
||||
|
||||
export default class ImageTranscriberPlugin extends Plugin {
|
||||
settings: PluginSettings;
|
||||
processingQueue: ProcessingQueue;
|
||||
|
|
@ -31,6 +86,7 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
this.isMobileDevice = Platform.isMobile;
|
||||
|
||||
this.notificationService = new NotificationService(this.settings);
|
||||
this.notificationService.setMobileMode(this.isMobileDevice);
|
||||
this.copilotService = new CopilotService();
|
||||
|
||||
this.addSettingTab(new TranscriptionSettingTab(this.app, this));
|
||||
|
|
@ -44,6 +100,7 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
this.noteCreator = new NoteCreator(this.settings, this.notificationService, this.app);
|
||||
this.processingQueue = new ProcessingQueue();
|
||||
this.processingQueue.setProcessCallback(this.processImageFile.bind(this));
|
||||
this.processingQueue.setMaxConcurrent(this.settings.maxConcurrentProcessing);
|
||||
|
||||
// Register vault event listeners now that the queue is ready
|
||||
this.registerEvent(
|
||||
|
|
@ -59,104 +116,98 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
.setIcon('image-file')
|
||||
.setSection('action-primary')
|
||||
.onClick(async () => {
|
||||
const targetFolder = file as TFolder;
|
||||
const targetFolder = file;
|
||||
new Notice(`Select images to add to folder: ${targetFolder.path}`);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
const input = activeDocument.body.createEl('input', { type: 'file' });
|
||||
input.accept = 'image/*';
|
||||
input.multiple = true;
|
||||
input.hide();
|
||||
|
||||
input.onchange = async (event) => {
|
||||
const M_inputElement = event.target as HTMLInputElement;
|
||||
const selectedFiles = M_inputElement.files;
|
||||
input.onchange = async () => {
|
||||
try {
|
||||
const selectedFiles = input.files;
|
||||
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
new Notice('No files selected or photo not captured successfully.');
|
||||
if (M_inputElement) M_inputElement.value = ''; // Reset to allow retry
|
||||
return;
|
||||
}
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
new Notice('No files selected or photo not captured successfully.');
|
||||
return;
|
||||
}
|
||||
|
||||
let showBatchImportNotice = true;
|
||||
if (this.settings.transcribeOnlySpecificFolder) {
|
||||
const specificFolderSetting = this.settings.specificFolderForTranscription;
|
||||
if (!specificFolderSetting) {
|
||||
showBatchImportNotice = false;
|
||||
} else {
|
||||
let normalizedSpecificFolder = normalizePath(specificFolderSetting);
|
||||
if (normalizedSpecificFolder === '.') normalizedSpecificFolder = '/';
|
||||
let normalizedTargetFolderPath = normalizePath(targetFolder.path);
|
||||
if (normalizedTargetFolderPath === '.') normalizedTargetFolderPath = '/';
|
||||
|
||||
if (normalizedTargetFolderPath !== normalizedSpecificFolder) {
|
||||
let showBatchImportNotice = true;
|
||||
if (this.settings.transcribeOnlySpecificFolder) {
|
||||
const specificFolderSetting = this.settings.specificFolderForTranscription;
|
||||
if (!specificFolderSetting) {
|
||||
showBatchImportNotice = false;
|
||||
} else {
|
||||
let normalizedSpecificFolder = normalizePath(specificFolderSetting);
|
||||
if (normalizedSpecificFolder === '.') normalizedSpecificFolder = '/';
|
||||
let normalizedTargetFolderPath = normalizePath(targetFolder.path);
|
||||
if (normalizedTargetFolderPath === '.') normalizedTargetFolderPath = '/';
|
||||
|
||||
if (normalizedTargetFolderPath !== normalizedSpecificFolder) {
|
||||
showBatchImportNotice = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBatchImportNotice) {
|
||||
new Notice(`Importing ${selectedFiles.length} image(s) to ${targetFolder.name}...`);
|
||||
}
|
||||
|
||||
for (const browserFile of Array.from(selectedFiles)) {
|
||||
const fileName = browserFile.name;
|
||||
const destinationPath = normalizePath(`${targetFolder.path}/${fileName}`);
|
||||
if (this.processingPaths.has(destinationPath)) {
|
||||
new Notice(`File ${fileName} is already being processed. Skipped.`);
|
||||
continue;
|
||||
if (showBatchImportNotice) {
|
||||
new Notice(`Importing ${selectedFiles.length} image(s) to ${targetFolder.name}...`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.processingPaths.add(destinationPath);
|
||||
|
||||
const arrayBuffer = await browserFile.arrayBuffer();
|
||||
|
||||
const existingFile = this.app.vault.getAbstractFileByPath(destinationPath);
|
||||
if (existingFile) {
|
||||
new Notice(`File ${fileName} already exists in ${targetFolder.name}. Skipped.`);
|
||||
for (const browserFile of Array.from(selectedFiles)) {
|
||||
const fileName = browserFile.name;
|
||||
const destinationPath = normalizePath(`${targetFolder.path}/${fileName}`);
|
||||
if (this.processingPaths.has(destinationPath)) {
|
||||
new Notice(`File ${fileName} is already being processed. Skipped.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tFile = await this.app.vault.createBinary(destinationPath, arrayBuffer);
|
||||
try {
|
||||
this.processingPaths.add(destinationPath);
|
||||
const arrayBuffer = await browserFile.arrayBuffer();
|
||||
const existingFile = this.app.vault.getAbstractFileByPath(destinationPath);
|
||||
if (existingFile) {
|
||||
new Notice(`File ${fileName} already exists in ${targetFolder.name}. Skipped.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tFile instanceof TFile && isImageFile(tFile)) {
|
||||
let shouldQueue = true;
|
||||
if (this.settings.transcribeOnlySpecificFolder) {
|
||||
const specificFolderSetting = this.settings.specificFolderForTranscription;
|
||||
if (!specificFolderSetting) {
|
||||
shouldQueue = false;
|
||||
} else {
|
||||
let normalizedSpecificFolder = normalizePath(specificFolderSetting);
|
||||
if (normalizedSpecificFolder === '.') normalizedSpecificFolder = '/';
|
||||
|
||||
let normalizedTargetFolderPath = normalizePath(targetFolder.path);
|
||||
if (normalizedTargetFolderPath === '.') normalizedTargetFolderPath = '/';
|
||||
|
||||
if (normalizedTargetFolderPath !== normalizedSpecificFolder) {
|
||||
const tFile = await this.app.vault.createBinary(destinationPath, arrayBuffer);
|
||||
if (tFile instanceof TFile && isImageFile(tFile)) {
|
||||
let shouldQueue = true;
|
||||
if (this.settings.transcribeOnlySpecificFolder) {
|
||||
const specificFolderSetting = this.settings.specificFolderForTranscription;
|
||||
if (!specificFolderSetting) {
|
||||
shouldQueue = false;
|
||||
this.notificationService.notifyVerbose(`Imported ${fileName} to ${targetFolder.name}, but it's not the designated transcription folder. Not queueing.`);
|
||||
} else {
|
||||
let normalizedSpecificFolder = normalizePath(specificFolderSetting);
|
||||
if (normalizedSpecificFolder === '.') normalizedSpecificFolder = '/';
|
||||
let normalizedTargetFolderPath = normalizePath(targetFolder.path);
|
||||
if (normalizedTargetFolderPath === '.') normalizedTargetFolderPath = '/';
|
||||
if (normalizedTargetFolderPath !== normalizedSpecificFolder) {
|
||||
shouldQueue = false;
|
||||
this.notificationService.notifyVerbose(`Imported ${fileName} to ${targetFolder.name}, but it's not the designated transcription folder. Not queueing.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldQueue) {
|
||||
new Notice(`Imported ${fileName} to ${targetFolder.name}.`);
|
||||
this.processingQueue.addToQueue(tFile);
|
||||
new Notice(`Added ${fileName} to transcription queue.`);
|
||||
}
|
||||
} else {
|
||||
if (showBatchImportNotice) {
|
||||
if (shouldQueue) {
|
||||
new Notice(`Imported ${fileName} to ${targetFolder.name}.`);
|
||||
this.processingQueue.addToQueue(tFile);
|
||||
new Notice(`Added ${fileName} to transcription queue.`);
|
||||
}
|
||||
} else if (showBatchImportNotice) {
|
||||
new Notice(`Imported ${fileName} to ${targetFolder.name}, but it's not a recognized image or failed to queue.`);
|
||||
}
|
||||
} catch {
|
||||
new Notice(`Failed to import ${fileName}.`);
|
||||
} finally {
|
||||
this.processingPaths.delete(destinationPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error importing file ${fileName}:`, error);
|
||||
new Notice(`Failed to import ${fileName}. Check console for details.`);
|
||||
} finally {
|
||||
this.processingPaths.delete(destinationPath);
|
||||
}
|
||||
} finally {
|
||||
input.value = '';
|
||||
input.remove();
|
||||
}
|
||||
if (M_inputElement) M_inputElement.value = '';
|
||||
};
|
||||
|
||||
input.click();
|
||||
|
|
@ -191,122 +242,90 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private initializeMobileSupport() {
|
||||
// Mobile-specific initialization code
|
||||
console.log("Initializing mobile support for Notes to Markdown plugin");
|
||||
|
||||
// Adjust processing queue for mobile - limit concurrent operations
|
||||
// Mobile devices have less processing power
|
||||
// Note: We'll need to add setMaxConcurrent to ProcessingQueue class
|
||||
// this.processingQueue.setMaxConcurrent(1);
|
||||
|
||||
// Handle mobile-specific file system access
|
||||
// On iOS, file access might be more restricted
|
||||
if (Platform.isIosApp) {
|
||||
console.log("iOS-specific adjustments applied");
|
||||
// iOS-specific adjustments for file handling
|
||||
this.adjustForIOS();
|
||||
}
|
||||
// Android-specific adjustments if needed
|
||||
else if (Platform.isAndroidApp) {
|
||||
console.log("Android-specific adjustments applied");
|
||||
// Android-specific adjustments
|
||||
} else if (Platform.isAndroidApp) {
|
||||
this.adjustForAndroid();
|
||||
}
|
||||
}
|
||||
|
||||
private adjustForIOS() {
|
||||
// iOS-specific adjustments for file system limitations
|
||||
console.log('Applying iOS-specific adjustments');
|
||||
|
||||
// 1. Adjust file path handling for iOS
|
||||
// iOS has a different file system structure and stricter permissions
|
||||
|
||||
// 2. Modify image processing settings for iOS
|
||||
// Reduce processing quality to improve performance on mobile
|
||||
if (this.settings.imageQuality && this.settings.imageQuality > 80) {
|
||||
console.log('Reducing image quality for iOS performance');
|
||||
const previousQuality = this.settings.imageQuality;
|
||||
this.settings.imageQuality = 80; // Lower quality for better performance
|
||||
this.settings.imageQuality = 80;
|
||||
this.notificationService.notifyVerbose(
|
||||
`Mobile optimization: Image quality reduced from ${previousQuality} to 80 for better performance.`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle iOS-specific file access patterns
|
||||
// iOS may require different approaches to file access
|
||||
|
||||
// 4. Adjust notification behavior for iOS
|
||||
// iOS notifications should be more concise
|
||||
this.notificationService.notifyInfo('Mobile compatibility mode enabled for iOS');
|
||||
}
|
||||
|
||||
private adjustForAndroid() {
|
||||
// Android-specific adjustments for file system access patterns
|
||||
console.log('Applying Android-specific adjustments');
|
||||
|
||||
// 1. Handle Android storage permissions
|
||||
// Android has a different permission model, especially for external storage
|
||||
|
||||
// 2. Adjust image processing for Android
|
||||
// Android devices vary widely in capabilities, so we need to be more adaptive
|
||||
if (this.settings.mobileOptimizationEnabled) {
|
||||
// Adjust concurrent processing for Android
|
||||
const previousConcurrent = this.settings.maxConcurrentProcessing;
|
||||
this.settings.maxConcurrentProcessing = 1; // Limit to 1 on Android for better stability
|
||||
|
||||
// Adjust image quality if needed
|
||||
this.settings.maxConcurrentProcessing = 1;
|
||||
if (this.settings.imageQuality > 85) {
|
||||
const previousQuality = this.settings.imageQuality;
|
||||
this.settings.imageQuality = 85; // Slightly lower quality for better performance
|
||||
this.settings.imageQuality = 85;
|
||||
this.notificationService.notifyVerbose(
|
||||
`Mobile optimization: Image quality adjusted from ${previousQuality} to 85 for Android.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle Android-specific file paths
|
||||
// Android file paths may need special handling
|
||||
|
||||
// 4. Adjust notification behavior for Android
|
||||
this.notificationService.notifyInfo('Mobile compatibility mode enabled for Android');
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
// Load saved data, potentially containing outdated settings format
|
||||
const loadedData = await this.loadData();
|
||||
const loadedData: LoadedPluginData | null = await this.loadData();
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
// Start with default settings
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
if (loadedData) {
|
||||
this.settings = Object.assign(this.settings, loadedData);
|
||||
|
||||
// Merge loaded data onto defaults
|
||||
if (loadedData) {
|
||||
this.settings = Object.assign(this.settings, loadedData);
|
||||
const oldSettingValue = loadedData.useFirstLineAsTitle;
|
||||
if (typeof oldSettingValue === 'boolean' && !loadedData.noteNamingOption) {
|
||||
this.settings.noteNamingOption = oldSettingValue
|
||||
? NoteNamingOption.FirstLine
|
||||
: NoteNamingOption.FolderDateNum;
|
||||
}
|
||||
|
||||
// --- Settings Migration ---
|
||||
// Check if the old setting exists and the new one doesn't
|
||||
// The `as any` is used here to access a potentially deprecated property
|
||||
const oldSettingValue = (loadedData as any).useFirstLineAsTitle;
|
||||
if (typeof oldSettingValue === 'boolean' && !loadedData.noteNamingOption) {
|
||||
// console.log('Migrating old note naming setting...');
|
||||
this.settings.noteNamingOption = oldSettingValue
|
||||
? NoteNamingOption.FirstLine
|
||||
: NoteNamingOption.FolderDateNum; // Or choose a different default like ImageName if preferred for migration
|
||||
|
||||
// Optionally remove the old setting from the object to keep data clean
|
||||
// delete (this.settings as any).useFirstLineAsTitle;
|
||||
// Decided against deleting for now, Object.assign will overwrite anyway if we save later
|
||||
}
|
||||
// --- End Settings Migration ---
|
||||
}
|
||||
const openAiSelection = preserveLegacyModelSelection(
|
||||
this.settings.openaiModel,
|
||||
this.settings.openaiCustomModel,
|
||||
CURRENT_OPENAI_MODELS,
|
||||
);
|
||||
this.settings.openaiModel = openAiSelection.selectedModel;
|
||||
this.settings.openaiCustomModel = openAiSelection.customModel;
|
||||
|
||||
const anthropicSelection = preserveLegacyModelSelection(
|
||||
this.settings.anthropicModel,
|
||||
this.settings.anthropicCustomModel,
|
||||
CURRENT_ANTHROPIC_MODELS,
|
||||
);
|
||||
this.settings.anthropicModel = anthropicSelection.selectedModel;
|
||||
this.settings.anthropicCustomModel = anthropicSelection.customModel;
|
||||
|
||||
const googleSelection = preserveLegacyModelSelection(
|
||||
this.settings.googleModel,
|
||||
this.settings.googleCustomModel,
|
||||
CURRENT_GOOGLE_MODELS,
|
||||
);
|
||||
this.settings.googleModel = googleSelection.selectedModel;
|
||||
this.settings.googleCustomModel = googleSelection.customModel;
|
||||
|
||||
const mistralSelection = preserveLegacyModelSelection(
|
||||
this.settings.mistralModel,
|
||||
this.settings.mistralCustomModel,
|
||||
CURRENT_MISTRAL_MODELS,
|
||||
);
|
||||
this.settings.mistralModel = mistralSelection.selectedModel;
|
||||
this.settings.mistralCustomModel = mistralSelection.customModel;
|
||||
}
|
||||
|
||||
// Ensure processedImagePaths is always an array, even if saved data is corrupted/missing
|
||||
if (!this.settings.processedImagePaths || !Array.isArray(this.settings.processedImagePaths)) {
|
||||
// console.warn('Processed image paths missing or invalid in settings data. Initializing as empty array.');
|
||||
this.settings.processedImagePaths = [];
|
||||
}
|
||||
|
||||
// Save settings *after* potential migration to ensure the new format is stored
|
||||
await this.saveSettings();
|
||||
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
@ -314,13 +333,6 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private async handleFileCreate(file: TAbstractFile) {
|
||||
// Mobile-specific handling for file creation
|
||||
if (this.isMobileDevice) {
|
||||
// On mobile, we might need to handle file creation differently
|
||||
console.log(`Mobile file creation detected: ${file.path}`);
|
||||
// Additional mobile-specific processing can be added here
|
||||
}
|
||||
|
||||
// --- Initial Checks ---
|
||||
// 1. Check if this file path was flagged as dropped in the editor
|
||||
if (file instanceof TFile && this.droppedInEditorPaths.has(file.path)) {
|
||||
|
|
@ -375,9 +387,7 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
this.processingPaths.add(file.path);
|
||||
|
||||
try {
|
||||
// console.log(`Adding initial file to processing queue: ${file.path}`);
|
||||
// Add the initial TFile object to the queue
|
||||
this.processingQueue.addToQueue(file as TFile);
|
||||
this.processingQueue.addToQueue(file);
|
||||
} catch (error) {
|
||||
console.error(`Error adding file ${file.path} to processing queue:`, error);
|
||||
} finally {
|
||||
|
|
@ -522,8 +532,6 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
// Check for existing file at the target path before moving
|
||||
const existingImage = this.app.vault.getAbstractFileByPath(newImagePath);
|
||||
if (existingImage && existingImage instanceof TFile) {
|
||||
const warnMsg = `Image named '${fileToMove.name}' already exists in '${targetFolder}'. Skipping transcription.`;
|
||||
console.warn(warnMsg);
|
||||
this.notificationService.notifyError(`Failed to process ${fileToMove.name}: A file with this name already exists in '${targetFolder}'.`);
|
||||
return null; // Indicate failure
|
||||
}
|
||||
|
|
@ -654,7 +662,7 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
* Handles files dropped directly onto the editor.
|
||||
* We want to prevent transcription for these images as Obsidian handles them.
|
||||
*/
|
||||
private async handleEditorDrop(evt: DragEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo): Promise<void> {
|
||||
private async handleEditorDrop(evt: DragEvent, _editor: Editor, info: MarkdownView | MarkdownFileInfo): Promise<void> {
|
||||
// console.log('Editor drop event detected.');
|
||||
if (!evt.dataTransfer) {
|
||||
// console.log('No dataTransfer object found on event.');
|
||||
|
|
@ -687,11 +695,9 @@ export default class ImageTranscriberPlugin extends Plugin {
|
|||
|
||||
// Cleanup mechanism: Remove the path after a short delay
|
||||
// This handles cases where the 'create' event might not fire or be missed
|
||||
setTimeout(() => {
|
||||
if (this.droppedInEditorPaths.delete(destinationPath)) {
|
||||
// console.log(`Cleaned up droppedInEditorPaths: ${destinationPath}`);
|
||||
}
|
||||
}, 1500); // 1.5 second delay
|
||||
activeWindow.setTimeout(() => {
|
||||
this.droppedInEditorPaths.delete(destinationPath);
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error determining attachment path for ${file.name}:`, error);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "images-to-notes",
|
||||
"name": "Images to Notes",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"minAppVersion": "1.8.10",
|
||||
"description": "Turn photos of handwritten or printed notes into Markdown using AI.",
|
||||
"author": "Rodolfo Terriquez",
|
||||
|
|
|
|||
|
|
@ -10,27 +10,33 @@ export enum ApiProvider {
|
|||
|
||||
// Specific model types for better type safety
|
||||
export type OpenAiModel =
|
||||
| "gpt-4.1"
|
||||
| "gpt-4.1-mini"
|
||||
| "o4-mini"
|
||||
| "gpt-5-2025-08-07"
|
||||
| "gpt-5-mini-2025-08-07"
|
||||
| "gpt-5.5"
|
||||
| "gpt-5.4"
|
||||
| "gpt-5.4-mini"
|
||||
| "gpt-4o"
|
||||
| "gpt-4o-mini"
|
||||
| "custom";
|
||||
export type AnthropicModel =
|
||||
| "claude-3-7-sonnet-latest"
|
||||
| "claude-sonnet-4-0"
|
||||
| "claude-sonnet-4-5"
|
||||
| "claude-opus-4-7"
|
||||
| "claude-sonnet-4-6"
|
||||
| "claude-haiku-4-5"
|
||||
| "custom";
|
||||
export type GoogleModel =
|
||||
| "gemini-2.0-flash"
|
||||
| "gemini-3.1-pro-preview"
|
||||
| "gemini-3-flash-preview"
|
||||
| "gemini-3.1-flash-lite"
|
||||
| "gemini-2.5-pro"
|
||||
| "gemini-2.5-flash"
|
||||
| "gemini-2.5-flash-lite"
|
||||
| "custom";
|
||||
export type MistralModel =
|
||||
| "mistral-ocr-2505"
|
||||
| "mistral-small-2503"
|
||||
| "mistral-medium-3-5"
|
||||
| "mistral-small-2603"
|
||||
| "mistral-large-2512"
|
||||
| "mistral-medium-2508"
|
||||
| "ministral-14b-2512"
|
||||
| "ministral-8b-2512"
|
||||
| "ministral-3b-2512"
|
||||
| "custom";
|
||||
export type CopilotModel = string;
|
||||
|
||||
|
|
@ -101,13 +107,13 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
anthropicApiKey: "",
|
||||
googleApiKey: "",
|
||||
mistralApiKey: "",
|
||||
openaiModel: "gpt-4.1-mini", // Updated default OpenAI model
|
||||
openaiCustomModel: "", // Custom model name for OpenAI
|
||||
anthropicModel: "claude-3-7-sonnet-latest", // Updated default Anthropic model
|
||||
anthropicCustomModel: "", // Custom model name for Anthropic
|
||||
googleModel: "gemini-2.0-flash", // Default Google model
|
||||
googleCustomModel: "", // Custom model name for Google
|
||||
mistralModel: "mistral-medium-2508",
|
||||
openaiModel: "gpt-5.4-mini",
|
||||
openaiCustomModel: "",
|
||||
anthropicModel: "claude-sonnet-4-6",
|
||||
anthropicCustomModel: "",
|
||||
googleModel: "gemini-2.5-flash",
|
||||
googleCustomModel: "",
|
||||
mistralModel: "mistral-medium-3-5",
|
||||
mistralCustomModel: "", // Custom model name for Mistral
|
||||
// OpenAI-compatible endpoint defaults
|
||||
openaiCompatibleEndpoint: "http://localhost:1234/v1", // Default LM Studio endpoint
|
||||
|
|
|
|||
134
package-lock.json
generated
134
package-lock.json
generated
|
|
@ -1,21 +1,22 @@
|
|||
{
|
||||
"name": "obsidian-image-transcriber",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-image-transcriber",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^25.0.0",
|
||||
"@rollup/plugin-commonjs": "^29.0.2",
|
||||
"@rollup/plugin-node-resolve": "^15.2.0",
|
||||
"@rollup/plugin-typescript": "^11.1.0",
|
||||
"@types/node": "^20.8.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"heic2any": "^0.0.4",
|
||||
"obsidian": "latest",
|
||||
"picomatch": "^4.0.4",
|
||||
"rollup": "^3.29.0",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.2.0"
|
||||
|
|
@ -61,21 +62,22 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
"version": "25.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz",
|
||||
"integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==",
|
||||
"version": "29.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz",
|
||||
"integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.0.1",
|
||||
"commondir": "^1.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"glob": "^8.0.3",
|
||||
"fdir": "^6.2.0",
|
||||
"is-reference": "1.2.1",
|
||||
"magic-string": "^0.30.3"
|
||||
"magic-string": "^0.30.3",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=16.0.0 || 14 >= 14.17"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.68.0||^3.0.0||^4.0.0"
|
||||
|
|
@ -205,23 +207,6 @@
|
|||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browser-image-compression": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
|
||||
|
|
@ -256,12 +241,23 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
|
|
@ -288,27 +284,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^5.0.1",
|
||||
"once": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
|
|
@ -329,25 +304,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
|
|
@ -391,19 +347,6 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
|
|
@ -429,16 +372,6 @@
|
|||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
|
|
@ -447,9 +380,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -560,13 +493,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-image-transcriber",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"description": "Transcribes text from images using AI.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
|
|
@ -16,13 +16,14 @@
|
|||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^25.0.0",
|
||||
"@rollup/plugin-commonjs": "^29.0.2",
|
||||
"@rollup/plugin-node-resolve": "^15.2.0",
|
||||
"@rollup/plugin-typescript": "^11.1.0",
|
||||
"@types/node": "^20.8.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"heic2any": "^0.0.4",
|
||||
"obsidian": "latest",
|
||||
"picomatch": "^4.0.4",
|
||||
"rollup": "^3.29.0",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.2.0"
|
||||
|
|
|
|||
|
|
@ -1,23 +1,55 @@
|
|||
import { App, requestUrl, RequestUrlParam, RequestUrlResponse, TFile } from "obsidian";
|
||||
import {
|
||||
PluginSettings,
|
||||
OpenAiModel,
|
||||
AnthropicModel,
|
||||
GoogleModel,
|
||||
MistralModel,
|
||||
CopilotModel,
|
||||
} from "../models/settings";
|
||||
import { CopilotService } from "./copilotService";
|
||||
import { App, requestUrl, RequestUrlParam, TFile } from "obsidian";
|
||||
import { PluginSettings, ApiProvider } from "../models/settings";
|
||||
import { CopilotService, type CopilotChatMessage } from "./copilotService";
|
||||
import { NotificationService } from "../ui/notificationService";
|
||||
import { encodeImageToBase64 } from "../utils/fileUtils";
|
||||
|
||||
// Constants for API endpoints and headers
|
||||
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
const GOOGLE_API_URL_V1 = "https://generativelanguage.googleapis.com/v1/models";
|
||||
const GOOGLE_API_URL_V1BETA = "https://generativelanguage.googleapis.com/v1beta/models";
|
||||
const MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions";
|
||||
|
||||
interface ServiceErrorLike {
|
||||
message?: string;
|
||||
response?: {
|
||||
status?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenAiLikeResponse extends ApiErrorResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AnthropicContentBlock {
|
||||
type?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface AnthropicResponse extends ApiErrorResponse {
|
||||
content?: AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
interface GoogleResponse extends ApiErrorResponse {
|
||||
candidates?: Array<{
|
||||
content?: {
|
||||
parts?: Array<{
|
||||
text?: string;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export class AIService {
|
||||
constructor(
|
||||
private settings: PluginSettings,
|
||||
|
|
@ -26,24 +58,16 @@ export class AIService {
|
|||
private copilotService: CopilotService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Transcribes an image using the configured AI provider.
|
||||
* @param file The image file to transcribe (after potential conversion/compression).
|
||||
* @returns A promise that resolves with the transcription text or null if failed.
|
||||
*/
|
||||
async transcribeImage(file: TFile): Promise<string | null> {
|
||||
let base64ImageWithPrefix: string;
|
||||
|
||||
// 1. Encode image
|
||||
try {
|
||||
base64ImageWithPrefix = await encodeImageToBase64(file, this.app);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
this.notificationService.notifyError(`Failed to encode image ${file.name}.`);
|
||||
console.error("Image encoding error:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Determine provider and settings
|
||||
const {
|
||||
provider,
|
||||
openaiApiKey,
|
||||
|
|
@ -65,188 +89,189 @@ export class AIService {
|
|||
userPrompt,
|
||||
openaiBaseUrl,
|
||||
} = this.settings;
|
||||
const imageUrl = base64ImageWithPrefix; // Use the data URI
|
||||
const imageUrl = base64ImageWithPrefix;
|
||||
|
||||
// Resolve custom model names
|
||||
const resolvedOpenAiModel = openaiModel === "custom" ? openaiCustomModel : openaiModel;
|
||||
const resolvedAnthropicModel =
|
||||
anthropicModel === "custom" ? anthropicCustomModel : anthropicModel;
|
||||
const resolvedGoogleModel = googleModel === "custom" ? googleCustomModel : googleModel;
|
||||
const resolvedMistralModel = mistralModel === "custom" ? mistralCustomModel : mistralModel;
|
||||
|
||||
// 3. Call appropriate API
|
||||
try {
|
||||
if (provider === "openai") {
|
||||
if (!openaiApiKey) {
|
||||
this.notificationService.notifyError("OpenAI API key is missing.");
|
||||
return null;
|
||||
}
|
||||
if (!resolvedOpenAiModel) {
|
||||
this.notificationService.notifyError(
|
||||
"OpenAI model is not configured. Please enter a custom model name.",
|
||||
switch (provider) {
|
||||
case ApiProvider.OpenAI:
|
||||
if (!openaiApiKey) {
|
||||
this.notificationService.notifyError("OpenAI API key is missing.");
|
||||
return null;
|
||||
}
|
||||
if (!resolvedOpenAiModel) {
|
||||
this.notificationService.notifyError(
|
||||
"OpenAI model is not configured. Please enter a custom model name.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithOpenAI(
|
||||
imageUrl,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
openaiApiKey,
|
||||
resolvedOpenAiModel,
|
||||
openaiBaseUrl,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithOpenAI(
|
||||
imageUrl,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
openaiApiKey,
|
||||
resolvedOpenAiModel,
|
||||
openaiBaseUrl,
|
||||
);
|
||||
} else if (provider === "anthropic") {
|
||||
if (!anthropicApiKey) {
|
||||
this.notificationService.notifyError("Anthropic API key is missing.");
|
||||
return null;
|
||||
}
|
||||
if (!resolvedAnthropicModel) {
|
||||
this.notificationService.notifyError(
|
||||
"Anthropic model is not configured. Please enter a custom model name.",
|
||||
|
||||
case ApiProvider.Anthropic: {
|
||||
if (!anthropicApiKey) {
|
||||
this.notificationService.notifyError("Anthropic API key is missing.");
|
||||
return null;
|
||||
}
|
||||
if (!resolvedAnthropicModel) {
|
||||
this.notificationService.notifyError(
|
||||
"Anthropic model is not configured. Please enter a custom model name.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const extractedImageData = this.extractBase64ImageParts(
|
||||
imageUrl,
|
||||
"Anthropic",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// Extract base64 data and media type for Anthropic
|
||||
const base64Data = imageUrl.split(",")[1];
|
||||
const mediaTypeMatch = imageUrl.match(/^data:(image\/[a-z]+);base64,/);
|
||||
if (!mediaTypeMatch || !base64Data) {
|
||||
this.notificationService.notifyError(
|
||||
"Could not extract image data for Anthropic.",
|
||||
if (!extractedImageData) {
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithAnthropic(
|
||||
extractedImageData.base64Data,
|
||||
extractedImageData.mediaType,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
anthropicApiKey,
|
||||
resolvedAnthropicModel,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const mediaType = mediaTypeMatch[1];
|
||||
return await this._transcribeWithAnthropic(
|
||||
base64Data,
|
||||
mediaType,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
anthropicApiKey,
|
||||
resolvedAnthropicModel,
|
||||
);
|
||||
} else if (provider === "google") {
|
||||
if (!googleApiKey) {
|
||||
this.notificationService.notifyError("Google API key is missing.");
|
||||
return null;
|
||||
}
|
||||
if (!resolvedGoogleModel) {
|
||||
this.notificationService.notifyError(
|
||||
"Google model is not configured. Please enter a custom model name.",
|
||||
|
||||
case ApiProvider.Google: {
|
||||
if (!googleApiKey) {
|
||||
this.notificationService.notifyError("Google API key is missing.");
|
||||
return null;
|
||||
}
|
||||
if (!resolvedGoogleModel) {
|
||||
this.notificationService.notifyError(
|
||||
"Google model is not configured. Please enter a custom model name.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const extractedImageData = this.extractBase64ImageParts(imageUrl, "Google");
|
||||
if (!extractedImageData) {
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithGoogle(
|
||||
extractedImageData.base64Data,
|
||||
extractedImageData.mediaType,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
googleApiKey,
|
||||
resolvedGoogleModel,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// Extract base64 data for Google
|
||||
const base64Data = imageUrl.split(",")[1];
|
||||
const mediaTypeMatch = imageUrl.match(/^data:(image\/[a-z]+);base64,/);
|
||||
if (!mediaTypeMatch || !base64Data) {
|
||||
this.notificationService.notifyError(
|
||||
"Could not extract image data for Google.",
|
||||
|
||||
case ApiProvider.Mistral:
|
||||
if (!mistralApiKey) {
|
||||
this.notificationService.notifyError("Mistral API key is missing.");
|
||||
return null;
|
||||
}
|
||||
if (!resolvedMistralModel) {
|
||||
this.notificationService.notifyError(
|
||||
"Mistral model is not configured. Please enter a custom model name.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithMistral(
|
||||
imageUrl,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
mistralApiKey,
|
||||
resolvedMistralModel,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const mediaType = mediaTypeMatch[1];
|
||||
return await this._transcribeWithGoogle(
|
||||
base64Data,
|
||||
mediaType,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
googleApiKey,
|
||||
resolvedGoogleModel,
|
||||
);
|
||||
} else if (provider === "mistral") {
|
||||
if (!mistralApiKey) {
|
||||
this.notificationService.notifyError("Mistral API key is missing.");
|
||||
return null;
|
||||
}
|
||||
if (!resolvedMistralModel) {
|
||||
this.notificationService.notifyError(
|
||||
"Mistral model is not configured. Please enter a custom model name.",
|
||||
|
||||
case ApiProvider.OpenAICompatible:
|
||||
if (!openaiCompatibleEndpoint) {
|
||||
this.notificationService.notifyError(
|
||||
"OpenAI-compatible endpoint URL is not configured.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!openaiCompatibleModel) {
|
||||
this.notificationService.notifyError(
|
||||
"OpenAI-compatible model name is not configured.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithOpenAI(
|
||||
imageUrl,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
openaiCompatibleApiKey || "not-required",
|
||||
openaiCompatibleModel,
|
||||
openaiCompatibleEndpoint,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithMistral(
|
||||
imageUrl,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
mistralApiKey,
|
||||
resolvedMistralModel,
|
||||
);
|
||||
} else if (provider === "openai-compatible") {
|
||||
if (!openaiCompatibleEndpoint) {
|
||||
this.notificationService.notifyError(
|
||||
"OpenAI-compatible endpoint URL is not configured.",
|
||||
|
||||
case ApiProvider.GitHubCopilot: {
|
||||
const { copilotOAuthToken, copilotModel, copilotCustomModel } = this.settings;
|
||||
if (!copilotOAuthToken) {
|
||||
this.notificationService.notifyError(
|
||||
"GitHub Copilot is not authenticated. Please log in via the plugin settings.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const resolvedCopilotModel =
|
||||
copilotModel === "custom" ? copilotCustomModel : copilotModel;
|
||||
if (!resolvedCopilotModel) {
|
||||
this.notificationService.notifyError(
|
||||
"GitHub Copilot model is not configured. Please select a model or enter a custom model name.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithCopilot(
|
||||
imageUrl,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
copilotOAuthToken,
|
||||
resolvedCopilotModel,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!openaiCompatibleModel) {
|
||||
this.notificationService.notifyError(
|
||||
"OpenAI-compatible model name is not configured.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithOpenAI(
|
||||
imageUrl,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
openaiCompatibleApiKey || "not-required", // Some servers need a non-empty key
|
||||
openaiCompatibleModel,
|
||||
openaiCompatibleEndpoint,
|
||||
);
|
||||
} else if (provider === "github-copilot") {
|
||||
const { copilotOAuthToken, copilotModel, copilotCustomModel } = this.settings;
|
||||
if (!copilotOAuthToken) {
|
||||
this.notificationService.notifyError(
|
||||
"GitHub Copilot is not authenticated. Please log in via the plugin settings.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const resolvedCopilotModel =
|
||||
copilotModel === "custom" ? copilotCustomModel : copilotModel;
|
||||
if (!resolvedCopilotModel) {
|
||||
this.notificationService.notifyError(
|
||||
"GitHub Copilot model is not configured. Please select a model or enter a custom model name.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this._transcribeWithCopilot(
|
||||
imageUrl,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
copilotOAuthToken,
|
||||
resolvedCopilotModel,
|
||||
);
|
||||
} else {
|
||||
this.notificationService.notifyError(
|
||||
`Unsupported AI provider selected: ${provider}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// More specific error handling based on caught error
|
||||
} catch (error: unknown) {
|
||||
const serviceError = error as ServiceErrorLike;
|
||||
let errorMessage = `Transcription failed for ${file.name}.`;
|
||||
if (error.message) {
|
||||
errorMessage += ` Error: ${error.message}`;
|
||||
if (serviceError.message) {
|
||||
errorMessage += ` Error: ${serviceError.message}`;
|
||||
}
|
||||
if (error.response?.status) {
|
||||
errorMessage += ` Status: ${error.response.status}`;
|
||||
if (serviceError.response?.status) {
|
||||
errorMessage += ` Status: ${serviceError.response.status}`;
|
||||
}
|
||||
this.notificationService.notifyError(errorMessage);
|
||||
console.error(`Transcription error for ${file.name}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an OpenAI model requires the newer max_completion_tokens parameter
|
||||
* instead of the deprecated max_tokens parameter.
|
||||
* Newer models (GPT-5, o-series, etc.) use max_completion_tokens.
|
||||
*/
|
||||
private extractBase64ImageParts(
|
||||
imageUrl: string,
|
||||
providerName: string,
|
||||
): { base64Data: string; mediaType: string } | null {
|
||||
const base64Data = imageUrl.split(",")[1];
|
||||
const mediaTypeMatch = imageUrl.match(/^data:(image\/[a-z]+);base64,/);
|
||||
if (!mediaTypeMatch || !base64Data) {
|
||||
this.notificationService.notifyError(
|
||||
`Could not extract image data for ${providerName}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
base64Data,
|
||||
mediaType: mediaTypeMatch[1],
|
||||
};
|
||||
}
|
||||
|
||||
private _useMaxCompletionTokens(model: string): boolean {
|
||||
const lowerModel = model.toLowerCase();
|
||||
// Models that require max_completion_tokens:
|
||||
// - o1, o3, o4 series (reasoning models)
|
||||
// - GPT-5 and newer
|
||||
// - Any model with version >= 5
|
||||
return (
|
||||
lowerModel.startsWith("o1") ||
|
||||
lowerModel.startsWith("o3") ||
|
||||
|
|
@ -256,29 +281,23 @@ export class AIService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the OpenAI API to transcribe the image.
|
||||
*/
|
||||
private async _transcribeWithOpenAI(
|
||||
imageUrl: string, // Now expects data URI
|
||||
imageUrl: string,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
baseUrl: string,
|
||||
): Promise<string | null> {
|
||||
// console.log(`Transcribing with OpenAI (${model})...`);
|
||||
this.notificationService.notifyVerbose(`Sending image to OpenAI (${model})...`);
|
||||
const startTime = Date.now();
|
||||
|
||||
// Determine which token limit parameter to use based on the model
|
||||
// Newer models (GPT-5, o-series) use max_completion_tokens instead of max_tokens
|
||||
const tokenLimitParam = this._useMaxCompletionTokens(model)
|
||||
? { max_completion_tokens: 4000 }
|
||||
: { max_tokens: 4000 };
|
||||
|
||||
const requestBody = {
|
||||
model: model,
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
|
|
@ -305,44 +324,29 @@ export class AIService {
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
throw: false, // Prevent requestUrl from throwing on non-2xx status codes
|
||||
throw: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await requestUrl(requestParams);
|
||||
const response = await requestUrl(requestParams);
|
||||
const data = response.json as OpenAiLikeResponse;
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const data = response.json;
|
||||
const transcription = data?.choices?.[0]?.message?.content;
|
||||
if (transcription) {
|
||||
// console.log('OpenAI transcription successful.');
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`OpenAI Response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription.trim();
|
||||
} else {
|
||||
console.error("OpenAI response missing transcription content:", data);
|
||||
throw new Error("Invalid response format from OpenAI.");
|
||||
}
|
||||
} else {
|
||||
console.error(`OpenAI API Error (${response.status}):`, response.text);
|
||||
let errorDetails =
|
||||
response.json?.error?.message ||
|
||||
response.text ||
|
||||
`HTTP status ${response.status}`;
|
||||
throw new Error(`OpenAI API error: ${errorDetails}`);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const transcription = data.choices?.[0]?.message?.content;
|
||||
if (transcription) {
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`OpenAI Response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calling OpenAI API:", error);
|
||||
// Re-throw the error to be caught by the main transcribeImage method
|
||||
throw error;
|
||||
throw new Error("Invalid response format from OpenAI.");
|
||||
}
|
||||
|
||||
const errorDetails =
|
||||
data.error?.message || response.text || `HTTP status ${response.status}`;
|
||||
throw new Error(`OpenAI API error: ${errorDetails}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Anthropic API to transcribe the image.
|
||||
*/
|
||||
private async _transcribeWithAnthropic(
|
||||
base64Data: string,
|
||||
mediaType: string,
|
||||
|
|
@ -351,13 +355,12 @@ export class AIService {
|
|||
apiKey: string,
|
||||
model: string,
|
||||
): Promise<string | null> {
|
||||
// console.log(`Transcribing with Anthropic (${model})...`);
|
||||
this.notificationService.notifyVerbose(`Sending image to Anthropic (${model})...`);
|
||||
const startTime = Date.now();
|
||||
|
||||
const requestBody = {
|
||||
model: model,
|
||||
max_tokens: 4000, // Increased token limit
|
||||
model,
|
||||
max_tokens: 4000,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{
|
||||
|
|
@ -386,55 +389,34 @@ export class AIService {
|
|||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
throw: false, // Prevent requestUrl from throwing on non-2xx status codes
|
||||
throw: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await requestUrl(requestParams);
|
||||
const response = await requestUrl(requestParams);
|
||||
const data = response.json as AnthropicResponse;
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const data = response.json;
|
||||
// Anthropic returns content as an array, find the text block
|
||||
const transcription = data?.content?.find(
|
||||
(block: any) => block.type === "text",
|
||||
)?.text;
|
||||
if (transcription) {
|
||||
// console.log('Anthropic transcription successful.');
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`Anthropic Response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription.trim();
|
||||
} else {
|
||||
console.error("Anthropic response missing transcription content:", data);
|
||||
throw new Error("Invalid response format from Anthropic.");
|
||||
}
|
||||
} else {
|
||||
console.error(`Anthropic API Error (${response.status}):`, response.text);
|
||||
let errorDetails =
|
||||
response.json?.error?.message ||
|
||||
response.text ||
|
||||
`HTTP status ${response.status}`;
|
||||
throw new Error(`Anthropic API error: ${errorDetails}`);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const transcription = data.content?.find(
|
||||
(block): block is AnthropicContentBlock & { type: "text"; text: string } =>
|
||||
block.type === "text" && typeof block.text === "string",
|
||||
)?.text;
|
||||
if (transcription) {
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`Anthropic Response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calling Anthropic API:", error);
|
||||
// Re-throw the error to be caught by the main transcribeImage method
|
||||
throw error;
|
||||
throw new Error("Invalid response format from Anthropic.");
|
||||
}
|
||||
|
||||
const errorDetails =
|
||||
data.error?.message || response.text || `HTTP status ${response.status}`;
|
||||
throw new Error(`Anthropic API error: ${errorDetails}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a Google model requires the v1beta API endpoint.
|
||||
* Preview and experimental models are only available on v1beta.
|
||||
*/
|
||||
private _useGoogleV1Beta(model: string): boolean {
|
||||
const lowerModel = model.toLowerCase();
|
||||
// Models that require v1beta API:
|
||||
// - Preview models (contain "preview" in name)
|
||||
// - Experimental models (contain "exp" in name)
|
||||
// - Gemini 2.0+ flash thinking models
|
||||
// - Gemini 3.x models (newer generation)
|
||||
return (
|
||||
lowerModel.includes("preview") ||
|
||||
lowerModel.includes("exp") ||
|
||||
|
|
@ -444,9 +426,6 @@ export class AIService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Google Gemini API to transcribe the image.
|
||||
*/
|
||||
private async _transcribeWithGoogle(
|
||||
base64Data: string,
|
||||
mediaType: string,
|
||||
|
|
@ -477,8 +456,9 @@ export class AIService {
|
|||
},
|
||||
};
|
||||
|
||||
// Use v1beta API for preview/experimental models, v1 for stable models
|
||||
const googleApiUrl = this._useGoogleV1Beta(model) ? GOOGLE_API_URL_V1BETA : GOOGLE_API_URL_V1;
|
||||
const googleApiUrl = this._useGoogleV1Beta(model)
|
||||
? GOOGLE_API_URL_V1BETA
|
||||
: GOOGLE_API_URL_V1;
|
||||
|
||||
const requestParams: RequestUrlParam = {
|
||||
url: `${googleApiUrl}/${model}:generateContent?key=${apiKey}`,
|
||||
|
|
@ -487,45 +467,31 @@ export class AIService {
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
throw: false, // Prevent requestUrl from throwing on non-2xx status codes
|
||||
throw: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await requestUrl(requestParams);
|
||||
const response = await requestUrl(requestParams);
|
||||
const data = response.json as GoogleResponse;
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const data = response.json;
|
||||
const transcription = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (transcription) {
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`Google Gemini Response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription.trim();
|
||||
} else {
|
||||
console.error("Google Gemini response missing transcription content:", data);
|
||||
throw new Error("Invalid response format from Google Gemini.");
|
||||
}
|
||||
} else {
|
||||
console.error(`Google Gemini API Error (${response.status}):`, response.text);
|
||||
let errorDetails =
|
||||
response.json?.error?.message ||
|
||||
response.text ||
|
||||
`HTTP status ${response.status}`;
|
||||
throw new Error(`Google Gemini API error: ${errorDetails}`);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const transcription = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (transcription) {
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`Google Gemini Response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calling Google Gemini API:", error);
|
||||
// Re-throw the error to be caught by the main transcribeImage method
|
||||
throw error;
|
||||
throw new Error("Invalid response format from Google Gemini.");
|
||||
}
|
||||
|
||||
const errorDetails =
|
||||
data.error?.message || response.text || `HTTP status ${response.status}`;
|
||||
throw new Error(`Google Gemini API error: ${errorDetails}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Mistral API to transcribe the image.
|
||||
*/
|
||||
private async _transcribeWithMistral(
|
||||
imageUrl: string, // Expects data URI
|
||||
imageUrl: string,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
|
|
@ -535,7 +501,7 @@ export class AIService {
|
|||
const startTime = Date.now();
|
||||
|
||||
const requestBody = {
|
||||
model: model,
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
|
|
@ -565,42 +531,28 @@ export class AIService {
|
|||
throw: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await requestUrl(requestParams);
|
||||
const response = await requestUrl(requestParams);
|
||||
const data = response.json as OpenAiLikeResponse;
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const data = response.json;
|
||||
const transcription = data?.choices?.[0]?.message?.content;
|
||||
if (transcription) {
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`Mistral Response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription.trim();
|
||||
} else {
|
||||
console.error("Mistral response missing transcription content:", data);
|
||||
throw new Error("Invalid response format from Mistral.");
|
||||
}
|
||||
} else {
|
||||
console.error(`Mistral API Error (${response.status}):`, response.text);
|
||||
let errorDetails =
|
||||
response.json?.error?.message ||
|
||||
response.text ||
|
||||
`HTTP status ${response.status}`;
|
||||
throw new Error(`Mistral API error: ${errorDetails}`);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const transcription = data.choices?.[0]?.message?.content;
|
||||
if (transcription) {
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`Mistral Response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calling Mistral API:", error);
|
||||
throw error;
|
||||
throw new Error("Invalid response format from Mistral.");
|
||||
}
|
||||
|
||||
const errorDetails =
|
||||
data.error?.message || response.text || `HTTP status ${response.status}`;
|
||||
throw new Error(`Mistral API error: ${errorDetails}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe an image using GitHub Copilot API.
|
||||
* Uses the CopilotService for OAuth token management and API calls.
|
||||
*/
|
||||
private async _transcribeWithCopilot(
|
||||
imageUrl: string, // data URI
|
||||
imageUrl: string,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
oauthToken: string,
|
||||
|
|
@ -609,7 +561,7 @@ export class AIService {
|
|||
this.notificationService.notifyVerbose(`Sending image to GitHub Copilot (${model})...`);
|
||||
const startTime = Date.now();
|
||||
|
||||
const messages = [
|
||||
const messages: CopilotChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{
|
||||
role: "user",
|
||||
|
|
@ -625,26 +577,21 @@ export class AIService {
|
|||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const transcription = await this.copilotService.chatCompletion(
|
||||
oauthToken,
|
||||
model,
|
||||
messages,
|
||||
4000,
|
||||
);
|
||||
const transcription = await this.copilotService.chatCompletion(
|
||||
oauthToken,
|
||||
model,
|
||||
messages,
|
||||
4000,
|
||||
);
|
||||
|
||||
if (transcription) {
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`Copilot response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription;
|
||||
} else {
|
||||
throw new Error("Invalid response format from Copilot API.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calling Copilot API:", error);
|
||||
throw error;
|
||||
if (!transcription) {
|
||||
throw new Error("Invalid response format from Copilot API.");
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
this.notificationService.notifyVerbose(
|
||||
`Copilot response received (${(endTime - startTime) / 1000}s).`,
|
||||
);
|
||||
return transcription;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
|
||||
// Copilot OAuth App client ID
|
||||
const COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
|
||||
const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
|
||||
const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
||||
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
||||
|
|
@ -21,24 +19,56 @@ export interface CopilotBearerToken {
|
|||
expires_at: number;
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface CopilotTokenResponse {
|
||||
token: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
interface CopilotChatResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
};
|
||||
}>;
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CopilotModelDescriptor {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface CopilotModelsResponse {
|
||||
data?: CopilotModelDescriptor[];
|
||||
}
|
||||
|
||||
interface CopilotTextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface CopilotImageContent {
|
||||
type: "image_url";
|
||||
image_url: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CopilotChatMessage = {
|
||||
role: string;
|
||||
content: string | Array<CopilotTextContent | CopilotImageContent>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages GitHub Copilot authentication via the OAuth Device Flow.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Request a device code from GitHub
|
||||
* 2. User visits github.com/login/device and enters the code
|
||||
* 3. Poll GitHub until the user authorizes -> get an OAuth token
|
||||
* 4. Exchange the OAuth token for a short-lived Copilot bearer token
|
||||
* 5. Use the bearer token for API calls (auto-refresh on expiry)
|
||||
*/
|
||||
export class CopilotService {
|
||||
private bearerToken: CopilotBearerToken | null = null;
|
||||
|
||||
/**
|
||||
* Step 1: Request device and user verification codes from GitHub.
|
||||
*/
|
||||
async requestDeviceCode(): Promise<DeviceCodeResponse> {
|
||||
const response = await requestUrl({
|
||||
url: GITHUB_DEVICE_CODE_URL,
|
||||
|
|
@ -60,16 +90,6 @@ export class CopilotService {
|
|||
return response.json as DeviceCodeResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Poll GitHub for the OAuth access token.
|
||||
* Returns the OAuth token (gho_xxx) once the user authorizes.
|
||||
*
|
||||
* @param deviceCode - The device_code from step 1
|
||||
* @param interval - Polling interval in seconds
|
||||
* @param expiresIn - Maximum time to poll in seconds (from device code response)
|
||||
* @param onPending - Optional callback when waiting for user authorization
|
||||
* @returns The GitHub OAuth access token
|
||||
*/
|
||||
async pollForOAuthToken(
|
||||
deviceCode: string,
|
||||
interval: number,
|
||||
|
|
@ -103,16 +123,14 @@ export class CopilotService {
|
|||
);
|
||||
}
|
||||
|
||||
const data = response.json;
|
||||
const data = response.json as OAuthTokenResponse;
|
||||
|
||||
if (data.access_token) {
|
||||
return data.access_token as string;
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
if (!data.error) {
|
||||
throw new Error(
|
||||
`Unexpected token response: ${JSON.stringify(data)}`,
|
||||
);
|
||||
throw new Error(`Unexpected token response: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
switch (data.error) {
|
||||
|
|
@ -120,8 +138,6 @@ export class CopilotService {
|
|||
onPending?.();
|
||||
continue;
|
||||
case "slow_down":
|
||||
// Add 5 seconds as required by RFC 8628, section 3.5
|
||||
// https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
|
||||
currentInterval += 5;
|
||||
continue;
|
||||
case "expired_token":
|
||||
|
|
@ -129,27 +145,18 @@ export class CopilotService {
|
|||
"The device code has expired. Please try logging in again.",
|
||||
);
|
||||
case "access_denied":
|
||||
throw new Error("Login was cancelled or denied by the user.");
|
||||
default:
|
||||
throw new Error(
|
||||
"Login was cancelled or denied by the user.",
|
||||
`OAuth error: ${data.error} - ${data.error_description || "Unknown error"}`,
|
||||
);
|
||||
default:
|
||||
throw new Error(
|
||||
`OAuth error: ${data.error} - ${data.error_description || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"The device code has expired. Please try logging in again.",
|
||||
);
|
||||
throw new Error("The device code has expired. Please try logging in again.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a GitHub OAuth token for a short-lived Copilot bearer token.
|
||||
*/
|
||||
async exchangeForCopilotToken(
|
||||
oauthToken: string,
|
||||
): Promise<CopilotBearerToken> {
|
||||
async exchangeForCopilotToken(oauthToken: string): Promise<CopilotBearerToken> {
|
||||
const response = await requestUrl({
|
||||
url: COPILOT_TOKEN_URL,
|
||||
method: "GET",
|
||||
|
|
@ -173,7 +180,7 @@ export class CopilotService {
|
|||
);
|
||||
}
|
||||
|
||||
const data = response.json;
|
||||
const data = response.json as CopilotTokenResponse;
|
||||
this.bearerToken = {
|
||||
token: data.token,
|
||||
expires_at: data.expires_at,
|
||||
|
|
@ -182,10 +189,6 @@ export class CopilotService {
|
|||
return this.bearerToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid Copilot bearer token, refreshing if expired.
|
||||
* Requires the OAuth token to be stored in settings.
|
||||
*/
|
||||
async getBearerToken(oauthToken: string): Promise<string> {
|
||||
if (!oauthToken) {
|
||||
throw new Error(
|
||||
|
|
@ -193,7 +196,6 @@ export class CopilotService {
|
|||
);
|
||||
}
|
||||
|
||||
// Check if we have a valid cached bearer token (with 60s buffer)
|
||||
if (
|
||||
this.bearerToken &&
|
||||
this.bearerToken.expires_at > Date.now() / 1000 + 60
|
||||
|
|
@ -201,34 +203,28 @@ export class CopilotService {
|
|||
return this.bearerToken.token;
|
||||
}
|
||||
|
||||
// Exchange for a new bearer token
|
||||
const newToken = await this.exchangeForCopilotToken(oauthToken);
|
||||
return newToken.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a chat completions request to the Copilot API.
|
||||
* Handles automatic token refresh on 401.
|
||||
*/
|
||||
async chatCompletion(
|
||||
oauthToken: string,
|
||||
model: string,
|
||||
messages: Array<{ role: string; content: any }>,
|
||||
messages: CopilotChatMessage[],
|
||||
maxTokens: number = 4000,
|
||||
): Promise<string> {
|
||||
const bearerToken = await this.getBearerToken(oauthToken);
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
model: model,
|
||||
messages: messages,
|
||||
model,
|
||||
messages,
|
||||
max_tokens: maxTokens,
|
||||
});
|
||||
|
||||
const response = await this.postChatCompletion(bearerToken, requestBody);
|
||||
|
||||
// Handle 401 by refreshing the bearer token and retrying once
|
||||
if (response.status === 401) {
|
||||
this.bearerToken = null; // Invalidate cached token
|
||||
this.bearerToken = null;
|
||||
const newBearerToken = await this.getBearerToken(oauthToken);
|
||||
const retryResponse = await this.postChatCompletion(newBearerToken, requestBody);
|
||||
|
||||
|
|
@ -238,32 +234,25 @@ export class CopilotService {
|
|||
);
|
||||
}
|
||||
|
||||
const data = retryResponse.json;
|
||||
return data?.choices?.[0]?.message?.content?.trim() || "";
|
||||
const data = retryResponse.json as CopilotChatResponse;
|
||||
return data.choices?.[0]?.message?.content?.trim() || "";
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
let errorDetails: string;
|
||||
try {
|
||||
const errorData = response.json;
|
||||
errorDetails = errorData?.error?.message || response.text;
|
||||
} catch {
|
||||
errorDetails = response.text || `HTTP ${response.status}`;
|
||||
}
|
||||
const errorData = response.json as CopilotChatResponse;
|
||||
errorDetails = errorData.error?.message || response.text || `HTTP ${response.status}`;
|
||||
throw new Error(`Copilot API error: ${errorDetails}`);
|
||||
}
|
||||
|
||||
const data = response.json;
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
const data = response.json as CopilotChatResponse;
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("Invalid response format from Copilot API.");
|
||||
}
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stored OAuth token is still valid by attempting a token exchange.
|
||||
*/
|
||||
async validateOAuthToken(oauthToken: string): Promise<boolean> {
|
||||
try {
|
||||
await this.exchangeForCopilotToken(oauthToken);
|
||||
|
|
@ -273,10 +262,6 @@ export class CopilotService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the list of available models from the Copilot API.
|
||||
* Returns an array of model ID strings.
|
||||
*/
|
||||
private async fetchModels(bearerToken: string) {
|
||||
return requestUrl({
|
||||
url: `${COPILOT_API_BASE}/models`,
|
||||
|
|
@ -303,7 +288,7 @@ export class CopilotService {
|
|||
"Editor-Plugin-Version": "ImgToNotes/1.0",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
},
|
||||
body: body,
|
||||
body,
|
||||
throw: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -313,7 +298,6 @@ export class CopilotService {
|
|||
const response = await this.fetchModels(bearerToken);
|
||||
|
||||
if (response.status === 401) {
|
||||
// Try refreshing the bearer token
|
||||
this.bearerToken = null;
|
||||
const newBearerToken = await this.getBearerToken(oauthToken);
|
||||
const retryResponse = await this.fetchModels(newBearerToken);
|
||||
|
|
@ -324,26 +308,27 @@ export class CopilotService {
|
|||
);
|
||||
}
|
||||
|
||||
const data = retryResponse.json;
|
||||
return (data?.data || []).map((m: any) => m.id as string);
|
||||
return this.extractModelIds(retryResponse.json as CopilotModelsResponse);
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to fetch Copilot models: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = response.json;
|
||||
return (data?.data || []).map((m: any) => m.id as string);
|
||||
return this.extractModelIds(response.json as CopilotModelsResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached bearer token (e.g., on logout).
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.bearerToken = null;
|
||||
}
|
||||
|
||||
private extractModelIds(data: CopilotModelsResponse): string[] {
|
||||
return (data.data || [])
|
||||
.map((model) => model.id)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => activeWindow.setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import { App, TFile, normalizePath, FileSystemAdapter } from "obsidian";
|
||||
import { App, TFile, normalizePath } from "obsidian";
|
||||
import { PluginSettings, NoteNamingOption, TranscriptionPlacement } from "../models/settings";
|
||||
import { NotificationService } from "../ui/notificationService";
|
||||
import { getParentFolderPath, sanitizeFilename, getFormattedDate } from "../utils/fileUtils";
|
||||
|
||||
// Helper function to remove markdown headings and links from the start of a string
|
||||
function stripMarkdown(text: string): string {
|
||||
// Remove leading # style headings (e.g., # heading, ## heading)
|
||||
let cleaned = text.replace(/^[#]+\s+/, "");
|
||||
// Remove markdown links (e.g., [link text](url))
|
||||
// This is a basic version, might need refinement for complex cases
|
||||
cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
||||
// Add more replacements here if needed (e.g., bold, italics)
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
|
|
@ -21,13 +16,6 @@ export class NoteCreator {
|
|||
private app: App,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new markdown note containing the transcription and a link to the image.
|
||||
* @param transcription The transcribed text.
|
||||
* @param imageFile The final TFile object for the processed image (used for linking and naming).
|
||||
* @param noteTargetParentPath The target folder path for the new note.
|
||||
* @returns A promise that resolves with the created TFile or null if creation failed.
|
||||
*/
|
||||
async createNote(
|
||||
transcription: string,
|
||||
imageFile: TFile,
|
||||
|
|
@ -40,49 +28,39 @@ export class NoteCreator {
|
|||
|
||||
let noteContent = transcription.trim();
|
||||
if (this.settings.includeImageInNote) {
|
||||
// Generate embed link (with !) so the image displays inline
|
||||
const imageLink = this.app.fileManager.generateMarkdownLink(imageFile, "/");
|
||||
const imageEmbed = imageLink.startsWith("!") ? imageLink : `!${imageLink}`;
|
||||
if (this.settings.transcriptionPlacement === TranscriptionPlacement.AboveImage) {
|
||||
noteContent = `${transcription.trim()}\n\n${imageEmbed}`;
|
||||
} else {
|
||||
// BelowImage
|
||||
noteContent = `${imageEmbed}\n\n${transcription.trim()}`;
|
||||
}
|
||||
} else {
|
||||
noteContent = transcription.trim();
|
||||
}
|
||||
|
||||
const newNoteFile = await this.app.vault.create(uniqueNotePath, noteContent);
|
||||
this.notificationService.notifySuccess(`Note created: ${newNoteFile.basename}`);
|
||||
return newNoteFile;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
this.notificationService.notifyError("Failed to create note.");
|
||||
console.error("Note creation error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a title for the new note based on plugin settings.
|
||||
* Uses the final image file for naming context.
|
||||
*/
|
||||
private _generateNoteTitle(transcription: string, imageFile: TFile): string {
|
||||
let titleBase = "";
|
||||
const date = getFormattedDate();
|
||||
const sanitizedImageName = sanitizeFilename(imageFile.basename);
|
||||
|
||||
switch (this.settings.noteNamingOption) {
|
||||
case NoteNamingOption.FirstLine:
|
||||
case NoteNamingOption.FirstLine: {
|
||||
const firstLine = transcription.trim().split("\n")[0];
|
||||
if (firstLine) {
|
||||
// Strip markdown before sanitizing
|
||||
titleBase = sanitizeFilename(stripMarkdown(firstLine));
|
||||
} else {
|
||||
// Fallback if transcription is empty or just whitespace
|
||||
titleBase = `Transcription for ${sanitizedImageName}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NoteNamingOption.ImageName:
|
||||
titleBase = sanitizedImageName;
|
||||
|
|
@ -92,27 +70,22 @@ export class NoteCreator {
|
|||
titleBase = `${date}_${sanitizedImageName}`;
|
||||
break;
|
||||
|
||||
case NoteNamingOption.FolderDateNum: // Keep this option as is for now
|
||||
default:
|
||||
// Use the original parent path logic
|
||||
case NoteNamingOption.FolderDateNum:
|
||||
default: {
|
||||
const parentFolder = getParentFolderPath(imageFile.path);
|
||||
const folderName =
|
||||
parentFolder === "/" ? "Root" : parentFolder.split("/").pop() || "Folder";
|
||||
titleBase = `${folderName}_${date}_${sanitizedImageName}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure title isn't overly long (optional)
|
||||
const MAX_TITLE_LENGTH = 100;
|
||||
return titleBase.substring(0, MAX_TITLE_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a unique path for the note by appending numbers if necessary.
|
||||
*/
|
||||
private async _findUniqueNotePath(folderPath: string, title: string): Promise<string> {
|
||||
let counter = 0;
|
||||
// Ensure title is not empty before creating path
|
||||
const validTitle = title || "Untitled Note";
|
||||
let uniquePath = normalizePath(`${folderPath}/${validTitle}.md`);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,147 +1,93 @@
|
|||
import { TFile } from 'obsidian';
|
||||
import { ProcessingJob, ProcessingStatus } from '../models/processingJob';
|
||||
import { ProcessingJob } from '../models/processingJob';
|
||||
|
||||
export class ProcessingQueue {
|
||||
private queue: ProcessingJob[] = [];
|
||||
private isProcessing: boolean = false;
|
||||
private processing: ProcessingJob | null = null;
|
||||
private maxConcurrent: number = 2; // Default max concurrent processing jobs
|
||||
private activeJobs: number = 0; // Track number of active jobs
|
||||
private maxConcurrent: number = 2;
|
||||
private activeJobs: number = 0;
|
||||
|
||||
// The actual processing logic is now injected via setProcessCallback
|
||||
private processCallback: (job: ProcessingJob) => Promise<void> = async () => { // Updated type
|
||||
// This should never be called if setProcessCallback is used correctly
|
||||
// console.error('ProcessingQueue: processCallback was not set!'); // Updated error message
|
||||
// Optionally throw an error or handle it gracefully
|
||||
// throw new Error("Process callback not set");
|
||||
private processCallback: (job: ProcessingJob) => Promise<void> = async () => {
|
||||
// Intentionally empty until a callback is registered.
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the maximum number of concurrent processing jobs.
|
||||
* Useful for mobile optimization to limit resource usage.
|
||||
* @param max The maximum number of concurrent jobs (1-5)
|
||||
*/
|
||||
|
||||
public setMaxConcurrent(max: number): void {
|
||||
// Ensure max is between 1 and 5
|
||||
this.maxConcurrent = Math.max(1, Math.min(5, max));
|
||||
console.log(`ProcessingQueue: Max concurrent jobs set to ${this.maxConcurrent}`);
|
||||
}
|
||||
|
||||
// Setter for the processing callback
|
||||
public setProcessCallback(callback: (job: ProcessingJob) => Promise<void>) { // Updated type
|
||||
public setProcessCallback(callback: (job: ProcessingJob) => Promise<void>): void {
|
||||
this.processCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a file to the processing queue.
|
||||
* @param file The initial image file to process.
|
||||
*/
|
||||
addToQueue(file: TFile) { // Simplified: only needs the initial file
|
||||
// Avoid adding duplicates if the file is already pending or processing
|
||||
if (this.queue.some(existingJob => existingJob.initialFile.path === file.path) || this.processing?.initialFile.path === file.path) {
|
||||
// console.log(`Job for ${file.path} already in queue.`);
|
||||
addToQueue(file: TFile): void {
|
||||
if (
|
||||
this.queue.some((existingJob) => existingJob.initialFile.path === file.path) ||
|
||||
this.processing?.initialFile.path === file.path
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newJob: ProcessingJob = {
|
||||
initialFile: file,
|
||||
status: 'pending', // Use string literal
|
||||
// error is initially undefined by default for optional properties
|
||||
// Removed startTime and explicit error: null assignment
|
||||
status: 'pending',
|
||||
};
|
||||
// Removed jobId generation
|
||||
|
||||
this.queue.push(newJob);
|
||||
// Removed jobMap usage for now
|
||||
// console.log(`Added ${file.path} to queue. Queue size: ${this.queue.length}`);
|
||||
this.startProcessing();
|
||||
// Removed return statement
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the processing of the next job if not already processing.
|
||||
*/
|
||||
private startProcessing(): void {
|
||||
if (this.isProcessing) {
|
||||
// console.log('Already processing, skipping trigger.');
|
||||
return;
|
||||
}
|
||||
this.processNext();
|
||||
|
||||
void this.processNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the next pending job in the queue.
|
||||
* Respects maxConcurrent limit for mobile optimization.
|
||||
*/
|
||||
async processNext() {
|
||||
// Check if we're already at the maximum number of concurrent jobs
|
||||
async processNext(): Promise<void> {
|
||||
if (this.activeJobs >= this.maxConcurrent) {
|
||||
// console.log(`Already at max concurrent jobs (${this.activeJobs}/${this.maxConcurrent}), waiting...`);
|
||||
return; // Already at max concurrent jobs
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're already processing (legacy check, can be removed if activeJobs is fully implemented)
|
||||
if (this.isProcessing) {
|
||||
return; // Already processing
|
||||
return;
|
||||
}
|
||||
|
||||
const nextJob = this.queue.find(job => job.status === 'pending');
|
||||
const nextJob = this.queue.find((job) => job.status === 'pending');
|
||||
|
||||
if (!nextJob) {
|
||||
// console.log('No pending jobs in queue.');
|
||||
this.isProcessing = false;
|
||||
this.processing = null;
|
||||
return; // No pending jobs
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
this.processing = nextJob;
|
||||
this.activeJobs++; // Increment active jobs counter
|
||||
|
||||
// console.log(`Processing job for initial file ${nextJob.initialFile.path}. Active jobs: ${this.activeJobs}/${this.maxConcurrent}`);
|
||||
this.activeJobs++;
|
||||
|
||||
try {
|
||||
// Call the actual processing function (will handle convert, compress, move, transcribe, etc.)
|
||||
await this.processCallback(nextJob);
|
||||
// Status (done/error) should be set within the callback or subsequent steps
|
||||
} catch (error) {
|
||||
// console.error(`Error processing job for initial file ${nextJob.initialFile.path}:`, error);
|
||||
this.markAsError(nextJob, error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
// Regardless of success or error, clean up
|
||||
// console.log(`Finished processing job for initial file ${nextJob.initialFile.path}. Queue size: ${this.queue.length}`);
|
||||
this.processing = null;
|
||||
this.isProcessing = false;
|
||||
this.activeJobs--; // Decrement active jobs counter
|
||||
|
||||
// Check if there are more jobs to process
|
||||
this.processNext();
|
||||
this.activeJobs--;
|
||||
void this.processNext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a job as successfully completed.
|
||||
* Called by the processCallback function.
|
||||
* @param job The job to mark as done.
|
||||
*/
|
||||
markAsDone(job: ProcessingJob): void {
|
||||
if (job.status !== 'done') { // Use string literal
|
||||
job.status = 'done'; // Use string literal
|
||||
// console.log(`Job for initial file ${job.initialFile.path} marked as done.`);
|
||||
if (job.status !== 'done') {
|
||||
job.status = 'done';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a job as failed with an error.
|
||||
* Called by the processCallback function or the catch block.
|
||||
* @param job The job to mark as error.
|
||||
* @param errorMessage The error message.
|
||||
*/
|
||||
markAsError(job: ProcessingJob, errorMessage: string): void {
|
||||
if (job.status !== 'error') { // Use string literal
|
||||
job.status = 'error'; // Use string literal
|
||||
if (job.status !== 'error') {
|
||||
job.status = 'error';
|
||||
job.error = errorMessage;
|
||||
// console.log(`Job for initial file ${job.initialFile.path} marked as error: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,118 +1,78 @@
|
|||
import { TFile } from 'obsidian';
|
||||
import { TranscriptionJob, TranscriptionStatus } from '../models/transcriptionJob';
|
||||
import { TranscriptionJob } from '../models/transcriptionJob';
|
||||
|
||||
export class TranscriptionQueue {
|
||||
private queue: TranscriptionJob[] = [];
|
||||
private isProcessing: boolean = false;
|
||||
private processing: TranscriptionJob | null = null;
|
||||
|
||||
// The actual processing logic is now injected via setProcessCallback
|
||||
private processCallback: (job: TranscriptionJob) => Promise<void> = async () => {
|
||||
// This should never be called if setProcessCallback is used correctly
|
||||
// console.error('TranscriptionQueue: processCallback was not set!');
|
||||
// Optionally throw an error or handle it gracefully
|
||||
// throw new Error("Process callback not set");
|
||||
// Intentionally empty until a callback is registered.
|
||||
};
|
||||
|
||||
// Setter for the processing callback
|
||||
public setProcessCallback(callback: (job: TranscriptionJob) => Promise<void>) {
|
||||
public setProcessCallback(callback: (job: TranscriptionJob) => Promise<void>): void {
|
||||
this.processCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a file to the transcription queue.
|
||||
* @param file The image file to transcribe.
|
||||
* @param originalParentPath The original folder path where the image was created.
|
||||
*/
|
||||
addToQueue(file: TFile, originalParentPath: string) {
|
||||
// Basic check for duplicates
|
||||
if (this.queue.some(job => job.file.path === file.path) || this.processing?.file.path === file.path) {
|
||||
// console.log(`Job for ${file.path} already in queue.`);
|
||||
addToQueue(file: TFile, originalParentPath: string): void {
|
||||
if (
|
||||
this.queue.some((job) => job.file.path === file.path) ||
|
||||
this.processing?.file.path === file.path
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newJob: TranscriptionJob = {
|
||||
file: file,
|
||||
originalParentPath: originalParentPath,
|
||||
file,
|
||||
originalParentPath,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
this.queue.push(newJob);
|
||||
// console.log(`Added ${file.path} to queue. Queue size: ${this.queue.length}`);
|
||||
this.triggerProcessing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the processing of the next job if not already processing.
|
||||
*/
|
||||
private triggerProcessing() {
|
||||
private triggerProcessing(): void {
|
||||
if (this.isProcessing) {
|
||||
// console.log('Already processing, skipping trigger.');
|
||||
return;
|
||||
}
|
||||
this.processNext();
|
||||
|
||||
void this.processNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the next pending job in the queue.
|
||||
*/
|
||||
async processNext() {
|
||||
async processNext(): Promise<void> {
|
||||
if (this.isProcessing) {
|
||||
return; // Already processing
|
||||
return;
|
||||
}
|
||||
|
||||
const nextJob = this.queue.find(job => job.status === 'pending');
|
||||
const nextJob = this.queue.find((job) => job.status === 'pending');
|
||||
|
||||
if (!nextJob) {
|
||||
// console.log('No pending jobs in queue.');
|
||||
this.isProcessing = false;
|
||||
return; // No pending jobs
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
this.processing = nextJob;
|
||||
nextJob.status = 'processing';
|
||||
// console.log(`Processing job for ${nextJob.file.path}`);
|
||||
|
||||
try {
|
||||
// Call the actual processing function (AI transcription)
|
||||
await this.processCallback(nextJob);
|
||||
// Status (done/error) should be set within the callback or subsequent steps
|
||||
} catch (error) {
|
||||
console.error(`Error processing job for ${nextJob.file.path}:`, error);
|
||||
this.markAsError(nextJob, error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
// Regardless of success or error, clean up
|
||||
this.processing = null;
|
||||
this.isProcessing = false;
|
||||
// console.log(`Finished processing job for ${nextJob.file.path}. Queue size: ${this.queue.length}`);
|
||||
// Check if there are more jobs to process
|
||||
this.processNext(); // Recursively call to process next item
|
||||
void this.processNext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a job as successfully completed.
|
||||
* @param job The job to mark as done.
|
||||
*/
|
||||
markAsDone(job: TranscriptionJob) {
|
||||
markAsDone(job: TranscriptionJob): void {
|
||||
job.status = 'done';
|
||||
// console.log(`Job for ${job.file.path} marked as done.`);
|
||||
// Job will be removed in the finally block of processNext
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a job as failed with an error.
|
||||
* @param job The job to mark as error.
|
||||
* @param errorMessage The error message.
|
||||
*/
|
||||
markAsError(job: TranscriptionJob, errorMessage: string) {
|
||||
markAsError(job: TranscriptionJob, errorMessage: string): void {
|
||||
job.status = 'error';
|
||||
job.error = errorMessage;
|
||||
// console.log(`Job for ${job.file.path} marked as error: ${errorMessage}`);
|
||||
// Job will be removed in the finally block of processNext
|
||||
}
|
||||
|
||||
// Optional: Methods for persisting/restoring queue state could be added here
|
||||
// loadQueueState() { ... }
|
||||
// saveQueueState() { ... }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
styles.css
33
styles.css
|
|
@ -1,8 +1,10 @@
|
|||
/* Ensure text areas fill the entire width */
|
||||
.imgtono-setting-item-control textarea {
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
max-width: 100% !important;
|
||||
.imgtono-setting-item-control textarea,
|
||||
.imgtono-setting-item-control .imgtono-full-width-input,
|
||||
.imgtono-setting-item-control .imgtono-full-width-select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Vertical layout container */
|
||||
|
|
@ -126,16 +128,18 @@
|
|||
@media (max-width: 768px) {
|
||||
/* Force all setting controls to take full width on mobile */
|
||||
.imgtono-setting-item-control {
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Force text areas to take full width on mobile */
|
||||
.imgtono-setting-item-control textarea {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin-right: 0 !important;
|
||||
.imgtono-setting-item-control textarea,
|
||||
.imgtono-setting-item-control .imgtono-full-width-input,
|
||||
.imgtono-setting-item-control .imgtono-full-width-select {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Increase touch target size for mobile */
|
||||
|
|
@ -179,6 +183,13 @@
|
|||
gap: 6px;
|
||||
}
|
||||
|
||||
.imgtono-confirmation-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.imgtono-spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
|
|
|
|||
|
|
@ -1,69 +1,39 @@
|
|||
import { Notice, Platform } from 'obsidian';
|
||||
import { PluginSettings } from '../models/settings'; // Adjust path if needed
|
||||
import { PluginSettings } from '../models/settings';
|
||||
|
||||
const NOTICE_TIMEOUT = 5000; // 5 seconds for standard notices
|
||||
const ERROR_NOTICE_TIMEOUT = 10000; // 10 seconds for error notices
|
||||
const MOBILE_NOTICE_TIMEOUT = 3000; // Shorter timeout for mobile devices
|
||||
const MOBILE_ERROR_NOTICE_TIMEOUT = 7000; // Shorter error timeout for mobile devices
|
||||
const NOTICE_TIMEOUT = 5000;
|
||||
const ERROR_NOTICE_TIMEOUT = 10000;
|
||||
const MOBILE_NOTICE_TIMEOUT = 3000;
|
||||
const MOBILE_ERROR_NOTICE_TIMEOUT = 7000;
|
||||
|
||||
/**
|
||||
* Service for displaying Obsidian notices with mobile compatibility.
|
||||
*/
|
||||
export class NotificationService {
|
||||
private settings: PluginSettings; // Store settings
|
||||
private isMobileMode: boolean = false; // Track if mobile mode is enabled
|
||||
private settings: PluginSettings;
|
||||
private isMobileMode: boolean = false;
|
||||
|
||||
// Update constructor to accept settings
|
||||
constructor(settings: PluginSettings) {
|
||||
this.settings = settings;
|
||||
// Auto-detect mobile on initialization
|
||||
this.isMobileMode = Platform.isMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether mobile mode is enabled for notifications.
|
||||
* @param enabled Whether mobile mode is enabled
|
||||
*/
|
||||
setMobileMode(enabled: boolean): void {
|
||||
this.isMobileMode = enabled;
|
||||
console.log(`NotificationService: Mobile mode ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an informational notice.
|
||||
* @param message The message to display.
|
||||
*/
|
||||
notifyInfo(message: string): void {
|
||||
const timeout = this.isMobileMode ? MOBILE_NOTICE_TIMEOUT : NOTICE_TIMEOUT;
|
||||
new Notice(this.formatMessage(message), timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a success notice.
|
||||
* @param message The message to display.
|
||||
*/
|
||||
notifySuccess(message: string): void {
|
||||
// Obsidian's Notice doesn't have built-in types like 'success',
|
||||
// but we can prepend or style if needed later.
|
||||
const timeout = this.isMobileMode ? MOBILE_NOTICE_TIMEOUT : NOTICE_TIMEOUT;
|
||||
new Notice(`✅ ${this.formatMessage(message)}`, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error notice.
|
||||
* @param message The error message to display.
|
||||
*/
|
||||
notifyError(message: string): void {
|
||||
// Use a longer timeout for errors
|
||||
const timeout = this.isMobileMode ? MOBILE_ERROR_NOTICE_TIMEOUT : ERROR_NOTICE_TIMEOUT;
|
||||
new Notice(`❌ ${this.formatMessage(`Error: ${message}`)}`, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a verbose notification - only shows if setting is true.
|
||||
* @param message The message to display.
|
||||
* @param timeout Optional custom timeout.
|
||||
*/
|
||||
notifyVerbose(message: string, timeout?: number): void {
|
||||
if (this.settings.verboseNotifications) {
|
||||
const actualTimeout = timeout || (this.isMobileMode ? MOBILE_NOTICE_TIMEOUT : NOTICE_TIMEOUT);
|
||||
|
|
@ -71,19 +41,11 @@ export class NotificationService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a message based on device type.
|
||||
* Makes messages more concise on mobile devices.
|
||||
* @param message The message to format.
|
||||
* @returns The formatted message.
|
||||
*/
|
||||
private formatMessage(message: string): string {
|
||||
if (!this.isMobileMode) {
|
||||
return message; // Return original message on desktop
|
||||
return message;
|
||||
}
|
||||
|
||||
// On mobile, make messages more concise
|
||||
// Truncate long messages
|
||||
const maxLength = 100;
|
||||
if (message.length > maxLength) {
|
||||
return message.substring(0, maxLength - 3) + '...';
|
||||
|
|
@ -91,4 +53,4 @@ export class NotificationService {
|
|||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Notice,
|
||||
ButtonComponent,
|
||||
TFolder,
|
||||
Modal,
|
||||
} from "obsidian";
|
||||
import ImageTranscriberPlugin from "../main"; // Corrected import
|
||||
import {
|
||||
|
|
@ -23,36 +24,91 @@ import {
|
|||
|
||||
// Define available models - These should match the types in settings.ts
|
||||
const OPENAI_MODELS: Record<OpenAiModel, string> = {
|
||||
"gpt-4.1": "GPT-4.1",
|
||||
"gpt-4.1-mini": "GPT-4.1 Mini",
|
||||
"o4-mini": "o4 Mini",
|
||||
"gpt-5-2025-08-07": "GPT-5",
|
||||
"gpt-5-mini-2025-08-07": "GPT-5 Mini",
|
||||
"gpt-5.5": "GPT-5.5",
|
||||
"gpt-5.4": "GPT-5.4",
|
||||
"gpt-5.4-mini": "GPT-5.4 Mini",
|
||||
"gpt-4o": "GPT-4o",
|
||||
"gpt-4o-mini": "GPT-4o Mini",
|
||||
custom: "Custom Model",
|
||||
};
|
||||
|
||||
const ANTHROPIC_MODELS: Record<AnthropicModel, string> = {
|
||||
"claude-3-7-sonnet-latest": "Claude Sonnet 3.7",
|
||||
"claude-sonnet-4-0": "Claude Sonnet 4.0",
|
||||
"claude-sonnet-4-5": "Claude Sonnet 4.5",
|
||||
"claude-opus-4-7": "Claude Opus 4.7",
|
||||
"claude-sonnet-4-6": "Claude Sonnet 4.6",
|
||||
"claude-haiku-4-5": "Claude Haiku 4.5",
|
||||
custom: "Custom Model",
|
||||
};
|
||||
|
||||
const GOOGLE_MODELS: Record<GoogleModel, string> = {
|
||||
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
||||
"gemini-3.1-pro-preview": "Gemini 3.1 Pro (Preview)",
|
||||
"gemini-3-flash-preview": "Gemini 3 Flash (Preview)",
|
||||
"gemini-3.1-flash-lite": "Gemini 3.1 Flash-Lite",
|
||||
"gemini-2.5-pro": "Gemini 2.5 Pro",
|
||||
"gemini-2.5-flash": "Gemini 2.5 Flash",
|
||||
"gemini-2.5-flash-lite": "Gemini 2.5 Flash Lite",
|
||||
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite",
|
||||
custom: "Custom Model",
|
||||
};
|
||||
|
||||
const MISTRAL_MODELS: Record<MistralModel, string> = {
|
||||
"mistral-ocr-2505": "Mistral OCR 2505",
|
||||
"mistral-small-2503": "Mistral Small 3.1",
|
||||
"mistral-medium-3-5": "Mistral Medium 3.5",
|
||||
"mistral-small-2603": "Mistral Small 4",
|
||||
"mistral-large-2512": "Mistral Large 3",
|
||||
"mistral-medium-2508": "Mistral Medium 3.1",
|
||||
"ministral-14b-2512": "Ministral 3 14B",
|
||||
"ministral-8b-2512": "Ministral 3 8B",
|
||||
"ministral-3b-2512": "Ministral 3 3B",
|
||||
custom: "Custom Model",
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unknown error";
|
||||
}
|
||||
|
||||
class ConfirmationModal extends Modal {
|
||||
private resolved = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private title: string,
|
||||
private message: string,
|
||||
private onResolve: (confirmed: boolean) => void,
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl("h3", { text: this.title });
|
||||
contentEl.createEl("p", { text: this.message });
|
||||
|
||||
const buttonRow = contentEl.createDiv({ cls: "imgtono-confirmation-actions" });
|
||||
new ButtonComponent(buttonRow)
|
||||
.setButtonText("Cancel")
|
||||
.onClick(() => this.finish(false));
|
||||
new ButtonComponent(buttonRow)
|
||||
.setButtonText("Clear history")
|
||||
.setWarning()
|
||||
.onClick(() => this.finish(true));
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
if (!this.resolved) {
|
||||
this.onResolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
private finish(confirmed: boolean): void {
|
||||
if (this.resolved) {
|
||||
return;
|
||||
}
|
||||
this.resolved = true;
|
||||
this.close();
|
||||
this.onResolve(confirmed);
|
||||
}
|
||||
}
|
||||
|
||||
// Copilot models are fetched dynamically from the API
|
||||
|
||||
export class TranscriptionSettingTab extends PluginSettingTab {
|
||||
|
|
@ -63,6 +119,12 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
private confirmAction(title: string, message: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
new ConfirmationModal(this.app, title, message, resolve).open();
|
||||
});
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
|
||||
|
|
@ -128,7 +190,7 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
new Setting(containerEl)
|
||||
.setName("OpenAI model")
|
||||
.setDesc(
|
||||
"Select the OpenAI model to use. GPT-4.1 Mini is great for its low cost and high accuracy.",
|
||||
"Select the OpenAI model to use. GPT-5.4 Mini is a strong current default for cost and latency, while GPT-5.5 is the flagship choice.",
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
// Use the imported type and constant
|
||||
|
|
@ -429,7 +491,7 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
await navigator.clipboard.writeText(deviceCode.user_code);
|
||||
clipboardSuccess = true;
|
||||
} catch {
|
||||
console.warn("Could not copy device code to clipboard.");
|
||||
// Clipboard access is best-effort and can fail on some platforms.
|
||||
}
|
||||
|
||||
// Open the verification URL in the user's browser
|
||||
|
|
@ -461,9 +523,8 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
|
||||
new Notice("Successfully logged in to GitHub Copilot!");
|
||||
this.display();
|
||||
} catch (error: any) {
|
||||
console.error("Copilot login error:", error);
|
||||
new Notice(`GitHub login failed: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
new Notice(`GitHub login failed: ${getErrorMessage(error)}`);
|
||||
loginSetting.setDesc(copilotLoginPrompt);
|
||||
button.setButtonText("Login with GitHub");
|
||||
button.setDisabled(false);
|
||||
|
|
@ -525,9 +586,7 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
descEl.empty();
|
||||
descEl.setText("Select the model to use via GitHub Copilot.");
|
||||
})
|
||||
.catch(async (err: any) => {
|
||||
console.error("Failed to fetch Copilot models:", err);
|
||||
|
||||
.catch(async () => {
|
||||
// On failure, only show "Custom Model"
|
||||
dropdown.selectEl.empty();
|
||||
dropdown.addOption("custom", "Custom Model");
|
||||
|
|
@ -605,7 +664,6 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
});
|
||||
systemPromptTextArea.inputEl.rows = 4;
|
||||
systemPromptTextArea.inputEl.style.width = "100%";
|
||||
|
||||
// Add reset button below the text area
|
||||
const systemPromptButtonContainer = systemPromptContainer.createDiv({
|
||||
|
|
@ -653,7 +711,6 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
});
|
||||
userPromptTextArea.inputEl.rows = 6; // Make it taller
|
||||
userPromptTextArea.inputEl.style.width = "100%";
|
||||
|
||||
// Add reset button below the text area
|
||||
const userPromptButtonContainer = userPromptContainer.createDiv({
|
||||
|
|
@ -761,16 +818,16 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
.setButtonText("Clear history")
|
||||
.setWarning() // Makes the button red for caution
|
||||
.onClick(async () => {
|
||||
// Simple confirmation dialog
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to clear the entire processed image history? This cannot be undone.",
|
||||
)
|
||||
) {
|
||||
const confirmed = await this.confirmAction(
|
||||
"Clear processed image history?",
|
||||
"Are you sure you want to clear the entire processed image history? This cannot be undone.",
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
this.plugin.settings.processedImagePaths = [];
|
||||
await this.plugin.saveSettings();
|
||||
new Notice("Processed image history cleared.");
|
||||
this.display(); // Re-render the settings tab to update the count
|
||||
this.display();
|
||||
} else {
|
||||
new Notice("Clear history cancelled.");
|
||||
}
|
||||
|
|
@ -802,7 +859,7 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
});
|
||||
folderInput = new TextComponent(folderControl);
|
||||
folderInput.setPlaceholder(placeholder).setValue(value).onChange(onChange);
|
||||
folderInput.inputEl.style.width = "100%";
|
||||
folderInput.inputEl.addClass("imgtono-full-width-input");
|
||||
|
||||
const folderButtonContainer = folderSettingContainer.createDiv({
|
||||
cls: "imgtono-setting-button-row",
|
||||
|
|
@ -840,16 +897,16 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
|
||||
const folders = this.app.vault
|
||||
.getAllLoadedFiles()
|
||||
.filter((file) => file instanceof TFolder) as TFolder[];
|
||||
.filter((file): file is TFolder => file instanceof TFolder);
|
||||
folders.forEach((folder) => {
|
||||
dropdown.addOption(folder.path, folder.path === "/" ? "Vault Root (/)" : folder.path);
|
||||
});
|
||||
|
||||
dropdown.setValue(value).onChange(onChange);
|
||||
dropdown.selectEl.style.width = "100%";
|
||||
dropdown.selectEl.addClass("imgtono-full-width-select");
|
||||
|
||||
if (!value && folders.length > 0) {
|
||||
const warningEl = folderHeader.createEl("p", {
|
||||
folderHeader.createEl("p", {
|
||||
text: "⚠️ Please select a folder.",
|
||||
cls: "imgtono-setting-warning",
|
||||
});
|
||||
|
|
@ -975,7 +1032,7 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
folderNameInput.setValue(this.plugin.settings.imageFolderName); // Ensure UI reflects the possibly defaulted value
|
||||
});
|
||||
folderNameInput.inputEl.style.width = "100%";
|
||||
folderNameInput.inputEl.addClass("imgtono-full-width-input");
|
||||
|
||||
const folderNameButtonContainer = folderNameContainer.createDiv({
|
||||
cls: "imgtono-setting-button-row",
|
||||
|
|
@ -1034,11 +1091,11 @@ export class TranscriptionSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.noteNamingOption = value as NoteNamingOption;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
dropdown.selectEl.style.width = "100%";
|
||||
dropdown.selectEl.addClass("imgtono-full-width-select");
|
||||
}
|
||||
|
||||
private createSourceFolderSetting(containerEl: HTMLElement): void {
|
||||
const setting = new Setting(containerEl)
|
||||
new Setting(containerEl)
|
||||
.setName("Transcribe only from a specific folder")
|
||||
.setDesc(
|
||||
"If enabled, only images added to the selected folder below will be transcribed.",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { TAbstractFile, TFile, App, normalizePath } from 'obsidian';
|
||||
import { TAbstractFile, TFile, App } from 'obsidian';
|
||||
|
||||
/**
|
||||
* Checks if a file is a supported image type (JPG, JPEG, PNG).
|
||||
* @param file The file to check.
|
||||
* @returns True if the file is a supported image, false otherwise.
|
||||
*/
|
||||
export function isImageFile(file: TAbstractFile): file is TFile {
|
||||
if (!(file instanceof TFile)) {
|
||||
return false;
|
||||
|
|
@ -13,50 +8,26 @@ export function isImageFile(file: TAbstractFile): file is TFile {
|
|||
return supportedExtensions.includes(file.extension.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent folder path from a full file path.
|
||||
* @param filePath The full path of the file.
|
||||
* @returns The path of the parent folder.
|
||||
*/
|
||||
export function getParentFolderPath(filePath: string): string {
|
||||
const lastSeparatorIndex = filePath.lastIndexOf('/');
|
||||
if (lastSeparatorIndex === -1) {
|
||||
// If no separator, it might be in the root or an invalid path
|
||||
return '/'; // Or handle as an error/edge case
|
||||
return '/';
|
||||
}
|
||||
return filePath.substring(0, lastSeparatorIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string to be used as a valid filename, removing forbidden characters.
|
||||
* @param name The original string.
|
||||
* @returns A sanitized filename string.
|
||||
*/
|
||||
export function sanitizeFilename(name: string): string {
|
||||
// Remove characters forbidden in filenames across different OS
|
||||
// Adjust the regex as needed for more specific sanitization
|
||||
return name.replace(/[<>:"/\\|?*]/g, '').replace(/[\n\r]/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current date formatted as YYYYMMDD.
|
||||
* @returns The formatted date string.
|
||||
*/
|
||||
export function getFormattedDate(): string {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Months are 0-indexed
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the content of an image file to a base64 string.
|
||||
* @param file The image file (TFile).
|
||||
* @param app The Obsidian App instance.
|
||||
* @returns A promise that resolves with the base64 encoded string.
|
||||
* @throws Will throw an error if reading the file fails.
|
||||
*/
|
||||
function _arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
|
@ -67,24 +38,17 @@ function _arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the actual MIME type of an image from its magic bytes.
|
||||
* Falls back to the file extension if the format is not recognized.
|
||||
*/
|
||||
function _detectMimeType(buffer: ArrayBuffer, fileExtension: string): string {
|
||||
const bytes = new Uint8Array(buffer.slice(0, 12));
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47
|
||||
&& bytes[4] === 0x0D && bytes[5] === 0x0A && bytes[6] === 0x1A && bytes[7] === 0x0A) {
|
||||
return "image/png";
|
||||
}
|
||||
// JPEG: FF D8 FF
|
||||
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
// Fallback to file extension
|
||||
const ext = fileExtension.toLowerCase();
|
||||
return `image/${ext === "jpg" ? "jpeg" : ext}`;
|
||||
}
|
||||
|
|
@ -93,11 +57,9 @@ export async function encodeImageToBase64(file: TFile, app: App): Promise<string
|
|||
try {
|
||||
const arrayBuffer = await app.vault.readBinary(file);
|
||||
const base64 = _arrayBufferToBase64(arrayBuffer);
|
||||
// Detect the actual image format from magic bytes, falling back to file extension
|
||||
const mimeType = _detectMimeType(arrayBuffer, file.extension);
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
} catch (error) {
|
||||
console.error(`Error encoding image ${file.path} to base64:`, error);
|
||||
} catch {
|
||||
throw new Error(`Failed to read and encode image: ${file.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,16 @@
|
|||
import { App, TFile, normalizePath } from 'obsidian';
|
||||
// @ts-ignore - heic2any doesn't have readily available types
|
||||
import heic2any from 'heic2any';
|
||||
import imageCompression from 'browser-image-compression';
|
||||
import imageCompression, { type Options as ImageCompressionOptions } from 'browser-image-compression';
|
||||
import { getParentFolderPath } from './fileUtils';
|
||||
import { NotificationService } from '../ui/notificationService';
|
||||
|
||||
/**
|
||||
* Converts a HEIC file to JPG format using the heic2any library.
|
||||
* Creates a new JPG file in the same vault folder.
|
||||
*
|
||||
* @param heicFile The HEIC file (TFile) to convert.
|
||||
* @param app The Obsidian App instance.
|
||||
* @param notificationService Service for displaying notifications.
|
||||
* @returns A promise that resolves with the TFile object for the new JPG file, or null if conversion fails.
|
||||
*/
|
||||
export async function convertHeicToJpg(
|
||||
heicFile: TFile,
|
||||
app: App,
|
||||
heicFile: TFile,
|
||||
app: App,
|
||||
notificationService: NotificationService
|
||||
): Promise<TFile | null> {
|
||||
// console.log(`Attempting to convert HEIC file: ${heicFile.path}`);
|
||||
if (heicFile.extension.toLowerCase() !== 'heic') {
|
||||
console.error('File provided to convertHeicToJpg is not a HEIC file:', heicFile.path);
|
||||
notificationService.notifyError(`Conversion failed: ${heicFile.name} is not a HEIC file.`);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -47,39 +36,27 @@ export async function convertHeicToJpg(
|
|||
const newFileName = `${heicFile.basename}.jpg`;
|
||||
const newFilePath = normalizePath(`${parentPath}/${newFileName}`);
|
||||
|
||||
// console.log(`Creating new JPG file at: ${newFilePath}`);
|
||||
|
||||
const existingFile = app.vault.getAbstractFileByPath(newFilePath);
|
||||
if (existingFile && existingFile instanceof TFile) {
|
||||
console.warn(`JPG file already exists: ${newFilePath}. Overwriting.`);
|
||||
if (existingFile instanceof TFile) {
|
||||
notificationService.notifyError(`Conversion skipped: ${newFileName} already exists.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newFile = await app.vault.createBinary(newFilePath, jpgArrayBuffer);
|
||||
notificationService.notifyVerbose(`Converted ${heicFile.name} to ${newFile.name}`);
|
||||
// console.log(`Successfully converted HEIC to JPG: ${newFile.path}`);
|
||||
return newFile;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error converting HEIC file ${heicFile.path}:`, error);
|
||||
notificationService.notifyError(`Failed to convert HEIC file: ${heicFile.name}. See console for details.`);
|
||||
} catch {
|
||||
notificationService.notifyError(`Failed to convert HEIC file: ${heicFile.name}.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses an image file (JPG, PNG) in place using browser-image-compression.
|
||||
*
|
||||
* @param imageFile The image file (TFile) to compress.
|
||||
* @param app The Obsidian App instance.
|
||||
* @param notificationService Service for displaying notifications.
|
||||
* @param options Optional compression options (e.g., maxSizeMB, maxWidthOrHeight).
|
||||
* @returns A promise that resolves with the TFile object (now compressed), or the original TFile if compression fails or isn't significant.
|
||||
*/
|
||||
export async function compressImage(
|
||||
imageFile: TFile,
|
||||
app: App,
|
||||
notificationService: NotificationService,
|
||||
options: any = { // Use 'any' for options type for now
|
||||
options: ImageCompressionOptions = {
|
||||
maxSizeMB: 1,
|
||||
maxWidthOrHeight: 1920,
|
||||
useWebWorker: true,
|
||||
|
|
@ -87,32 +64,24 @@ export async function compressImage(
|
|||
): Promise<TFile | null> {
|
||||
const compressibleExtensions = ['jpg', 'jpeg', 'png'];
|
||||
if (!compressibleExtensions.includes(imageFile.extension.toLowerCase())) {
|
||||
// console.log(`Skipping compression for non-compressible file type: ${imageFile.path}`);
|
||||
return imageFile;
|
||||
}
|
||||
|
||||
// console.log(`Attempting to compress image: ${imageFile.path}`);
|
||||
try {
|
||||
const arrayBuffer = await app.vault.readBinary(imageFile);
|
||||
const blob = new Blob([arrayBuffer], { type: `image/${imageFile.extension}` });
|
||||
const file = new File([blob], imageFile.name, { type: blob.type }); // Create File object
|
||||
// console.log(`Original file size: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
const file = new File([blob], imageFile.name, { type: blob.type });
|
||||
|
||||
const compressedFile = await imageCompression(file, options); // Pass the File object
|
||||
// console.log(`Compressed file size: ${(compressedFile.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
const compressedFile = await imageCompression(file, options);
|
||||
|
||||
if (compressedFile.size < file.size) { // Compare File sizes
|
||||
if (compressedFile.size < file.size) {
|
||||
const compressedBuffer = await compressedFile.arrayBuffer();
|
||||
await app.vault.modifyBinary(imageFile, compressedBuffer);
|
||||
// console.log(`Successfully compressed image and updated file: ${imageFile.path}`);
|
||||
} else {
|
||||
// console.log(`Compression did not significantly reduce file size. Skipping overwrite for: ${imageFile.path}`);
|
||||
}
|
||||
return imageFile;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error compressing image ${imageFile.path}:`, error);
|
||||
notificationService.notifyError(`Failed to compress image: ${imageFile.name}. See console for details.`);
|
||||
} catch {
|
||||
notificationService.notifyError(`Failed to compress image: ${imageFile.name}.`);
|
||||
return imageFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@
|
|||
"1.0.10": "1.8.10",
|
||||
"1.1.0": "1.8.10",
|
||||
"1.1.1": "1.8.10",
|
||||
"1.1.3": "1.8.10"
|
||||
"1.1.3": "1.8.10",
|
||||
"1.1.4": "1.8.10"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue