Fix Obsidian review issues and release 1.1.4

This commit is contained in:
rodolfo-terriquez 2026-05-12 14:19:49 -06:00
parent a056a6f786
commit d52c0bd021
18 changed files with 817 additions and 1094 deletions

View file

@ -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 }}

View file

@ -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
View file

@ -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);

View file

@ -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",

View file

@ -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
View file

@ -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"
}
}
}

View file

@ -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"

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -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`);

View file

@ -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}`);
}
}
}
}

View file

@ -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() { ... }
}
}

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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.",

View file

@ -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}`);
}
}
}

View file

@ -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;
}
}
}

View file

@ -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"
}