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.
This commit is contained in:
commit
78496330f3
16 files changed
+1576
No files matched your search
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user