commit 2eb6d5f3a4b892d2c28cd6a072d4a16073e01a72 Author: Rhys Date: Sun May 3 19:23:16 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ad3282 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +main.js +*.js.map diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2bc6e4 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# DocDrop + +An [Obsidian](https://obsidian.md) plugin that converts PDF files to Markdown using Microsoft's [MarkItDown](https://github.com/microsoft/markitdown) CLI tool. + +Right-click any PDF in your vault and have it converted to a clean Markdown file in seconds — entirely on your machine, no cloud required (unless you opt in to the advanced AI features). + +--- + +## Requirements + +- **Obsidian** 1.0.0 or later (desktop only — this plugin uses Node.js `child_process` and cannot run on Obsidian Mobile) +- **Python 3.10+** +- **MarkItDown** CLI installed: + +```bash +pip install markitdown +``` + +Verify installation: + +```bash +markitdown --version +``` + +**Optional — for AI-powered OCR of scanned PDFs:** + +```bash +pip install markitdown-ocr openai +``` + +--- + +## Installation + +### From the Obsidian Community Plugin Browser (recommended) + +1. Open Obsidian → **Settings** → **Community Plugins** +2. Disable Safe Mode if prompted +3. Click **Browse** and search for **DocDrop** +4. Click **Install**, then **Enable** + +### Manual installation + +1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/rgottwald/docdrop/releases) +2. Copy them into your vault at: + ``` + /.obsidian/plugins/docdrop/ + ``` +3. Reload Obsidian and enable the plugin under **Settings → Community Plugins** + +### Build from source + +```bash +git clone https://github.com/rgottwald/docdrop +cd docdrop +npm install +npm run build +``` + +Then copy `main.js`, `manifest.json`, and `styles.css` into your vault's plugin folder as above. + +--- + +## Usage + +### Right-click menu + +Right-click any PDF file in the Obsidian file explorer and select **Convert to Markdown with DocDrop**. The converted `.md` file is saved in the same folder as the PDF (or a custom folder — see Settings). + +### Command palette + +Open the command palette (`Cmd/Ctrl+P`) and run: + +``` +DocDrop: Convert active PDF to Markdown +``` + +This converts whichever PDF file is currently open/active. + +### Output file + +The output file is named identically to the PDF but with a `.md` extension. If a file with that name already exists, you will be prompted to confirm before overwriting. + +--- + +## Settings + +Open **Settings → DocDrop** to configure the plugin. + +### General + +| Setting | Description | +|---|---| +| **Executable path** | Full path to the `markitdown` binary, or just `markitdown` if it is on your system PATH. Change this if you get a "command not found" error — set it to the full path, e.g. `/Library/Frameworks/Python.framework/Versions/3.12/bin/markitdown`. | +| **Output location** | Where converted Markdown files are saved. "Same folder as PDF" places the `.md` file next to the source PDF. "Custom folder" lets you specify any vault-relative folder (it must already exist). | + +### Conversion options + +| Setting | Description | +|---|---| +| **Keep images** | Embeds images from the PDF as base64 data directly in the Markdown file. This preserves visuals at the cost of a much larger output file. Leave off if you only need the text content. | +| **MIME type hint** | Tells MarkItDown what kind of file it is receiving (e.g. `application/pdf`). Almost never needed — MarkItDown detects the file type automatically. Only set this if conversion produces wrong results. | +| **Character encoding hint** | Tells MarkItDown what text encoding to use (e.g. `UTF-8`, `ISO-8859-1`). Leave blank unless converted text contains garbled or misread characters. | + +### markitdown-ocr plugin (optional) + +[markitdown-ocr](https://github.com/microsoft/markitdown) is a separately-installed plugin that adds AI-powered OCR — it uses a vision-capable language model (such as OpenAI GPT-4o) to read text from images inside PDFs. This is especially useful for scanned documents or PDFs that are essentially images of pages rather than text. + +**Install the plugin first:** + +```bash +pip install markitdown-ocr +``` + +| Setting | Description | +|---|---| +| **Enable markitdown-ocr** | Activates all installed MarkItDown plugins, including markitdown-ocr. Has no effect if the plugin is not installed. | +| **OpenAI API key** | Your secret key from OpenAI (or a compatible provider). Required for markitdown-ocr to call the vision model. Get one at [platform.openai.com](https://platform.openai.com) → API keys. Looks like `sk-proj-...`. | +| **AI model** | The vision model markitdown-ocr uses to read images. Must support image/vision input. Default: `gpt-4o`. Cheaper alternative: `gpt-4o-mini` (slightly less accurate). | +| **OpenAI API base URL** | Override the API server. Leave blank for the default OpenAI servers. Set this if you use Azure OpenAI (e.g. `https://your-resource.openai.azure.com/`) or a self-hosted compatible service. | + +> **Cost note:** markitdown-ocr sends image data to OpenAI's API, which is billed per token/image. For large or image-heavy PDFs this can add up. Check [OpenAI pricing](https://openai.com/pricing) before enabling on many files. + +### Azure Document Intelligence (optional) + +[Azure AI Document Intelligence](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence) is a paid Microsoft Azure cloud service that uses AI to extract text from PDFs with significantly higher accuracy than offline conversion — especially for scanned documents, handwritten text, tables, and complex multi-column layouts. + +Requires an active Microsoft Azure subscription with the Document Intelligence resource provisioned. + +| Setting | Description | +|---|---| +| **Use Document Intelligence** | Send the PDF to Azure for conversion instead of processing it locally. Requires internet access and a paid Azure subscription. | +| **Endpoint URL** | The URL of your Azure Document Intelligence resource. Find it in the [Azure Portal](https://portal.azure.com): open your Document Intelligence resource → **Keys and Endpoint**. Looks like `https://your-resource-name.cognitiveservices.azure.com/`. | +| **API key** | The secret key that authenticates with Azure. Found in the same place: Azure Portal → your Document Intelligence resource → **Keys and Endpoint** → **KEY 1** or **KEY 2**. Either key works. | + +> **Cost note:** Azure Document Intelligence bills per page processed. Review [Azure pricing](https://azure.microsoft.com/en-us/pricing/details/ai-document-intelligence/) before use. + +--- + +## Troubleshooting + +**"ENOENT" or "command not found" error** +MarkItDown cannot be found. Obsidian's process does not inherit your shell PATH. Solutions: +- Set **Executable path** in plugin settings to the full absolute path, e.g.: + `/Library/Frameworks/Python.framework/Versions/3.12/bin/markitdown` +- Find the path by running `which markitdown` in your terminal. + +**Converted file has garbled characters** +Try setting the **Character encoding hint** to `UTF-8` in settings. + +**Scanned PDF produces no text (or very little)** +The PDF contains images of pages rather than real text. Enable **markitdown-ocr** (with a valid OpenAI API key) or **Azure Document Intelligence** for AI-powered OCR. + +**Azure Document Intelligence returns 401 Unauthorized** +Your API key is incorrect or has been regenerated. Copy a fresh key from the Azure Portal → your Document Intelligence resource → Keys and Endpoint. + +--- + +## Privacy & data + +- **Default (offline) conversion:** All processing happens locally on your machine. No data leaves your computer. +- **markitdown-ocr:** PDF image data is sent to OpenAI's API (or your configured base URL). Governed by [OpenAI's privacy policy](https://openai.com/policies/privacy-policy). +- **Azure Document Intelligence:** PDF data is sent to Microsoft Azure. Governed by [Microsoft's privacy policy](https://privacy.microsoft.com) and your Azure service agreement. +- **API keys** are stored in your vault at `.obsidian/plugins/docdrop/data.json`. Do not commit this file to a public repository. + +--- + +## Credits + +This plugin would not be possible without: + +- **[MarkItDown](https://github.com/microsoft/markitdown)** by [Microsoft](https://github.com/microsoft) — the core CLI tool that powers all PDF-to-Markdown conversion. Created by Adam Fourney and the Microsoft AutoGen team. Licensed under the MIT License. + +- **[markitdown-ocr](https://github.com/microsoft/markitdown)** by [Microsoft](https://github.com/microsoft) — the optional OCR plugin for AI-powered image text extraction. Licensed under the MIT License. + +- **[Obsidian](https://obsidian.md)** by Obsidian — the extensible knowledge base application this plugin is built for. + +- **[Azure AI Document Intelligence](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence)** by Microsoft — the optional cloud AI service for high-accuracy document processing. + +- **[OpenAI](https://openai.com)** — the AI platform used by markitdown-ocr for vision-based OCR. + +--- + +## License + +MIT License. See [LICENSE](LICENSE) for details. + +This plugin is not affiliated with, endorsed by, or officially supported by Microsoft, Obsidian, or OpenAI. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..b17bc09 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,40 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..92ce311 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "docdrop", + "name": "DocDrop", + "version": "1.0.0", + "minAppVersion": "1.0.0", + "description": "Convert PDF files to Markdown using the markitdown CLI.", + "author": "Rhys Gottwald", + "authorUrl": "", + "isDesktopOnly": true +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d06a32a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,648 @@ +{ + "name": "markitdown-pdf-converter", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "markitdown-pdf-converter", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "obsidian": "latest" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "builtin-modules": "^4.0.0", + "esbuild": "^0.25.5", + "typescript": "^5.8.3" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz", + "integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..442ebb5 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "docdrop", + "version": "1.0.0", + "description": "Obsidian plugin: convert PDFs to Markdown via Microsoft's markitdown", + "main": "main.js", + "type": "module", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production" + }, + "license": "MIT", + "dependencies": { + "obsidian": "latest" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "builtin-modules": "^4.0.0", + "esbuild": "^0.25.5", + "typescript": "^5.8.3" + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..32c7e15 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,556 @@ +import { + App, + Modal, + Notice, + Plugin, + PluginSettingTab, + Setting, + TAbstractFile, + TFile, + TFolder, +} from "obsidian"; +import { execFile } from "child_process"; + +// ─── Settings ──────────────────────────────────────────────────────────────── + +interface DocDropSettings { + executablePath: string; + outputMode: "same" | "custom"; + customOutputFolder: string; + keepDataUris: boolean; + usePlugins: boolean; + openAiApiKey: string; + openAiBaseUrl: string; + openAiModel: string; + useDocIntel: boolean; + docIntelEndpoint: string; + docIntelApiKey: string; + mimeType: string; + charset: string; +} + +const DEFAULT_SETTINGS: DocDropSettings = { + executablePath: "markitdown", + outputMode: "same", + customOutputFolder: "", + keepDataUris: false, + usePlugins: false, + openAiApiKey: "", + openAiBaseUrl: "", + openAiModel: "gpt-4o", + useDocIntel: false, + docIntelEndpoint: "", + docIntelApiKey: "", + mimeType: "", + charset: "", +}; + +// ─── Overwrite Confirmation Modal ──────────────────────────────────────────── + +class OverwriteModal extends Modal { + private fileName: string; + private onConfirm: () => void; + private onCancel: () => void; + + constructor( + app: App, + fileName: string, + onConfirm: () => void, + onCancel: () => void + ) { + super(app); + this.fileName = fileName; + this.onConfirm = onConfirm; + this.onCancel = onCancel; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "File already exists" }); + contentEl.createEl("p", { + text: `"${this.fileName}" already exists. Overwrite it?`, + }); + + const buttonRow = contentEl.createDiv({ cls: "modal-button-container" }); + + buttonRow + .createEl("button", { text: "Overwrite", cls: "mod-warning" }) + .addEventListener("click", () => { + this.close(); + this.onConfirm(); + }); + + buttonRow + .createEl("button", { text: "Cancel" }) + .addEventListener("click", () => { + this.close(); + this.onCancel(); + }); + } + + onClose(): void { + this.contentEl.empty(); + } +} + +// ─── Settings Tab ──────────────────────────────────────────────────────────── + +class DocDropSettingTab extends PluginSettingTab { + plugin: DocDropPlugin; + + constructor(app: App, plugin: DocDropPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "DocDrop" }); + + new Setting(containerEl) + .setName("Executable path") + .setDesc( + 'Full path to the markitdown binary, or just "markitdown" if it is on your PATH.' + ) + .addText((text) => + text + .setPlaceholder("markitdown") + .setValue(this.plugin.settings.executablePath) + .onChange(async (value) => { + this.plugin.settings.executablePath = value.trim() || "markitdown"; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Output location") + .setDesc("Where to save converted Markdown files.") + .addDropdown((drop) => + drop + .addOption("same", "Same folder as PDF") + .addOption("custom", "Custom folder") + .setValue(this.plugin.settings.outputMode) + .onChange(async (value) => { + this.plugin.settings.outputMode = value as "same" | "custom"; + await this.plugin.saveSettings(); + this.display(); + }) + ); + + if (this.plugin.settings.outputMode === "custom") { + new Setting(containerEl) + .setName("Custom output folder") + .setDesc( + 'Vault-relative path, e.g. "Converted". The folder must already exist.' + ) + .addText((text) => + text + .setPlaceholder("Converted") + .setValue(this.plugin.settings.customOutputFolder) + .onChange(async (value) => { + this.plugin.settings.customOutputFolder = value.trim(); + await this.plugin.saveSettings(); + }) + ); + } + + containerEl.createEl("h3", { text: "Conversion options" }); + + new Setting(containerEl) + .setName("Keep images") + .setDesc( + "Preserve images embedded in the PDF as base64 data inside the Markdown file. " + + "Turning this on makes the output file much larger but keeps all visuals inline. " + + "Leave off if you only need the text." + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.keepDataUris) + .onChange(async (value) => { + this.plugin.settings.keepDataUris = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("MIME type hint") + .setDesc( + "Tells markitdown what kind of file it is receiving, e.g. \"application/pdf\". " + + "Normally not needed — markitdown detects the type automatically. " + + "Only set this if conversion produces wrong results and you suspect a detection failure." + ) + .addText((text) => + text + .setPlaceholder("application/pdf") + .setValue(this.plugin.settings.mimeType) + .onChange(async (value) => { + this.plugin.settings.mimeType = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Character encoding hint") + .setDesc( + "Tells markitdown what text encoding the file uses, e.g. \"UTF-8\" or \"ISO-8859-1\". " + + "Leave blank unless converted text contains garbled characters — markitdown detects encoding automatically." + ) + .addText((text) => + text + .setPlaceholder("UTF-8") + .setValue(this.plugin.settings.charset) + .onChange(async (value) => { + this.plugin.settings.charset = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + // ── markitdown-ocr ────────────────────────────────────────────────────── + + containerEl.createEl("h3", { text: "markitdown-ocr plugin (optional)" }); + + containerEl.createEl("p", { + text: + "markitdown-ocr is a free, separately-installed plugin that uses an AI vision model (like " + + "OpenAI's GPT-4o) to read text from images inside PDFs — useful for scanned documents or " + + "PDFs that are just pictures of pages. Install it first with: pip install markitdown-ocr. " + + "You will need an OpenAI account (or a compatible service) to provide the AI.", + cls: "setting-item-description", + }); + + new Setting(containerEl) + .setName("Enable markitdown-ocr") + .setDesc( + "Activate any installed markitdown plugins, including markitdown-ocr. " + + "Has no effect if markitdown-ocr is not installed. " + + "Install it with: pip install markitdown-ocr" + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.usePlugins) + .onChange(async (value) => { + this.plugin.settings.usePlugins = value; + await this.plugin.saveSettings(); + this.display(); + }) + ); + + if (this.plugin.settings.usePlugins) { + new Setting(containerEl) + .setName("OpenAI API key") + .setDesc( + "Your secret API key from OpenAI (or a compatible service). " + + "Required for markitdown-ocr to call the AI vision model. " + + "Get one at platform.openai.com → API keys. Looks like: sk-proj-..." + ) + .addText((text) => { + text + .setPlaceholder("sk-proj-...") + .setValue(this.plugin.settings.openAiApiKey) + .onChange(async (value) => { + this.plugin.settings.openAiApiKey = value.trim(); + await this.plugin.saveSettings(); + }); + text.inputEl.type = "password"; + }); + + new Setting(containerEl) + .setName("AI model") + .setDesc( + "The vision-capable AI model markitdown-ocr will use to read images. " + + "Must support image/vision input. " + + "\"gpt-4o\" is the default and works well. Other options: gpt-4o-mini (cheaper, slightly less accurate)." + ) + .addText((text) => + text + .setPlaceholder("gpt-4o") + .setValue(this.plugin.settings.openAiModel) + .onChange(async (value) => { + this.plugin.settings.openAiModel = value.trim() || "gpt-4o"; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("OpenAI API base URL (optional)") + .setDesc( + "Override the API server markitdown-ocr connects to. " + + "Leave blank to use the default OpenAI servers. " + + "Set this if you are using Azure OpenAI or a self-hosted compatible service " + + "(e.g. https://your-resource.openai.azure.com/). " + ) + .addText((text) => + text + .setPlaceholder("https://api.openai.com/v1") + .setValue(this.plugin.settings.openAiBaseUrl) + .onChange(async (value) => { + this.plugin.settings.openAiBaseUrl = value.trim(); + await this.plugin.saveSettings(); + }) + ); + } + + // ── Azure Document Intelligence ───────────────────────────────────────── + + containerEl.createEl("h3", { text: "Azure Document Intelligence (optional)" }); + + containerEl.createEl("p", { + text: + "Document Intelligence is a paid Microsoft Azure cloud service that uses AI to read PDFs with " + + "much higher accuracy than offline conversion — especially for scanned documents, handwriting, " + + "tables, and complex layouts. Requires an Azure account. " + + "Leave these blank to use free offline conversion instead.", + cls: "setting-item-description", + }); + + new Setting(containerEl) + .setName("Use Document Intelligence") + .setDesc( + "Send the PDF to Microsoft Azure for conversion instead of processing it locally. " + + "Produces better results for scanned or image-based PDFs, but requires internet access and a paid Azure subscription." + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.useDocIntel) + .onChange(async (value) => { + this.plugin.settings.useDocIntel = value; + await this.plugin.saveSettings(); + this.display(); + }) + ); + + if (this.plugin.settings.useDocIntel) { + new Setting(containerEl) + .setName("Endpoint URL") + .setDesc( + "The URL of your Azure Document Intelligence resource. " + + "Find it in the Azure Portal: open your Document Intelligence resource → Keys and Endpoint. " + + "Looks like: https://your-resource-name.cognitiveservices.azure.com/" + ) + .addText((text) => + text + .setPlaceholder("https://your-resource.cognitiveservices.azure.com/") + .setValue(this.plugin.settings.docIntelEndpoint) + .onChange(async (value) => { + this.plugin.settings.docIntelEndpoint = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("API key") + .setDesc( + "The secret key that authenticates you with Azure Document Intelligence. " + + "Find it in the Azure Portal: open your Document Intelligence resource → Keys and Endpoint → KEY 1 or KEY 2. " + + "Either key works." + ) + .addText((text) => { + text + .setPlaceholder("Your Azure API key") + .setValue(this.plugin.settings.docIntelApiKey) + .onChange(async (value) => { + this.plugin.settings.docIntelApiKey = value.trim(); + await this.plugin.saveSettings(); + }); + text.inputEl.type = "password"; + }); + } + } +} + +// ─── Main Plugin ───────────────────────────────────────────────────────────── + +export default class DocDropPlugin extends Plugin { + settings!: DocDropSettings; + + async onload(): Promise { + await this.loadSettings(); + + this.addSettingTab(new DocDropSettingTab(this.app, this)); + + this.addCommand({ + id: "convert-active-pdf", + name: "Convert active PDF to Markdown", + callback: () => { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + new Notice("No file is currently open."); + return; + } + if (activeFile.extension.toLowerCase() !== "pdf") { + new Notice("The active file is not a PDF."); + return; + } + this.convertPdf(activeFile); + }, + }); + + this.registerEvent( + this.app.workspace.on("file-menu", (menu, file: TAbstractFile) => { + if (!(file instanceof TFile)) return; + if (file.extension.toLowerCase() !== "pdf") return; + + menu.addItem((item) => { + item + .setTitle("Convert to Markdown with DocDrop") + .setIcon("file-text") + .onClick(() => this.convertPdf(file)); + }); + }) + ); + } + + onunload(): void {} + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + private async convertPdf(pdfFile: TFile): Promise { + const adapter = this.app.vault.adapter; + const basePath = (adapter as any).getBasePath() as string; + const absolutePdfPath = `${basePath}/${pdfFile.path}`; + + const outputVaultPath = this.resolveOutputPath(pdfFile); + if (outputVaultPath === null) { + new Notice( + `Custom output folder "${this.settings.customOutputFolder}" does not exist in the vault.` + ); + return; + } + + const existing = this.app.vault.getAbstractFileByPath(outputVaultPath); + if (existing instanceof TFile) { + new OverwriteModal( + this.app, + outputVaultPath, + () => this.runConversion(absolutePdfPath, outputVaultPath, existing), + () => new Notice("Conversion cancelled.") + ).open(); + return; + } + + await this.runConversion(absolutePdfPath, outputVaultPath, null); + } + + private resolveOutputPath(pdfFile: TFile): string | null { + const baseName = pdfFile.basename; + + if (this.settings.outputMode === "same") { + const parentPath = pdfFile.parent?.path ?? ""; + // vault root parent path is "/" in some Obsidian versions; normalise to "" + const folder = parentPath === "/" ? "" : parentPath; + return folder ? `${folder}/${baseName}.md` : `${baseName}.md`; + } + + const folderPath = this.settings.customOutputFolder.replace(/\/$/, ""); + if (folderPath === "") { + return `${baseName}.md`; + } + const folder = this.app.vault.getAbstractFileByPath(folderPath); + if (!(folder instanceof TFolder)) { + return null; + } + return `${folderPath}/${baseName}.md`; + } + + private async runConversion( + absolutePdfPath: string, + outputVaultPath: string, + existingFile: TFile | null + ): Promise { + const progressNotice = new Notice(`Converting "${outputVaultPath}"…`, 0); + + try { + const markdown = await this.invokeMarkItDown(absolutePdfPath); + progressNotice.hide(); + + if (existingFile) { + await this.app.vault.modify(existingFile, markdown); + } else { + await this.app.vault.create(outputVaultPath, markdown); + } + + new Notice(`Converted: ${outputVaultPath}`, 5000); + } catch (err: unknown) { + progressNotice.hide(); + const message = err instanceof Error ? err.message : String(err); + new Notice(`DocDrop failed:\n${message}`, 8000); + console.error("[DocDrop] conversion error", err); + } + } + + private invokeMarkItDown(absolutePdfPath: string): Promise { + const extraPaths = [ + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/Library/Frameworks/Python.framework/Versions/3.12/bin", + "/Library/Frameworks/Python.framework/Versions/3.11/bin", + "/Library/Frameworks/Python.framework/Versions/3.10/bin", + "/usr/bin", + "/bin", + ]; + const augmentedPath = [ + ...(process.env.PATH?.split(":") ?? []), + ...extraPaths, + ].join(":"); + + const args: string[] = [absolutePdfPath]; + if (this.settings.keepDataUris) args.push("--keep-data-uris"); + if (this.settings.usePlugins) args.push("--use-plugins"); + if (this.settings.mimeType) args.push("--mime-type", this.settings.mimeType); + if (this.settings.charset) args.push("--charset", this.settings.charset); + if (this.settings.useDocIntel) { + args.push("--use-docintel"); + if (this.settings.docIntelEndpoint) { + args.push("--endpoint", this.settings.docIntelEndpoint); + } + } + + const extraEnv: Record = { PATH: augmentedPath }; + if (this.settings.useDocIntel && this.settings.docIntelApiKey) { + extraEnv["AZURE_API_KEY"] = this.settings.docIntelApiKey; + } + if (this.settings.usePlugins && this.settings.openAiApiKey) { + extraEnv["OPENAI_API_KEY"] = this.settings.openAiApiKey; + } + if (this.settings.usePlugins && this.settings.openAiModel) { + extraEnv["OPENAI_MODEL"] = this.settings.openAiModel; + } + if (this.settings.usePlugins && this.settings.openAiBaseUrl) { + extraEnv["OPENAI_BASE_URL"] = this.settings.openAiBaseUrl; + } + + return new Promise((resolve, reject) => { + execFile( + this.settings.executablePath, + args, + { + encoding: "utf8", + maxBuffer: 50 * 1024 * 1024, + env: { ...process.env, ...extraEnv }, + }, + (error, stdout, stderr) => { + if (error) { + const detail = stderr?.trim() || error.message; + reject(new Error(`Exit code ${error.code}: ${detail}`)); + return; + } + if (stderr?.trim()) { + console.warn("[DocDrop] stderr:", stderr.trim()); + } + resolve(stdout); + } + ); + }); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..da50182 --- /dev/null +++ b/styles.css @@ -0,0 +1 @@ +/* markitdown-pdf-converter styles */ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2084692 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "lib": ["DOM", "ES5", "ES6", "ES7"], + "moduleResolution": "bundler", + "noImplicitAny": true, + "strictNullChecks": true, + "useUnknownInCatchVariables": true + }, + "include": ["src/**/*.ts"] +}