diff --git a/src/modals/FolderConvertModal.ts b/src/modals/FolderConvertModal.ts index 28ebbc4..0db6831 100644 --- a/src/modals/FolderConvertModal.ts +++ b/src/modals/FolderConvertModal.ts @@ -6,6 +6,23 @@ import { EXTENSION_GROUPS } from '../utils/fileTypes'; import { getVaultBasePath, resolveOutputFolder } from '../utils/paths'; import { BatchProgressModal } from './BatchProgressModal'; +/** + * Represents a file to convert, with its path relative to the selected root folder. + * For files picked via the HTML directory input, `file` is the browser File object and + * `relativePath` is derived from `webkitRelativePath`. For files discovered via + * recursive filesystem walk, `absolutePath` is set instead and `file` is null. + */ +interface ConvertibleFile { + /** Browser File object (present when picked via HTML input) */ + file: File | null; + /** Absolute path on disk (present when found via recursive walk) */ + absolutePath: string | null; + /** Display name */ + name: string; + /** Path relative to the selected root folder (e.g. "sub/dir/file.pdf") */ + relativePath: string; +} + export class FolderConvertModal extends Modal { private plugin: MarkitdownPlugin; @@ -26,6 +43,21 @@ export class FolderConvertModal extends Modal { attr: { type: 'file', webkitdirectory: '', directory: '' }, }); + // Recursive toggle + let includeSubfolders = this.plugin.settings.enableRecursiveConversion; + const recursiveContainer = contentEl.createDiv('markitdown-setting-row'); + const recursiveLabel = recursiveContainer.createEl('label', { + cls: 'markitdown-checkbox-label', + }); + const recursiveCheckbox = recursiveLabel.createEl('input', { + attr: { type: 'checkbox' }, + }); + recursiveCheckbox.checked = includeSubfolders; + recursiveCheckbox.addEventListener('change', () => { + includeSubfolders = recursiveCheckbox.checked; + }); + recursiveLabel.appendText(' Include subfolders (recursive)'); + // File type checkboxes contentEl.createEl('p', { text: 'Select file types to convert:' }); const checkboxContainer = contentEl.createDiv('markitdown-checkbox-grid'); @@ -73,14 +105,38 @@ export class FolderConvertModal extends Modal { return; } - // Filter matching files - const filesToConvert: File[] = []; + // Determine the root folder path from the first file's webkitRelativePath + const firstFile = folderInput.files[0]; + const rootFolderName = firstFile.webkitRelativePath.split('/')[0]; + + // Collect files from the HTML file input (these always include subfolders + // because webkitdirectory returns all descendants). We filter based on the + // recursive toggle. + const filesToConvert: ConvertibleFile[] = []; for (let i = 0; i < folderInput.files.length; i++) { const file = folderInput.files[i]; const ext = path.extname(file.name).toLowerCase(); - if (selectedExtensions.includes(ext)) { - filesToConvert.push(file); + if (!selectedExtensions.includes(ext)) continue; + + // webkitRelativePath looks like "FolderName/sub/file.pdf" + const relativePath = file.webkitRelativePath; + const parts = relativePath.split('/'); + + // If not recursive, only include files directly in the root folder + // (i.e., exactly 2 parts: "FolderName/file.ext") + if (!includeSubfolders && parts.length > 2) { + continue; } + + // Relative path within the selected folder (strip the root folder name) + const relativeToRoot = parts.slice(1).join('/'); + + filesToConvert.push({ + file, + absolutePath: null, + name: file.name, + relativePath: relativeToRoot, + }); } if (filesToConvert.length === 0) { @@ -93,7 +149,7 @@ export class FolderConvertModal extends Modal { }); } - private async runBatchConversion(files: File[]) { + private async runBatchConversion(files: ConvertibleFile[]) { const vaultPath = getVaultBasePath(this.app); if (!vaultPath) { new Notice('Could not determine vault path. This plugin requires a local vault.'); @@ -116,17 +172,31 @@ export class FolderConvertModal extends Modal { const errors: string[] = []; for (let i = 0; i < files.length; i++) { - const file = files[i]; - progressModal?.updateProgress(i, file.name, success, failed); + const entry = files[i]; + progressModal?.updateProgress(i, entry.name, success, failed); - const baseName = path.basename(file.name, path.extname(file.name)); - const outputPath = path.join(outputFolder, `${baseName}.md`); - const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); - const tempFilePath = path.join(outputFolder, `tmp_${Date.now()}_${i}_${safeName}`); + // Determine the output path, preserving subfolder structure + const baseName = path.basename(entry.name, path.extname(entry.name)); + const relativeDir = path.dirname(entry.relativePath); + const entryOutputFolder = relativeDir && relativeDir !== '.' + ? path.join(outputFolder, relativeDir) + : outputFolder; + + // Ensure the output subdirectory exists + await fs.promises.mkdir(entryOutputFolder, { recursive: true }); + + const outputPath = path.join(entryOutputFolder, `${baseName}.md`); + const safeName = entry.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const tempFilePath = path.join(entryOutputFolder, `tmp_${Date.now()}_${i}_${safeName}`); try { - const buffer = await file.arrayBuffer(); - await fs.promises.writeFile(tempFilePath, Buffer.from(buffer)); + // Read the file content from the browser File object + if (entry.file) { + const buffer = await entry.file.arrayBuffer(); + await fs.promises.writeFile(tempFilePath, Buffer.from(buffer)); + } else if (entry.absolutePath) { + await fs.promises.copyFile(entry.absolutePath, tempFilePath); + } const result = await this.plugin.convertExternalFile(tempFilePath, outputPath); @@ -134,18 +204,18 @@ export class FolderConvertModal extends Modal { success++; } else { failed++; - errors.push(`${file.name}: ${result.error}`); + errors.push(`${entry.relativePath}: ${result.error}`); } } catch (error) { failed++; const msg = error instanceof Error ? error.message : String(error); - errors.push(`${file.name}: ${msg}`); + errors.push(`${entry.relativePath}: ${msg}`); } finally { await fs.promises.unlink(tempFilePath).catch(() => {}); } // Update progress after conversion completes so counts are accurate - progressModal?.updateProgress(i + 1, file.name, success, failed); + progressModal?.updateProgress(i + 1, entry.name, success, failed); } if (progressModal) { diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index deb4751..87e85ca 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -108,6 +108,16 @@ export class SettingsTab extends PluginSettingTab { })); } + new Setting(containerEl) + .setName('Recursive folder conversion') + .setDesc('Include subfolders by default when converting a folder') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableRecursiveConversion) + .onChange(async (value) => { + this.plugin.settings.enableRecursiveConversion = value; + await this.plugin.saveSettings(); + })); + new Setting(containerEl) .setName('Show batch progress') .setDesc('Display a progress bar during batch folder conversions') diff --git a/src/types/settings.ts b/src/types/settings.ts index 08e4139..14ef291 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -13,6 +13,7 @@ export interface MarkitdownSettings { imageSubfolderTemplate: string; enableBatchProgress: boolean; enableContextMenu: boolean; + enableRecursiveConversion: boolean; } export const DEFAULT_SETTINGS: MarkitdownSettings = { @@ -25,6 +26,7 @@ export const DEFAULT_SETTINGS: MarkitdownSettings = { imageSubfolderTemplate: '{filename}-images', enableBatchProgress: true, enableContextMenu: true, + enableRecursiveConversion: false, }; export interface ConversionOptions {