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.
- raindrop_cli-0.5.2.dist-info/METADATA +530 -0
- raindrop_cli-0.5.2.dist-info/RECORD +16 -0
- raindrop_cli-0.5.2.dist-info/WHEEL +4 -0
- raindrop_cli-0.5.2.dist-info/entry_points.txt +2 -0
- raindrop_cli-0.5.2.dist-info/licenses/LICENSE +21 -0
- rd_cli/__init__.py +25 -0
- rd_cli/__main__.py +6 -0
- rd_cli/cli.py +757 -0
- rd_cli/client.py +727 -0
- rd_cli/commands.py +1180 -0
- rd_cli/completion.py +225 -0
- rd_cli/config.py +162 -0
- rd_cli/errors.py +56 -0
- rd_cli/output.py +305 -0
- rd_cli/pinboard.py +275 -0
- rd_cli/sync.py +252 -0
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
|