mirror of
https://github.com/ethanolivertroy/obsidian-markitdown.git
synced 2026-07-22 05:41:54 +00:00
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:
parent
cd86670ce2
commit
d6fa068498
23 changed files with 1947 additions and 3985 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
923
main.ts
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
3291
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -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
63
python/check_install.py
Normal 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
60
python/install_package.py
Normal 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()
|
||||
251
python/markitdown_wrapper.py
Normal file
251
python/markitdown_wrapper.py
Normal 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: 
|
||||
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()
|
||||
106
src/converter/MarkitdownConverter.ts
Normal file
106
src/converter/MarkitdownConverter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/modals/BatchProgressModal.ts
Normal file
54
src/modals/BatchProgressModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
94
src/modals/FileConvertModal.ts
Normal file
94
src/modals/FileConvertModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
165
src/modals/FolderConvertModal.ts
Normal file
165
src/modals/FolderConvertModal.ts
Normal 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
133
src/modals/SetupModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
62
src/settings/PluginArgsEditor.ts
Normal file
62
src/settings/PluginArgsEditor.ts
Normal 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
222
src/settings/SettingsTab.ts
Normal 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
51
src/types/settings.ts
Normal 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
36
src/utils/fileTypes.ts
Normal 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
59
src/utils/paths.ts
Normal 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
177
src/utils/python.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
125
styles.css
125
styles.css
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue