mirror of
https://github.com/rodolfo-terriquez/images-to-notes.git
synced 2026-07-22 05:48:18 +00:00
710 lines
28 KiB
TypeScript
710 lines
28 KiB
TypeScript
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';
|
|
import { isImageFile, getParentFolderPath } from './utils/fileUtils';
|
|
import { convertHeicToJpg, compressImage } from './utils/imageUtils';
|
|
import { NotificationService } from './ui/notificationService';
|
|
import { AIService } from './services/aiService';
|
|
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;
|
|
notificationService: NotificationService;
|
|
copilotService: CopilotService;
|
|
aiService: AIService;
|
|
noteCreator: NoteCreator;
|
|
isReady: boolean = false;
|
|
private droppedInEditorPaths = new Set<string>();
|
|
private processingPaths = new Set<string>();
|
|
private isMobileDevice: boolean = false;
|
|
|
|
async onload() {
|
|
// console.time('ImageTranscriberPlugin onload');
|
|
// console.log('Loading Images to notes Plugin');
|
|
await this.loadSettings();
|
|
|
|
// Check if running on mobile
|
|
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));
|
|
|
|
// Defer heavy initializations and event registrations until layout is ready
|
|
this.app.workspace.onLayoutReady(() => {
|
|
// console.log('Workspace layout ready.');
|
|
|
|
// Initialize services and queue here
|
|
this.aiService = new AIService(this.settings, this.notificationService, this.app, this.copilotService);
|
|
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(
|
|
this.app.vault.on('create', this.handleFileCreate.bind(this))
|
|
);
|
|
|
|
this.registerEvent(
|
|
this.app.workspace.on('file-menu', (menu: Menu, file) => {
|
|
if (file instanceof TFolder) {
|
|
menu.addItem((item) => {
|
|
item
|
|
.setTitle('New image to note')
|
|
.setIcon('image-file')
|
|
.setSection('action-primary')
|
|
.onClick(async () => {
|
|
const targetFolder = file;
|
|
new Notice(`Select images to add to folder: ${targetFolder.path}`);
|
|
|
|
const input = activeDocument.body.createEl('input', { type: 'file' });
|
|
input.accept = 'image/*';
|
|
input.multiple = true;
|
|
input.hide();
|
|
|
|
input.onchange = async () => {
|
|
try {
|
|
const selectedFiles = input.files;
|
|
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
} 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) {
|
|
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);
|
|
}
|
|
}
|
|
} finally {
|
|
input.value = '';
|
|
input.remove();
|
|
}
|
|
};
|
|
|
|
input.click();
|
|
});
|
|
});
|
|
}
|
|
})
|
|
);
|
|
|
|
this.isReady = true;
|
|
});
|
|
|
|
// Listen for drops onto the editor - this can be registered immediately
|
|
this.registerEvent(
|
|
this.app.workspace.on('editor-drop', this.handleEditorDrop.bind(this))
|
|
);
|
|
|
|
// Mobile-specific initialization if needed
|
|
if (this.isMobileDevice) {
|
|
// Adjust any settings or behavior for mobile
|
|
this.initializeMobileSupport();
|
|
}
|
|
|
|
//console.timeEnd('ImageTranscriberPlugin onload');
|
|
// console.log('Images to notes Plugin loaded. File watcher registration deferred until layout ready.');
|
|
}
|
|
|
|
onunload() {
|
|
// console.log('Unloading Images to notes Plugin');
|
|
// Cleanup logic: The registerEvent method handles unregistering the vault event.
|
|
// Queue processing might need explicit stopping if it involves long operations.
|
|
}
|
|
|
|
private initializeMobileSupport() {
|
|
if (Platform.isIosApp) {
|
|
this.adjustForIOS();
|
|
} else if (Platform.isAndroidApp) {
|
|
this.adjustForAndroid();
|
|
}
|
|
}
|
|
|
|
private adjustForIOS() {
|
|
if (this.settings.imageQuality && this.settings.imageQuality > 80) {
|
|
const previousQuality = this.settings.imageQuality;
|
|
this.settings.imageQuality = 80;
|
|
this.notificationService.notifyVerbose(
|
|
`Mobile optimization: Image quality reduced from ${previousQuality} to 80 for better performance.`
|
|
);
|
|
}
|
|
this.notificationService.notifyInfo('Mobile compatibility mode enabled for iOS');
|
|
}
|
|
|
|
private adjustForAndroid() {
|
|
if (this.settings.mobileOptimizationEnabled) {
|
|
this.settings.maxConcurrentProcessing = 1;
|
|
if (this.settings.imageQuality > 85) {
|
|
const previousQuality = this.settings.imageQuality;
|
|
this.settings.imageQuality = 85;
|
|
this.notificationService.notifyVerbose(
|
|
`Mobile optimization: Image quality adjusted from ${previousQuality} to 85 for Android.`
|
|
);
|
|
}
|
|
}
|
|
this.notificationService.notifyInfo('Mobile compatibility mode enabled for Android');
|
|
}
|
|
|
|
async loadSettings() {
|
|
const loadedData: LoadedPluginData | null = await this.loadData();
|
|
this.settings = { ...DEFAULT_SETTINGS };
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (!this.settings.processedImagePaths || !Array.isArray(this.settings.processedImagePaths)) {
|
|
this.settings.processedImagePaths = [];
|
|
}
|
|
|
|
await this.saveSettings();
|
|
}
|
|
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
|
|
private async handleFileCreate(file: TAbstractFile) {
|
|
// --- Initial Checks ---
|
|
// 1. Check if this file path was flagged as dropped in the editor
|
|
if (file instanceof TFile && this.droppedInEditorPaths.has(file.path)) {
|
|
// console.log(`Skipping transcription for image dropped in editor: ${file.path}`);
|
|
this.droppedInEditorPaths.delete(file.path); // Clean up the flag
|
|
return;
|
|
}
|
|
|
|
// 3. Ignore non-image files or folders early on
|
|
if (!(file instanceof TFile && isImageFile(file))) {
|
|
// console.log(`Ignoring non-image file or folder: ${file.path}`);
|
|
return;
|
|
}
|
|
|
|
// --- Folder-Specific Transcription Check ---
|
|
if (this.settings.transcribeOnlySpecificFolder) {
|
|
const specificFolder = this.settings.specificFolderForTranscription;
|
|
if (!specificFolder) {
|
|
// console.log('Specific folder transcription is ON, but no folder selected. Skipping file:', file.path);
|
|
return; // Don't proceed to add to processingPaths or queue
|
|
}
|
|
|
|
const parentPathOfFile = getParentFolderPath(file.path);
|
|
|
|
let normalizedSpecificFolder = normalizePath(specificFolder);
|
|
// Ensure root path ('/') is used for comparison if specific folder is root
|
|
if (normalizedSpecificFolder === '.') normalizedSpecificFolder = '/';
|
|
|
|
let normalizedParentPathOfFile = normalizePath(parentPathOfFile);
|
|
// Ensure root path ('/') is used for comparison if file is in root
|
|
if (normalizedParentPathOfFile === '.') normalizedParentPathOfFile = '/';
|
|
|
|
// console.log(`Folder check: File in "${normalizedParentPathOfFile}", Target folder "${normalizedSpecificFolder}"`);
|
|
|
|
if (normalizedParentPathOfFile !== normalizedSpecificFolder) {
|
|
// console.log(`File ${file.path} is not in the designated folder "${specificFolder}". Skipping transcription.`);
|
|
// this.notificationService.notifyVerbose(`Skipped: ${file.name} is not in the designated transcription folder.`); // Potentially too noisy
|
|
return; // Don't proceed
|
|
}
|
|
// console.log(`File ${file.path} is in the designated folder "${specificFolder}". Proceeding.`);
|
|
}
|
|
// --- End Folder-Specific Transcription Check ---
|
|
|
|
// 4. Check if this path is already being added to the queue (brief lock)
|
|
if (this.processingPaths.has(file.path)) {
|
|
// console.log(`Initial path already being added to queue, skipping: ${file.path}`);
|
|
return;
|
|
}
|
|
// --- End Initial Checks ---
|
|
|
|
// Mark this path as being added (briefly)
|
|
this.processingPaths.add(file.path);
|
|
|
|
try {
|
|
this.processingQueue.addToQueue(file);
|
|
} catch (error) {
|
|
console.error(`Error adding file ${file.path} to processing queue:`, error);
|
|
} finally {
|
|
// Remove from the temporary lock set once added (or if error occurs)
|
|
if (this.processingPaths.delete(file.path)) {
|
|
// console.log(`Finished adding lock for: ${file.path}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes a single image file job sequentially. Orchestrates the different steps.
|
|
* @param job The processing job containing the initial file reference.
|
|
*/
|
|
private async processImageFile(job: ProcessingJob): Promise<void> {
|
|
const initialFile = job.initialFile;
|
|
let fileToProcess: TFile | null = initialFile;
|
|
|
|
try {
|
|
this.notificationService.notifyInfo(`Processing ${initialFile.name}...`);
|
|
|
|
// Determine destination paths based on settings
|
|
const { imageDestination, noteDestination } = this.determineDestinationPaths(initialFile);
|
|
|
|
// Ensure destination folders exist before proceeding
|
|
await this.ensureFolderExists(imageDestination, 'image');
|
|
await this.ensureFolderExists(noteDestination, 'note');
|
|
|
|
|
|
// 1. HEIC Conversion
|
|
fileToProcess = await this.handleHeicConversion(fileToProcess);
|
|
if (!fileToProcess) {
|
|
this.processingQueue.markAsError(job, 'HEIC conversion failed');
|
|
return; // Stop processing this job
|
|
}
|
|
|
|
// 2. Image Compression
|
|
await this.handleImageCompression(fileToProcess);
|
|
// Compression failure is logged but doesn't stop the process
|
|
|
|
// 3. File Moving & Path Handling
|
|
const moveResult = await this.handleFileMove(fileToProcess, imageDestination);
|
|
if (!moveResult) {
|
|
// Error logged within handleFileMove, mark job as error
|
|
this.processingQueue.markAsError(job, 'File move/folder handling failed');
|
|
return;
|
|
}
|
|
const { finalPath } = moveResult;
|
|
// fileToProcess reference might be updated by renameFile inside handleFileMove,
|
|
// but we primarily need the finalPath string now. Let's get the TFile reference for the final path.
|
|
const finalFileRef = this.app.vault.getAbstractFileByPath(finalPath);
|
|
if (!(finalFileRef instanceof TFile)) {
|
|
console.error(`Could not get TFile reference for final path: ${finalPath}`);
|
|
this.processingQueue.markAsError(job, 'Internal error: Could not get final file reference');
|
|
return;
|
|
}
|
|
fileToProcess = finalFileRef; // Update fileToProcess to the potentially moved file reference
|
|
|
|
// 4. Check if Already Processed (using final path)
|
|
if (this.isAlreadyProcessed(finalPath)) {
|
|
// console.log(`Image already processed (based on final path), skipping transcription: ${finalPath}`);
|
|
this.processingQueue.markAsDone(job); // Mark as done, even though skipped
|
|
return;
|
|
}
|
|
|
|
// 5. Transcription & Note Creation
|
|
const transcriptionSuccess = await this.runTranscriptionAndCreateNote(fileToProcess, noteDestination);
|
|
if (!transcriptionSuccess) {
|
|
// Error logged within runTranscriptionAndCreateNote, mark job as error
|
|
this.processingQueue.markAsError(job, 'Transcription or Note creation failed');
|
|
return;
|
|
}
|
|
|
|
// 6. Mark as Processed & Save
|
|
await this.markAsProcessed(finalPath);
|
|
|
|
// Mark the job as done in the queue
|
|
this.processingQueue.markAsDone(job);
|
|
this.notificationService.notifyVerbose(`Successfully processed ${initialFile.name}.`);
|
|
|
|
} catch (error) {
|
|
console.error(`Unhandled error during processing job for ${initialFile.path}:`, error);
|
|
this.notificationService.notifyError(`Unexpected error processing ${initialFile.name}.`);
|
|
this.processingQueue.markAsError(job, `Unexpected processing error: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles HEIC to JPG conversion if necessary.
|
|
* @param file The file to potentially convert.
|
|
* @returns The TFile reference to use for subsequent steps (original or converted), or null on conversion failure.
|
|
*/
|
|
private async handleHeicConversion(file: TFile): Promise<TFile | null> {
|
|
if (file.extension.toLowerCase() === 'heic') {
|
|
// console.log(`HEIC file detected, attempting conversion: ${file.path}`);
|
|
const convertedFile = await convertHeicToJpg(file, this.app, this.notificationService);
|
|
if (convertedFile) {
|
|
// console.log(`HEIC converted successfully to: ${convertedFile.path}`);
|
|
return convertedFile; // Use the converted JPG file
|
|
} else {
|
|
console.error(`HEIC conversion failed for: ${file.path}.`);
|
|
return null; // Indicate failure
|
|
}
|
|
}
|
|
return file; // Return original file if not HEIC
|
|
}
|
|
|
|
/**
|
|
* Handles image compression. Logs success or warning on failure.
|
|
* @param file The file to compress.
|
|
*/
|
|
private async handleImageCompression(file: TFile): Promise<void> {
|
|
try {
|
|
const resultFile = await compressImage(file, this.app, this.notificationService);
|
|
if (resultFile && resultFile.path === file.path) {
|
|
// console.log(`Compression step completed (or skipped) for: ${file.path}`);
|
|
} else if (!resultFile) {
|
|
console.error(`Compression utility unexpectedly returned null for: ${file.path}`);
|
|
}
|
|
} catch (compressionError) {
|
|
console.error(`Error during compression call for ${file.path}:`, compressionError);
|
|
this.notificationService.notifyError(`Compression failed for ${file.name} due to an unexpected error.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles moving the file to the designated image folder, creating it if necessary,
|
|
* and checking for duplicates.
|
|
* @param fileToMove The file to potentially move (after conversion/compression).
|
|
* @param targetFolder The folder to move the image to.
|
|
* @returns An object with the final path, or null on failure.
|
|
*/
|
|
private async handleFileMove(fileToMove: TFile, targetFolder: string): Promise<{ finalPath: string } | null> {
|
|
const newImagePath = normalizePath(`${targetFolder}/${fileToMove.name}`);
|
|
|
|
if (newImagePath === fileToMove.path) {
|
|
// console.log(`Image does not need to be moved. Current path: ${fileToMove.path}`);
|
|
return { finalPath: fileToMove.path };
|
|
}
|
|
|
|
try {
|
|
// Check for existing file at the target path before moving
|
|
const existingImage = this.app.vault.getAbstractFileByPath(newImagePath);
|
|
if (existingImage && existingImage instanceof TFile) {
|
|
this.notificationService.notifyError(`Failed to process ${fileToMove.name}: A file with this name already exists in '${targetFolder}'.`);
|
|
return null; // Indicate failure
|
|
}
|
|
|
|
// Move the file
|
|
// console.log(`Moving image from ${fileToMove.path} to: ${newImagePath}`);
|
|
await this.app.fileManager.renameFile(fileToMove, newImagePath);
|
|
// console.log(`Successfully moved image to: ${fileToMove.path}`);
|
|
return { finalPath: fileToMove.path };
|
|
|
|
} catch (moveError) {
|
|
const errorMsg = `Error during move for ${fileToMove.name} targeting '${newImagePath}':`;
|
|
console.error(errorMsg, moveError);
|
|
this.notificationService.notifyError(`Failed to move ${fileToMove.name}. Skipping transcription.`);
|
|
return null; // Indicate failure
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a file path has already been processed.
|
|
* @param filePath The path to check.
|
|
* @returns True if the path is in the processed list, false otherwise.
|
|
*/
|
|
private isAlreadyProcessed(filePath: string): boolean {
|
|
// console.log(`Checking processed paths for final path: ${filePath}`);
|
|
// console.log(`Current processed paths: ${JSON.stringify(this.settings.processedImagePaths)}`);
|
|
return this.settings.processedImagePaths.includes(filePath);
|
|
}
|
|
|
|
private determineDestinationPaths(initialFile: TFile): { imageDestination: string, noteDestination: string } {
|
|
const currentParentPath = getParentFolderPath(initialFile.path);
|
|
|
|
// Determine Image Destination
|
|
let imageDestination: string;
|
|
if (this.settings.imageDestinationOption === 'specificFolder' && this.settings.specificImageFolderPath) {
|
|
imageDestination = normalizePath(this.settings.specificImageFolderPath);
|
|
} else {
|
|
const imageSubfolderName = this.settings.imageFolderName || DEFAULT_SETTINGS.imageFolderName;
|
|
imageDestination = normalizePath(`${currentParentPath}/${imageSubfolderName}`);
|
|
}
|
|
|
|
// Determine Note Destination
|
|
let noteDestination: string;
|
|
if (this.settings.noteDestinationOption === 'specificFolder' && this.settings.specificNoteFolderPath) {
|
|
noteDestination = normalizePath(this.settings.specificNoteFolderPath);
|
|
} else {
|
|
noteDestination = currentParentPath;
|
|
}
|
|
|
|
return { imageDestination, noteDestination };
|
|
}
|
|
|
|
private async ensureFolderExists(folderPath: string, type: 'image' | 'note'): Promise<void> {
|
|
if (!folderPath || folderPath === '.' || folderPath === '/') return;
|
|
|
|
try {
|
|
const folderExists = await this.app.vault.adapter.exists(folderPath);
|
|
if (!folderExists) {
|
|
// console.log(`Creating ${type} destination folder at: ${folderPath}`);
|
|
await this.app.vault.createFolder(folderPath);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error ensuring ${type} folder exists at '${folderPath}':`, error);
|
|
this.notificationService.notifyError(`Failed to create destination folder for ${type}s. Please check settings and permissions.`);
|
|
throw error; // Propagate error to stop processing
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs the AI transcription and creates the corresponding note.
|
|
* @param imageFile The final TFile object of the image (after move/conversion).
|
|
* @param noteTargetParentPath The folder path where the note should be created.
|
|
* @returns True if transcription and note creation were successful, false otherwise.
|
|
*/
|
|
private async runTranscriptionAndCreateNote(imageFile: TFile, noteTargetParentPath: string): Promise<boolean> {
|
|
try {
|
|
// Change this intermediate step to notifyVerbose
|
|
this.notificationService.notifyVerbose(`Starting transcription for ${imageFile.name}...`);
|
|
const transcription = await this.aiService.transcribeImage(imageFile);
|
|
|
|
if (transcription === null) {
|
|
console.error(`Transcription failed for ${imageFile.path}`);
|
|
// Error handled by AIService
|
|
return false;
|
|
}
|
|
|
|
// Change this intermediate step to notifyVerbose
|
|
this.notificationService.notifyVerbose(`Transcription received for ${imageFile.name}. Creating note...`);
|
|
const noteFile = await this.noteCreator.createNote(transcription, imageFile, noteTargetParentPath);
|
|
|
|
if (!noteFile) {
|
|
console.error(`Note creation failed for ${imageFile.path}`);
|
|
// Error handled by NoteCreator
|
|
return false;
|
|
}
|
|
|
|
return true; // Success
|
|
|
|
} catch (error) {
|
|
console.error(`Error during transcription or note creation for ${imageFile.path}:`, error);
|
|
this.notificationService.notifyError(`Error during AI processing or note creation for ${imageFile.name}.`);
|
|
return false; // Indicate failure
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds the file path to the processed list and saves settings.
|
|
* @param filePath The file path to mark as processed.
|
|
*/
|
|
private async markAsProcessed(filePath: string): Promise<void> {
|
|
try {
|
|
// console.log(`Attempting to mark as processed and save: ${filePath}`);
|
|
if (!this.settings.processedImagePaths.includes(filePath)) {
|
|
this.settings.processedImagePaths.push(filePath);
|
|
await this.saveSettings(); // Persist the change
|
|
// console.log(`Successfully saved processed path: ${filePath}. New list: ${JSON.stringify(this.settings.processedImagePaths)}`);
|
|
} else {
|
|
// console.log(`Path ${filePath} was already in the processed list.`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to save processed path ${filePath}:`, error);
|
|
this.notificationService.notifyError(`Failed to save processed state for ${filePath}. It might be processed again later.`);
|
|
// Decide if this should throw or just log
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
// console.log('Editor drop event detected.');
|
|
if (!evt.dataTransfer) {
|
|
// console.log('No dataTransfer object found on event.');
|
|
return;
|
|
}
|
|
|
|
const files = evt.dataTransfer.files;
|
|
if (files.length === 0) {
|
|
// console.log('No files found in dataTransfer.');
|
|
return;
|
|
}
|
|
|
|
const sourcePath = info.file?.path; // Path of the note where the drop occurred
|
|
// console.log(`Drop occurred in file: ${sourcePath || 'unknown'}`);
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
// Basic check for image MIME type or extension - refine if needed
|
|
if (file.type.startsWith('image/')) {
|
|
// console.log(`Image file detected in editor drop: ${file.name} (Type: ${file.type})`);
|
|
// Determine where Obsidian *will* save this file
|
|
try {
|
|
// Await the asynchronous function call
|
|
const destinationPath = await this.app.fileManager.getAvailablePathForAttachment(file.name, sourcePath);
|
|
// console.log(`Anticipated path for ${file.name}: ${destinationPath}`);
|
|
|
|
if (destinationPath) {
|
|
this.droppedInEditorPaths.add(destinationPath);
|
|
// console.log(`Added to droppedInEditorPaths: ${destinationPath}`);
|
|
|
|
// Cleanup mechanism: Remove the path after a short delay
|
|
// This handles cases where the 'create' event might not fire or be missed
|
|
activeWindow.setTimeout(() => {
|
|
this.droppedInEditorPaths.delete(destinationPath);
|
|
}, 1500);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error determining attachment path for ${file.name}:`, error);
|
|
}
|
|
} else {
|
|
// console.log(`Non-image file dropped in editor: ${file.name}`);
|
|
}
|
|
}
|
|
}
|
|
}
|