raindrop-cli 0.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rd_cli/sync.py ADDED
@@ -0,0 +1,252 @@
1
+ """Two-way additive sync between Raindrop and Pinboard.
2
+
3
+ Additive means the sync only ever *adds* and *merges*; it never deletes. Two
4
+ libraries converge to the union of their bookmarks, matched by a normalized URL
5
+ (which is also the dedup key). The model gap is bridged by encoding, reversibly,
6
+ in tags: a Raindrop collection becomes a slugged Pinboard tag, and Pinboard's
7
+ `toread`/Raindrop's `important` ride along as tags too. Highlights stay
8
+ Raindrop-only (Pinboard has no home for them).
9
+
10
+ The planning half (`plan_sync` and the mapping helpers) is pure and network-free
11
+ so it can be tested directly; `apply_plan` is the only part that writes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
20
+
21
+ # Query keys that carry no identity; dropped before matching so tracking-tagged
22
+ # and clean copies of the same page dedup together. Real query params (e.g. an
23
+ # Anna's Archive search) are kept.
24
+ TRACKING = {
25
+ "utm_source",
26
+ "utm_medium",
27
+ "utm_campaign",
28
+ "utm_term",
29
+ "utm_content",
30
+ "utm_reader",
31
+ "utm_name",
32
+ "fbclid",
33
+ "gclid",
34
+ "mc_cid",
35
+ "mc_eid",
36
+ "igshid",
37
+ "ref",
38
+ "ref_src",
39
+ "ref_url",
40
+ "spm",
41
+ "yclid",
42
+ "_hsenc",
43
+ "_hsmi",
44
+ "postshare",
45
+ "share",
46
+ }
47
+
48
+
49
+ def normalize_url(url: str) -> str:
50
+ """A comparison key: unify scheme to https, drop ``www.`` and the fragment,
51
+ strip tracking params (but keep meaningful ones), and sort the rest."""
52
+ p = urlsplit(url.strip())
53
+ host = (p.hostname or "").lower()
54
+ if host.startswith("www."):
55
+ host = host[4:]
56
+ if p.port:
57
+ host = f"{host}:{p.port}"
58
+ path = p.path.rstrip("/") or "/"
59
+ kept = sorted(
60
+ (k, v)
61
+ for k, v in parse_qsl(p.query, keep_blank_values=True)
62
+ if k.lower() not in TRACKING
63
+ )
64
+ return urlunsplit(("https", host, path, urlencode(kept), ""))
65
+
66
+
67
+ def _slug(title: str) -> str:
68
+ return re.sub(r"[^a-z0-9]+", "-", title.strip().lower()).strip("-")
69
+
70
+
71
+ def raindrop_to_pinboard(rd: dict, coll_title_by_id: dict[int, str]) -> dict:
72
+ """Fields for creating this raindrop on Pinboard."""
73
+ tags = list(rd.get("tags") or [])
74
+ cid = (rd.get("collection") or {}).get("$id")
75
+ title = coll_title_by_id.get(cid) if cid is not None else None
76
+ if title:
77
+ slug = _slug(title)
78
+ if slug and slug not in tags:
79
+ tags.append(slug)
80
+ if rd.get("important") and "important" not in tags:
81
+ tags.append("important")
82
+ note = (rd.get("note") or rd.get("excerpt") or "").strip()
83
+ return {
84
+ "url": rd["link"],
85
+ "title": rd.get("title") or rd["link"],
86
+ "extended": note,
87
+ "tags": tags,
88
+ "toread": False,
89
+ "shared": True,
90
+ }
91
+
92
+
93
+ def pinboard_to_raindrop(pb: dict, coll_id_by_slug: dict[str, int]) -> dict:
94
+ """Fields for creating this Pinboard post as a raindrop. A tag that matches a
95
+ Raindrop collection routes the item into it (and is dropped from the tag
96
+ list); everything else lands in Unsorted."""
97
+ collection_id = -1
98
+ remaining: list[str] = []
99
+ for t in (pb.get("tags") or "").split():
100
+ cid = coll_id_by_slug.get(_slug(t))
101
+ if cid is not None and collection_id == -1:
102
+ collection_id = cid
103
+ else:
104
+ remaining.append(t)
105
+ if pb.get("toread") == "yes" and "toread" not in remaining:
106
+ remaining.append("toread")
107
+ return {
108
+ "link": pb["href"],
109
+ "title": pb.get("description") or pb["href"],
110
+ "note": (pb.get("extended") or "").strip(),
111
+ "tags": remaining,
112
+ "collection_id": collection_id,
113
+ }
114
+
115
+
116
+ def merge_notes(a: str, b: str) -> str:
117
+ """Keep both notes without duplicating on repeat syncs (idempotent)."""
118
+ a, b = (a or "").strip(), (b or "").strip()
119
+ if a == b or not b or b in a:
120
+ return a
121
+ if not a or a in b:
122
+ return b
123
+ return f"{a}\n\n{b}"
124
+
125
+
126
+ @dataclass
127
+ class SyncPlan:
128
+ to_pinboard: list[dict] = field(default_factory=list) # raindrop fields
129
+ to_raindrop: list[dict] = field(default_factory=list) # pinboard fields
130
+ merges: list[dict] = field(
131
+ default_factory=list
132
+ ) # {"rd","pb","tags","rd_note","pb_note"}
133
+ rd_dupes: int = 0
134
+ pb_dupes: int = 0
135
+
136
+ @property
137
+ def total(self) -> int:
138
+ return len(self.to_pinboard) + len(self.to_raindrop) + len(self.merges)
139
+
140
+
141
+ def plan_sync(
142
+ raindrops: list[dict],
143
+ pb_posts: list[dict],
144
+ coll_title_by_id: dict[int, str],
145
+ coll_id_by_slug: dict[str, int],
146
+ rd_keep=None,
147
+ pb_keep=None,
148
+ ) -> SyncPlan:
149
+ """Diff the two sides by normalized URL. Pure; no network.
150
+
151
+ ``rd_keep``/``pb_keep`` are optional scope predicates: an item is only
152
+ *pushed/merged* if its predicate passes, but matching always uses the full
153
+ sets, so a scoped raindrop that also exists (unscoped) on the other side is
154
+ still recognized and never re-imported as new.
155
+ """
156
+ rd_keep = rd_keep or (lambda rd: True)
157
+ pb_keep = pb_keep or (lambda pb: True)
158
+ rd_by_norm: dict[str, dict] = {}
159
+ rd_dupes = 0
160
+ for rd in raindrops:
161
+ key = normalize_url(rd["link"])
162
+ if key in rd_by_norm:
163
+ rd_dupes += 1
164
+ else:
165
+ rd_by_norm[key] = rd
166
+ pb_by_norm: dict[str, dict] = {}
167
+ pb_dupes = 0
168
+ for pb in pb_posts:
169
+ key = normalize_url(pb["href"])
170
+ if key in pb_by_norm:
171
+ pb_dupes += 1
172
+ else:
173
+ pb_by_norm[key] = pb
174
+
175
+ plan = SyncPlan(rd_dupes=rd_dupes, pb_dupes=pb_dupes)
176
+ for key, rd in rd_by_norm.items():
177
+ if key not in pb_by_norm and rd_keep(rd):
178
+ plan.to_pinboard.append(raindrop_to_pinboard(rd, coll_title_by_id))
179
+ for key, pb in pb_by_norm.items():
180
+ rd = rd_by_norm.get(key)
181
+ if rd is None:
182
+ if pb_keep(pb):
183
+ plan.to_raindrop.append(pinboard_to_raindrop(pb, coll_id_by_slug))
184
+ continue
185
+ # A merge touches both sides, so it must satisfy every active scope.
186
+ if not (rd_keep(rd) and pb_keep(pb)):
187
+ continue
188
+ # On both: union tags (in each side's own vocabulary), merge notes.
189
+ pb_as_rd = pinboard_to_raindrop(pb, coll_id_by_slug)
190
+ rd_as_pb = raindrop_to_pinboard(rd, coll_title_by_id)
191
+ rd_tags = sorted(set(rd.get("tags") or []) | set(pb_as_rd["tags"]))
192
+ pb_tags = sorted(set((pb.get("tags") or "").split()) | set(rd_as_pb["tags"]))
193
+ note = merge_notes(rd.get("note") or "", pb.get("extended") or "")
194
+ rd_changed = set(rd_tags) != set(rd.get("tags") or []) or (
195
+ note != (rd.get("note") or "").strip()
196
+ )
197
+ pb_changed = set(pb_tags) != set((pb.get("tags") or "").split()) or (
198
+ note != (pb.get("extended") or "").strip()
199
+ )
200
+ if rd_changed or pb_changed:
201
+ plan.merges.append(
202
+ {
203
+ "rd": rd,
204
+ "pb": pb,
205
+ "rd_tags": rd_tags,
206
+ "pb_tags": pb_tags,
207
+ "note": note,
208
+ "rd_changed": rd_changed,
209
+ "pb_changed": pb_changed,
210
+ }
211
+ )
212
+ return plan
213
+
214
+
215
+ def apply_plan(plan: SyncPlan, rd_client: Any, pb_client: Any) -> dict[str, int]:
216
+ """Execute a plan. Honors each client's ``dry_run``. Returns a count summary."""
217
+ counts = {"added_pinboard": 0, "added_raindrop": 0, "merged": 0}
218
+ for fields in plan.to_pinboard:
219
+ pb_client.add_post(
220
+ fields["url"],
221
+ fields["title"],
222
+ extended=fields["extended"],
223
+ tags=fields["tags"],
224
+ shared=fields["shared"],
225
+ toread=fields["toread"],
226
+ )
227
+ counts["added_pinboard"] += 1
228
+ for fields in plan.to_raindrop:
229
+ rd_client.create_raindrop(
230
+ fields["link"],
231
+ title=fields["title"],
232
+ note=fields["note"],
233
+ tags=fields["tags"],
234
+ collection_id=fields["collection_id"],
235
+ )
236
+ counts["added_raindrop"] += 1
237
+ for m in plan.merges:
238
+ if m["rd_changed"]:
239
+ rd_client.update_raindrop(m["rd"]["_id"], tags=m["rd_tags"], note=m["note"])
240
+ if m["pb_changed"]:
241
+ pb = m["pb"]
242
+ pb_client.add_post(
243
+ pb["href"],
244
+ pb.get("description") or pb["href"],
245
+ extended=m["note"],
246
+ tags=m["pb_tags"],
247
+ replace=True,
248
+ shared=pb.get("shared") != "no",
249
+ toread=pb.get("toread") == "yes",
250
+ )
251
+ counts["merged"] += 1
252
+ return counts