#!/usr/bin/python3 """Class C (DESIGN.md §7): union-merge the Chromium bookmark bar across machines. Reads a file holding one concatenated JSON bookmark document per machine, flattens every bar to (folder-path, title, url) tuples, takes the union, and rebuilds one tree. Union-by-URL is idempotent, so bookmarks need no conflict concept at all — which is the only reason this file is 60 lines instead of 600. Deletions deliberately do NOT propagate, consistent with the tombstone rule for files: edits win, deletes lose. Escape hatch is documented in README. stdout: JSON { urls, hosts, dropped, bookmark_bar } """ import json import sys def load_docs(text): """Decode the hub's concatenated exports. The hub file is built as `### .json` followed by that host's JSON object, repeated. Returns [(host, doc), ...] so the report can name its sources. """ dec = json.JSONDecoder() out = [] for chunk in _split(text): host, body = chunk obj = _one(dec, body) if obj is not None: out.append((host, obj)) return out def _split(text): """Split into [(host, json_text), ...] on '### name' marker lines.""" chunks, host, buf = [], "?", [] for line in text.splitlines(keepends=True): if line.startswith("### "): body = "".join(buf) if body.strip(): chunks.append((host, body)) host = line[4:].strip() if host.endswith(".json"): host = host[:-5] buf = [] else: buf.append(line) body = "".join(buf) if body.strip(): chunks.append((host, body)) return chunks def _one(dec, body): i, n = 0, len(body) while i < n and body[i] in " \r\n\t": i += 1 if i >= n: return None obj, _ = dec.raw_decode(body, i) return obj def flatten(bar, prefix, out, seen): for node in bar.get("children") or []: ntype = node.get("type") if ntype == "folder": flatten(node, prefix + [node.get("name") or "?"], out, seen) elif ntype == "url": url, title = node.get("url"), node.get("name") or "" key = (tuple(prefix), url) if key in seen: # same bar, same folder, same URL -> one copy continue seen.add(key) out.append({"path": prefix, "title": title, "url": url}) # typed_pair / bookmark_bar root itself: ignored def rebuild(rows): def nodes(prefix): leaves = [ {"type": "url", "name": r["title"], "url": r["url"], "date_added": "0"} for r in rows if r["path"] == prefix ] names, seen = [], set() for r in rows: if len(r["path"]) > len(prefix) and r["path"][: len(prefix)] == prefix: nm = r["path"][len(prefix)] if nm not in seen: seen.add(nm) names.append(nm) folders = [ {"type": "folder", "name": nm, "date_added": "0", "children": nodes(prefix + [nm])} for nm in names ] return leaves + folders return {"type": "bookmark_bar", "name": "Bookmarks bar", "children": nodes([])} def main(): if len(sys.argv) != 2: sys.exit("usage: merge_bookmarks.py ") with open(sys.argv[1], encoding="utf-8", errors="replace") as fh: docs = load_docs(fh.read()) if not docs: sys.exit("merge_bookmarks: no JSON documents found") rows, seen = [], set() hosts = [] for host, d in docs: hosts.append(host) bar = (d.get("roots") or {}).get("bookmark_bar") if bar: flatten(bar, [], rows, seen) # collapse identical URLs that sit in different folders, keeping the shallowest byurl = {} for r in rows: cur = byurl.get(r["url"]) if cur is None or len(r["path"]) < len(cur["path"]): byurl[r["url"]] = r dropped = len(rows) - len(byurl) final = sorted(byurl.values(), key=lambda r: (r["path"], r["title"].lower())) json.dump( { "urls": len(final), "hosts": hosts, "duplicate_urls_collapsed": dropped, "bookmark_bar": rebuild(final), "flat": final, }, sys.stdout, indent=1, sort_keys=False, ) sys.stdout.write("\n") if __name__ == "__main__": main()