embed-clip-images

Download all assets from an obsidian clipper directory.

#!/usr/bin/env python3
"""
Download remote images in clipped Obsidian markdown files to a local attachments
folder and rewrite the links in place.

Already-local links (relative paths, data: URIs) are left untouched, making
the script safe to re-run.

Usage:
  embed-clip-images [--attachments DIR] file.md [file2.md ...]
  embed-clip-images [--attachments DIR] /path/to/dir/   # recurse all .md files

Defaults:
  --attachments  ~/Documents/notes/src/to-read/attachments
"""

import argparse
import hashlib
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

DEFAULT_ATTACHMENTS = Path.home() / "Documents/notes/src/to-read/attachments"
DEFAULT_CONTENT_ROOT = Path.home() / "Documents/notes/src"

IMAGE_RE = re.compile(r'(!\[[^\]]*\]\()([^)]+)(\))')


def is_remote(url: str) -> bool:
    return url.startswith("http://") or url.startswith("https://")


def local_filename(url: str, attachments: Path) -> Path:
    """Derive a stable local filename from a URL, avoiding collisions."""
    parsed = urllib.parse.urlparse(url)
    basename = Path(parsed.path).name or "image"
    # strip query string from name but keep extension
    basename = urllib.parse.unquote(basename).split("?")[0]
    if not basename or basename == "/":
        basename = "image"
    # prefix with a short hash so same-name images from different sites don't clash
    short_hash = hashlib.sha1(url.encode()).hexdigest()[:8]
    stem = Path(basename).stem[:60]  # cap length
    suffix = Path(basename).suffix or ".jpg"
    return attachments / f"{stem}-{short_hash}{suffix}"


def download(url: str, dest: Path) -> bool:
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            dest.write_bytes(resp.read())
        return True
    except (urllib.error.URLError, OSError) as e:
        print(f"  WARN: could not download {url}: {e}", file=sys.stderr)
        return False


def relative_link(image: Path, content_root: Path) -> str:
    return image.relative_to(content_root).as_posix()


def process_file(note: Path, attachments: Path, content_root: Path) -> None:
    content = note.read_text(encoding="utf-8")
    matches = IMAGE_RE.findall(content)
    remote = [(pre, url, post) for pre, url, post in matches if is_remote(url)]

    if not remote:
        print(f"skip (no remote images): {note.name}")
        return

    attachments.mkdir(parents=True, exist_ok=True)
    changed = False

    def replace(m: re.Match) -> str:
        nonlocal changed
        pre, url, post = m.group(1), m.group(2), m.group(3)
        if not is_remote(url):
            return m.group(0)
        dest = local_filename(url, attachments)
        if not dest.exists():
            print(f"  downloading: {url}")
            if not download(url, dest):
                return m.group(0)  # leave original on failure
        link = relative_link(dest, content_root)
        changed = True
        return f"{pre}{link}{post}"

    new_content = IMAGE_RE.sub(replace, content)

    if changed:
        note.write_text(new_content, encoding="utf-8")
        print(f"updated:                 {note.name}")


def collect_files(paths: list[str]) -> list[Path]:
    files = []
    for arg in paths:
        p = Path(arg)
        if p.is_dir():
            files.extend(sorted(p.rglob("*.md")))
        elif p.is_file():
            files.append(p)
        else:
            print(f"not found: {arg}", file=sys.stderr)
    return files


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__,
                                     formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--attachments", type=Path, default=DEFAULT_ATTACHMENTS,
                        help="Directory to save downloaded images (default: %(default)s)")
    parser.add_argument("--content-root", type=Path, default=DEFAULT_CONTENT_ROOT,
                        help="Quartz content root; image links are relative to this (default: %(default)s)")
    parser.add_argument("paths", nargs="+", metavar="FILE_OR_DIR")
    args = parser.parse_args()

    for f in collect_files(args.paths):
        process_file(f, args.attachments, args.content_root)


if __name__ == "__main__":
    main()
download embed-clip-images