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.
143 lines
4.4 KiB
Python
Executable File
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()
|