#!/usr/bin/env python3 """ # Script ob-bookmarks ## Metadata #obsidian/ob-script #python **Parent**:: [[Obsidian Chore Scripts]], [[Executable Markdown File]] ## Synopsis Export Chrome bookmarks into Obsidian, with note properties in YAML and native tags. Folder notes support `Name:: value` or `**Name**:: value`, simple scalar values, single wikilinks, bare URLs, and hashtag-only Tags/classification values. Property names become lower kebab-case; numeric Highlights becomes highlight-count, while link-valued Highlights stays highlights. List properties such as parent, topics, and authors become one-item YAML lists. Normalization collisions require review. Legacy Status and Zettel fields accept one plain value or the corresponding hashtag and emit tags, not classification properties. Status values: i, now, later, someday, x, gave-up; gave-up also adds x without inferring completion from now/later. Zettel values: fleeting, permanent, index, literature, keyword (hashtags use zettel/). Kind accepts a plain value or ordered hashtag list containing bookmarks-collection or kind/bookmarks-collection. Flat kinds gain kind/; existing nested paths keep their spelling and order. Other explicit tags are not reinterpreted as kinds. Source still must match the generated from/browser. Retired Done/Priority fields require review; use explicit workflow tags instead. Legacy field/tag conflicts still require review. Unsupported or conflicting classifications, rich/ambiguous values (including comma-separated lists), duplicate keys, conflicting generated properties, and colliding output names abort the entire export before any files are written. Nonmetadata prose and section-local annotations remain in the body. Importing this script performs no browser/database/export I/O. Run it directly for the usual Edge database and vault destination, or use `--input PATH --output PATH` for explicit sources and destinations. Existing identical files are not rewritten. Example:: [[Tools (Bookmarks)]] ## Script ```python #""" from pathlib import Path from urllib.parse import unquote from io import StringIO import argparse import math import unicodedata import tempfile import json import textwrap import os import re import shutil import filecmp import subprocess import yaml # ruff: noqa: E501 BOOKMARKLET_PREFIX = "javascript:" BOOKMARKLET_PREFIX_LEN = len(BOOKMARKLET_PREFIX) FOLDER_COMMENT_PREFIX = "edge://favorites/?id=" PRETTIER_EXISTS = shutil.which("prettier") is not None DATA_URL_RE = re.compile("data:(text/html;)?(charset=[^,]+,)?") METADATA_RE = re.compile(r"^([a-zA-Z0-9 _-]+):: ", re.M) NEWLINE_RE = re.compile(r" (\\\\)+ ") export_dir = Path.home() / "Dropbox" / "Brain" / "robot" / "Bookmarks Library" prettier_exe = "prettier.cmd" if os.name == "nt" else "prettier" class FrontmatterDumper(yaml.SafeDumper): def increase_indent(self, flow=False, indentless=False): return super().increase_indent(flow, indentless=False) def choose_scalar_style(self): style = super().choose_scalar_style() return '"' if style == "'" else style def decode_javascript(encoded_js): unquoted = unquote(encoded_js) if PRETTIER_EXISTS: return subprocess.check_output( [prettier_exe, "--parser", "flow"], input=unquoted, shell=True, text=True ).rstrip() return unquoted.rstrip() def decode_html(encoded_html): unquoted = unquote(encoded_html) if PRETTIER_EXISTS: return ( subprocess.check_output( [ prettier_exe, "--parser", "html", "--html-whitespace-sensitivity", "ignore", ], input=unquoted.encode("utf-8"), ) .decode("utf-8") .rstrip() ) return unquoted.rstrip() def replace_newlines(text): return NEWLINE_RE.sub(lambda m: "\n" * (len(m.group(1)) // 2), text) def folder_note_text(note): note = replace_newlines(note) if note.startswith("# "): return note.split("\n", 1)[1] if "\n" in note else "" return note def tag_values(value): values = value.split() if not values or any(not re.fullmatch(r"#[\w/-]+", v) or v[1:].isdigit() for v in values): raise ValueError("Expected a nonempty list of valid hashtags: " + value) return [v[1:] for v in values] def scalar_value(value): if value != value.strip() or not value: raise ValueError("Empty or whitespace-ambiguous property value") if re.fullmatch(r"\[\[[^\[\]\n]+\]\]", value) or re.fullmatch(r"[a-zA-Z][a-zA-Z0-9+.-]*://\S+", value): return value if re.search(r"[*`\[\]<>#{},]", value) or re.search(r"(?<!\w)_[^_]+_(?!\w)", value): raise ValueError("Rich/structured property value needs review: " + value) if value in {"true", "false", "null"}: return {"true": True, "false": False, "null": None}[value] if re.fullmatch(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?", value): number = float(value) if any(c in value for c in ".eE") else int(value) if isinstance(number, float) and not math.isfinite(number): raise ValueError("Nonfinite property number") return number return value def split_folder_note(note): text = folder_note_text(note) if text.startswith("#") and not re.match(r"^#{1,6} ", text): text = "Tags:: " + text lines = text.splitlines(keepends=True) fields, body, seen = [], [], set() fence = comment = html = None section = False for i, line in enumerate(lines): marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)quot;, line.rstrip("\n")) if fence: body.append(line) if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence) and not marker[2].strip(): fence = None continue if comment or html: body.append(line) if comment and comment in line: comment = None if html and re.search(r"</" + html + r"\s*>", line, re.I): html = None continue if marker: fence = marker[1] body.append(line) continue if "<!--" in line or "%%" in line: start, end = ("<!--", "-->") if "<!--" in line else ("%%", "%%") if end not in line.split(start, 1)[1]: comment = end body.append(line) continue tag = re.match(r"^ {0,3}<([a-z][a-z0-9-]*)(?:\s|>)", line, re.I) if tag: if tag[1].lower() not in {"br", "hr", "img", "input", "link", "meta", "source"} and not re.search(r"</" + tag[1] + r"\s*>", line, re.I) and not line.rstrip().endswith("/>"): html = tag[1] body.append(line) continue section |= bool(re.match(r"^#{1,6} ", line)) match = re.fullmatch(r"(?:\*\*([^*]+)\*\*|([\w][\w -]*)):: ?(.*)\n?", line) if section or not match: body.append(line) continue key = match[1] or match[2] if key != key.strip() or key.lower() in seen: raise ValueError("Ambiguous or repeated property: " + key) if i + 1 < len(lines) and lines[i + 1].strip() and re.match(r"^(?:\s|[-*] )", lines[i + 1]) and not lines[i + 1].startswith("**"): raise ValueError("Ambiguous multiline property: " + key) seen.add(key.lower()) fields.append((key, match[3])) return fields, METADATA_RE.sub(r"**\1**:: ", "".join(body)) CLASSIFICATION_VALUES = { "status": {"i", "now", "later", "someday", "x", "gave-up"}, "zettel": {"fleeting", "permanent", "index", "literature", "keyword"}, } LIST_KEYS = {"alternate-urls", "authors", "children", "developer", "friends", "parent", "platform", "reference", "related", "topics"} def property_key(name, value=None): if name.lower() == "highlights" and isinstance(value, (int, float)) and not isinstance(value, bool): return "highlight-count" name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", name) name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name) return re.sub(r"[\s_]+", "-", name).lower() def classification_values(key, raw): tagged = raw.startswith("#") values = [v if key == "kind" and "/" in v else v.lower() for v in tag_values(raw)] if tagged else [raw] if key == "zettel" and tagged: if any(not v.startswith("zettel/") for v in values): raise ValueError("Legacy Zettel hashtags must use the zettel/ prefix") values = [v.removeprefix("zettel/") for v in values] values = list(dict.fromkeys(values)) if key == "kind": if any(not re.fullmatch(r"[\w/-]+", v) or v.isdigit() for v in values): raise ValueError("Invalid kind value: " + raw) values = ["kind/bookmarks-collection" if v.lower() == "bookmarks-collection" else v if "/" in v else "kind/" + v for v in values] if not any(v.lower() == "kind/bookmarks-collection" for v in values): raise ValueError("Kind must include generated bookmarks-collection classification") return list(dict.fromkeys(values)) if len(values) != 1 or values[0] not in CLASSIFICATION_VALUES[key]: raise ValueError("Expected one supported " + key + " value: " + raw) return values[0] def folder_properties(folder, parent, has_note): first = folder["children"][0] result = {} if (has_note and "#private" in first["name"]) or folder["name"] == "Work": result["publish"] = False if parent is not None: result["parent"] = [f"[[{parent['name']} (Bookmarks)|{parent['name']}]]"] result["tags"] = [] result["generated-by"] = "[[ob-bookmarks]]" result["chrome-url"] = f"edge://favorites/{'' if parent is None else '?id=' + folder['id']}" kinds = ["kind/bookmarks-collection"] classifications = {} tags, explicit_tags = ["from/browser"], [] def add_tags(values): for tag in values: if tag.lower() not in [v.lower() for v in tags]: tags.append(tag) fields, prose = split_folder_note(first["name"]) if has_note else ([], "") seen = set() for name, raw in fields: key = property_key(name) if key in {"done", "priority"}: raise ValueError("Use explicit workflow tags instead of retired classification properties: " + name) is_tags = key in {"tags", "source"} or (key in {"category", "document-tags"} and raw.startswith("#")) if key in {"kind", "status", "zettel"}: value = classification_values(key, raw) elif is_tags: value = tag_values(raw) else: value = scalar_value(raw) key = property_key(name, value) if key in LIST_KEYS: value = [value] if key in seen: raise ValueError("Properties normalize to the same name: " + key) seen.add(key) if key == "source": if [v.lower() for v in value] != ["from/browser"]: raise ValueError("Property conflicts with generated classification: " + name) elif is_tags: explicit_tags.extend(value) add_tags(value) elif key == "kind": kinds = value elif key in CLASSIFICATION_VALUES: classifications[key] = value add_tags(["zettel/" + value] if key == "zettel" else ["x", "gave-up"] if value == "gave-up" else [value]) elif key in result: if type(result[key]) is not type(value) or result[key] != value: raise ValueError("Property conflicts with generated metadata: " + name) else: result[key] = value for key, allowed in CLASSIFICATION_VALUES.items(): legacy = [tag.lower() for tag in explicit_tags] if key == "zettel": legacy = [tag.removeprefix("zettel/") for tag in legacy if tag.startswith("zettel/")] equivalent = {classifications.get(key)} if key == "status" and classifications.get(key) in {"x", "gave-up"}: equivalent = {"x", "gave-up"} if key in classifications and any(value in allowed and value not in equivalent for value in legacy): raise ValueError("Property conflicts with explicit classification tags: " + key) if any(tag.lower() == "gave-up" for tag in tags): add_tags(["x"]) other_tags, tags = tags, [] add_tags(kinds + other_tags) result["tags"] = tags return result, prose def validate_folder_name(name): if not name or name in {".", ".."} or name != name.strip() or name.endswith(".") or re.search(r'[<>:"/\\|?*\x00-\x1f]', name): raise ValueError("Unsafe export folder name: " + repr(name)) TRANSLATIONS = { "其他收藏夹": "Other Favorites", "收藏夹栏": "Favorites Bar", "Other favorites": "Other Favorites", "Favorites bar": "Favorites Bar", } def translate_name(name): return TRANSLATIONS.get(name, name) def export_bookmarks_folder(dir, folder, parent, exports): folder = {**folder, "name": translate_name(folder["name"])} name = folder["name"] validate_folder_name(name) if len(folder["children"]) == 0: return relative = dir / f"{name} (Bookmarks).md" collision_key = unicodedata.normalize("NFC", relative.as_posix()).casefold() if any(unicodedata.normalize("NFC", p.as_posix()).casefold() == collision_key for p in exports): raise ValueError("Colliding export path: " + str(relative)) exports[relative] = None with StringIO() as md_file: first_child = folder["children"][0] has_folder_note = first_child["type"] != "folder" and first_child[ "url" ].startswith(FOLDER_COMMENT_PREFIX) try: properties, prose = folder_properties(folder, parent, has_folder_note) except ValueError as error: raise ValueError(f"{relative}: {error}") from error print("---", file=md_file) yaml.dump(properties, md_file, Dumper=FrontmatterDumper, allow_unicode=True, default_flow_style=False, sort_keys=False, width=float("inf")) print("---", file=md_file) print(f"# {name} (Bookmarks)\n", file=md_file) if prose: print("## Metadata\n", file=md_file) print(prose, file=md_file) print("", file=md_file) print("## Children\n", file=md_file) for child in folder["children"]: if ( child["name"] == "§ Inbox" or child["name"] == "Ω Archive" or "#private" in child["name"] ): pass elif child["type"] == "folder": child = {**child, "name": translate_name(child["name"])} export_bookmarks_folder(dir / child["name"], child, folder, exports) print( f"- 📁 [[{child['name']} (Bookmarks)|{child['name']}]]", file=md_file, ) else: url = child["url"] split_parts = replace_newlines(child["name"]).splitlines(keepends=False) child_name = split_parts[0] description = "\n".join(split_parts[1:]) anchor = child["id"] data_url_match = DATA_URL_RE.match(url) if data_url_match: data_url_syntax = "" data_url_content = url if "html" in (data_url_match.group(1) or ""): data_url_syntax = "html" data_url_content = decode_html(url[data_url_match.end() :]) print( textwrap.dedent( f"""\ - {child_name} #bookmarklet ^{anchor} ```{data_url_syntax} {{}} ``` """ ).format(textwrap.indent(data_url_content, " ")), file=md_file, ) elif url.startswith(BOOKMARKLET_PREFIX): print( textwrap.dedent( """\ - {} #bookmarklet ^{} ```javascript {} ``` """ ).format( child_name, anchor, textwrap.indent( decode_javascript(url[BOOKMARKLET_PREFIX_LEN:]), " " ), ), file=md_file, ) elif not url.startswith(FOLDER_COMMENT_PREFIX): print( f"- {child_name} [{url.split('://')[1].split('/')[0]}]({url}) ^{anchor}", file=md_file, ) if description != "": if description.startswith("#"): description = description.replace("\n", "\n\n", 1) if not url.startswith(FOLDER_COMMENT_PREFIX): print("", file=md_file) print( textwrap.indent(description, " "), file=md_file, ) print("", file=md_file) exports[relative] = md_file.getvalue().encode("utf-8") def render_exports(roots): exports = {} root = {"name": "Roots", "type": "folder", "children": [roots["bookmark_bar"], roots["other"]]} export_bookmarks_folder(Path(), root, None, exports) return exports def publish_exports(exports, destination): destination = Path(destination) for relative in exports: if not (destination / relative).resolve().is_relative_to(destination.resolve()): raise ValueError("Export path escapes destination: " + str(relative)) destination.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".bookmarks-", dir=destination) as tmp: tmp = Path(tmp) for relative, content in exports.items(): src_file = tmp / relative src_file.parent.mkdir(parents=True, exist_ok=True) src_file.write_bytes(content) for relative in exports: src_file = tmp / relative target_file = destination / relative target_file.parent.mkdir(parents=True, exist_ok=True) if not target_file.exists() or not filecmp.cmp(src_file, target_file, shallow=False): print("NEW:", relative) os.replace(src_file, target_file) def export_database(database, destination): with Path(database).open(encoding="utf-8") as stream: roots = json.load(stream)["roots"] exports = render_exports(roots) publish_exports(exports, destination) return exports def main(argv=None): parser = argparse.ArgumentParser(description="Export bookmark metadata to YAML after whole-tree validation.") parser.add_argument("--input", type=Path, default=Path.home() / ( "AppData/Local/Microsoft/Edge/User Data/Default/Bookmarks" if os.name == "nt" else "Library/Application Support/Microsoft Edge/Default/Bookmarks")) parser.add_argument("--output", type=Path, default=export_dir) args = parser.parse_args(argv) try: export_database(args.input, args.output) except (ValueError, KeyError, OSError) as error: parser.exit(1, f"Bookmark export aborted: {error}\n") if __name__ == "__main__": main() """ # vim: ft=python ``` """