yashmalik

Quartz Obsidian Copy Script

Aug 6th, 2026
38
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 6.14 KB | Source Code | 0 0
  1. #!/usr/bin/env python3
  2. """Copy attachments referenced by the markdown files in Quartz's content/
  3. folder from the Obsidian vault.
  4.  
  5. Handles both Obsidian-style embeds/links and standard markdown links:
  6.  
  7.    ![[Pasted image 2024.png]]          (wiki embed)
  8.    ![[Pasted image 2024.png|300]]      (wiki embed with options)
  9.    [[document.pdf]]                    (wiki link to an attachment)
  10.    ![alt text](Attachments/img.png)    (markdown image)
  11.    [label](Attachments/document.pdf)   (markdown link to an attachment)
  12.  
  13. Files are copied into content/ preserving their path relative to the vault
  14. root (e.g. Attachments/foo.png -> content/Attachments/foo.png) so existing
  15. links keep working.
  16.  
  17. Usage:
  18.    ./scripts/copy_attachments.py [--vault PATH] [--content PATH] [--dry-run]
  19. """
  20.  
  21. import argparse
  22. import re
  23. import shutil
  24. import sys
  25. from pathlib import Path
  26. from urllib.parse import unquote
  27.  
  28. # Extensions treated as attachments (everything else is assumed to be a note).
  29. ATTACHMENT_EXTS = {
  30.     ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
  31.     ".avif", ".tiff", ".heic",
  32.     ".pdf", ".zip", ".gz", ".tar", ".7z", ".rar",
  33.     ".mp3", ".mp4", ".mov", ".wav", ".ogg", ".webm", ".m4a", ".flac",
  34.     ".excalidraw", ".canvas",
  35. }
  36.  
  37. # Directories to ignore when indexing the vault.
  38. IGNORED_DIRS = {".git", ".obsidian", ".trash", ".smart-env", ".claude", "node_modules"}
  39.  
  40. # --- Reference patterns ------------------------------------------------------
  41.  
  42. FENCED_CODE_RE = re.compile(r"```.*?```|~~~.*?~~~", re.DOTALL)
  43. WIKI_RE = re.compile(r"(!?)\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]")
  44. MD_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
  45.  
  46.  
  47. def strip_code_blocks(text: str) -> str:
  48.     """Remove fenced code blocks so examples inside them are not matched."""
  49.     return FENCED_CODE_RE.sub("", text)
  50.  
  51.  
  52. def find_references(md_file: Path) -> list[str]:
  53.     """Return all attachment targets referenced by a markdown file."""
  54.     text = strip_code_blocks(md_file.read_text(encoding="utf-8"))
  55.     refs: list[str] = []
  56.  
  57.     for _is_embed, target in WIKI_RE.findall(text):
  58.         target = target.strip()
  59.         # Only attachments: embeds of notes (no extension) are transclusions.
  60.         if Path(target).suffix.lower() in ATTACHMENT_EXTS:
  61.             refs.append(target)
  62.  
  63.     for raw_target in MD_RE.findall(text):
  64.         target = unquote(raw_target.strip().strip("<>")).split("#")[0].strip()
  65.         if not target or "://" in target or target.startswith("mailto:"):
  66.             continue
  67.         if Path(target).suffix.lower() in ATTACHMENT_EXTS:
  68.             refs.append(target)
  69.  
  70.     return refs
  71.  
  72.  
  73. def index_vault(vault: Path) -> tuple[dict[str, Path], list[Path]]:
  74.     """Index vault files by basename; also keep the full list for path matching."""
  75.     by_name: dict[str, Path] = {}
  76.     all_files: list[Path] = []
  77.     for path in vault.rglob("*"):
  78.         if not path.is_file():
  79.             continue
  80.         if any(part in IGNORED_DIRS for part in path.relative_to(vault).parts):
  81.             continue
  82.         all_files.append(path)
  83.         by_name.setdefault(path.name, path)  # first match wins
  84.     return by_name, all_files
  85.  
  86.  
  87. def resolve(ref: str, vault: Path, by_name: dict[str, Path],
  88.             all_files: list[Path]) -> Path | None:
  89.     """Resolve an attachment reference to a file inside the vault."""
  90.     ref_path = Path(ref)
  91.  
  92.     # 1. Reference contains a path -> match by vault-relative path (or suffix).
  93.     if len(ref_path.parts) > 1:
  94.         candidate = vault / ref
  95.         if candidate.is_file():
  96.             return candidate
  97.         for path in all_files:
  98.             rel = path.relative_to(vault)
  99.             if str(rel).endswith(ref) or rel.parts[-len(ref_path.parts):] == ref_path.parts:
  100.                 return path
  101.         return None
  102.  
  103.     # 2. Bare filename -> match by basename.
  104.     return by_name.get(ref)
  105.  
  106.  
  107. def main() -> int:
  108.     repo_root = Path(__file__).resolve().parent.parent
  109.     parser = argparse.ArgumentParser(description=__doc__,
  110.                                      formatter_class=argparse.RawDescriptionHelpFormatter)
  111.     parser.add_argument("--vault", type=Path, default=Path.home() / "Documents/obsidian",
  112.                         help="Path to the Obsidian vault (default: ~/Documents/obsidian)")
  113.     parser.add_argument("--content", type=Path, default=repo_root / "content",
  114.                         help="Path to Quartz's content folder (default: <repo>/content)")
  115.     parser.add_argument("--dry-run", action="store_true",
  116.                         help="Show what would be copied without copying")
  117.     args = parser.parse_args()
  118.  
  119.     vault: Path = args.vault.expanduser().resolve()
  120.     content: Path = args.content.expanduser().resolve()
  121.  
  122.     if not vault.is_dir():
  123.         sys.exit(f"error: vault not found: {vault}")
  124.     if not content.is_dir():
  125.         sys.exit(f"error: content folder not found: {content}")
  126.  
  127.     md_files = sorted(content.rglob("*.md"))
  128.     print(f"Scanning {len(md_files)} markdown file(s) in {content}")
  129.  
  130.     by_name, all_files = index_vault(vault)
  131.  
  132.     copied, up_to_date, missing = 0, 0, []
  133.     seen: set[Path] = set()
  134.  
  135.     for md_file in md_files:
  136.         for ref in find_references(md_file):
  137.             src = resolve(ref, vault, by_name, all_files)
  138.             if src is None:
  139.                 missing.append((md_file.name, ref))
  140.                 continue
  141.             if src in seen:
  142.                 continue
  143.             seen.add(src)
  144.  
  145.             dest = content / src.relative_to(vault)
  146.             if dest.exists() and dest.read_bytes() == src.read_bytes():
  147.                 up_to_date += 1
  148.                 continue
  149.  
  150.             print(f"{'[dry-run] ' if args.dry_run else ''}copy: {src} -> {dest}")
  151.             if not args.dry_run:
  152.                 dest.parent.mkdir(parents=True, exist_ok=True)
  153.                 shutil.copy2(src, dest)
  154.             copied += 1
  155.  
  156.     print(f"\n{copied} copied, {up_to_date} already up to date, {len(missing)} missing")
  157.     for md_name, ref in missing:
  158.         print(f"  WARNING: '{ref}' referenced in '{md_name}' not found in vault", file=sys.stderr)
  159.  
  160.     return 1 if missing else 0
  161.  
  162.  
  163. if __name__ == "__main__":
  164.     sys.exit(main())
  165.  
Advertisement
Add Comment
Please, Sign In to add comment