From ef411fbe232ba3cc2d8626404e06c17d39e517b3 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Tue, 26 May 2026 14:27:01 +0200 Subject: [PATCH] initial commit --- importFileToObsidian/README.md | 165 ++++++++++++++++++ .../__pycache__/main.cpython-314.pyc | Bin 0 -> 2544 bytes .../__pycache__/md_handler.cpython-314.pyc | Bin 0 -> 1629 bytes .../__pycache__/pdf_handler.cpython-314.pyc | Bin 0 -> 4583 bytes importFileToObsidian/handlers/md_handler.py | 29 +++ importFileToObsidian/handlers/pdf_handler.py | 98 +++++++++++ importFileToObsidian/import-to-obsidian.sh | 12 ++ importFileToObsidian/main.py | 59 +++++++ importFileToObsidian/requirements.txt | 1 + .../utils/__pycache__/config.cpython-314.pyc | Bin 0 -> 3547 bytes importFileToObsidian/utils/config.py | 63 +++++++ 11 files changed, 427 insertions(+) create mode 100644 importFileToObsidian/README.md create mode 100644 importFileToObsidian/__pycache__/main.cpython-314.pyc create mode 100644 importFileToObsidian/handlers/__pycache__/md_handler.cpython-314.pyc create mode 100644 importFileToObsidian/handlers/__pycache__/pdf_handler.cpython-314.pyc create mode 100644 importFileToObsidian/handlers/md_handler.py create mode 100644 importFileToObsidian/handlers/pdf_handler.py create mode 100755 importFileToObsidian/import-to-obsidian.sh create mode 100755 importFileToObsidian/main.py create mode 100644 importFileToObsidian/requirements.txt create mode 100644 importFileToObsidian/utils/__pycache__/config.cpython-314.pyc create mode 100644 importFileToObsidian/utils/config.py diff --git a/importFileToObsidian/README.md b/importFileToObsidian/README.md new file mode 100644 index 0000000..0613031 --- /dev/null +++ b/importFileToObsidian/README.md @@ -0,0 +1,165 @@ +# Obsidian Import Utility + +Import **Markdown** and **PDF** files into an [Obsidian](https://obsidian.md) vault with a single click. + +## Features + +- **.md files** + - Copies contents into a new Obsidian note. + - Uses the filename as the note title (prepended as `# Title`). + +- **.pdf files** + - Converts PDF text into a markdown note. + - Extracts embedded images and embeds them in the note. + - Creates a **hidden folder** per file (e.g. `.myfile_assets/`) containing: + - The original PDF + - All extracted images + - Links back to the original PDF from the note. + +## Installation + +1. Install Python 3 (usually already present on Linux). +2. Install the required dependency: + + ```bash + pip install -r requirements.txt + ``` + + Or directly: + ```bash + pip install pymupdf + ``` + +3. Tell the script where your Obsidian vault lives. Choose **one** method: + + - **Environment variable** (recommended for Thunar): + ```bash + export OBSIDIAN_VAULT="$HOME/Obsidian/MyVault" + ``` + - **Command-line flag**: + ```bash + python main.py --vault ~/Obsidian/MyVault /path/to/file.pdf + ``` + - **Auto-detect**: if you have exactly one folder inside `~/Obsidian`, it will be picked automatically. + +## Usage + +### From the terminal + +```bash +# Markdown +python main.py /path/to/notes.md + +# PDF +python main.py /path/to/document.pdf + +# With explicit vault +python main.py --vault ~/Obsidian/Work /path/to/slides.pdf +``` + +### With the wrapper script + +```bash +./import-to-obsidian.sh /path/to/file.pdf +``` + +Edit `VAULT_PATH=""` inside `import-to-obsidian.sh` if you prefer a hard-coded default. + +--- + +## Thunar Custom Action + +To add a right-click menu entry in **Thunar** (Xfce file manager): + +### Option A – Using the Thunar GUI + +1. Open Thunar. +2. Go to **Edit → Configure custom actions…** +3. Click **+** (Add a new custom action). +4. Fill in the fields: + + | Field | Value | + |-------|-------| + | **Name** | Import to Obsidian | + | **Description** | Import selected file into Obsidian vault | + | **Command** | `/usr/bin/python3 /full/path/to/main.py --vault /home/USER/Obsidian/MyVault %F` | + | **Icon** | `folder-documents` (or any icon you like) | + +5. Switch to the **Appearance Conditions** tab. +6. Check **Text files** and **Other files** (or just `*.md;*.pdf` if you want to restrict it). +7. Click **OK**. + +> **Tip:** `%F` passes all selected files. If you want the action to appear only for single selections you can use `%f` instead. + +### Option B – Editing `uca.xml` directly + +Paste the following snippet into `~/.config/Thunar/uca.xml` inside the `` block (adjust paths): + +```xml + + folder-documents + Import to Obsidian + /usr/bin/python3 /home/USER/Code/projects/obsidianUtils/importFileToObsidian/main.py --vault /home/USER/Obsidian/MyVault %F + Import selected file(s) into Obsidian vault + *.md;*.pdf + + + +``` + +Restart Thunar or press `Ctrl+Shift+R` inside a Thunar window to reload custom actions. + +--- + +## Environment Variable in `.bashrc` + +If you want the vault path to be available everywhere (including Thunar when launched from your shell), add this to `~/.bashrc` or `~/.profile`: + +```bash +export OBSIDIAN_VAULT="$HOME/Obsidian/MyVault" +``` + +> Note: Thunar started from a graphical session (not from a terminal) may not see variables defined in `.bashrc`. In that case, hard-code the `--vault` flag in the custom action command. + +--- + +## File Structure After Import + +### Markdown import + +``` +MyVault/ +└── notes.md # imported note with '# notes' as title +``` + +### PDF import + +``` +MyVault/ +├── document.md +└── .document_assets/ + ├── document.pdf + ├── page_1_img_1.png + ├── page_1_img_2.jpg + └── page_2_img_1.png +``` + +The generated `document.md` contains: +- The extracted text per page. +- Embedded images under an "Extracted Images" section. +- A link back to the original PDF inside the hidden assets folder. + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `Vault path does not exist` | Double-check `--vault` or `OBSIDIAN_VAULT`. | +| `PDF handling requires 'pymupdf'` | Run `pip install pymupdf`. | +| Thunar action does not appear | Make sure the file pattern matches (`*.md;*.pdf`) and restart Thunar (`Ctrl+Shift+R`). | +| Images are missing in the note | `pymupdf` extracts raw embedded images; vector graphics or scanned pages may not yield separate image files. | + +## License + +MIT (or choose your own). diff --git a/importFileToObsidian/__pycache__/main.cpython-314.pyc b/importFileToObsidian/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f87382b9f9c36e809d1d2b38433ac04b8152b69 GIT binary patch literal 2544 zcmcIm-*3}a6uyq_*s8(Q5rL?KWI$+%Z!P=@}1tJ3(NChj@Zfi&!%XUhe zNn0PLiA~c8+5>$AX%g&Dm^6upi9NTpBbisENn`J2?WBpl>|7^qyB|n<*sf&XbIF$iQ!bhOlmeFpp&9pesnG(v(AaeZUDoA4t=qjKg#g z8=YoHgHF(snKU=bJG>{e>CmX)2pvcc#*rgl4$It;iwLO@yA53yJJCQR6dv>VtcIvRe3e8$KXG@JvI?&P&(-Pcqn>%PW}2GPN*kh89<#T!O1 z)@B_mH{oEtkekx%UYysB>$qs?Q@WAMAAY8D)MAE?6oO7m&2Ds}+3KHozbIx?X zQ|u>^QxN8#Kz)7QJn;Md{>1uE&t=Kf{^DOyg)>RYWis$l=$of=hMLzDKUYu*4HZO! zQPY&>DBiaflE;L<Sb|jO|~I-Zy|iC6J4SF zxpbgbG~Vi4VBT%Myw&?ylZO29mfn{#318ak{q-=}EGtXi`Gl2$S#|lCY~Uh2u(^ve zmq37Q+J^L(1N|MjZEc#fY#7OrgY>pJ|A(tX^1^6*pPzLQsmIAYOlDgS;q%P@w?FMj z=1k@|Gxo6F>y(b(q}!2BC0ok-{{?fU;Eal@W=~kU>F7nnWe81_Tgmw*B;-tMm}rQs zd&EdQm}xCIz#_ z>r$Gv2jDhbwusyd7`t5KQg9g}Se;-Y$H6`ggBv6K4G;92;&!kJ`kMF8WdTzzC+l#Vp7!ufT<=8;X*YTuxEc;)J4D z-VW;LzOBG*((M&x@m#Z-87LBal<9}d$CjS6Edk!)ys=) zHi=ArJ{09kv<_orVNd&3p{SPf+A-?@451c~eFR=>G(}O*nGnUS??P1M2P8a2yMIRg mPtk#=sQZ^-Cly^Ar4SRoSze*{e9!P7jXb9jv)AeY=RW`=a5uI9 literal 0 HcmV?d00001 diff --git a/importFileToObsidian/handlers/__pycache__/md_handler.cpython-314.pyc b/importFileToObsidian/handlers/__pycache__/md_handler.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cb6afcb31ff62727b449879dd984635c850622d0 GIT binary patch literal 1629 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 KiT{^iDE=3-x&^WT literal 0 HcmV?d00001 diff --git a/importFileToObsidian/handlers/md_handler.py b/importFileToObsidian/handlers/md_handler.py new file mode 100644 index 0000000..15f2b6b --- /dev/null +++ b/importFileToObsidian/handlers/md_handler.py @@ -0,0 +1,29 @@ +"""Handler for importing Markdown files into Obsidian.""" + +import shutil +from pathlib import Path + +from utils.config import ensure_unique_path, sanitize_filename + + +def handle_markdown(file_path: Path, vault_path: Path) -> Path: + """Import a Markdown file into the Obsidian vault. + + - Copies contents to a new note. + - Uses the filename (without extension) as the note title. + - Prepends a title heading if the file does not already start with it. + """ + title = sanitize_filename(file_path.stem) + target_name = f"{title}.md" + target_path = ensure_unique_path(vault_path / target_name) + + content = file_path.read_text(encoding="utf-8") + + # Prepend title if the file doesn't already start with it as an H1 + first_line = content.splitlines()[0] if content.strip() else "" + if not first_line.strip().startswith(f"# {title}"): + content = f"# {title}\n\n{content}" + + target_path.write_text(content, encoding="utf-8") + print(f"[MD] Created note: {target_path}") + return target_path diff --git a/importFileToObsidian/handlers/pdf_handler.py b/importFileToObsidian/handlers/pdf_handler.py new file mode 100644 index 0000000..c9fe6f9 --- /dev/null +++ b/importFileToObsidian/handlers/pdf_handler.py @@ -0,0 +1,98 @@ +"""Handler for importing PDF files into Obsidian.""" + +import shutil +from pathlib import Path + +from utils.config import ensure_unique_path, sanitize_filename + + +def handle_pdf(file_path: Path, vault_path: Path) -> Path: + """Import a PDF file into the Obsidian vault. + + - Converts PDF content to a new Obsidian markdown note. + - Creates a hidden folder for extracted images and the original PDF. + - Embeds extracted images and links to the original PDF. + """ + try: + import fitz # pymupdf + except ImportError: + raise ImportError( + "PDF handling requires 'pymupdf'. Install it with:\n pip install pymupdf" + ) + + title = sanitize_filename(file_path.stem) + note_name = f"{title}.md" + note_path = ensure_unique_path(vault_path / note_name) + + # Hidden assets folder: .{title}_assets + assets_folder_name = f".{title}_assets" + assets_folder = vault_path / assets_folder_name + counter = 1 + while assets_folder.exists(): + assets_folder_name = f".{title}_assets_{counter}" + assets_folder = vault_path / assets_folder_name + counter += 1 + assets_folder.mkdir(parents=True, exist_ok=True) + + # Copy original PDF into assets folder + pdf_copy_name = f"{title}.pdf" + pdf_copy_path = ensure_unique_path(assets_folder / pdf_copy_name) + shutil.copy2(file_path, pdf_copy_path) + + # Open PDF and extract content + images + doc = fitz.open(file_path) + markdown_lines = [f"# {title}", ""] + + # Link to original PDF + rel_pdf_path = f"{assets_folder_name}/{pdf_copy_path.name}" + markdown_lines.append(f"**Original PDF:** [{pdf_copy_path.name}]({rel_pdf_path})") + markdown_lines.append("") + + # Extract images and text per page + image_entries = [] # list of (page_num, img_path, rel_path) + for page_num in range(len(doc)): + page = doc.load_page(page_num) + + # Extract images + img_list = page.get_images(full=True) + for img_index, img in enumerate(img_list): + xref = img[0] + try: + base_image = doc.extract_image(xref) + image_bytes = base_image["image"] + ext = base_image["ext"] + img_filename = f"page_{page_num + 1}_img_{img_index + 1}.{ext}" + img_path = assets_folder / img_filename + # Handle duplicates + img_path = ensure_unique_path(img_path) + img_path.write_bytes(image_bytes) + rel_img = f"{assets_folder_name}/{img_path.name}" + image_entries.append((page_num + 1, img_path, rel_img)) + except Exception as e: + print( + f"[PDF] Warning: could not extract image {img_index} on page {page_num + 1}: {e}" + ) + + # Extract text + text = page.get_text() + if text.strip(): + markdown_lines.append(f"## Page {page_num + 1}") + markdown_lines.append("") + markdown_lines.append(text.strip()) + markdown_lines.append("") + + doc.close() + + # Add image section if any images were extracted + if image_entries: + markdown_lines.append("## Extracted Images") + markdown_lines.append("") + for page_num, img_path, rel_img in image_entries: + markdown_lines.append(f"### Page {page_num}") + markdown_lines.append(f"![{img_path.name}]({rel_img})") + markdown_lines.append("") + + note_path.write_text("\n".join(markdown_lines), encoding="utf-8") + print(f"[PDF] Created note: {note_path}") + print(f"[PDF] Assets folder: {assets_folder}") + return note_path diff --git a/importFileToObsidian/import-to-obsidian.sh b/importFileToObsidian/import-to-obsidian.sh new file mode 100755 index 0000000..4f7f6ac --- /dev/null +++ b/importFileToObsidian/import-to-obsidian.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Wrapper script for importing files into Obsidian. +# Adjust VAULT_PATH below or leave empty to rely on ~/.bashrc / env / auto-detection. + +VAULT_PATH="" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +if [ -n "$VAULT_PATH" ]; then + python3 "$SCRIPT_DIR/main.py" --vault "$VAULT_PATH" "$@" +else + python3 "$SCRIPT_DIR/main.py" "$@" +fi diff --git a/importFileToObsidian/main.py b/importFileToObsidian/main.py new file mode 100755 index 0000000..036b520 --- /dev/null +++ b/importFileToObsidian/main.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Obsidian Import Utility + +Import files into an Obsidian vault: + - .md -> copy contents to a new note (filename as title) + - .pdf -> convert to markdown, extract images, link original PDF + +Usage: + python main.py /path/to/file.pdf + python main.py --vault ~/Obsidian/MyVault /path/to/file.md +""" + +import argparse +import sys +from pathlib import Path + +from handlers.md_handler import handle_markdown +from handlers.pdf_handler import handle_pdf +from utils.config import get_vault_path + + +def main(): + parser = argparse.ArgumentParser(description="Import files into an Obsidian vault.") + parser.add_argument("file", help="Path to the file to import.") + parser.add_argument( + "--vault", + dest="vault", + default=None, + help="Path to the Obsidian vault. Overrides OBSIDIAN_VAULT env var.", + ) + args = parser.parse_args() + + file_path = Path(args.file).expanduser().resolve() + if not file_path.exists(): + print(f"Error: file not found: {file_path}", file=sys.stderr) + sys.exit(1) + + try: + vault_path = get_vault_path(args.vault) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + suffix = file_path.suffix.lower() + + if suffix == ".md": + handle_markdown(file_path, vault_path) + elif suffix == ".pdf": + handle_pdf(file_path, vault_path) + else: + print( + f"Error: unsupported file type '{suffix}'. Supported: .md, .pdf", + file=sys.stderr, + ) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/importFileToObsidian/requirements.txt b/importFileToObsidian/requirements.txt new file mode 100644 index 0000000..eee9252 --- /dev/null +++ b/importFileToObsidian/requirements.txt @@ -0,0 +1 @@ +pymupdf diff --git a/importFileToObsidian/utils/__pycache__/config.cpython-314.pyc b/importFileToObsidian/utils/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..324ef8248bb4a3ed9b12e4ef6eeffe42a43cf578 GIT binary patch literal 3547 zcmcH*U2hx5aqojiQKU#p)R*;PoovSeZHclRtBoaFjwD+)V^wA^F_WUQ*fV*Qt|9L( zdq-6vjDQb)pp+C+;ug|_Tj(JV2~eOYP#~{<$Pb_*3u3PdY9IyLzR6Vzqi>yE@+esq z(iTMrxV_n(+1Z($*_k=-Z>UEAPgYxg{2MnyUz3RgTou{>6(9v9Aq&kSiHR~61AjKk z-r_8-hpcf?{+7#f^`LuP%;#)B`1hhq)rrLSc@Nel*9$Y@@XT^c4U)SJNp)=~6U;DH zo#cU8J(*b^$qTrT;`I`98ioD&?@s7iTAj;b*-~{)l(m$Y)U#PYbC#M>Embkaw2s9) z_f0jW%9^NV4INvN>CYIOkIB}2n6c~b7-X}Y$-5&-DlQ+;tNi^H{(OCf5K%QX$$%~7 zBf!H)Zft)Nh60MCS@zYRB9wx)F7$n7o{^Xtpj2Rnb+En;2SaeDGeio70T!&{{z~|4 zPcow>7UW#UvIVSIIjn^_OgL@Mv@j;8g7N}bmz6D(D0IpW5Ub6BsgC$U6cK&0yk83C{+$#I9DmBGci z|KvVXI>muh! zo@WkGg@e@`+XMC(yo$o^{FJ1adS*cpt$F1jZ_tM*8jvx>G}g1?#8gz2@m!y%Xba+k zjQd2$GASiZ-m93=lQbWhk$h*IuF#Ds9kLNTMp0Q*O>0=R{ddOSh+dD5O()`Gcc+F(Ix!jZz0q)!I-3q ze1Y0-yv%>mCBS%mLA(H5|E?fH=>x6`R7xKv4zqbp&MLSL&@h8Zc84GJ60LI7leCu6 z;T+ISQcp=ib1_}d$na=lkNiHFpflV66Z_4QYrZG2lI>$Vs)71NEHGUzC)r4jBcW7gmgnoK(c*yo}`}guE%{G*eD1v`tCF zb0M}v;_zG<9X>TM^WfS!n@Qx^h!|$c`=YyT7jyus0sX)kP!67swjgKJl+7mRO`;7% zd?nl)O<98`D6b?O$@o0b>6KL+^wi%`xcLWvOOY)HLL|D^$2YjGz?o(K8HWO`PrZSa z;ici#)Rwoi#CINCq;o+$h$Bbt|1bU)EJ&q_MLmwm6@Qz*F9N;h?*^vj8G_-FwMTxx z=IQJ}P2iTU3rtB^9_QiX%T^qnCj3>;SLM+k#0-iN$wz=j5DDZXU~sdH!FM9k zq(G~}`%s*Pc7-=wZw=R|f zP@t5^&?rN0Rj_F3Vpe`f5mgI9QGGY3MDSUV6Xr2#18m;3l&me7xpZ1xv;{-PPzS

rY?@BVRhG&zanK`T^D#unbGU$z{ zi=h_*Pd)WEu3TNZx^iv#S~<{CY$(HgWND;uz1$F5xwmw$FnNIZ1BILAdVjgKV>P}$ zxc=69bR)g-b}1Ao)|Hz(wwqrnddh+Joj}iKpl2u0`(>cF9O{C!Sgs4K4*txy?51%O zW~Rel+nq?H^d$7mq`k4-q*!Fs`<0R>SnnCCHbo+Dz1y4-enVyW^OvJ|C zyhUpZeQuc8^ckYkEAO)IN>)$hGRi0(0TOvj=2aN>IEG< Path: + """Resolve the Obsidian vault path from CLI arg, env var, or default locations.""" + if cli_vault: + vault = Path(cli_vault).expanduser().resolve() + if not vault.exists(): + raise FileNotFoundError(f"Vault path does not exist: {vault}") + return vault + + env_vault = os.environ.get("OBSIDIAN_VAULT") + if env_vault: + vault = Path(env_vault).expanduser().resolve() + if vault.exists(): + return vault + + # Try common default locations + obsidian_dir = Path.home() / "Obsidian" + if obsidian_dir.exists(): + vaults = [d for d in obsidian_dir.iterdir() if d.is_dir()] + if len(vaults) == 1: + return vaults[0] + elif len(vaults) > 1: + raise RuntimeError( + f"Multiple vaults found in {obsidian_dir}. " + "Set OBSIDIAN_VAULT or use --vault." + ) + + raise RuntimeError( + "Could not determine Obsidian vault path. " + "Use --vault or set OBSIDIAN_VAULT environment variable." + ) + + +def sanitize_filename(name: str) -> str: + """Remove or replace characters unsafe for filenames.""" + invalid = '<>:"/\\|?*' + for ch in invalid: + name = name.replace(ch, '_') + return name.strip('. ') + + +def ensure_unique_path(target: Path) -> Path: + """If target exists, append a counter to make it unique.""" + if not target.exists(): + return target + stem = target.stem + suffix = target.suffix + parent = target.parent + counter = 1 + while True: + new_name = f"{stem}_{counter}{suffix}" + candidate = parent / new_name + if not candidate.exists(): + return candidate + counter += 1