Files
homehub/lib/merge_bookmarks.py
druppy 78496330f3 homehub: rsync-based home-drive sync via Synology hub
DESIGN.md is the rationale; README.md the operations half.
Four data classes: files (mtime-arbitrated two-way), git repos (manifest +
clone, never byte-synced), the browser link bar (union merge), and everything
else denied by default.
2026-10-24 16:36:40 +02:00

143 lines
4.4 KiB
Python
Executable File

#!/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 `### <host>.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 <concatenated-bookmark-files>")
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()