← 返回会话

merge_flat.py

4.6 KB · 纯文本展示
#!/usr/bin/env python3
"""把 Chrome 默认 Profile 的书签摊平并入 Safari Bookmarks.plist(Safari 必须先退出)。

- 删除标题以「Chrome 书签」开头的残留文件夹(递归,整棵树)
- Chrome roots.bookmark_bar 的每一项直接成为 Safari BookmarksBar 的子项(文件夹仍是同级文件夹)
- Chrome roots.other 同样摊进 BookmarksMenu
- 按 URLString 去重
"""
import json
import os
import plistlib
import shutil
import sys
import time
import uuid

SAFARI_PLIST = os.path.expanduser("~/Library/Safari/Bookmarks.plist")
CHROME_BOOKMARKS = os.path.expanduser(
    "~/Library/Application Support/Google/Chrome/Default/Bookmarks"
)
LEGACY_PREFIX = "Chrome 书签"


def new_uuid():
    return str(uuid.uuid4()).upper()


def load_chrome():
    with open(CHROME_BOOKMARKS, encoding="utf-8") as fh:
        return json.load(fh)


def make_leaf(node):
    return {
        "URIDictionary": {"title": node.get("name") or node.get("url")},
        "URLString": node["url"],
        "WebBookmarkType": "WebBookmarkTypeLeaf",
        "WebBookmarkUUID": new_uuid(),
    }


def convert(node, seen, stats):
    """把 Chrome 节点转成 Safari 节点;url 已存在则返回 None。"""
    if node.get("type") == "url":
        url = node.get("url")
        if not url or url in seen:
            stats["skipped"] += 1
            return None
        seen.add(url)
        stats["added"] += 1
        return make_leaf(node)
    children = []
    for child in node.get("children") or []:
        converted = convert(child, seen, stats)
        if converted is not None:
            children.append(converted)
    if not children:
        return None
    return {
        "Title": node.get("name") or "未命名",
        "WebBookmarkType": "WebBookmarkTypeList",
        "WebBookmarkUUID": new_uuid(),
        "Children": children,
    }


def collect_urls(node, acc):
    if node.get("WebBookmarkType") == "WebBookmarkTypeLeaf":
        url = node.get("URLString")
        if url:
            acc.add(url)
    for child in node.get("Children") or []:
        collect_urls(child, acc)
    return acc


def strip_legacy(node, removed):
    """递归删除标题以「Chrome 书签」开头的列表,返回保留下来的 children。"""
    kept = []
    for child in node.get("Children") or []:
        if (
            child.get("WebBookmarkType") == "WebBookmarkTypeList"
            and str(child.get("Title") or "").startswith(LEGACY_PREFIX)
        ):
            removed.append(child.get("Title"))
            continue
        strip_legacy(child, removed)
        kept.append(child)
    if "Children" in node or kept:
        node["Children"] = kept
    return removed


def find_root(bookmarks, server_id):
    for child in bookmarks.get("Children") or []:
        sync = child.get("Sync") or {}
        if sync.get("ServerID") == server_id:
            return child
    for child in bookmarks.get("Children") or []:
        if str(child.get("Title") or "") == server_id:
            return child
    return None


def main():
    if "Safari" in os.popen("pgrep -x Safari").read():
        raise SystemExit("Safari 仍在运行,先退出再改 plist")

    stats = {"added": 0, "skipped": 0}
    removed = []

    with open(SAFARI_PLIST, "rb") as fh:
        bookmarks = plistlib.load(fh)

    strip_legacy(bookmarks, removed)

    bar = find_root(bookmarks, "Favorites Bar")
    menu = find_root(bookmarks, "Bookmarks Menu")
    if bar is None or menu is None:
        raise SystemExit("找不到 BookmarksBar / BookmarksMenu 节点")

    seen = collect_urls(bookmarks, set())

    chrome = load_chrome()
    roots = chrome.get("roots", {})

    for key, target in (("bookmark_bar", bar), ("other", menu)):
        root = roots.get(key) or {}
        converted = []
        for child in root.get("children") or []:
            item = convert(child, seen, stats)
            if item is not None:
                converted.append(item)
        target.setdefault("Children", [])
        target["Children"].extend(converted)

    tmp = SAFARI_PLIST + ".tmp-flat"
    with open(tmp, "wb") as fh:
        plistlib.dump(bookmarks, fh, fmt=plistlib.FMT_BINARY)
    os.chmod(tmp, 0o644)
    os.replace(tmp, SAFARI_PLIST)

    print(
        json.dumps(
            {
                "added": stats["added"],
                "skipped_duplicate": stats["skipped"],
                "removed_legacy_folders": removed,
                "bar_children": len(bar.get("Children") or []),
                "menu_children": len(menu.get("Children") or []),
                "written_at": time.strftime("%Y-%m-%d %H:%M:%S"),
            },
            ensure_ascii=False,
        )
    )


if __name__ == "__main__":
    sys.exit(main())