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/client.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""``RaindropClient`` — a thin, dependency-free wrapper over the Raindrop.io
|
|
2
|
+
REST API v1 (https://api.raindrop.io/rest/v1).
|
|
3
|
+
|
|
4
|
+
Everything goes through :meth:`RaindropClient._request`, which is the single
|
|
5
|
+
place that attaches the auth header, applies a timeout, JSON-encodes bodies,
|
|
6
|
+
lowercases boolean query params (the API rejects Python's ``True``/``False``),
|
|
7
|
+
retries on ``429``/``5xx``, and maps error responses to the typed exceptions in
|
|
8
|
+
:mod:`rd_cli.errors`. The per-endpoint methods below are deliberately small so
|
|
9
|
+
this class can later graduate into a standalone client library.
|
|
10
|
+
|
|
11
|
+
The constructor accepts an ``opener`` and ``sleep`` so tests can inject a fake
|
|
12
|
+
transport and avoid real network / real waiting.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import calendar
|
|
18
|
+
import email.utils
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
from collections.abc import Iterator
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
from .errors import APIError, AuthError, NotFoundError, RateLimitError
|
|
31
|
+
|
|
32
|
+
BASE_URL = "https://api.raindrop.io/rest/v1"
|
|
33
|
+
USER_AGENT = f"rd-cli/{__version__} (+https://github.com/VirInvictus/rd-cli)"
|
|
34
|
+
|
|
35
|
+
# System collection ids (see CLAUDE.md).
|
|
36
|
+
ALL = 0
|
|
37
|
+
UNSORTED = -1
|
|
38
|
+
TRASH = -99
|
|
39
|
+
|
|
40
|
+
PERPAGE_MAX = 50
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
44
|
+
"""Redirect handler that declines to follow. Returning ``None`` from
|
|
45
|
+
``redirect_request`` makes urllib raise the 3xx as an ``HTTPError``, which
|
|
46
|
+
is how ``_request`` gets at the ``Location`` header."""
|
|
47
|
+
|
|
48
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RaindropClient:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
token: str,
|
|
56
|
+
*,
|
|
57
|
+
base_url: str = BASE_URL,
|
|
58
|
+
timeout: float = 30.0,
|
|
59
|
+
max_retries: int = 3,
|
|
60
|
+
dry_run: bool = False,
|
|
61
|
+
opener: urllib.request.OpenerDirector | None = None,
|
|
62
|
+
sleep=time.sleep,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.token = token
|
|
65
|
+
self.base_url = base_url.rstrip("/")
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
self.max_retries = max_retries
|
|
68
|
+
self.dry_run = dry_run
|
|
69
|
+
self._opener = opener or urllib.request.build_opener()
|
|
70
|
+
# The permanent-copy endpoint answers 307 with the real location in a
|
|
71
|
+
# header; urllib's default opener would silently follow it and hand back
|
|
72
|
+
# the copy's bytes instead of its URL. An injected opener (tests) is used
|
|
73
|
+
# for both so a fake transport still sees every call.
|
|
74
|
+
self._noredirect_opener = opener or urllib.request.build_opener(_NoRedirect)
|
|
75
|
+
self._sleep = sleep
|
|
76
|
+
|
|
77
|
+
# -- core -----------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _request(
|
|
80
|
+
self,
|
|
81
|
+
method: str,
|
|
82
|
+
path: str,
|
|
83
|
+
*,
|
|
84
|
+
params: dict[str, Any] | None = None,
|
|
85
|
+
json_body: Any = None,
|
|
86
|
+
files: dict[str, tuple[str, bytes, str]] | None = None,
|
|
87
|
+
form: dict[str, str] | None = None,
|
|
88
|
+
expect_json: bool = True,
|
|
89
|
+
allow_redirects: bool = True,
|
|
90
|
+
) -> Any:
|
|
91
|
+
url = self.base_url + path
|
|
92
|
+
query = _encode_params(params)
|
|
93
|
+
if query:
|
|
94
|
+
url = f"{url}?{query}"
|
|
95
|
+
|
|
96
|
+
data: bytes | None = None
|
|
97
|
+
headers = {
|
|
98
|
+
"Authorization": f"Bearer {self.token}",
|
|
99
|
+
"User-Agent": USER_AGENT,
|
|
100
|
+
"Accept": "application/json",
|
|
101
|
+
}
|
|
102
|
+
if files is not None:
|
|
103
|
+
data, content_type = _multipart(files, form or {})
|
|
104
|
+
headers["Content-Type"] = content_type
|
|
105
|
+
elif json_body is not None:
|
|
106
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
107
|
+
headers["Content-Type"] = "application/json"
|
|
108
|
+
|
|
109
|
+
if self.dry_run and method != "GET":
|
|
110
|
+
print(
|
|
111
|
+
f"DRY RUN {method} {path} {_dry_run_preview(json_body, files, form)}",
|
|
112
|
+
file=sys.stderr,
|
|
113
|
+
)
|
|
114
|
+
return {"result": True, "item": {}, "items": [], "modified": 0, "count": 0}
|
|
115
|
+
|
|
116
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
117
|
+
|
|
118
|
+
opener = self._opener if allow_redirects else self._noredirect_opener
|
|
119
|
+
|
|
120
|
+
attempt = 0
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
with opener.open(req, timeout=self.timeout) as resp:
|
|
124
|
+
body = resp.read()
|
|
125
|
+
if not expect_json:
|
|
126
|
+
return body
|
|
127
|
+
return json.loads(body) if body else {}
|
|
128
|
+
except urllib.error.HTTPError as exc:
|
|
129
|
+
# With redirects suppressed a 3xx is the answer, not an error:
|
|
130
|
+
# the caller wants the target URL, so hand back Location.
|
|
131
|
+
if not allow_redirects and exc.code in (301, 302, 303, 307, 308):
|
|
132
|
+
return exc.headers.get("Location")
|
|
133
|
+
retry_after = self._retry_wait(exc, attempt)
|
|
134
|
+
if retry_after is not None and attempt < self.max_retries:
|
|
135
|
+
self._sleep(retry_after)
|
|
136
|
+
attempt += 1
|
|
137
|
+
continue
|
|
138
|
+
raise _to_api_error(exc) from exc
|
|
139
|
+
except urllib.error.URLError as exc:
|
|
140
|
+
if attempt < self.max_retries:
|
|
141
|
+
self._sleep(_backoff(attempt))
|
|
142
|
+
attempt += 1
|
|
143
|
+
continue
|
|
144
|
+
raise APIError(f"Network error: {exc.reason}") from exc
|
|
145
|
+
|
|
146
|
+
def _retry_wait(self, exc: urllib.error.HTTPError, attempt: int) -> float | None:
|
|
147
|
+
"""Return seconds to wait before retrying, or ``None`` if not retryable."""
|
|
148
|
+
if exc.code == 429:
|
|
149
|
+
header = exc.headers.get("Retry-After")
|
|
150
|
+
if header:
|
|
151
|
+
if header.strip().isdigit():
|
|
152
|
+
return min(float(header), 60.0)
|
|
153
|
+
# Retry-After may also be an HTTP-date.
|
|
154
|
+
parsed = email.utils.parsedate(header)
|
|
155
|
+
if parsed:
|
|
156
|
+
wait = calendar.timegm(parsed) - time.time()
|
|
157
|
+
return max(0.0, min(wait, 60.0))
|
|
158
|
+
reset = exc.headers.get("X-RateLimit-Reset")
|
|
159
|
+
if reset and reset.isdigit():
|
|
160
|
+
return max(0.0, min(float(reset) - time.time(), 60.0))
|
|
161
|
+
return _backoff(attempt)
|
|
162
|
+
if 500 <= exc.code < 600:
|
|
163
|
+
return _backoff(attempt)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
# -- raindrops: single ----------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def get_raindrop(self, raindrop_id: int) -> dict:
|
|
169
|
+
return self._request("GET", f"/raindrop/{raindrop_id}").get("item", {})
|
|
170
|
+
|
|
171
|
+
def create_raindrop(self, link: str, **fields: Any) -> dict:
|
|
172
|
+
payload = _raindrop_payload(link=link, **fields)
|
|
173
|
+
return self._request("POST", "/raindrop", json_body=payload).get("item", {})
|
|
174
|
+
|
|
175
|
+
def update_raindrop(self, raindrop_id: int, **fields: Any) -> dict:
|
|
176
|
+
payload = _raindrop_payload(**fields)
|
|
177
|
+
return self._request("PUT", f"/raindrop/{raindrop_id}", json_body=payload).get(
|
|
178
|
+
"item", {}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def delete_raindrop(self, raindrop_id: int, *, permanent: bool = False) -> bool:
|
|
182
|
+
"""Delete a raindrop. It moves to Trash first; with ``permanent=True`` a
|
|
183
|
+
second delete removes it from Trash for good. (The undocumented
|
|
184
|
+
``?permanent=true`` query param was tested and does *not* one-shot a live
|
|
185
|
+
raindrop, so we use the documented two-step instead.)"""
|
|
186
|
+
ok = bool(
|
|
187
|
+
self._request("DELETE", f"/raindrop/{raindrop_id}").get("result", False)
|
|
188
|
+
)
|
|
189
|
+
if permanent:
|
|
190
|
+
try:
|
|
191
|
+
self._request("DELETE", f"/raindrop/{raindrop_id}")
|
|
192
|
+
except NotFoundError:
|
|
193
|
+
pass # already gone (it was in Trash before the first delete)
|
|
194
|
+
return ok
|
|
195
|
+
|
|
196
|
+
def upload_file(
|
|
197
|
+
self, filename: str, content: bytes, mime: str, collection_id: int = UNSORTED
|
|
198
|
+
) -> dict:
|
|
199
|
+
files = {"file": (filename, content, mime)}
|
|
200
|
+
form = {"collectionId": str(collection_id)}
|
|
201
|
+
return self._request("PUT", "/raindrop/file", files=files, form=form).get(
|
|
202
|
+
"item", {}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def upload_cover(
|
|
206
|
+
self, raindrop_id: int, filename: str, content: bytes, mime: str
|
|
207
|
+
) -> dict:
|
|
208
|
+
files = {"cover": (filename, content, mime)}
|
|
209
|
+
return self._request("PUT", f"/raindrop/{raindrop_id}/cover", files=files).get(
|
|
210
|
+
"item", {}
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def suggest_new(self, link: str) -> dict:
|
|
214
|
+
return self._request("POST", "/raindrop/suggest", json_body={"link": link}).get(
|
|
215
|
+
"item", {}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def suggest_existing(self, raindrop_id: int) -> dict:
|
|
219
|
+
return self._request("GET", f"/raindrop/{raindrop_id}/suggest").get("item", {})
|
|
220
|
+
|
|
221
|
+
def get_permanent_copy_url(self, raindrop_id: int) -> str | None:
|
|
222
|
+
"""URL of the raindrop's permanent copy, or ``None`` if there isn't one.
|
|
223
|
+
|
|
224
|
+
The endpoint answers 307 with the storage URL in ``Location`` (the copy
|
|
225
|
+
itself is a PRO feature, so a free account or an unarchived link yields
|
|
226
|
+
no redirect). The returned URL is pre-signed and short-lived, which is
|
|
227
|
+
why it is fetched on demand rather than cached.
|
|
228
|
+
"""
|
|
229
|
+
result = self._request(
|
|
230
|
+
"GET", f"/raindrop/{raindrop_id}/cache", allow_redirects=False
|
|
231
|
+
)
|
|
232
|
+
return result if isinstance(result, str) and result else None
|
|
233
|
+
|
|
234
|
+
# -- raindrops: multiple --------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def get_raindrops(
|
|
237
|
+
self,
|
|
238
|
+
collection_id: int = ALL,
|
|
239
|
+
*,
|
|
240
|
+
search: str = "",
|
|
241
|
+
sort: str = "-created",
|
|
242
|
+
page: int = 0,
|
|
243
|
+
perpage: int = PERPAGE_MAX,
|
|
244
|
+
nested: bool = False,
|
|
245
|
+
) -> dict:
|
|
246
|
+
params: dict[str, Any] = {
|
|
247
|
+
"sort": sort,
|
|
248
|
+
"page": page,
|
|
249
|
+
"perpage": min(perpage, PERPAGE_MAX),
|
|
250
|
+
"nested": nested,
|
|
251
|
+
}
|
|
252
|
+
if search:
|
|
253
|
+
params["search"] = search
|
|
254
|
+
return self._request("GET", f"/raindrops/{collection_id}", params=params)
|
|
255
|
+
|
|
256
|
+
def iter_raindrops(
|
|
257
|
+
self,
|
|
258
|
+
collection_id: int = ALL,
|
|
259
|
+
*,
|
|
260
|
+
search: str = "",
|
|
261
|
+
sort: str = "-created",
|
|
262
|
+
nested: bool = False,
|
|
263
|
+
perpage: int = PERPAGE_MAX,
|
|
264
|
+
) -> Iterator[dict]:
|
|
265
|
+
"""Yield every raindrop across all pages."""
|
|
266
|
+
page = 0
|
|
267
|
+
while True:
|
|
268
|
+
data = self.get_raindrops(
|
|
269
|
+
collection_id,
|
|
270
|
+
search=search,
|
|
271
|
+
sort=sort,
|
|
272
|
+
page=page,
|
|
273
|
+
perpage=perpage,
|
|
274
|
+
nested=nested,
|
|
275
|
+
)
|
|
276
|
+
items = data.get("items", [])
|
|
277
|
+
yield from items
|
|
278
|
+
if len(items) < perpage:
|
|
279
|
+
return
|
|
280
|
+
page += 1
|
|
281
|
+
|
|
282
|
+
def create_raindrops(self, items: list[dict]) -> list[dict]:
|
|
283
|
+
return self._request("POST", "/raindrops", json_body={"items": items}).get(
|
|
284
|
+
"items", []
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def update_raindrops(
|
|
288
|
+
self,
|
|
289
|
+
collection_id: int,
|
|
290
|
+
*,
|
|
291
|
+
ids: list[int] | None = None,
|
|
292
|
+
search: str = "",
|
|
293
|
+
nested: bool = False,
|
|
294
|
+
move_to: int | None = None,
|
|
295
|
+
**fields: Any,
|
|
296
|
+
) -> int:
|
|
297
|
+
"""Batch-update raindrops. ``collection_id`` is the path scope (the
|
|
298
|
+
collection the raindrops currently live in; system ``0`` is *not*
|
|
299
|
+
supported here per the API). ``move_to`` sets the destination
|
|
300
|
+
collection; other fields (``important``, ``tags`` (append; ``[]``
|
|
301
|
+
clears), ``cover``) pass through."""
|
|
302
|
+
params = {"nested": nested} if nested else None
|
|
303
|
+
payload = _raindrop_payload(**fields)
|
|
304
|
+
if move_to is not None:
|
|
305
|
+
payload["collection"] = {"$id": move_to}
|
|
306
|
+
if ids:
|
|
307
|
+
payload["ids"] = ids
|
|
308
|
+
if search:
|
|
309
|
+
payload["search"] = search
|
|
310
|
+
data = self._request(
|
|
311
|
+
"PUT", f"/raindrops/{collection_id}", params=params, json_body=payload
|
|
312
|
+
)
|
|
313
|
+
return int(data.get("modified", 0))
|
|
314
|
+
|
|
315
|
+
def delete_raindrops(
|
|
316
|
+
self,
|
|
317
|
+
collection_id: int,
|
|
318
|
+
*,
|
|
319
|
+
ids: list[int] | None = None,
|
|
320
|
+
search: str = "",
|
|
321
|
+
nested: bool = False,
|
|
322
|
+
) -> int:
|
|
323
|
+
params: dict[str, Any] = {}
|
|
324
|
+
if nested:
|
|
325
|
+
params["nested"] = nested
|
|
326
|
+
if search:
|
|
327
|
+
params["search"] = search
|
|
328
|
+
payload = {"ids": ids} if ids else None
|
|
329
|
+
data = self._request(
|
|
330
|
+
"DELETE",
|
|
331
|
+
f"/raindrops/{collection_id}",
|
|
332
|
+
params=params or None,
|
|
333
|
+
json_body=payload,
|
|
334
|
+
)
|
|
335
|
+
return int(data.get("modified", 0))
|
|
336
|
+
|
|
337
|
+
def export(
|
|
338
|
+
self,
|
|
339
|
+
collection_id: int = ALL,
|
|
340
|
+
*,
|
|
341
|
+
fmt: str = "csv",
|
|
342
|
+
sort: str = "-created",
|
|
343
|
+
search: str = "",
|
|
344
|
+
) -> bytes:
|
|
345
|
+
params: dict[str, Any] = {"sort": sort}
|
|
346
|
+
if search:
|
|
347
|
+
params["search"] = search
|
|
348
|
+
return self._request(
|
|
349
|
+
"GET",
|
|
350
|
+
f"/raindrops/{collection_id}/export.{fmt}",
|
|
351
|
+
params=params,
|
|
352
|
+
expect_json=False,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# -- collections ----------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
def get_collections(self) -> list[dict]:
|
|
358
|
+
return self._request("GET", "/collections").get("items", [])
|
|
359
|
+
|
|
360
|
+
def get_child_collections(self) -> list[dict]:
|
|
361
|
+
return self._request("GET", "/collections/childrens").get("items", [])
|
|
362
|
+
|
|
363
|
+
def get_collection(self, collection_id: int) -> dict:
|
|
364
|
+
return self._request("GET", f"/collection/{collection_id}").get("item", {})
|
|
365
|
+
|
|
366
|
+
def create_collection(
|
|
367
|
+
self,
|
|
368
|
+
title: str,
|
|
369
|
+
*,
|
|
370
|
+
view: str | None = None,
|
|
371
|
+
sort: int | None = None,
|
|
372
|
+
public: bool | None = None,
|
|
373
|
+
parent_id: int | None = None,
|
|
374
|
+
) -> dict:
|
|
375
|
+
payload = _collection_payload(
|
|
376
|
+
title=title, view=view, sort=sort, public=public, parent_id=parent_id
|
|
377
|
+
)
|
|
378
|
+
return self._request("POST", "/collection", json_body=payload).get("item", {})
|
|
379
|
+
|
|
380
|
+
def update_collection(self, collection_id: int, **fields: Any) -> dict:
|
|
381
|
+
payload = _collection_payload(**fields)
|
|
382
|
+
return self._request(
|
|
383
|
+
"PUT", f"/collection/{collection_id}", json_body=payload
|
|
384
|
+
).get("item", {})
|
|
385
|
+
|
|
386
|
+
def delete_collection(self, collection_id: int) -> bool:
|
|
387
|
+
return bool(
|
|
388
|
+
self._request("DELETE", f"/collection/{collection_id}").get("result", False)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def delete_collections(self, ids: list[int]) -> bool:
|
|
392
|
+
return bool(
|
|
393
|
+
self._request("DELETE", "/collections", json_body={"ids": ids}).get(
|
|
394
|
+
"result", False
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def reorder_collections(self, sort: str) -> bool:
|
|
399
|
+
return bool(
|
|
400
|
+
self._request("PUT", "/collections", json_body={"sort": sort}).get(
|
|
401
|
+
"result", False
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def expand_collections(self, expanded: bool) -> bool:
|
|
406
|
+
return bool(
|
|
407
|
+
self._request("PUT", "/collections", json_body={"expanded": expanded}).get(
|
|
408
|
+
"result", False
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def merge_collections(self, to: int, ids: list[int]) -> bool:
|
|
413
|
+
return bool(
|
|
414
|
+
self._request(
|
|
415
|
+
"PUT", "/collections/merge", json_body={"to": to, "ids": ids}
|
|
416
|
+
).get("result", False)
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def clean_collections(self) -> int:
|
|
420
|
+
"""Remove all empty collections; return how many were removed."""
|
|
421
|
+
return int(self._request("PUT", "/collections/clean").get("count", 0))
|
|
422
|
+
|
|
423
|
+
def empty_trash(self) -> bool:
|
|
424
|
+
return bool(
|
|
425
|
+
self._request("DELETE", f"/collection/{TRASH}").get("result", False)
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def upload_collection_cover(
|
|
429
|
+
self, collection_id: int, filename: str, content: bytes, mime: str
|
|
430
|
+
) -> dict:
|
|
431
|
+
files = {"cover": (filename, content, mime)}
|
|
432
|
+
return self._request(
|
|
433
|
+
"PUT", f"/collection/{collection_id}/cover", files=files
|
|
434
|
+
).get("item", {})
|
|
435
|
+
|
|
436
|
+
def search_covers(self, text: str) -> list[dict]:
|
|
437
|
+
"""Search the icon/cover library (grouped by provider)."""
|
|
438
|
+
return self._request(
|
|
439
|
+
"GET", f"/collections/covers/{urllib.parse.quote(text)}"
|
|
440
|
+
).get("items", [])
|
|
441
|
+
|
|
442
|
+
def featured_covers(self) -> list[dict]:
|
|
443
|
+
return self._request("GET", "/collections/covers").get("items", [])
|
|
444
|
+
|
|
445
|
+
# -- tags -----------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
def get_tags(self, collection_id: int | None = None) -> list[dict]:
|
|
448
|
+
path = "/tags" + (f"/{collection_id}" if collection_id is not None else "")
|
|
449
|
+
return self._request("GET", path).get("items", [])
|
|
450
|
+
|
|
451
|
+
def rename_tag(self, old: str, new: str, collection_id: int | None = None) -> bool:
|
|
452
|
+
return self.merge_tags([old], new, collection_id)
|
|
453
|
+
|
|
454
|
+
def merge_tags(
|
|
455
|
+
self, tags: list[str], new: str, collection_id: int | None = None
|
|
456
|
+
) -> bool:
|
|
457
|
+
path = "/tags" + (f"/{collection_id}" if collection_id is not None else "")
|
|
458
|
+
payload = {"replace": new, "tags": tags}
|
|
459
|
+
return bool(self._request("PUT", path, json_body=payload).get("result", False))
|
|
460
|
+
|
|
461
|
+
def delete_tags(self, tags: list[str], collection_id: int | None = None) -> bool:
|
|
462
|
+
path = "/tags" + (f"/{collection_id}" if collection_id is not None else "")
|
|
463
|
+
return bool(
|
|
464
|
+
self._request("DELETE", path, json_body={"tags": tags}).get("result", False)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# -- highlights -----------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
def get_all_highlights(self, *, page: int = 0, perpage: int = 25) -> list[dict]:
|
|
470
|
+
params = {"page": page, "perpage": min(perpage, PERPAGE_MAX)}
|
|
471
|
+
return self._request("GET", "/highlights", params=params).get("items", [])
|
|
472
|
+
|
|
473
|
+
def get_collection_highlights(
|
|
474
|
+
self, collection_id: int, *, page: int = 0, perpage: int = 25
|
|
475
|
+
) -> list[dict]:
|
|
476
|
+
params = {"page": page, "perpage": min(perpage, PERPAGE_MAX)}
|
|
477
|
+
return self._request("GET", f"/highlights/{collection_id}", params=params).get(
|
|
478
|
+
"items", []
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def iter_highlights(self, *, perpage: int = PERPAGE_MAX) -> Iterator[dict]:
|
|
482
|
+
page = 0
|
|
483
|
+
while True:
|
|
484
|
+
items = self.get_all_highlights(page=page, perpage=perpage)
|
|
485
|
+
yield from items
|
|
486
|
+
if len(items) < perpage:
|
|
487
|
+
return
|
|
488
|
+
page += 1
|
|
489
|
+
|
|
490
|
+
def get_raindrop_highlights(self, raindrop_id: int) -> list[dict]:
|
|
491
|
+
return self.get_raindrop(raindrop_id).get("highlights", [])
|
|
492
|
+
|
|
493
|
+
def add_highlight(
|
|
494
|
+
self, raindrop_id: int, text: str, *, color: str = "yellow", note: str = ""
|
|
495
|
+
) -> list[dict]:
|
|
496
|
+
highlight = {"text": text, "color": color}
|
|
497
|
+
if note:
|
|
498
|
+
highlight["note"] = note
|
|
499
|
+
item = self._request(
|
|
500
|
+
"PUT", f"/raindrop/{raindrop_id}", json_body={"highlights": [highlight]}
|
|
501
|
+
).get("item", {})
|
|
502
|
+
return item.get("highlights", [])
|
|
503
|
+
|
|
504
|
+
def update_highlight(
|
|
505
|
+
self,
|
|
506
|
+
raindrop_id: int,
|
|
507
|
+
highlight_id: str,
|
|
508
|
+
*,
|
|
509
|
+
text: str | None = None,
|
|
510
|
+
color: str | None = None,
|
|
511
|
+
note: str | None = None,
|
|
512
|
+
) -> list[dict]:
|
|
513
|
+
highlight: dict[str, Any] = {"_id": highlight_id}
|
|
514
|
+
if text is not None:
|
|
515
|
+
highlight["text"] = text
|
|
516
|
+
if color is not None:
|
|
517
|
+
highlight["color"] = color
|
|
518
|
+
if note is not None:
|
|
519
|
+
highlight["note"] = note
|
|
520
|
+
item = self._request(
|
|
521
|
+
"PUT", f"/raindrop/{raindrop_id}", json_body={"highlights": [highlight]}
|
|
522
|
+
).get("item", {})
|
|
523
|
+
return item.get("highlights", [])
|
|
524
|
+
|
|
525
|
+
def delete_highlight(self, raindrop_id: int, highlight_id: str) -> list[dict]:
|
|
526
|
+
"""Remove a highlight (empty ``text`` signals deletion). Returns the
|
|
527
|
+
remaining highlights."""
|
|
528
|
+
item = self._request(
|
|
529
|
+
"PUT",
|
|
530
|
+
f"/raindrop/{raindrop_id}",
|
|
531
|
+
json_body={"highlights": [{"_id": highlight_id, "text": ""}]},
|
|
532
|
+
).get("item", {})
|
|
533
|
+
return item.get("highlights", [])
|
|
534
|
+
|
|
535
|
+
# -- user / filters / stats ----------------------------------------------
|
|
536
|
+
|
|
537
|
+
def get_user(self) -> dict:
|
|
538
|
+
return self._request("GET", "/user").get("user", {})
|
|
539
|
+
|
|
540
|
+
def get_user_by_name(self, name: str) -> dict:
|
|
541
|
+
return self._request("GET", f"/user/{name}").get("user", {})
|
|
542
|
+
|
|
543
|
+
def update_user(self, **fields: Any) -> dict:
|
|
544
|
+
"""Update the authenticated user. Accepts ``fullName``, ``email``,
|
|
545
|
+
``config`` (dict), ``groups`` (list), and ``newpassword`` +
|
|
546
|
+
``oldpassword``. Only non-``None`` fields are sent."""
|
|
547
|
+
payload = {k: v for k, v in fields.items() if v is not None}
|
|
548
|
+
return self._request("PUT", "/user", json_body=payload).get("user", {})
|
|
549
|
+
|
|
550
|
+
def get_stats(self) -> dict:
|
|
551
|
+
"""System collection counts plus meta (pro, duplicates, broken)."""
|
|
552
|
+
return self._request("GET", "/user/stats")
|
|
553
|
+
|
|
554
|
+
def get_filters(
|
|
555
|
+
self, collection_id: int = ALL, *, tags_sort: str = "-count", search: str = ""
|
|
556
|
+
) -> dict:
|
|
557
|
+
params: dict[str, Any] = {"tagsSort": tags_sort}
|
|
558
|
+
if search:
|
|
559
|
+
params["search"] = search
|
|
560
|
+
return self._request("GET", f"/filters/{collection_id}", params=params)
|
|
561
|
+
|
|
562
|
+
# -- import ---------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
def parse_url(self, url: str) -> dict:
|
|
565
|
+
return self._request("GET", "/import/url/parse", params={"url": url}).get(
|
|
566
|
+
"item", {}
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def check_urls_exist(self, urls: list[str]) -> dict:
|
|
570
|
+
"""Return ``{"result": bool, "ids": [...]}`` for already-saved URLs."""
|
|
571
|
+
return self._request("POST", "/import/url/exists", json_body={"urls": urls})
|
|
572
|
+
|
|
573
|
+
def parse_import_file(self, filename: str, content: bytes, mime: str) -> list[dict]:
|
|
574
|
+
"""Convert a Netscape/Pocket/Instapaper HTML export to structured JSON
|
|
575
|
+
(folders + bookmarks). Does not create anything; feed the result to
|
|
576
|
+
``create_raindrops`` to import."""
|
|
577
|
+
files = {"import": (filename, content, mime)}
|
|
578
|
+
return self._request("POST", "/import/file", files=files).get("items", [])
|
|
579
|
+
|
|
580
|
+
# -- backups --------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
def get_backups(self) -> list[dict]:
|
|
583
|
+
return self._request("GET", "/backups").get("items", [])
|
|
584
|
+
|
|
585
|
+
def generate_backup(self) -> bytes:
|
|
586
|
+
return self._request("GET", "/backup", expect_json=False)
|
|
587
|
+
|
|
588
|
+
def download_backup(self, backup_id: str, fmt: str = "csv") -> bytes:
|
|
589
|
+
return self._request("GET", f"/backup/{backup_id}.{fmt}", expect_json=False)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# -- payload builders ---------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _raindrop_payload(**fields: Any) -> dict:
|
|
596
|
+
"""Build a raindrop create/update body from keyword fields, dropping ``None``.
|
|
597
|
+
|
|
598
|
+
``collection_id`` is translated to the API's ``{"collection": {"$id": id}}``
|
|
599
|
+
shape; ``please_parse`` sends an empty object to trigger background parsing.
|
|
600
|
+
"""
|
|
601
|
+
payload: dict[str, Any] = {}
|
|
602
|
+
simple = (
|
|
603
|
+
"link",
|
|
604
|
+
"title",
|
|
605
|
+
"excerpt",
|
|
606
|
+
"note",
|
|
607
|
+
"important",
|
|
608
|
+
"tags",
|
|
609
|
+
"cover",
|
|
610
|
+
"type",
|
|
611
|
+
"order",
|
|
612
|
+
"media",
|
|
613
|
+
"created",
|
|
614
|
+
"lastUpdate",
|
|
615
|
+
"highlights",
|
|
616
|
+
"reminder",
|
|
617
|
+
)
|
|
618
|
+
for key in simple:
|
|
619
|
+
if key in fields and fields[key] is not None:
|
|
620
|
+
payload[key] = fields[key]
|
|
621
|
+
collection_id = fields.get("collection_id")
|
|
622
|
+
if collection_id is not None:
|
|
623
|
+
payload["collection"] = {"$id": collection_id}
|
|
624
|
+
if fields.get("please_parse"):
|
|
625
|
+
payload["pleaseParse"] = {}
|
|
626
|
+
return payload
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _collection_payload(**fields: Any) -> dict:
|
|
630
|
+
payload: dict[str, Any] = {}
|
|
631
|
+
for key in ("title", "view", "sort", "public", "expanded", "cover"):
|
|
632
|
+
if key in fields and fields[key] is not None:
|
|
633
|
+
payload[key] = fields[key]
|
|
634
|
+
parent_id = fields.get("parent_id")
|
|
635
|
+
if parent_id is not None:
|
|
636
|
+
payload["parent"] = {"$id": parent_id}
|
|
637
|
+
return payload
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# -- request helpers ----------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _encode_params(params: dict[str, Any] | None) -> str:
|
|
644
|
+
if not params:
|
|
645
|
+
return ""
|
|
646
|
+
clean: dict[str, str] = {}
|
|
647
|
+
for key, value in params.items():
|
|
648
|
+
if value is None:
|
|
649
|
+
continue
|
|
650
|
+
if isinstance(value, bool):
|
|
651
|
+
clean[key] = "true" if value else "false"
|
|
652
|
+
else:
|
|
653
|
+
clean[key] = str(value)
|
|
654
|
+
return urllib.parse.urlencode(clean)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _multipart(
|
|
658
|
+
files: dict[str, tuple[str, bytes, str]], form: dict[str, str]
|
|
659
|
+
) -> tuple[bytes, str]:
|
|
660
|
+
"""Encode a ``multipart/form-data`` body without external deps."""
|
|
661
|
+
boundary = "----rdcli" + os.urandom(8).hex()
|
|
662
|
+
crlf = b"\r\n"
|
|
663
|
+
parts: list[bytes] = []
|
|
664
|
+
for name, value in form.items():
|
|
665
|
+
parts.append(f"--{boundary}".encode())
|
|
666
|
+
parts.append(f'Content-Disposition: form-data; name="{name}"'.encode())
|
|
667
|
+
parts.append(b"")
|
|
668
|
+
parts.append(value.encode("utf-8"))
|
|
669
|
+
for name, (filename, content, mime) in files.items():
|
|
670
|
+
parts.append(f"--{boundary}".encode())
|
|
671
|
+
parts.append(
|
|
672
|
+
f'Content-Disposition: form-data; name="{name}"; '
|
|
673
|
+
f'filename="{filename}"'.encode()
|
|
674
|
+
)
|
|
675
|
+
parts.append(f"Content-Type: {mime}".encode())
|
|
676
|
+
parts.append(b"")
|
|
677
|
+
parts.append(content)
|
|
678
|
+
parts.append(f"--{boundary}--".encode())
|
|
679
|
+
parts.append(b"")
|
|
680
|
+
body = crlf.join(parts)
|
|
681
|
+
return body, f"multipart/form-data; boundary={boundary}"
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _dry_run_preview(
|
|
685
|
+
json_body: Any,
|
|
686
|
+
files: dict[str, tuple[str, bytes, str]] | None,
|
|
687
|
+
form: dict[str, str] | None,
|
|
688
|
+
) -> str:
|
|
689
|
+
"""Human-readable body preview for ``--dry-run``. Distinguishes a JSON body,
|
|
690
|
+
a multipart upload (whose raw bytes we never dump), and a bodyless request
|
|
691
|
+
(a plain DELETE/PUT) — the last used to be mislabeled ``<multipart>``."""
|
|
692
|
+
if json_body is not None:
|
|
693
|
+
return json.dumps(json_body)
|
|
694
|
+
if files is not None:
|
|
695
|
+
parts = ", ".join(files)
|
|
696
|
+
return (
|
|
697
|
+
f"<multipart {json.dumps(form)} files=[{parts}]>"
|
|
698
|
+
if form
|
|
699
|
+
else (f"<multipart files=[{parts}]>")
|
|
700
|
+
)
|
|
701
|
+
return "<no body>"
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _backoff(attempt: int) -> float:
|
|
705
|
+
"""Exponential backoff: 0.5s, 1s, 2s, ... capped at 30s."""
|
|
706
|
+
return min(0.5 * (2**attempt), 30.0)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _to_api_error(exc: urllib.error.HTTPError) -> APIError:
|
|
710
|
+
message = exc.reason or "request failed"
|
|
711
|
+
payload: dict | None = None
|
|
712
|
+
try:
|
|
713
|
+
raw = exc.read()
|
|
714
|
+
if raw:
|
|
715
|
+
decoded = json.loads(raw)
|
|
716
|
+
if isinstance(decoded, dict):
|
|
717
|
+
payload = decoded
|
|
718
|
+
message = decoded.get("errorMessage") or decoded.get("error") or message
|
|
719
|
+
except (ValueError, OSError):
|
|
720
|
+
pass
|
|
721
|
+
if exc.code in (401, 403):
|
|
722
|
+
return AuthError(message, status=exc.code, payload=payload)
|
|
723
|
+
if exc.code == 404:
|
|
724
|
+
return NotFoundError(message, status=exc.code, payload=payload)
|
|
725
|
+
if exc.code == 429:
|
|
726
|
+
return RateLimitError(message, status=exc.code, payload=payload)
|
|
727
|
+
return APIError(message, status=exc.code, payload=payload)
|