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 ``` """