Rewrite plugin v2: fix shell injection, modularize, add features (ENG-533)

## Summary
- **Security**: Replaced shell injection-vulnerable `exec()` calls with `spawn()` using argument arrays and `shell: false`, bundling Python wrapper scripts that use `argparse`
- **Architecture**: Decomposed monolithic 735-line `main.ts` into 12 focused modules across `src/` and `python/`
- **Features**: Added image extraction from PDFs, plugin arguments editor, context menu conversion, batch progress indicators, setup wizard, and Unicode support via `PYTHONUTF8=1`
- **Compatibility**: Reverted minAppVersion to 0.15.0, python3 fallback for macOS/Linux

Closes ENG-533
This commit is contained in:
Ethan Troy 2026-02-17 01:08:20 -05:00 committed by GitHub
parent cd86670ce2
commit d6fa068498
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1947 additions and 3985 deletions

View file

@ -84,7 +84,7 @@ This plugin acts as a bridge between Obsidian and Microsoft's Markitdown Python
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/markitdown/`
- Copy over `main.js`, `styles.css`, `manifest.json`, and the `python/` directory to your vault `VaultFolder/.obsidian/plugins/markitdown/`
## Credits

View file

@ -1,7 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -33,7 +32,7 @@ const context = await esbuild.context({
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
target: "es2020",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,

923
main.ts
View file

@ -1,179 +1,239 @@
import {
App,
Editor,
MarkdownView,
Modal,
Notice,
Plugin,
PluginSettingTab,
Setting,
TFile,
TFolder,
normalizePath,
FileSystemAdapter,
requestUrl,
WorkspaceLeaf
} from 'obsidian';
import { spawn } from 'child_process';
import * as fs from 'fs';
import { Notice, Plugin, TFile } from 'obsidian';
import * as path from 'path';
import { exec } from 'child_process';
// Add type for the app with setting property
interface AppWithSetting extends App {
setting: {
open: () => void;
openTabById: (id: string) => void;
}
}
interface MarkitdownSettings {
pythonPath: string;
enablePlugins: boolean;
docintelEndpoint: string;
outputPath: string;
}
const DEFAULT_SETTINGS: MarkitdownSettings = {
pythonPath: 'python',
enablePlugins: false,
docintelEndpoint: '',
outputPath: ''
}
import {
MarkitdownSettings,
DEFAULT_SETTINGS,
ConversionOptions,
ConversionResult,
DependencyStatus,
PluginArgEntry,
} from './src/types/settings';
import { MarkitdownConverter } from './src/converter/MarkitdownConverter';
import { checkDependencies, installPackage } from './src/utils/python';
import {
getVaultBasePath,
resolveOutputFolder,
resolveImageDir,
toVaultRelative,
} from './src/utils/paths';
import { isConvertible } from './src/utils/fileTypes';
import { SettingsTab } from './src/settings/SettingsTab';
import { FileConvertModal } from './src/modals/FileConvertModal';
import { FolderConvertModal } from './src/modals/FolderConvertModal';
import { SetupModal } from './src/modals/SetupModal';
export default class MarkitdownPlugin extends Plugin {
settings: MarkitdownSettings;
pythonInstalled: boolean = false;
markitdownInstalled: boolean = false;
settings: MarkitdownSettings = DEFAULT_SETTINGS;
dependencyStatus: DependencyStatus = {
pythonInstalled: false,
pythonVersion: null,
markitdownInstalled: false,
markitdownVersion: null,
};
converter: MarkitdownConverter = new MarkitdownConverter('python', '.');
private resolvedPythonPath = 'python';
async onload() {
await this.loadSettings();
// Check for Python and Markitdown installation
await this.checkDependencies();
const pluginDir = this.getPluginDir();
const depCheck = await checkDependencies(this.settings.pythonPath, pluginDir);
this.dependencyStatus = depCheck.status;
// Use the resolved python path (handles python→python3 fallback)
this.resolvedPythonPath = depCheck.resolvedPythonPath;
this.converter = new MarkitdownConverter(this.resolvedPythonPath, pluginDir);
// Add ribbon icon
const ribbonIconEl = this.addRibbonIcon(
'file-text',
'Convert to Markdown with Markitdown',
() => {
new MarkitdownFileModal(this.app, this).open();
}
);
// Add command to convert selected file
// Ribbon icon
this.addRibbonIcon('file-text', 'Convert to Markdown', () => {
this.openConvertModal();
});
// Commands
this.addCommand({
id: 'convert-file-markitdown',
name: 'Convert file to Markdown with Markitdown',
callback: () => {
if (!this.markitdownInstalled) {
new MarkitdownSetupModal(this.app, this).open();
return;
}
new MarkitdownFileModal(this.app, this).open();
}
id: 'convert-file',
name: 'Convert file to Markdown',
callback: () => this.openConvertModal(),
});
// Add command to convert folder
this.addCommand({
id: 'convert-folder-markitdown',
name: 'Convert folder contents to Markdown with Markitdown',
callback: () => {
if (!this.markitdownInstalled) {
new MarkitdownSetupModal(this.app, this).open();
return;
}
new MarkitdownFolderModal(this.app, this).open();
}
id: 'convert-folder',
name: 'Convert folder to Markdown',
callback: () => this.openFolderModal(),
});
// Add settings tab
this.addSettingTab(new MarkitdownSettingTab(this.app, this));
}
async checkDependencies() {
try {
// Check for Python
await this.execCommand(`${this.settings.pythonPath} --version`);
this.pythonInstalled = true;
// Check for Markitdown
try {
await this.execCommand(`${this.settings.pythonPath} -m pip show markitdown`);
this.markitdownInstalled = true;
} catch (error) {
this.markitdownInstalled = false;
}
} catch (error) {
this.pythonInstalled = false;
this.markitdownInstalled = false;
console.error("Failed to check Python installation", error);
// Context menu
if (this.settings.enableContextMenu) {
this.registerFileMenu();
}
}
async execCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(command, { env: { ...process.env, PYTHONUTF8: '1' } }, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
});
});
}
async installMarkitdown(): Promise<boolean> {
try {
await this.execCommand(`${this.settings.pythonPath} -m pip install 'markitdown[all]'`);
this.markitdownInstalled = true;
return true;
} catch (error) {
console.error("Failed to install Markitdown", error);
return false;
}
}
async convertFile(filePath: string, outputPath: string): Promise<string> {
return new Promise((resolve, reject) => {
// Build a command that uses the markitdown CLI syntax
// According to the error message, markitdown just takes a filename as argument
let command = `markitdown "${filePath}" > "${outputPath}"`;
// Set environment to ensure UTF-8 encoding for Unicode support (including diacritics)
const env = { ...process.env, PYTHONUTF8: '1' };
// Execute as a shell command
exec(command, { env }, (error, stdout, stderr) => {
if (error) {
// Try alternative approach with Python module if the first method fails
const moduleCommand = `${this.settings.pythonPath} -c "from markitdown import convert; convert('${filePath}', output_file='${outputPath}')"`;
exec(moduleCommand, { env }, (moduleError, moduleStdout, moduleStderr) => {
if (moduleError) {
// One last attempt with just a basic command
const basicCommand = `${this.settings.pythonPath} -m markitdown "${filePath}" > "${outputPath}"`;
exec(basicCommand, { env }, (basicError, basicStdout, basicStderr) => {
if (basicError) {
reject(new Error(`Markitdown failed to convert the file: ${basicError.message}\n${basicStderr}`));
return;
}
resolve(basicStdout);
});
return;
}
resolve(moduleStdout);
});
return;
}
resolve(stdout);
});
});
// Settings tab
this.addSettingTab(new SettingsTab(this.app, this));
}
onunload() {
// Nothing special to clean up
// registerEvent handles cleanup automatically
}
/** Open file conversion modal, or setup modal if not installed. */
private openConvertModal() {
if (!this.dependencyStatus.markitdownInstalled) {
new SetupModal(this.app, this).open();
return;
}
new FileConvertModal(this.app, this).open();
}
/** Open folder conversion modal, or setup modal if not installed. */
private openFolderModal() {
if (!this.dependencyStatus.markitdownInstalled) {
new SetupModal(this.app, this).open();
return;
}
new FolderConvertModal(this.app, this).open();
}
/** Register right-click context menu on supported file types. */
private registerFileMenu() {
this.registerEvent(
this.app.workspace.on('file-menu', (menu, file) => {
if (!(file instanceof TFile)) return;
if (!isConvertible(file.extension)) return;
menu.addItem((item) => {
item.setTitle('Convert to Markdown')
.setIcon('file-text')
.onClick(() => this.convertVaultFile(file));
});
})
);
}
/** Convert a file that already exists in the vault (from context menu). */
async convertVaultFile(file: TFile): Promise<void> {
const vaultPath = getVaultBasePath(this.app);
if (!vaultPath) {
new Notice('Could not determine vault path. This plugin requires a local vault.');
return;
}
if (!this.dependencyStatus.markitdownInstalled) {
new SetupModal(this.app, this).open();
return;
}
const inputPath = path.join(vaultPath, file.path);
const outputFolder = resolveOutputFolder(vaultPath, this.settings.outputPath);
const baseName = file.basename;
const outputPath = path.join(outputFolder, `${baseName}.md`);
const options = this.buildConversionOptions(outputPath);
new Notice('Converting file...');
const result = await this.converter.convert(inputPath, outputPath, options);
if (result.success) {
const msg = result.imagesExtracted
? `Converted successfully (${result.imagesExtracted} images extracted)`
: 'Converted successfully';
new Notice(msg);
await this.openConvertedFile(outputPath, vaultPath);
} else {
new Notice(`Conversion failed: ${result.error}`);
}
}
/**
* Convert an external file (from file input dialog, not in vault).
* Used by FileConvertModal and FolderConvertModal.
*/
async convertExternalFile(
inputPath: string,
outputPath: string
): Promise<ConversionResult> {
const options = this.buildConversionOptions(outputPath);
return this.converter.convert(inputPath, outputPath, options);
}
/** Build ConversionOptions from current settings. */
buildConversionOptions(outputPath: string): ConversionOptions {
const options: ConversionOptions = {
enablePlugins: this.settings.enablePlugins,
docintelEndpoint: this.settings.docintelEndpoint || undefined,
};
if (this.settings.pluginArgs.length > 0) {
options.pluginArgs = this.pluginArgsToRecord(this.settings.pluginArgs);
}
if (this.settings.imageExtractionEnabled) {
options.extractImages = true;
options.imageDir = resolveImageDir(
outputPath,
this.settings.imageSubfolderTemplate
);
}
return options;
}
/** Convert PluginArgEntry[] to Record for Python. */
private pluginArgsToRecord(entries: PluginArgEntry[]): Record<string, unknown> {
const record: Record<string, unknown> = Object.create(null);
for (const entry of entries) {
if (!entry.key.trim()) continue;
// Reject prototype pollution keys
if (entry.key === '__proto__' || entry.key === 'constructor' || entry.key === 'prototype') continue;
// Try to parse as JSON value, fall back to string
try {
record[entry.key] = JSON.parse(entry.value);
} catch {
record[entry.key] = entry.value;
}
}
return record;
}
/** Open a converted file in the workspace. */
async openConvertedFile(outputPath: string, vaultPath: string): Promise<void> {
const relativePath = toVaultRelative(outputPath, vaultPath);
const existingFile = this.app.vault.getAbstractFileByPath(relativePath);
if (existingFile instanceof TFile) {
await this.app.workspace.getLeaf().openFile(existingFile);
}
}
/** Get the absolute path to the plugin directory. */
getPluginDir(): string {
const vaultPath = getVaultBasePath(this.app);
if (vaultPath && this.manifest.dir) {
return path.join(vaultPath, this.manifest.dir);
}
// Non-local vaults (e.g., Obsidian Sync without local adapter) cannot resolve plugin dir
console.warn('markitdown: Could not resolve vault base path. Plugin features may not work correctly.');
return path.resolve(this.manifest.dir ?? '.');
}
/** Install markitdown package using the resolved Python path. */
async installMarkitdown(onProgress?: (line: string) => void): Promise<boolean> {
const pluginDir = this.getPluginDir();
const success = await installPackage(
this.resolvedPythonPath,
pluginDir,
'markitdown[all]',
onProgress
);
if (success) {
this.dependencyStatus.markitdownInstalled = true;
}
return success;
}
/** Refresh dependency status and resolved Python path. */
async refreshDependencies(): Promise<void> {
const pluginDir = this.getPluginDir();
const depCheck = await checkDependencies(this.settings.pythonPath, pluginDir);
this.dependencyStatus = depCheck.status;
this.resolvedPythonPath = depCheck.resolvedPythonPath;
this.converter = new MarkitdownConverter(this.resolvedPythonPath, pluginDir);
}
async loadSettings() {
@ -184,552 +244,3 @@ export default class MarkitdownPlugin extends Plugin {
await this.saveData(this.settings);
}
}
class MarkitdownFileModal extends Modal {
plugin: MarkitdownPlugin;
constructor(app: App, plugin: MarkitdownPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const {contentEl} = this;
contentEl.addClass('markitdown-modal');
contentEl.createEl('h2', {text: 'Convert file to markdown'});
if (!this.plugin.markitdownInstalled) {
contentEl.createEl('p', {
text: 'Markitdown is not installed. Please install it in the settings tab.'
});
const buttonEl = contentEl.createEl('button', {
text: 'Go to settings'
});
buttonEl.addEventListener('click', () => {
this.close();
// Open settings with proper typing
if ('setting' in this.app) {
const appWithSetting = this.app as AppWithSetting;
appWithSetting.setting.open();
appWithSetting.setting.openTabById('obsidian-markitdown');
}
});
return;
}
// Create file selector
contentEl.createEl('p', {text: 'Select a file to convert:'});
const fileInputContainer = contentEl.createDiv('markitdown-file-input-container');
const fileInput = fileInputContainer.createEl('input', {
attr: {
type: 'file',
accept: '.pdf,.docx,.pptx,.xlsx,.xls,.html,.htm,.txt,.csv,.json,.xml,.jpg,.jpeg,.png,.gif,.wav,.mp3,.zip'
}
});
// Create convert button
const buttonContainer = contentEl.createDiv('markitdown-button-container');
const convertButton = buttonContainer.createEl('button', {
text: 'Convert'
});
convertButton.addEventListener('click', async () => {
if (fileInput.files && fileInput.files.length > 0) {
const file = fileInput.files[0];
try {
// Get vault path if using FileSystemAdapter
let vaultPath = '';
if (this.app.vault.adapter instanceof FileSystemAdapter) {
vaultPath = this.app.vault.adapter.getBasePath();
}
if (!vaultPath) {
new Notice('Could not determine vault path. This plugin requires a local vault.');
return;
}
// Determine output path
let outputFolder = this.plugin.settings.outputPath || '';
if (!outputFolder) {
outputFolder = path.join(vaultPath, 'markitdown-output');
if (!fs.existsSync(outputFolder)) {
fs.mkdirSync(outputFolder, { recursive: true });
}
} else {
outputFolder = path.join(vaultPath, outputFolder);
if (!fs.existsSync(outputFolder)) {
fs.mkdirSync(outputFolder, { recursive: true });
}
}
// Create output filename
const baseName = path.basename(file.name, path.extname(file.name));
const outputPath = path.join(outputFolder, `${baseName}.md`);
new Notice('Converting file...');
// Convert the file - file.path is not available in the DOM File interface
// Instead we need to create a temporary file
const tempFilePath = path.join(outputFolder, `${Date.now()}_${file.name}`);
// Write the file to disk first
const buffer = await file.arrayBuffer();
fs.writeFileSync(tempFilePath, Buffer.from(buffer));
// Then convert it
await this.plugin.convertFile(tempFilePath, outputPath);
// Clean up temp file
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
// Refresh the vault to see the new file
await this.app.vault.adapter.exists(outputPath);
new Notice(`File converted and saved to ${outputPath}`);
this.close();
// Try to open the converted file
const relativePath = path.relative(vaultPath, outputPath).replace(/\\/g, '/');
const existingFile = this.app.vault.getAbstractFileByPath(relativePath);
if (existingFile instanceof TFile) {
this.app.workspace.getLeaf().openFile(existingFile);
}
} catch (error) {
console.error('Error during conversion:', error);
new Notice(`Error: ${error.message}`);
}
} else {
new Notice('Please select a file first');
}
});
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class MarkitdownFolderModal extends Modal {
plugin: MarkitdownPlugin;
constructor(app: App, plugin: MarkitdownPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const {contentEl} = this;
contentEl.addClass('markitdown-modal');
contentEl.createEl('h2', {text: 'Convert folder contents to markdown'});
if (!this.plugin.markitdownInstalled) {
contentEl.createEl('p', {
text: 'Markitdown is not installed. Please install it in the settings tab.'
});
const buttonEl = contentEl.createEl('button', {
text: 'Go to settings'
});
buttonEl.addEventListener('click', () => {
this.close();
// Open settings with proper typing
if ('setting' in this.app) {
const appWithSetting = this.app as AppWithSetting;
appWithSetting.setting.open();
appWithSetting.setting.openTabById('obsidian-markitdown');
}
});
return;
}
// Create folder selector
contentEl.createEl('p', {text: 'Select a folder to process:'});
const folderInputContainer = contentEl.createDiv('markitdown-file-input-container');
const folderInput = folderInputContainer.createEl('input', {
attr: {
type: 'file',
webkitdirectory: '',
directory: ''
}
});
// File extensions filter
contentEl.createEl('p', {text: 'Select file types to convert:'});
const extensions = [
{name: 'PDF files', ext: '.pdf'},
{name: 'Word documents', ext: '.docx'},
{name: 'PowerPoint presentations', ext: '.pptx'},
{name: 'Excel spreadsheets', ext: '.xlsx,.xls'},
{name: 'Web pages', ext: '.html,.htm'},
{name: 'Text files', ext: '.txt'},
{name: 'Data files', ext: '.csv,.json,.xml'},
{name: 'Images', ext: '.jpg,.jpeg,.png,.gif'},
{name: 'Audio files', ext: '.wav,.mp3'},
{name: 'Archives', ext: '.zip'}
];
const checkboxContainer = contentEl.createDiv('markitdown-checkbox-grid');
const selectedExtensions: string[] = [];
extensions.forEach(ext => {
const checkboxLabel = checkboxContainer.createEl('label', {cls: 'markitdown-checkbox-label'});
const checkbox = checkboxLabel.createEl('input', {
attr: {
type: 'checkbox',
value: ext.ext
}
});
checkbox.addEventListener('change', () => {
const exts = ext.ext.split(',');
if (checkbox.checked) {
exts.forEach(e => {
if (!selectedExtensions.includes(e)) {
selectedExtensions.push(e);
}
});
} else {
exts.forEach(e => {
const index = selectedExtensions.indexOf(e);
if (index > -1) {
selectedExtensions.splice(index, 1);
}
});
}
});
checkboxLabel.appendText(ext.name);
});
// Create convert button
const buttonContainer = contentEl.createDiv('markitdown-button-container');
const convertButton = buttonContainer.createEl('button', {
text: 'Convert'
});
convertButton.addEventListener('click', async () => {
if (folderInput.files && folderInput.files.length > 0) {
if (selectedExtensions.length === 0) {
new Notice('Please select at least one file type');
return;
}
try {
// Get vault path if using FileSystemAdapter
let vaultPath = '';
if (this.app.vault.adapter instanceof FileSystemAdapter) {
vaultPath = this.app.vault.adapter.getBasePath();
}
if (!vaultPath) {
new Notice('Could not determine vault path. This plugin requires a local vault.');
return;
}
// Determine output path
let outputFolder = this.plugin.settings.outputPath || '';
if (!outputFolder) {
outputFolder = path.join(vaultPath, 'markitdown-output');
if (!fs.existsSync(outputFolder)) {
fs.mkdirSync(outputFolder, { recursive: true });
}
} else {
outputFolder = path.join(vaultPath, outputFolder);
if (!fs.existsSync(outputFolder)) {
fs.mkdirSync(outputFolder, { recursive: true });
}
}
// Get files to convert
const filesToConvert: File[] = [];
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 (filesToConvert.length === 0) {
new Notice('No matching files found in the selected folder');
return;
}
new Notice(`Converting ${filesToConvert.length} files...`);
this.close();
// Convert each file
let successCount = 0;
let failCount = 0;
for (const file of filesToConvert) {
try {
// Create output filename
const baseName = path.basename(file.name, path.extname(file.name));
const outputPath = path.join(outputFolder, `${baseName}.md`);
// Write the file to disk first since file.path is not available
const tempFilePath = path.join(outputFolder, `${Date.now()}_${file.name}`);
const buffer = await file.arrayBuffer();
fs.writeFileSync(tempFilePath, Buffer.from(buffer));
// Convert the file
await this.plugin.convertFile(tempFilePath, outputPath);
// Clean up temp file
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
successCount++;
} catch (error) {
console.error(`Error converting ${file.name}:`, error);
failCount++;
}
}
// Refresh the vault to see the new files
await this.app.vault.adapter.list(outputFolder);
new Notice(`Conversion complete: ${successCount} successful, ${failCount} failed`);
} catch (error) {
console.error('Error during folder conversion:', error);
new Notice(`Error: ${error.message}`);
}
} else {
new Notice('Please select a folder first');
}
});
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class MarkitdownSetupModal extends Modal {
plugin: MarkitdownPlugin;
constructor(app: App, plugin: MarkitdownPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const {contentEl} = this;
contentEl.addClass('markitdown-modal');
contentEl.createEl('h2', {text: 'Markitdown Setup'});
if (!this.plugin.pythonInstalled) {
contentEl.createEl('p', {
text: 'Python is not installed or not found at the specified path. Please install Python and configure the path in settings.'
});
const buttonEl = contentEl.createEl('button', {
text: 'Go to settings'
});
buttonEl.addEventListener('click', () => {
this.close();
// Open settings with proper typing
if ('setting' in this.app) {
const appWithSetting = this.app as AppWithSetting;
appWithSetting.setting.open();
appWithSetting.setting.openTabById('obsidian-markitdown');
}
});
return;
}
contentEl.createEl('p', {
text: 'Markitdown is not installed. Would you like to install it now?'
});
contentEl.createEl('p', {
text: 'This will install the Markitdown Python package using pip.'
});
const buttonContainer = contentEl.createDiv('markitdown-button-container');
const cancelButton = buttonContainer.createEl('button', {
text: 'Cancel'
});
cancelButton.addEventListener('click', () => {
this.close();
});
const installButton = buttonContainer.createEl('button', {
text: 'Install Markitdown'
});
installButton.addEventListener('click', async () => {
installButton.disabled = true;
installButton.setText('Installing...');
try {
const success = await this.plugin.installMarkitdown();
if (success) {
new Notice('Markitdown installed successfully!');
this.close();
} else {
contentEl.createEl('p', {
text: 'Failed to install Markitdown. Please check the console for errors.'
});
installButton.disabled = false;
installButton.setText('Try Again');
}
} catch (error) {
console.error('Error installing Markitdown:', error);
contentEl.createEl('p', {
text: `Error: ${error.message}`
});
installButton.disabled = false;
installButton.setText('Try Again');
}
});
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class MarkitdownSettingTab extends PluginSettingTab {
plugin: MarkitdownPlugin;
constructor(app: App, plugin: MarkitdownPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Python path')
.setDesc('Path to python executable (e.g., python, python3, or full path)')
.addText(text => text
.setPlaceholder('python')
.setValue(this.plugin.settings.pythonPath)
.onChange(async (value) => {
this.plugin.settings.pythonPath = value;
await this.plugin.saveSettings();
this.plugin.checkDependencies();
}));
new Setting(containerEl)
.setName('Enable Markitdown plugins')
.setDesc('Enable third-party plugins for markitdown')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enablePlugins)
.onChange(async (value) => {
this.plugin.settings.enablePlugins = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Azure Document Intelligence endpoint')
.setDesc('Optional: Use Azure Document Intelligence for better conversion (requires API key setup)')
.addText(text => text
.setPlaceholder('https://your-resource.cognitiveservices.azure.com/')
.setValue(this.plugin.settings.docintelEndpoint)
.onChange(async (value) => {
this.plugin.settings.docintelEndpoint = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Output folder')
.setDesc('Folder path for converted files (relative to vault root, leave empty for default "markitdown-output")')
.addText(text => text
.setPlaceholder('markitdown-output')
.setValue(this.plugin.settings.outputPath)
.onChange(async (value) => {
this.plugin.settings.outputPath = value;
await this.plugin.saveSettings();
}));
// Status section
new Setting(containerEl)
.setName('Status')
.setHeading();
const statusContainer = containerEl.createDiv('markitdown-status-container');
// Python status
const pythonStatus = statusContainer.createDiv('markitdown-status-item');
const pythonIcon = pythonStatus.createSpan();
pythonIcon.addClass('markitdown-status-icon');
pythonIcon.addClass(this.plugin.pythonInstalled ? 'success' : 'error');
pythonIcon.setText(this.plugin.pythonInstalled ? '✓' : '✗');
pythonStatus.createSpan().setText(`Python: ${this.plugin.pythonInstalled ? 'Installed' : 'Not installed'}`);
// Markitdown status
const markitdownStatus = statusContainer.createDiv('markitdown-status-item');
const markitdownIcon = markitdownStatus.createSpan();
markitdownIcon.addClass('markitdown-status-icon');
markitdownIcon.addClass(this.plugin.markitdownInstalled ? 'success' : 'error');
markitdownIcon.setText(this.plugin.markitdownInstalled ? '✓' : '✗');
markitdownStatus.createSpan().setText(`Markitdown: ${this.plugin.markitdownInstalled ? 'Installed' : 'Not installed'}`);
// Install button if Markitdown is not installed
if (!this.plugin.markitdownInstalled && this.plugin.pythonInstalled) {
const installButton = containerEl.createEl('button', {
text: 'Install Markitdown',
cls: 'markitdown-install-button'
});
installButton.addEventListener('click', async () => {
installButton.disabled = true;
installButton.setText('Installing...');
try {
const success = await this.plugin.installMarkitdown();
if (success) {
new Notice('Markitdown installed successfully!');
this.display(); // Refresh the settings panel
} else {
new Notice('Failed to install Markitdown. Please check the console for errors.');
installButton.disabled = false;
installButton.setText('Try Again');
}
} catch (error) {
console.error('Error installing Markitdown:', error);
new Notice(`Error: ${error.message}`);
installButton.disabled = false;
installButton.setText('Try Again');
}
});
}
}
}

View file

@ -1,10 +1,10 @@
{
"id": "markitdown",
"name": "Markitdown File Converter",
"version": "1.0.2",
"version": "2.0.0",
"minAppVersion": "0.15.0",
"description": "Convert PDFs, Office documents, images, and other file formats to Markdown using Microsoft's Markitdown tool",
"author": "Ethan Troy",
"authorUrl": "https://github.com/ethanolivertroy",
"isDesktopOnly": true
}
}

3291
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-markitdown",
"version": "1.0.1",
"version": "2.0.0",
"description": "Convert various file formats to Markdown using Microsoft's Markitdown library",
"main": "main.js",
"scripts": {
@ -12,13 +12,11 @@
"author": "Ethan Troy",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"@types/node": "^20.11.0",
"builtin-modules": "3.3.0",
"esbuild": "0.25.0",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
"tslib": "^2.6.0",
"typescript": "^5.4.0"
}
}

63
python/check_install.py Normal file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Check Python environment and installed packages.
Usage:
python check_install.py --check markitdown
python check_install.py --check all
Output (JSON to stdout):
{"python_version": "3.11.5", "packages": {"markitdown": {"installed": true, "version": "0.1.3"}}}
"""
import argparse
import json
import sys
def check_package(name: str) -> dict:
"""Check if a Python package is installed and return its version."""
try:
from importlib.metadata import version, PackageNotFoundError
try:
ver = version(name)
return {"installed": True, "version": ver}
except PackageNotFoundError:
return {"installed": False, "version": None}
except ImportError:
# Python < 3.8 fallback
try:
import pkg_resources
ver = pkg_resources.get_distribution(name).version
return {"installed": True, "version": ver}
except Exception:
return {"installed": False, "version": None}
def main():
parser = argparse.ArgumentParser(description="Check Python package installations")
parser.add_argument(
"--check",
required=True,
help="Package to check: 'markitdown' or 'all'",
)
args = parser.parse_args()
packages_to_check = []
if args.check == "all":
packages_to_check = ["markitdown"]
else:
packages_to_check = [args.check]
result = {
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"packages": {},
}
for pkg in packages_to_check:
result["packages"][pkg] = check_package(pkg)
print(json.dumps(result))
if __name__ == "__main__":
main()

60
python/install_package.py Normal file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Install a Python package using pip with progress output.
Usage:
python install_package.py --package "markitdown[all]"
Output:
Streams pip output line by line to stdout.
Final line is JSON: {"success": true} or {"success": false, "error": "message"}
"""
import argparse
import json
import subprocess
import sys
def main():
parser = argparse.ArgumentParser(description="Install a Python package via pip")
parser.add_argument(
"--package",
required=True,
help="Package specification (e.g., 'markitdown[all]')",
)
args = parser.parse_args()
try:
process = subprocess.Popen(
[sys.executable, "-m", "pip", "install", args.package],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
for line in process.stdout:
print(line, end="", flush=True)
process.wait()
if process.returncode == 0:
print(json.dumps({"success": True}))
else:
print(
json.dumps(
{
"success": False,
"error": f"pip exited with code {process.returncode}",
}
)
)
sys.exit(1)
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Convert files to Markdown using Microsoft's Markitdown.
All arguments are passed via argparse no string interpolation of user input.
This script is called from TypeScript via child_process.spawn() with shell=false.
Usage:
python markitdown_wrapper.py \
--input /path/to/file.pdf \
--output /path/to/output.md \
[--enable-plugins] \
[--plugin-args '{"key": "value"}'] \
[--docintel-endpoint https://...] \
[--extract-images] \
[--image-dir /path/to/images/]
stdout: JSON {"success": true, "images_extracted": N}
stderr: JSON {"error": "message"} on failure
Exit code: 0 = success, 1 = failure
"""
import argparse
import base64
import json
import os
import re
import sys
import time
def extract_images_from_markdown(markdown_text: str, image_dir: str) -> tuple[str, int]:
"""Scan markdown for base64 data URIs, decode them to files, and rewrite links.
Returns (updated_markdown, images_extracted_count).
"""
os.makedirs(image_dir, exist_ok=True)
count = 0
image_dir_name = os.path.basename(image_dir)
# Match base64 data URIs in markdown image syntax: ![alt](data:image/png;base64,...)
pattern = r'(!\[[^\]]*\])\(data:image/([^;]+);base64,([A-Za-z0-9+/=\s]+)\)'
def replace_data_uri(match):
nonlocal count
next_index = count + 1
alt_text = match.group(1)
img_format = match.group(2)
b64_data = match.group(3).replace("\n", "").replace("\r", "").replace(" ", "")
# Normalize and sanitize format — only allow known image extensions
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp", "svg", "ico"}
ext = img_format.lower()
if ext == "jpeg":
ext = "jpg"
elif ext == "svg+xml":
ext = "svg"
# Strip anything that isn't alphanumeric to prevent path traversal
ext = re.sub(r'[^a-z0-9]', '', ext)
if ext not in ALLOWED_EXTENSIONS:
ext = "png" # safe fallback
filename = f"image_{next_index:03d}.{ext}"
filepath = os.path.join(image_dir, filename)
try:
image_bytes = base64.b64decode(b64_data)
with open(filepath, "wb") as f:
f.write(image_bytes)
except Exception as e:
# If decoding fails, leave the original data URI
print(
json.dumps({"warning": f"Failed to decode image {next_index}: {e}"}),
file=sys.stderr,
)
return match.group(0)
count = next_index
return f"{alt_text}(./{image_dir_name}/{filename})"
updated_markdown = re.sub(pattern, replace_data_uri, markdown_text)
return updated_markdown, count
def main():
parser = argparse.ArgumentParser(
description="Convert files to Markdown using Markitdown"
)
parser.add_argument("--input", required=True, help="Input file path")
parser.add_argument("--output", required=True, help="Output markdown file path")
parser.add_argument(
"--enable-plugins",
action="store_true",
help="Enable third-party Markitdown plugins",
)
parser.add_argument(
"--plugin-args",
default="{}",
help="JSON string of plugin arguments",
)
parser.add_argument(
"--docintel-endpoint",
help="Azure Document Intelligence endpoint URL",
)
parser.add_argument(
"--extract-images",
action="store_true",
help="Extract base64 images to files",
)
parser.add_argument(
"--image-dir",
help="Directory to save extracted images",
)
args = parser.parse_args()
if args.extract_images and not args.image_dir:
parser.error("--image-dir is required when --extract-images is specified")
start_time = time.time()
try:
# Validate input file exists and is readable
if not os.path.isfile(args.input):
print(
json.dumps(
{
"error": f"Input file does not exist: {os.path.basename(args.input)}",
"type": "FileError",
}
),
file=sys.stderr,
)
sys.exit(1)
if not os.access(args.input, os.R_OK):
print(
json.dumps(
{
"error": f"Input file is not readable: {os.path.basename(args.input)}",
"type": "FileError",
}
),
file=sys.stderr,
)
sys.exit(1)
# Set Azure endpoint if provided
if args.docintel_endpoint:
os.environ["AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT"] = args.docintel_endpoint
# Import markitdown
try:
from markitdown import MarkItDown
except ImportError:
print(
json.dumps(
{
"error": "markitdown package is not installed. Run: pip install 'markitdown[all]'",
"type": "ImportError",
}
),
file=sys.stderr,
)
sys.exit(1)
# Parse plugin args
plugin_kwargs = {}
if args.plugin_args and args.plugin_args != "{}":
try:
plugin_kwargs = json.loads(args.plugin_args)
except json.JSONDecodeError as e:
print(
json.dumps(
{
"error": f"Invalid plugin-args JSON: {e}",
"type": "ValueError",
}
),
file=sys.stderr,
)
sys.exit(1)
if not isinstance(plugin_kwargs, dict):
print(
json.dumps(
{
"error": "plugin-args must be a JSON object (key-value map)",
"type": "ValueError",
}
),
file=sys.stderr,
)
sys.exit(1)
# Create converter
converter_kwargs = {}
if args.enable_plugins:
converter_kwargs["enable_plugins"] = True
converter = MarkItDown(**converter_kwargs)
# Convert the file
result = converter.convert(args.input, **plugin_kwargs)
markdown_text = result.text_content
if not markdown_text:
print(
json.dumps(
{
"error": "Conversion produced empty output",
"type": "ConversionError",
}
),
file=sys.stderr,
)
sys.exit(1)
# Extract images if requested
images_extracted = 0
if args.extract_images and args.image_dir:
markdown_text, images_extracted = extract_images_from_markdown(
markdown_text, args.image_dir
)
# Write output
output_dir = os.path.dirname(os.path.abspath(args.output))
if output_dir:
os.makedirs(output_dir, exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
f.write(markdown_text)
elapsed_ms = int((time.time() - start_time) * 1000)
print(
json.dumps(
{
"success": True,
"images_extracted": images_extracted,
"processing_time_ms": elapsed_ms,
}
)
)
except Exception as e:
print(
json.dumps({"error": str(e), "type": type(e).__name__}),
file=sys.stderr,
)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,106 @@
import * as path from 'path';
import * as fs from 'fs';
import { ConversionOptions, ConversionResult } from '../types/settings';
import { runPythonScript, getPythonScriptPath } from '../utils/python';
import { SUPPORTED_EXTENSIONS } from '../utils/fileTypes';
export class MarkitdownConverter {
constructor(
private pythonPath: string,
private pluginDir: string
) {}
getSupportedExtensions(): string[] {
return SUPPORTED_EXTENSIONS.map(ext => `.${ext}`);
}
canConvert(ext: string): boolean {
const normalized = ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`;
return this.getSupportedExtensions().includes(normalized);
}
async convert(
inputPath: string,
outputPath: string,
options?: ConversionOptions
): Promise<ConversionResult> {
const startTime = Date.now();
const scriptPath = getPythonScriptPath('markitdown_wrapper.py', this.pluginDir);
// Build argument array — never string interpolation
const args: string[] = ['--input', inputPath, '--output', outputPath];
if (options?.enablePlugins) {
args.push('--enable-plugins');
}
if (options?.pluginArgs && Object.keys(options.pluginArgs).length > 0) {
args.push('--plugin-args', JSON.stringify(options.pluginArgs));
}
if (options?.docintelEndpoint) {
args.push('--docintel-endpoint', options.docintelEndpoint);
}
if (options?.extractImages && options?.imageDir) {
args.push('--extract-images', '--image-dir', options.imageDir);
}
try {
const result = await runPythonScript(this.pythonPath, scriptPath, args);
if (result.exitCode !== 0) {
let errorMsg = 'Unknown error';
try {
const errJson = JSON.parse(result.stderr);
// Use the error type/message from Python, but strip absolute paths
errorMsg = errJson.error || 'Unknown error';
} catch {
errorMsg = result.stderr || 'Unknown error';
}
// Strip absolute paths from error messages to avoid leaking internal paths
errorMsg = errorMsg.replace(/(?:\/[\w.-]+)+\//g, '…/');
return {
success: false,
error: `Conversion failed: ${errorMsg}`,
processingTime: Date.now() - startTime,
};
}
// Verify output file exists
if (!fs.existsSync(outputPath)) {
return {
success: false,
error: 'Output file was not created',
processingTime: Date.now() - startTime,
};
}
// Parse success response
let imagesExtracted = 0;
try {
const response = JSON.parse(result.stdout);
imagesExtracted = response.images_extracted ?? 0;
} catch {
// stdout might be empty or non-JSON
}
return {
success: true,
outputPath,
processingTime: Date.now() - startTime,
imagesExtracted,
};
} catch (error: unknown) {
const rawMessage = error instanceof Error ? error.message : String(error);
// Strip absolute paths to avoid leaking internal directory structure
const message = rawMessage.replace(/(?:\/[\w.-]+)+\//g, '…/');
return {
success: false,
error: `Conversion error: ${message}`,
processingTime: Date.now() - startTime,
};
}
}
}

View file

@ -0,0 +1,54 @@
import { App, Modal } from 'obsidian';
export class BatchProgressModal extends Modal {
private progressFill!: HTMLElement;
private statusEl!: HTMLElement;
private currentFileEl!: HTMLElement;
private totalFiles: number;
constructor(app: App, totalFiles: number) {
super(app);
this.totalFiles = totalFiles;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('markitdown-modal');
contentEl.createEl('h2', { text: 'Converting files' });
// Progress bar
const progressBar = contentEl.createDiv('markitdown-progress-bar');
this.progressFill = progressBar.createDiv('markitdown-progress-fill');
this.progressFill.style.width = '0%';
// Status text
this.statusEl = contentEl.createDiv('markitdown-progress-status');
this.statusEl.setText(`0 / ${this.totalFiles} files converted`);
// Current file
this.currentFileEl = contentEl.createDiv('markitdown-progress-current');
this.currentFileEl.setText('Preparing...');
}
updateProgress(current: number, currentFile: string, success: number, failed: number) {
const pct = this.totalFiles > 0 ? Math.round((current / this.totalFiles) * 100) : 0;
this.progressFill.style.width = `${pct}%`;
this.statusEl.setText(
`${current} / ${this.totalFiles} files converted (${success} successful, ${failed} failed)`
);
this.currentFileEl.setText(`Converting: ${currentFile}`);
}
complete(success: number, failed: number) {
this.progressFill.style.width = '100%';
this.statusEl.setText(
`Conversion complete: ${success} successful, ${failed} failed`
);
this.currentFileEl.setText('Done!');
setTimeout(() => this.close(), 2000);
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,94 @@
import { App, Modal, Notice, TFile } from 'obsidian';
import * as path from 'path';
import * as fs from 'fs';
import type MarkitdownPlugin from '../../main';
import { FILE_INPUT_ACCEPT } from '../utils/fileTypes';
import { getVaultBasePath, resolveOutputFolder, toVaultRelative } from '../utils/paths';
export class FileConvertModal extends Modal {
private plugin: MarkitdownPlugin;
constructor(app: App, plugin: MarkitdownPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('markitdown-modal');
contentEl.createEl('h2', { text: 'Convert file to Markdown' });
contentEl.createEl('p', { text: 'Select a file to convert:' });
const fileInputContainer = contentEl.createDiv('markitdown-file-input-container');
const fileInput = fileInputContainer.createEl('input', {
attr: { type: 'file', accept: FILE_INPUT_ACCEPT },
});
const buttonContainer = contentEl.createDiv('markitdown-button-container');
const convertButton = buttonContainer.createEl('button', {
text: 'Convert',
cls: 'mod-cta',
});
convertButton.addEventListener('click', async () => {
if (!fileInput.files || fileInput.files.length === 0) {
new Notice('Please select a file first');
return;
}
const file = fileInput.files[0];
convertButton.disabled = true;
convertButton.setText('Converting...');
try {
const vaultPath = getVaultBasePath(this.app);
if (!vaultPath) {
new Notice('Could not determine vault path. This plugin requires a local vault.');
convertButton.disabled = false;
convertButton.setText('Convert');
return;
}
const outputFolder = resolveOutputFolder(vaultPath, this.plugin.settings.outputPath);
const baseName = path.basename(file.name, path.extname(file.name));
const outputPath = path.join(outputFolder, `${baseName}.md`);
// Write the DOM File to a temp file on disk
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const tempFilePath = path.join(outputFolder, `tmp_${Date.now()}_${safeName}`);
const buffer = await file.arrayBuffer();
await fs.promises.writeFile(tempFilePath, Buffer.from(buffer));
try {
const result = await this.plugin.convertExternalFile(tempFilePath, outputPath);
if (result.success) {
const msg = result.imagesExtracted
? `Converted successfully (${result.imagesExtracted} images extracted)`
: 'Converted successfully';
new Notice(msg);
this.close();
await this.plugin.openConvertedFile(outputPath, vaultPath);
} else {
new Notice(`Conversion failed: ${result.error}`);
convertButton.disabled = false;
convertButton.setText('Convert');
}
} finally {
// Clean up temp file
await fs.promises.unlink(tempFilePath).catch(() => {});
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
new Notice(`Error: ${msg}`);
convertButton.disabled = false;
convertButton.setText('Convert');
}
});
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,165 @@
import { App, Modal, Notice } from 'obsidian';
import * as path from 'path';
import * as fs from 'fs';
import type MarkitdownPlugin from '../../main';
import { EXTENSION_GROUPS } from '../utils/fileTypes';
import { getVaultBasePath, resolveOutputFolder } from '../utils/paths';
import { BatchProgressModal } from './BatchProgressModal';
export class FolderConvertModal extends Modal {
private plugin: MarkitdownPlugin;
constructor(app: App, plugin: MarkitdownPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('markitdown-modal');
contentEl.createEl('h2', { text: 'Convert folder contents to Markdown' });
// Folder picker
contentEl.createEl('p', { text: 'Select a folder to process:' });
const folderInputContainer = contentEl.createDiv('markitdown-file-input-container');
const folderInput = folderInputContainer.createEl('input', {
attr: { type: 'file', webkitdirectory: '', directory: '' },
});
// File type checkboxes
contentEl.createEl('p', { text: 'Select file types to convert:' });
const checkboxContainer = contentEl.createDiv('markitdown-checkbox-grid');
const selectedExtensions: string[] = [];
for (const group of EXTENSION_GROUPS) {
const label = checkboxContainer.createEl('label', { cls: 'markitdown-checkbox-label' });
const checkbox = label.createEl('input', {
attr: { type: 'checkbox', value: group.extensions },
});
checkbox.addEventListener('change', () => {
const exts = group.extensions.split(',');
if (checkbox.checked) {
for (const ext of exts) {
if (!selectedExtensions.includes(ext)) {
selectedExtensions.push(ext);
}
}
} else {
for (const ext of exts) {
const idx = selectedExtensions.indexOf(ext);
if (idx > -1) selectedExtensions.splice(idx, 1);
}
}
});
label.appendText(group.name);
}
// Convert button
const buttonContainer = contentEl.createDiv('markitdown-button-container');
const convertButton = buttonContainer.createEl('button', {
text: 'Convert',
cls: 'mod-cta',
});
convertButton.addEventListener('click', async () => {
if (!folderInput.files || folderInput.files.length === 0) {
new Notice('Please select a folder first');
return;
}
if (selectedExtensions.length === 0) {
new Notice('Please select at least one file type');
return;
}
// Filter matching files
const filesToConvert: File[] = [];
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 (filesToConvert.length === 0) {
new Notice('No matching files found in the selected folder');
return;
}
this.close();
await this.runBatchConversion(filesToConvert);
});
}
private async runBatchConversion(files: File[]) {
const vaultPath = getVaultBasePath(this.app);
if (!vaultPath) {
new Notice('Could not determine vault path. This plugin requires a local vault.');
return;
}
const outputFolder = resolveOutputFolder(vaultPath, this.plugin.settings.outputPath);
// Progress modal
let progressModal: BatchProgressModal | null = null;
if (this.plugin.settings.enableBatchProgress) {
progressModal = new BatchProgressModal(this.app, files.length);
progressModal.open();
} else {
new Notice(`Converting ${files.length} files...`);
}
let success = 0;
let failed = 0;
const errors: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
progressModal?.updateProgress(i, file.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}`);
try {
const buffer = await file.arrayBuffer();
await fs.promises.writeFile(tempFilePath, Buffer.from(buffer));
const result = await this.plugin.convertExternalFile(tempFilePath, outputPath);
if (result.success) {
success++;
} else {
failed++;
errors.push(`${file.name}: ${result.error}`);
}
} catch (error) {
failed++;
const msg = error instanceof Error ? error.message : String(error);
errors.push(`${file.name}: ${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);
}
if (progressModal) {
progressModal.complete(success, failed);
} else {
let msg = `Conversion complete: ${success} successful, ${failed} failed`;
if (errors.length > 0 && errors.length <= 3) {
msg += '\n' + errors.join('\n');
}
new Notice(msg);
}
}
onClose() {
this.contentEl.empty();
}
}

133
src/modals/SetupModal.ts Normal file
View file

@ -0,0 +1,133 @@
import { App, Modal, Notice } from 'obsidian';
import type MarkitdownPlugin from '../../main';
export class SetupModal extends Modal {
private plugin: MarkitdownPlugin;
constructor(app: App, plugin: MarkitdownPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('markitdown-modal');
contentEl.createEl('h2', { text: 'Markitdown setup' });
if (!this.plugin.dependencyStatus.pythonInstalled) {
this.showPythonNotFound(contentEl);
return;
}
if (this.plugin.dependencyStatus.markitdownInstalled) {
this.showAlreadyInstalled(contentEl);
return;
}
this.showInstallPrompt(contentEl);
}
private showPythonNotFound(el: HTMLElement) {
el.createEl('p', {
text: 'Python is not installed or not found at the configured path.',
});
const detailsEl = el.createEl('div', { cls: 'markitdown-setup-details' });
detailsEl.createEl('p', { text: 'To get started:' });
const list = detailsEl.createEl('ol');
list.createEl('li', { text: 'Install Python 3.8+ from python.org' });
list.createEl('li', { text: 'Set the correct Python path in plugin settings' });
list.createEl('li', { text: 'Restart Obsidian or re-run this setup' });
const buttonContainer = el.createDiv('markitdown-button-container');
const settingsBtn = buttonContainer.createEl('button', { text: 'Open settings' });
settingsBtn.addEventListener('click', () => {
this.close();
// No public API exists to open the settings tab to a specific plugin.
// app.setting is an internal Obsidian API — may break in future versions.
// @ts-expect-error — Obsidian internal API: no public method to open settings
this.app.setting?.open();
// @ts-expect-error — Obsidian internal API: no public method to navigate to plugin tab
this.app.setting?.openTabById(this.plugin.manifest.id);
});
}
private showAlreadyInstalled(el: HTMLElement) {
const ver = this.plugin.dependencyStatus.markitdownVersion;
el.createEl('p', {
text: `Markitdown is installed${ver ? ` (v${ver})` : ''}. You're ready to convert files.`,
});
const buttonContainer = el.createDiv('markitdown-button-container');
const closeBtn = buttonContainer.createEl('button', { text: 'Close' });
closeBtn.addEventListener('click', () => this.close());
}
private showInstallPrompt(el: HTMLElement) {
el.createEl('p', {
text: 'Markitdown is not installed. Would you like to install it now?',
});
el.createEl('p', {
text: 'This will install the markitdown Python package using pip.',
cls: 'setting-item-description',
});
// Progress log area
const logEl = el.createDiv('markitdown-setup-log');
logEl.style.display = 'none';
const buttonContainer = el.createDiv('markitdown-button-container');
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
const installBtn = buttonContainer.createEl('button', {
text: 'Install Markitdown',
cls: 'mod-cta',
});
installBtn.addEventListener('click', async () => {
installBtn.disabled = true;
installBtn.setText('Installing...');
cancelBtn.disabled = true;
logEl.style.display = 'block';
logEl.empty();
try {
const success = await this.plugin.installMarkitdown((line) => {
const lineEl = logEl.createEl('div', {
text: line,
cls: 'markitdown-setup-log-line',
});
lineEl.scrollIntoView();
});
if (success) {
new Notice('Markitdown installed successfully!');
await this.plugin.refreshDependencies();
this.close();
} else {
logEl.createEl('div', {
text: 'Installation failed. Check the log above for details.',
cls: 'markitdown-setup-log-error',
});
installBtn.disabled = false;
installBtn.setText('Try again');
cancelBtn.disabled = false;
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logEl.createEl('div', {
text: `Error: ${msg}`,
cls: 'markitdown-setup-log-error',
});
installBtn.disabled = false;
installBtn.setText('Try again');
cancelBtn.disabled = false;
}
});
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,62 @@
import { PluginArgEntry } from '../types/settings';
/**
* Renders a dynamic list of key-value pair inputs for configuring
* third-party Markitdown plugin arguments.
*/
export class PluginArgsEditor {
constructor(
private containerEl: HTMLElement,
private entries: PluginArgEntry[],
private onChange: (entries: PluginArgEntry[]) => void,
) {}
render() {
this.containerEl.empty();
for (let i = 0; i < this.entries.length; i++) {
const row = this.containerEl.createDiv('markitdown-plugin-arg-row');
const keyInput = row.createEl('input', {
attr: { type: 'text', placeholder: 'Argument name' },
cls: 'markitdown-plugin-arg-key',
});
keyInput.value = this.entries[i].key;
keyInput.addEventListener('input', () => {
this.entries[i].key = keyInput.value;
this.onChange(this.entries);
});
const valueInput = row.createEl('input', {
attr: { type: 'text', placeholder: 'Value' },
cls: 'markitdown-plugin-arg-value',
});
valueInput.value = this.entries[i].value;
valueInput.addEventListener('input', () => {
this.entries[i].value = valueInput.value;
this.onChange(this.entries);
});
const deleteBtn = row.createEl('button', {
text: '\u00D7',
cls: 'markitdown-plugin-arg-delete',
attr: { 'aria-label': 'Remove argument' },
});
deleteBtn.addEventListener('click', () => {
this.entries.splice(i, 1);
this.onChange(this.entries);
this.render();
});
}
const addBtn = this.containerEl.createEl('button', {
text: '+ Add argument',
cls: 'markitdown-plugin-arg-add',
});
addBtn.addEventListener('click', () => {
this.entries.push({ key: '', value: '' });
this.onChange(this.entries);
this.render();
});
}
}

222
src/settings/SettingsTab.ts Normal file
View file

@ -0,0 +1,222 @@
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
import type MarkitdownPlugin from '../../main';
import { PluginArgsEditor } from './PluginArgsEditor';
export class SettingsTab extends PluginSettingTab {
plugin: MarkitdownPlugin;
constructor(app: App, plugin: MarkitdownPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// ── Python ──────────────────────────────
new Setting(containerEl)
.setName('Python')
.setHeading();
new Setting(containerEl)
.setName('Python path')
.setDesc('Path to Python executable (e.g., python, python3, or a full path)')
.addText(text => text
.setPlaceholder('python')
.setValue(this.plugin.settings.pythonPath)
.onChange(async (value) => {
this.plugin.settings.pythonPath = value;
await this.plugin.saveSettings();
await this.plugin.refreshDependencies();
this.display();
}));
// ── Conversion ──────────────────────────
new Setting(containerEl)
.setName('Conversion')
.setHeading();
new Setting(containerEl)
.setName('Output folder')
.setDesc('Folder for converted files (relative to vault root). Leave empty for "markitdown-output".')
.addText(text => text
.setPlaceholder('markitdown-output')
.setValue(this.plugin.settings.outputPath)
.onChange(async (value) => {
this.plugin.settings.outputPath = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Extract images')
.setDesc('Extract embedded images from PDFs and save as separate files')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.imageExtractionEnabled)
.onChange(async (value) => {
this.plugin.settings.imageExtractionEnabled = value;
await this.plugin.saveSettings();
this.display();
}));
if (this.plugin.settings.imageExtractionEnabled) {
new Setting(containerEl)
.setName('Image subfolder')
.setDesc('Subfolder name template for extracted images. Use {filename} as placeholder.')
.addText(text => text
.setPlaceholder('{filename}-images')
.setValue(this.plugin.settings.imageSubfolderTemplate)
.onChange(async (value) => {
this.plugin.settings.imageSubfolderTemplate = value || '{filename}-images';
await this.plugin.saveSettings();
}));
}
new Setting(containerEl)
.setName('Show batch progress')
.setDesc('Display a progress bar during batch folder conversions')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableBatchProgress)
.onChange(async (value) => {
this.plugin.settings.enableBatchProgress = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Context menu')
.setDesc('Show "Convert to Markdown" in the file explorer right-click menu (requires restart)')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableContextMenu)
.onChange(async (value) => {
this.plugin.settings.enableContextMenu = value;
await this.plugin.saveSettings();
}));
// ── Advanced ─────────────────────────────
new Setting(containerEl)
.setName('Advanced')
.setHeading();
new Setting(containerEl)
.setName('Enable Markitdown plugins')
.setDesc('Enable third-party Markitdown plugins (e.g., markitdown-pdf-separators)')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enablePlugins)
.onChange(async (value) => {
this.plugin.settings.enablePlugins = value;
await this.plugin.saveSettings();
this.display();
}));
if (this.plugin.settings.enablePlugins) {
const pluginArgsSetting = new Setting(containerEl)
.setName('Plugin arguments')
.setDesc('Key-value arguments passed to Markitdown plugins');
const editorContainer = containerEl.createDiv('markitdown-plugin-args-container');
const editor = new PluginArgsEditor(
editorContainer,
this.plugin.settings.pluginArgs,
async (entries) => {
this.plugin.settings.pluginArgs = entries;
await this.plugin.saveSettings();
}
);
editor.render();
}
new Setting(containerEl)
.setName('Azure Document Intelligence endpoint')
.setDesc('Optional: Azure endpoint URL for enhanced PDF conversion')
.addText(text => text
.setPlaceholder('https://your-resource.cognitiveservices.azure.com/')
.setValue(this.plugin.settings.docintelEndpoint)
.onChange(async (value) => {
this.plugin.settings.docintelEndpoint = value;
await this.plugin.saveSettings();
}));
// ── Status ───────────────────────────────
new Setting(containerEl)
.setName('Status')
.setHeading();
const statusContainer = containerEl.createDiv('markitdown-status-container');
const dep = this.plugin.dependencyStatus;
// Python status
this.renderStatusItem(
statusContainer,
'Python',
dep.pythonInstalled,
dep.pythonVersion ? `v${dep.pythonVersion}` : undefined
);
// Markitdown status
this.renderStatusItem(
statusContainer,
'Markitdown',
dep.markitdownInstalled,
dep.markitdownVersion ? `v${dep.markitdownVersion}` : undefined
);
// Install button
if (!dep.markitdownInstalled && dep.pythonInstalled) {
const installBtn = containerEl.createEl('button', {
text: 'Install Markitdown',
cls: 'markitdown-install-button',
});
installBtn.addEventListener('click', async () => {
installBtn.disabled = true;
installBtn.setText('Installing...');
try {
const success = await this.plugin.installMarkitdown();
if (success) {
new Notice('Markitdown installed successfully!');
await this.plugin.refreshDependencies();
this.display();
} else {
new Notice('Failed to install Markitdown. Check the console for errors.');
installBtn.disabled = false;
installBtn.setText('Try again');
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
new Notice(`Error: ${msg}`);
installBtn.disabled = false;
installBtn.setText('Try again');
}
});
}
// Refresh button
const refreshBtn = containerEl.createEl('button', {
text: 'Refresh status',
cls: 'markitdown-install-button',
});
refreshBtn.addEventListener('click', async () => {
refreshBtn.disabled = true;
refreshBtn.setText('Checking...');
await this.plugin.refreshDependencies();
this.display();
});
}
private renderStatusItem(
container: HTMLElement,
label: string,
installed: boolean,
version?: string
) {
const item = container.createDiv('markitdown-status-item');
const icon = item.createSpan('markitdown-status-icon');
icon.addClass(installed ? 'success' : 'error');
icon.setText(installed ? '\u2713' : '\u2717');
let text = `${label}: ${installed ? 'Installed' : 'Not installed'}`;
if (version) text += ` (${version})`;
item.createSpan().setText(text);
}
}

51
src/types/settings.ts Normal file
View file

@ -0,0 +1,51 @@
export interface PluginArgEntry {
key: string;
value: string;
}
export interface MarkitdownSettings {
pythonPath: string;
enablePlugins: boolean;
pluginArgs: PluginArgEntry[];
docintelEndpoint: string;
outputPath: string;
imageExtractionEnabled: boolean;
imageSubfolderTemplate: string;
enableBatchProgress: boolean;
enableContextMenu: boolean;
}
export const DEFAULT_SETTINGS: MarkitdownSettings = {
pythonPath: 'python',
enablePlugins: false,
pluginArgs: [],
docintelEndpoint: '',
outputPath: '',
imageExtractionEnabled: false,
imageSubfolderTemplate: '{filename}-images',
enableBatchProgress: true,
enableContextMenu: true,
};
export interface ConversionOptions {
enablePlugins?: boolean;
pluginArgs?: Record<string, unknown>;
docintelEndpoint?: string;
extractImages?: boolean;
imageDir?: string;
}
export interface ConversionResult {
success: boolean;
outputPath?: string;
error?: string;
processingTime?: number;
imagesExtracted?: number;
}
export interface DependencyStatus {
pythonInstalled: boolean;
pythonVersion: string | null;
markitdownInstalled: boolean;
markitdownVersion: string | null;
}

36
src/utils/fileTypes.ts Normal file
View file

@ -0,0 +1,36 @@
export const SUPPORTED_EXTENSIONS = [
'pdf', 'docx', 'pptx', 'xlsx', 'xls',
'html', 'htm', 'txt', 'csv', 'json', 'xml',
'jpg', 'jpeg', 'png', 'gif',
'wav', 'mp3',
'zip', 'epub',
];
/** File accept string for HTML file input elements. */
export const FILE_INPUT_ACCEPT = SUPPORTED_EXTENSIONS
.map(ext => `.${ext}`)
.join(',');
interface ExtensionGroup {
name: string;
extensions: string;
}
/** Extension groups for batch conversion checkbox UI. */
export const EXTENSION_GROUPS: ExtensionGroup[] = [
{ name: 'PDF files', extensions: '.pdf' },
{ name: 'Word documents', extensions: '.docx' },
{ name: 'PowerPoint presentations', extensions: '.pptx' },
{ name: 'Excel spreadsheets', extensions: '.xlsx,.xls' },
{ name: 'Web pages', extensions: '.html,.htm' },
{ name: 'Text files', extensions: '.txt' },
{ name: 'Data files', extensions: '.csv,.json,.xml' },
{ name: 'Images', extensions: '.jpg,.jpeg,.png,.gif' },
{ name: 'Audio files', extensions: '.wav,.mp3' },
{ name: 'Archives & eBooks', extensions: '.zip,.epub' },
];
export function isConvertible(extension: string): boolean {
const ext = extension.toLowerCase().replace(/^\./, '');
return SUPPORTED_EXTENSIONS.includes(ext);
}

59
src/utils/paths.ts Normal file
View file

@ -0,0 +1,59 @@
import { App, FileSystemAdapter } from 'obsidian';
import * as path from 'path';
import * as fs from 'fs';
/**
* Get the absolute vault base path. Returns null if not a local vault.
*/
export function getVaultBasePath(app: App): string | null {
if (app.vault.adapter instanceof FileSystemAdapter) {
return app.vault.adapter.getBasePath();
}
return null;
}
/**
* Resolve the output folder path, creating it if needed.
*/
export function resolveOutputFolder(vaultPath: string, outputSetting: string): string {
let outputFolder: string;
if (outputSetting) {
outputFolder = path.resolve(vaultPath, outputSetting);
} else {
outputFolder = path.join(vaultPath, 'markitdown-output');
}
// Prevent path traversal outside the vault
const resolvedVault = path.resolve(vaultPath);
if (!outputFolder.startsWith(resolvedVault + path.sep) && outputFolder !== resolvedVault) {
outputFolder = path.join(vaultPath, 'markitdown-output');
}
if (!fs.existsSync(outputFolder)) {
fs.mkdirSync(outputFolder, { recursive: true });
}
return outputFolder;
}
/**
* Compute the image extraction directory for a given output markdown file.
* Applies the template (e.g., "{filename}-images") to generate the folder name.
*/
export function resolveImageDir(outputMdPath: string, template: string): string {
const dir = path.dirname(outputMdPath);
const baseName = path.basename(outputMdPath, '.md');
// Strip path separators from template to prevent path traversal
const sanitizedTemplate = template.replace(/[/\\]/g, '-');
const folderName = sanitizedTemplate.replace('{filename}', baseName);
return path.join(dir, folderName);
}
/**
* Given an absolute path and the vault base path, return the vault-relative path.
* Normalizes separators to forward slashes for Obsidian compatibility.
*/
export function toVaultRelative(absolutePath: string, vaultPath: string): string {
return path.relative(vaultPath, absolutePath).replace(/\\/g, '/');
}

177
src/utils/python.ts Normal file
View file

@ -0,0 +1,177 @@
import { spawn } from 'child_process';
import * as path from 'path';
import { DependencyStatus } from '../types/settings';
interface PythonResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* Execute a Python script using spawn() with argument arrays.
* NEVER uses exec(). NEVER interpolates user data into shell strings.
* shell: false (the default) prevents shell interpretation of arguments.
*/
export function runPythonScript(
pythonPath: string,
scriptPath: string,
args: string[],
env?: Record<string, string>
): Promise<PythonResult> {
return new Promise((resolve, reject) => {
const child = spawn(pythonPath, [scriptPath, ...args], {
shell: false,
env: {
...process.env,
PYTHONUTF8: '1',
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('error', (err: Error) => {
reject(err);
});
child.on('close', (code: number | null) => {
resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code ?? 1,
});
});
});
}
/**
* Get the absolute path to a bundled Python wrapper script.
*/
export function getPythonScriptPath(scriptName: string, pluginDir: string): string {
return path.join(pluginDir, 'python', scriptName);
}
/**
* Check Python installation and package versions using check_install.py.
*/
/**
* Check Python installation and package versions using check_install.py.
* Returns the status and the resolved python path (which may differ from
* the configured path if the python3 fallback was used).
*/
export async function checkDependencies(
pythonPath: string,
pluginDir: string
): Promise<{ status: DependencyStatus; resolvedPythonPath: string }> {
const status: DependencyStatus = {
pythonInstalled: false,
pythonVersion: null,
markitdownInstalled: false,
markitdownVersion: null,
};
const scriptPath = getPythonScriptPath('check_install.py', pluginDir);
// Try configured path first, then python3 fallback
const pathsToTry = [pythonPath];
if (pythonPath === 'python') {
pathsToTry.push('python3');
}
for (const tryPath of pathsToTry) {
try {
const result = await runPythonScript(tryPath, scriptPath, ['--check', 'all']);
if (result.exitCode === 0 && result.stdout) {
const data = JSON.parse(result.stdout);
status.pythonInstalled = true;
status.pythonVersion = data.python_version ?? null;
if (data.packages?.markitdown) {
status.markitdownInstalled = data.packages.markitdown.installed;
status.markitdownVersion = data.packages.markitdown.version ?? null;
}
return { status, resolvedPythonPath: tryPath };
}
} catch (err) {
// This path didn't work — log for debugging, try next
console.debug(`markitdown: ${tryPath} failed:`, err);
continue;
}
}
return { status, resolvedPythonPath: pythonPath };
}
/**
* Install a Python package using install_package.py.
*/
export async function installPackage(
pythonPath: string,
pluginDir: string,
packageSpec: string,
onProgress?: (line: string) => void
): Promise<boolean> {
return new Promise((resolve, reject) => {
const scriptPath = getPythonScriptPath('install_package.py', pluginDir);
const child = spawn(pythonPath, [scriptPath, '--package', packageSpec], {
shell: false,
env: {
...process.env,
PYTHONUTF8: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let lastLine = '';
child.stdout.on('data', (data: Buffer) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
lastLine = line.trim();
onProgress?.(line.trim());
}
}
});
child.stderr.on('data', (data: Buffer) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
onProgress?.(line.trim());
}
}
});
child.on('error', (err: Error) => {
reject(err);
});
child.on('close', (code: number | null) => {
if (code === 0) {
// Check if the last line is a success JSON
try {
const result = JSON.parse(lastLine);
resolve(result.success === true);
} catch {
resolve(true);
}
} else {
resolve(false);
}
});
});
}

View file

@ -31,7 +31,7 @@ Styles for the Markitdown Obsidian plugin
cursor: not-allowed;
}
/* Button container styles */
/* Button container */
.markitdown-button-container {
display: flex;
justify-content: flex-end;
@ -39,7 +39,7 @@ Styles for the Markitdown Obsidian plugin
gap: 10px;
}
/* Settings tab status container */
/* Status container (settings tab) */
.markitdown-status-container {
background-color: var(--background-secondary);
padding: 12px;
@ -80,7 +80,7 @@ Styles for the Markitdown Obsidian plugin
gap: 6px;
}
/* Installation button */
/* Install button */
.markitdown-install-button {
display: block;
margin-top: 16px;
@ -101,4 +101,121 @@ Styles for the Markitdown Obsidian plugin
.markitdown-install-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* Progress bar (batch conversions) */
.markitdown-progress-bar {
width: 100%;
height: 20px;
background-color: var(--background-modifier-border);
border-radius: 4px;
overflow: hidden;
margin: 12px 0;
}
.markitdown-progress-fill {
height: 100%;
background-color: var(--interactive-accent);
transition: width 0.3s ease;
border-radius: 4px;
}
.markitdown-progress-status {
font-size: 0.9em;
color: var(--text-muted);
margin-bottom: 4px;
}
.markitdown-progress-current {
font-size: 0.85em;
color: var(--text-faint);
}
/* Setup modal */
.markitdown-setup-details {
margin: 12px 0;
padding: 12px;
background-color: var(--background-secondary);
border-radius: 6px;
}
.markitdown-setup-details ol {
padding-left: 20px;
margin: 8px 0;
}
.markitdown-setup-log {
max-height: 200px;
overflow-y: auto;
padding: 8px;
background-color: var(--background-primary-alt);
border-radius: 4px;
margin: 12px 0;
font-family: var(--font-monospace);
font-size: 0.8em;
}
.markitdown-setup-log-line {
padding: 2px 0;
color: var(--text-muted);
}
.markitdown-setup-log-error {
padding: 4px 0;
color: var(--text-error);
font-weight: 500;
}
/* Plugin args editor */
.markitdown-plugin-args-container {
margin: 8px 0 16px;
}
.markitdown-plugin-arg-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
align-items: center;
}
.markitdown-plugin-arg-key,
.markitdown-plugin-arg-value {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background-color: var(--background-primary);
color: var(--text-normal);
font-size: 0.9em;
}
.markitdown-plugin-arg-delete {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.2em;
padding: 2px 6px;
border-radius: 4px;
}
.markitdown-plugin-arg-delete:hover {
color: var(--text-error);
background-color: var(--background-modifier-hover);
}
.markitdown-plugin-arg-add {
background: none;
border: 1px dashed var(--background-modifier-border);
color: var(--text-muted);
cursor: pointer;
padding: 4px 12px;
border-radius: 4px;
font-size: 0.85em;
width: 100%;
}
.markitdown-plugin-arg-add:hover {
color: var(--text-normal);
border-color: var(--interactive-accent);
}

View file

@ -1,24 +1,18 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2020",
"allowJs": true,
"noImplicitAny": true,
"strict": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"lib": ["DOM", "ES2020"]
},
"include": ["main.ts", "src/**/*.ts"],
"exclude": ["node_modules", "python"]
}

View file

@ -1,4 +1,5 @@
{
"1.0.0": "0.15.0",
"1.0.1": "0.15.0"
"1.0.1": "0.15.0",
"2.0.0": "0.15.0"
}