|
#!/usr/bin/env python3 |
|
"""Convert an UpNote markdown export into Obsidian "Clippings" notes. |
|
|
|
UpNote exports each web clipping as a UUID-named .md file with no frontmatter: |
|
a `#`/`##` title line, the source URL as a markdown link, then the article body. |
|
Notebook membership is recorded only as symlinks under `notebooks/<Name>/`. |
|
|
|
This script derives YAML frontmatter (categories/title/url/publication/published/ |
|
clipped/description/author/tags) from the file content plus the symlink map, renames |
|
each note to its (cleaned) title, and writes the result to an output `Clippings/` |
|
directory, leaving the original UUID export untouched. |
|
|
|
Run: uv run upnote_to_obsidian.py [INPUT_DIR] [-o OUTPUT_DIR] [--dry-run] |
|
Default INPUT_DIR is ./upnote-markdown, OUTPUT_DIR is INPUT_DIR/Clippings. |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import os |
|
import re |
|
import sys |
|
from collections import defaultdict |
|
from datetime import date |
|
from pathlib import Path |
|
from urllib.parse import urlparse |
|
|
|
# --- Domain -> human publication name. A seed list: extend it with your own |
|
# sources. Unknown domains fall back to the title's trailing " - <source>" |
|
# segment, then to the bare domain. --- |
|
DOMAIN_PUBLICATION = { |
|
"washingtonpost.com": "The Washington Post", |
|
"nytimes.com": "The New York Times", |
|
"reuters.com": "Reuters", |
|
"propublica.org": "ProPublica", |
|
"theguardian.com": "The Guardian", |
|
"politico.eu": "POLITICO", |
|
"politico.com": "POLITICO", |
|
"theatlantic.com": "The Atlantic", |
|
"time.com": "TIME", |
|
"nature.com": "Nature", |
|
"inquirer.com": "The Philadelphia Inquirer", |
|
"academic.oup.com": "Oxford Academic", |
|
"nybooks.com": "The New York Review of Books", |
|
} |
|
|
|
# Title/publication separators, longest/most-specific first. |
|
TITLE_SEPARATORS = [" — ", " – ", " - ", " | "] |
|
|
|
# A standalone date line, e.g. "November 26, 2025" or "2025-04-25". |
|
MONTHS = {m.lower(): i for i, m in enumerate( |
|
["January", "February", "March", "April", "May", "June", "July", |
|
"August", "September", "October", "November", "December"], start=1)} |
|
RE_TEXT_DATE = re.compile( |
|
r"^\s*(January|February|March|April|May|June|July|August|September|" |
|
r"October|November|December)\s+(\d{1,2}),\s+(\d{4})\s*$", re.IGNORECASE) |
|
RE_ISO_DATE = re.compile(r"^\s*(\d{4})-(\d{2})-(\d{2})\s*$") |
|
|
|
# First markdown link on a line: [label](target) |
|
RE_MD_LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)") |
|
# Author credit line, e.g. "By PETER EISLER, NED PARKER and JOSEPH TANFANI" |
|
RE_BY_LINE = re.compile(r"^\s*By\s+(?P<author>[^.]+?)\s*$", re.IGNORECASE) |
|
|
|
ILLEGAL_FS_CHARS = re.compile(r'[/\\:*?"<>|]') |
|
|
|
|
|
def slugify_tag(name: str) -> str: |
|
"""Notebook name -> kebab-case Obsidian tag (no spaces allowed in tags).""" |
|
s = name.strip().lower() |
|
s = re.sub(r"[^\w\s-]", "", s) # drop punctuation |
|
s = re.sub(r"[\s_]+", "-", s) # spaces/underscores -> hyphen |
|
return re.sub(r"-{2,}", "-", s).strip("-") |
|
|
|
|
|
def sanitize_filename(title: str, maxlen: int = 120) -> str: |
|
"""Title -> safe filename stem (illegal chars removed, whitespace collapsed).""" |
|
s = ILLEGAL_FS_CHARS.sub("", title) |
|
s = re.sub(r"\s+", " ", s).strip().strip(".") |
|
return s[:maxlen].strip() or "untitled" |
|
|
|
|
|
def build_notebook_map(input_dir: Path, trash_url_to_root: dict[str, str]) -> dict[str, set[str]]: |
|
"""Map root-file basename -> set of notebook names, following symlinks. |
|
|
|
Symlinks resolving into trash/ are remapped to the root file sharing the |
|
trashed note's URL (so a tag on a trashed copy lands on the kept article). |
|
""" |
|
notebooks_dir = input_dir / "notebooks" |
|
mapping: dict[str, set[str]] = defaultdict(set) |
|
if not notebooks_dir.is_dir(): |
|
return mapping |
|
|
|
for nb in sorted(notebooks_dir.iterdir()): |
|
if not nb.is_dir(): |
|
continue |
|
for link in sorted(nb.iterdir()): |
|
if not link.name.endswith(".md"): |
|
continue |
|
target = Path(os.path.realpath(link)) |
|
if not target.exists(): |
|
continue |
|
if target.parent.name == "trash": |
|
# Redirect tag to the root copy with the same URL, if any. |
|
url = extract_url(target.read_text(encoding="utf-8")) |
|
root_base = trash_url_to_root.get(url) if url else None |
|
if root_base: |
|
mapping[root_base].add(nb.name) |
|
# else: orphaned trash link -> dropped (no root copy to tag) |
|
else: |
|
mapping[target.name].add(nb.name) |
|
return mapping |
|
|
|
|
|
def extract_url(text: str) -> str | None: |
|
"""First markdown-link *target* in the note (the clipped source URL).""" |
|
for line in text.splitlines(): |
|
m = RE_MD_LINK.search(line) |
|
if m: |
|
# Unescape markdown backslash-escapes (e.g. \_ in NYT share links). |
|
return re.sub(r"\\([_*()\[\]])", r"\1", m.group(1)).strip() |
|
return None |
|
|
|
|
|
def split_title_publication(title: str, url: str | None): |
|
"""Return (clean_title, publication, author_from_title). Prefer the domain |
|
map for publication; strip a recognized publication (or "by <author>") |
|
suffix from the title when present.""" |
|
domain = "" |
|
if url: |
|
domain = urlparse(url).netloc.lower().removeprefix("www.") |
|
pub_from_domain = DOMAIN_PUBLICATION.get(domain) |
|
|
|
# Look for a trailing " - / | / — / – <segment>" in the title. |
|
for sep in TITLE_SEPARATORS: |
|
if sep in title: |
|
head, _, tail = title.rpartition(sep) |
|
tail = tail.strip() |
|
head = head.strip() |
|
# A "by <name>" suffix is an author credit, not a publication. |
|
m = re.match(r"(?i)^by\s+(.+)$", tail) |
|
if m: |
|
return head, pub_from_domain, m.group(1).strip() |
|
# Strip the suffix when it matches the domain publication, or when we |
|
# have no domain publication and the tail looks like a source name. |
|
if pub_from_domain and tail.lower() == pub_from_domain.lower(): |
|
return head, pub_from_domain, None |
|
if not pub_from_domain and tail: |
|
return head, tail, None |
|
# tail didn't match a known publication; keep scanning shorter seps |
|
return title.strip(), (pub_from_domain or domain or None), None |
|
|
|
|
|
def normalize_date(line: str) -> str | None: |
|
m = RE_ISO_DATE.match(line) |
|
if m: |
|
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}" |
|
m = RE_TEXT_DATE.match(line) |
|
if m: |
|
month = MONTHS[m.group(1).lower()] |
|
return f"{int(m.group(3)):04d}-{month:02d}-{int(m.group(2)):02d}" |
|
return None |
|
|
|
|
|
def is_noise_line(line: str) -> bool: |
|
"""Lines that are not article prose (headings, images, links, breaks, |
|
captions, bylines, dates) -- used only to find the description.""" |
|
s = line.strip() |
|
if not s or s in ("<br>", "By") or s.startswith("#"): |
|
return True |
|
if RE_BY_LINE.match(s): # byline, e.g. "By JANE DOE" |
|
return True |
|
if normalize_date(s): # date line |
|
return True |
|
if re.fullmatch(r"\(.*\)", s): # caption like "(iStock)" |
|
return True |
|
# Lone link/image line: strip link/image markup (including a dangling |
|
# `](url)` or `[` from a link split across lines) and bare URLs; if no |
|
# letters remain the line carried no prose (covers the bare-URL line). |
|
residual = re.sub(r"!?\[[^\]]*\]\([^)]*\)", "", s) |
|
residual = re.sub(r"\]\([^)]*\)", "", residual) |
|
residual = re.sub(r"https?://\S+", "", residual).strip(" []()") |
|
if not re.search(r"[A-Za-z]", residual): |
|
return True |
|
return False |
|
|
|
|
|
def parse_note(text: str): |
|
"""Extract metadata + body lines from a raw UpNote note.""" |
|
lines = text.splitlines() |
|
# Title: first non-empty line, minus leading #'s. |
|
title_idx, title = None, None |
|
for i, line in enumerate(lines): |
|
if line.strip(): |
|
title_idx = i |
|
title = re.sub(r"^#{1,6}\s*", "", line).strip() |
|
break |
|
|
|
url = extract_url(text) |
|
|
|
# Body = everything after the title, dropping the title's duplicate H1/H2 |
|
# and the bare source-URL line (now captured in frontmatter). |
|
body_start = (title_idx + 1) if title_idx is not None else 0 |
|
body_lines = lines[body_start:] |
|
|
|
# Find description (first prose paragraph) and published date in the body. |
|
description, published, author = None, None, None |
|
for idx, line in enumerate(body_lines): |
|
s = line.strip() |
|
if published is None: |
|
d = normalize_date(s) |
|
if d: |
|
published = d |
|
if author is None: |
|
m = RE_BY_LINE.match(s) |
|
# Require it to look like a real byline (has a letter, not too long). |
|
if m and re.search(r"[A-Za-z]", m.group("author")) and len(s) < 120: |
|
author = m.group("author").strip() |
|
if description is None and not is_noise_line(line): |
|
description = s |
|
|
|
# Drop the redundant duplicate-title line and the bare-URL line from the body. |
|
cleaned = [] |
|
dropped_dup_title = False |
|
dropped_url = False |
|
for line in body_lines: |
|
s = line.strip() |
|
if not dropped_dup_title and title and re.sub(r"^#{1,6}\s*", "", s).strip() == title: |
|
dropped_dup_title = True |
|
continue |
|
if not dropped_url and url and RE_MD_LINK.search(s) and url in s.replace("\\", ""): |
|
dropped_url = True |
|
continue |
|
cleaned.append(line) |
|
# Trim leading blank/`<br>` lines left where the title/URL were removed. |
|
while cleaned and cleaned[0].strip() in ("", "<br>"): |
|
cleaned.pop(0) |
|
body = "\n".join(cleaned).strip("\n") |
|
|
|
return { |
|
"title": title or "Untitled", |
|
"url": url, |
|
"description": description, |
|
"published": published, |
|
"author": author, |
|
"body": body, |
|
} |
|
|
|
|
|
def yaml_str(value: str) -> str: |
|
"""Quote a scalar for YAML when needed; escape embedded double quotes.""" |
|
if value == "" or re.search(r'[:#\[\]{}",\'`]|^[\s>|*&!%@-]|^\d', value) or value != value.strip(): |
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' |
|
return value |
|
|
|
|
|
def dump_frontmatter(meta: dict, tags: list[str], category: str = "Clippings") -> str: |
|
"""Emit YAML frontmatter, omitting empty fields, matching the vault style. |
|
|
|
`category` is added as a `categories: [[<category>]]` wikilink (the kepano |
|
vault convention); pass an empty string to omit the categories block. |
|
""" |
|
out = ["---"] |
|
if category: |
|
out.append("categories:") |
|
out.append(f' - "[[{category}]]"') |
|
out.append(f"title: {yaml_str(meta['title'])}") |
|
if meta.get("url"): |
|
out.append(f"url: {meta['url']}") |
|
if meta.get("publication"): |
|
out.append(f"publication: {yaml_str(meta['publication'])}") |
|
if meta.get("author"): |
|
out.append("author:") |
|
out.append(f' - "[[{meta["author"]}]]"') |
|
if meta.get("published"): |
|
out.append(f"published: {meta['published']}") |
|
out.append(f"clipped: {meta['clipped']}") |
|
if meta.get("description"): |
|
out.append(f"description: {yaml_str(meta['description'])}") |
|
if tags: |
|
out.append("topics:") |
|
for t in tags: |
|
out.append(f" - {t}") |
|
out.append('imported-from: "UpNote"') |
|
out.append("---") |
|
return "\n".join(out) |
|
|
|
|
|
def convert(input_dir: Path, output_dir: Path, dry_run: bool, category: str = "Clippings") -> int: |
|
root_files = sorted(p for p in input_dir.glob("*.md") if p.is_file()) |
|
if not root_files: |
|
print(f"No .md files found in {input_dir}", file=sys.stderr) |
|
return 1 |
|
|
|
# URL -> root basename, so trashed notebook links can be remapped to root copies. |
|
trash_url_to_root: dict[str, str] = {} |
|
for p in root_files: |
|
url = extract_url(p.read_text(encoding="utf-8")) |
|
if url: |
|
trash_url_to_root.setdefault(url, p.name) |
|
|
|
notebook_map = build_notebook_map(input_dir, trash_url_to_root) |
|
|
|
if not dry_run: |
|
output_dir.mkdir(parents=True, exist_ok=True) |
|
|
|
used_names: dict[str, int] = {} |
|
rows = [] |
|
for p in root_files: |
|
text = p.read_text(encoding="utf-8") |
|
meta = parse_note(text) |
|
clean_title, publication, title_author = split_title_publication(meta["title"], meta["url"]) |
|
meta["title"] = clean_title |
|
meta["publication"] = publication |
|
# Prefer a byline found in the body; fall back to a "by ..." title suffix. |
|
meta["author"] = meta.get("author") or title_author |
|
meta["clipped"] = date.fromtimestamp(p.stat().st_mtime).isoformat() |
|
|
|
tags = sorted(slugify_tag(n) for n in notebook_map.get(p.name, set())) |
|
|
|
# Filename = title, de-duplicated with " (N)". |
|
stem = sanitize_filename(clean_title) |
|
n = used_names.get(stem.lower(), 0) + 1 |
|
used_names[stem.lower()] = n |
|
out_name = f"{stem}.md" if n == 1 else f"{stem} ({n}).md" |
|
|
|
content = dump_frontmatter(meta, tags, category) + "\n\n" + meta["body"] + "\n" |
|
if not dry_run: |
|
(output_dir / out_name).write_text(content, encoding="utf-8") |
|
|
|
rows.append((p.name[:12], out_name, publication or "-", ",".join(tags) or "-", |
|
"y" if meta["description"] else "-", |
|
"y" if meta["published"] else "-", |
|
"y" if meta["author"] else "-")) |
|
|
|
# Summary table. |
|
print(f"{'uuid':12} {'output file':50} {'publication':22} {'tags':24} desc pub auth") |
|
print("-" * 130) |
|
for uuid, name, pub, tags, d, pub_d, auth in rows: |
|
print(f"{uuid:12} {name[:50]:50} {pub[:22]:22} {tags[:24]:24} {d} {pub_d} {auth}") |
|
print("-" * 130) |
|
action = "Would write" if dry_run else "Wrote" |
|
print(f"{action} {len(rows)} files to {output_dir}") |
|
return 0 |
|
|
|
|
|
def main() -> int: |
|
ap = argparse.ArgumentParser(description=__doc__, |
|
formatter_class=argparse.RawDescriptionHelpFormatter) |
|
ap.add_argument("input_dir", nargs="?", default="upnote-markdown", |
|
help="UpNote export dir (default: upnote-markdown)") |
|
ap.add_argument("-o", "--output-dir", default=None, |
|
help="Output dir (default: INPUT_DIR/Clippings)") |
|
ap.add_argument("--category", default="Clippings", |
|
help='Wikilink category added to frontmatter (default: ' |
|
'"Clippings"); pass "" to omit the categories block') |
|
ap.add_argument("--dry-run", action="store_true", |
|
help="Print the summary table without writing files") |
|
args = ap.parse_args() |
|
|
|
input_dir = Path(args.input_dir).resolve() |
|
output_dir = Path(args.output_dir).resolve() if args.output_dir else input_dir / "Clippings" |
|
return convert(input_dir, output_dir, args.dry_run, args.category) |
|
|
|
|
|
if __name__ == "__main__": |
|
raise SystemExit(main()) |