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/commands.py
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
"""Command handlers. Each ``cmd_*`` function takes ``(client, args)`` and returns
|
|
2
|
+
a process exit code. ``cfg_*`` handlers operate on local config and ignore the
|
|
3
|
+
client (which may be ``None``). ``cli.py`` wires these to argparse subcommands.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import mimetypes
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import webbrowser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from . import config, output, sync
|
|
16
|
+
from .client import RaindropClient
|
|
17
|
+
from .pinboard import PinboardClient
|
|
18
|
+
|
|
19
|
+
# -- confirmation -------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _assume_yes(args: Any) -> bool:
|
|
23
|
+
"""True when the user has pre-agreed, via ``--yes`` or ``RD_ASSUME_YES``.
|
|
24
|
+
|
|
25
|
+
The env var exists for cron and scripts, which cannot answer a prompt but
|
|
26
|
+
also should not have to thread ``--yes`` through every call site.
|
|
27
|
+
"""
|
|
28
|
+
if getattr(args, "yes", False):
|
|
29
|
+
return True
|
|
30
|
+
return os.environ.get("RD_ASSUME_YES", "").strip().lower() in ("1", "true", "yes")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _confirmed(args: Any, question: str) -> bool:
|
|
34
|
+
"""Gate a destructive operation behind a confirmation.
|
|
35
|
+
|
|
36
|
+
``--dry-run`` passes straight through: it performs no writes, and its whole
|
|
37
|
+
point is to show what would happen without an interrogation first.
|
|
38
|
+
"""
|
|
39
|
+
if getattr(args, "dry_run", False):
|
|
40
|
+
return True
|
|
41
|
+
return output.confirm(question, assume_yes=_assume_yes(args))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _scope_count(client: RaindropClient, collection: int, search: str, nested: bool):
|
|
45
|
+
"""Best-effort count of the raindrops a scope operation would touch.
|
|
46
|
+
|
|
47
|
+
The list endpoint is not documented to return a total, so treat a missing
|
|
48
|
+
``count`` as unknown and let the caller word the prompt without a number
|
|
49
|
+
rather than assert a wrong one.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
envelope = client.get_raindrops(
|
|
53
|
+
collection, search=search, nested=nested, perpage=1
|
|
54
|
+
)
|
|
55
|
+
except Exception:
|
|
56
|
+
return None
|
|
57
|
+
count = envelope.get("count") if isinstance(envelope, dict) else None
|
|
58
|
+
return count if isinstance(count, int) else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _scope_phrase(count, noun: str = "raindrop") -> str:
|
|
62
|
+
if count is None:
|
|
63
|
+
return f"every {noun}"
|
|
64
|
+
return f"{count} {noun}" + ("" if count == 1 else "s")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# -- raindrops ----------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_list(client: RaindropClient, args: Any) -> int:
|
|
71
|
+
if getattr(args, "all", False):
|
|
72
|
+
items = list(
|
|
73
|
+
client.iter_raindrops(
|
|
74
|
+
args.collection,
|
|
75
|
+
search=args.search,
|
|
76
|
+
sort=args.sort,
|
|
77
|
+
nested=args.nested,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
items = client.get_raindrops(
|
|
82
|
+
args.collection,
|
|
83
|
+
search=args.search,
|
|
84
|
+
sort=args.sort,
|
|
85
|
+
page=args.page,
|
|
86
|
+
perpage=args.perpage,
|
|
87
|
+
nested=args.nested,
|
|
88
|
+
).get("items", [])
|
|
89
|
+
if args.json:
|
|
90
|
+
output.emit_json(items)
|
|
91
|
+
return 0
|
|
92
|
+
if not items:
|
|
93
|
+
output.error("no raindrops found")
|
|
94
|
+
return 0
|
|
95
|
+
for item in items:
|
|
96
|
+
print(output.format_raindrop_line(item, detailed=args.detailed))
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_view(client: RaindropClient, args: Any) -> int:
|
|
101
|
+
item = client.get_raindrop(args.id)
|
|
102
|
+
if args.json:
|
|
103
|
+
output.emit_json(item)
|
|
104
|
+
return 0
|
|
105
|
+
if not item:
|
|
106
|
+
output.error(f"raindrop {args.id} not found")
|
|
107
|
+
return 1
|
|
108
|
+
print(output.format_raindrop_detail(item))
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_open(client: RaindropClient, args: Any) -> int:
|
|
113
|
+
"""Open one or more raindrops in the browser (or print their URLs)."""
|
|
114
|
+
resolved: list[dict] = []
|
|
115
|
+
failed = False
|
|
116
|
+
for rid in args.ids:
|
|
117
|
+
item = client.get_raindrop(rid)
|
|
118
|
+
if not item:
|
|
119
|
+
output.error(f"raindrop {rid} not found")
|
|
120
|
+
failed = True
|
|
121
|
+
continue
|
|
122
|
+
if args.cache:
|
|
123
|
+
url = client.get_permanent_copy_url(rid)
|
|
124
|
+
if not url:
|
|
125
|
+
output.error(
|
|
126
|
+
f"raindrop {rid} has no permanent copy "
|
|
127
|
+
"(the archive is a PRO feature, and only some links are stored)"
|
|
128
|
+
)
|
|
129
|
+
failed = True
|
|
130
|
+
continue
|
|
131
|
+
else:
|
|
132
|
+
url = item.get("link")
|
|
133
|
+
if not url:
|
|
134
|
+
output.error(f"raindrop {rid} has no link")
|
|
135
|
+
failed = True
|
|
136
|
+
continue
|
|
137
|
+
resolved.append({"id": rid, "url": url, "title": item.get("title") or ""})
|
|
138
|
+
|
|
139
|
+
if args.json:
|
|
140
|
+
output.emit_json(resolved)
|
|
141
|
+
elif args.print_url:
|
|
142
|
+
for entry in resolved:
|
|
143
|
+
print(entry["url"])
|
|
144
|
+
|
|
145
|
+
if not args.print_url:
|
|
146
|
+
for entry in resolved:
|
|
147
|
+
# Failing to launch is not fatal: on a headless box there may be no
|
|
148
|
+
# browser at all, and the URL is still worth surfacing.
|
|
149
|
+
if not webbrowser.open(entry["url"]):
|
|
150
|
+
output.error(f"could not launch a browser for {entry['url']}")
|
|
151
|
+
failed = True
|
|
152
|
+
|
|
153
|
+
return 1 if failed else 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_add(client: RaindropClient, args: Any) -> int:
|
|
157
|
+
urls = _collect_urls(args)
|
|
158
|
+
if urls is not None:
|
|
159
|
+
return _add_many(client, args, urls)
|
|
160
|
+
if not args.url:
|
|
161
|
+
output.error("provide a URL, or --file/--stdin to add many")
|
|
162
|
+
return 1
|
|
163
|
+
item = client.create_raindrop(
|
|
164
|
+
args.url,
|
|
165
|
+
title=args.title,
|
|
166
|
+
collection_id=args.collection,
|
|
167
|
+
tags=args.tags,
|
|
168
|
+
excerpt=args.excerpt,
|
|
169
|
+
note=args.note,
|
|
170
|
+
important=args.important or None,
|
|
171
|
+
please_parse=not args.no_parse,
|
|
172
|
+
)
|
|
173
|
+
if args.json:
|
|
174
|
+
output.emit_json(item)
|
|
175
|
+
return 0
|
|
176
|
+
output.success(f"added [{item.get('_id')}] {item.get('title') or args.url}")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _collect_urls(args: Any) -> list[str] | None:
|
|
181
|
+
"""URLs for batch add from ``--file`` / ``--stdin``, or ``None`` for single."""
|
|
182
|
+
if getattr(args, "stdin", False):
|
|
183
|
+
text = sys.stdin.read()
|
|
184
|
+
elif getattr(args, "file", None):
|
|
185
|
+
text = Path(args.file).read_text(encoding="utf-8")
|
|
186
|
+
else:
|
|
187
|
+
return None
|
|
188
|
+
return [line.strip() for line in text.splitlines() if line.strip()]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _add_many(client: RaindropClient, args: Any, urls: list[str]) -> int:
|
|
192
|
+
created: list[dict] = []
|
|
193
|
+
for chunk in _chunks(urls, 100): # API caps a batch at 100 items
|
|
194
|
+
items = [
|
|
195
|
+
{"link": url, "collection": {"$id": args.collection}, "pleaseParse": {}}
|
|
196
|
+
for url in chunk
|
|
197
|
+
]
|
|
198
|
+
created.extend(client.create_raindrops(items))
|
|
199
|
+
if args.json:
|
|
200
|
+
output.emit_json(created)
|
|
201
|
+
return 0
|
|
202
|
+
output.success(f"added {len(created)} raindrop(s)")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_edit(client: RaindropClient, args: Any) -> int:
|
|
207
|
+
item = client.update_raindrop(
|
|
208
|
+
args.id,
|
|
209
|
+
title=args.title,
|
|
210
|
+
tags=args.tags,
|
|
211
|
+
collection_id=args.collection,
|
|
212
|
+
note=args.note,
|
|
213
|
+
excerpt=args.excerpt,
|
|
214
|
+
important=_tristate(args.important, args.not_important),
|
|
215
|
+
)
|
|
216
|
+
if args.json:
|
|
217
|
+
output.emit_json(item)
|
|
218
|
+
return 0
|
|
219
|
+
output.success(f"edited [{item.get('_id')}] {item.get('title') or ''}".rstrip())
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cmd_rm(client: RaindropClient, args: Any) -> int:
|
|
224
|
+
if not args.ids and args.from_collection is None:
|
|
225
|
+
output.error("provide raindrop id(s), or --from <collection> for scope mode")
|
|
226
|
+
return 1
|
|
227
|
+
# Scope mode: delete everything in a source collection (optionally filtered
|
|
228
|
+
# by search) in one batch call. The batch endpoint's path is a scope, so a
|
|
229
|
+
# real source collection is required (0 is unsupported for remove-many).
|
|
230
|
+
if getattr(args, "from_collection", None) is not None:
|
|
231
|
+
count = _scope_count(
|
|
232
|
+
client, args.from_collection, args.search or "", args.nested
|
|
233
|
+
)
|
|
234
|
+
where = f"collection {args.from_collection}"
|
|
235
|
+
if args.search:
|
|
236
|
+
where += f" matching {args.search!r}"
|
|
237
|
+
if not _confirmed(
|
|
238
|
+
args,
|
|
239
|
+
f"Remove {_scope_phrase(count)} in {where}?",
|
|
240
|
+
):
|
|
241
|
+
output.error("aborted")
|
|
242
|
+
return 1
|
|
243
|
+
n = client.delete_raindrops(
|
|
244
|
+
args.from_collection, search=args.search or "", nested=args.nested
|
|
245
|
+
)
|
|
246
|
+
if args.json:
|
|
247
|
+
output.emit_json({"modified": n})
|
|
248
|
+
return 0
|
|
249
|
+
output.success(
|
|
250
|
+
f"removed {n} raindrop(s) from collection {args.from_collection}"
|
|
251
|
+
)
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
# Id mode: loop the single-item endpoint (always correct regardless of which
|
|
255
|
+
# collection each raindrop lives in). Only the permanent path asks: a plain
|
|
256
|
+
# remove lands in Trash and is undoable, so a prompt there is just noise.
|
|
257
|
+
if args.permanent and not _confirmed(
|
|
258
|
+
args,
|
|
259
|
+
f"Permanently delete {len(args.ids)} raindrop(s)? This cannot be undone.",
|
|
260
|
+
):
|
|
261
|
+
output.error("aborted")
|
|
262
|
+
return 1
|
|
263
|
+
results = {
|
|
264
|
+
rid: client.delete_raindrop(rid, permanent=args.permanent) for rid in args.ids
|
|
265
|
+
}
|
|
266
|
+
if args.json:
|
|
267
|
+
output.emit_json(results)
|
|
268
|
+
return 0 if all(results.values()) else 1
|
|
269
|
+
ok = sum(1 for v in results.values() if v)
|
|
270
|
+
verb = "permanently deleted" if args.permanent else "moved to trash"
|
|
271
|
+
output.success(f"{verb} {ok}/{len(results)} raindrop(s)")
|
|
272
|
+
return 0 if ok == len(results) else 1
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_export(client: RaindropClient, args: Any) -> int:
|
|
276
|
+
data = client.export(
|
|
277
|
+
args.collection, fmt=args.format, sort=args.sort, search=args.search
|
|
278
|
+
)
|
|
279
|
+
if args.output:
|
|
280
|
+
with open(args.output, "wb") as fh:
|
|
281
|
+
fh.write(data)
|
|
282
|
+
output.success(f"wrote {len(data)} bytes to {args.output}")
|
|
283
|
+
else:
|
|
284
|
+
sys.stdout.buffer.write(data)
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_mv(client: RaindropClient, args: Any) -> int:
|
|
289
|
+
dest = args.collection
|
|
290
|
+
if not args.ids and args.from_collection is None:
|
|
291
|
+
output.error("provide raindrop id(s), or --from <collection> for scope mode")
|
|
292
|
+
return 1
|
|
293
|
+
# Scope mode: move everything in a source collection (optional search) at once.
|
|
294
|
+
if getattr(args, "from_collection", None) is not None:
|
|
295
|
+
count = _scope_count(
|
|
296
|
+
client, args.from_collection, args.search or "", args.nested
|
|
297
|
+
)
|
|
298
|
+
where = f"collection {args.from_collection}"
|
|
299
|
+
if args.search:
|
|
300
|
+
where += f" matching {args.search!r}"
|
|
301
|
+
if not _confirmed(
|
|
302
|
+
args,
|
|
303
|
+
f"Move {_scope_phrase(count)} from {where} into collection {dest}?",
|
|
304
|
+
):
|
|
305
|
+
output.error("aborted")
|
|
306
|
+
return 1
|
|
307
|
+
n = client.update_raindrops(
|
|
308
|
+
args.from_collection,
|
|
309
|
+
search=args.search or "",
|
|
310
|
+
nested=args.nested,
|
|
311
|
+
move_to=dest,
|
|
312
|
+
)
|
|
313
|
+
if args.json:
|
|
314
|
+
output.emit_json({"modified": n})
|
|
315
|
+
return 0
|
|
316
|
+
output.success(f"moved {n} raindrop(s) to collection {dest}")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
# Id mode: loop single-item updates (correct across heterogeneous sources).
|
|
320
|
+
moved = 0
|
|
321
|
+
for rid in args.ids:
|
|
322
|
+
client.update_raindrop(rid, collection_id=dest)
|
|
323
|
+
moved += 1
|
|
324
|
+
if args.json:
|
|
325
|
+
output.emit_json({"moved": moved, "collection": dest})
|
|
326
|
+
return 0
|
|
327
|
+
output.success(f"moved {moved} raindrop(s) to collection {dest}")
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def cmd_tag(client: RaindropClient, args: Any) -> int:
|
|
332
|
+
add = args.add or []
|
|
333
|
+
remove = set(args.remove or [])
|
|
334
|
+
if not (add or remove or args.clear):
|
|
335
|
+
output.error("nothing to do: pass --add, --remove, or --clear")
|
|
336
|
+
return 1
|
|
337
|
+
|
|
338
|
+
# Scope mode: append tags (or clear all) across a whole collection/search.
|
|
339
|
+
if getattr(args, "from_collection", None) is not None:
|
|
340
|
+
if remove:
|
|
341
|
+
output.error(
|
|
342
|
+
"--remove is not supported in scope mode; use ids, or "
|
|
343
|
+
"`tags rm <tag>` to strip a tag from every raindrop"
|
|
344
|
+
)
|
|
345
|
+
return 1
|
|
346
|
+
new_tags: list[str] = [] if args.clear else add
|
|
347
|
+
count = _scope_count(
|
|
348
|
+
client, args.from_collection, args.search or "", args.nested
|
|
349
|
+
)
|
|
350
|
+
where = f"collection {args.from_collection}"
|
|
351
|
+
if args.search:
|
|
352
|
+
where += f" matching {args.search!r}"
|
|
353
|
+
# Appending tags is additive and cheap to undo; --clear destroys every
|
|
354
|
+
# tag in scope, so only that branch asks.
|
|
355
|
+
if args.clear and not _confirmed(
|
|
356
|
+
args,
|
|
357
|
+
f"Clear all tags from {_scope_phrase(count)} in {where}?",
|
|
358
|
+
):
|
|
359
|
+
output.error("aborted")
|
|
360
|
+
return 1
|
|
361
|
+
n = client.update_raindrops(
|
|
362
|
+
args.from_collection,
|
|
363
|
+
search=args.search or "",
|
|
364
|
+
nested=args.nested,
|
|
365
|
+
tags=new_tags,
|
|
366
|
+
)
|
|
367
|
+
if args.json:
|
|
368
|
+
output.emit_json({"modified": n})
|
|
369
|
+
return 0
|
|
370
|
+
output.success(f"updated tags on {n} raindrop(s)")
|
|
371
|
+
return 0
|
|
372
|
+
|
|
373
|
+
# Id mode: compute the new tag set per raindrop (add/remove/clear precisely).
|
|
374
|
+
changed = 0
|
|
375
|
+
for rid in args.ids:
|
|
376
|
+
current = [] if args.clear else list(client.get_raindrop(rid).get("tags") or [])
|
|
377
|
+
merged = [t for t in current if t not in remove]
|
|
378
|
+
for t in add:
|
|
379
|
+
if t not in merged:
|
|
380
|
+
merged.append(t)
|
|
381
|
+
client.update_raindrop(rid, tags=merged)
|
|
382
|
+
changed += 1
|
|
383
|
+
if args.json:
|
|
384
|
+
output.emit_json({"updated": changed})
|
|
385
|
+
return 0
|
|
386
|
+
output.success(f"updated tags on {changed} raindrop(s)")
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# -- collections --------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def cmd_collections_list(client: RaindropClient, args: Any) -> int:
|
|
394
|
+
items = client.get_collections()
|
|
395
|
+
if args.json:
|
|
396
|
+
output.emit_json(items)
|
|
397
|
+
return 0
|
|
398
|
+
print(output.format_collections_flat(items))
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def cmd_collections_tree(client: RaindropClient, args: Any) -> int:
|
|
403
|
+
roots = client.get_collections()
|
|
404
|
+
children = client.get_child_collections()
|
|
405
|
+
if args.json:
|
|
406
|
+
output.emit_json({"roots": roots, "children": children})
|
|
407
|
+
return 0
|
|
408
|
+
print(output.format_collection_tree(roots, children))
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cmd_collections_view(client: RaindropClient, args: Any) -> int:
|
|
413
|
+
item = client.get_collection(args.id)
|
|
414
|
+
if args.json:
|
|
415
|
+
output.emit_json(item)
|
|
416
|
+
return 0
|
|
417
|
+
if not item:
|
|
418
|
+
output.error(f"collection {args.id} not found")
|
|
419
|
+
return 1
|
|
420
|
+
print(output.format_collections_flat([item]))
|
|
421
|
+
return 0
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def cmd_collections_add(client: RaindropClient, args: Any) -> int:
|
|
425
|
+
item = client.create_collection(
|
|
426
|
+
args.title,
|
|
427
|
+
view=args.view,
|
|
428
|
+
public=args.public or None,
|
|
429
|
+
parent_id=args.parent,
|
|
430
|
+
)
|
|
431
|
+
if args.json:
|
|
432
|
+
output.emit_json(item)
|
|
433
|
+
return 0
|
|
434
|
+
output.success(f"created collection [{item.get('_id')}] {item.get('title')}")
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def cmd_collections_edit(client: RaindropClient, args: Any) -> int:
|
|
439
|
+
item = client.update_collection(
|
|
440
|
+
args.id,
|
|
441
|
+
title=args.title,
|
|
442
|
+
view=args.view,
|
|
443
|
+
public=_tristate(args.public, args.private),
|
|
444
|
+
parent_id=args.parent,
|
|
445
|
+
)
|
|
446
|
+
if args.json:
|
|
447
|
+
output.emit_json(item)
|
|
448
|
+
return 0
|
|
449
|
+
output.success(f"edited collection [{item.get('_id')}] {item.get('title')}")
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def cmd_collections_rm(client: RaindropClient, args: Any) -> int:
|
|
454
|
+
# Deleting a collection takes its raindrops with it (they go to Trash), so
|
|
455
|
+
# the blast radius is everything inside, not the one id typed.
|
|
456
|
+
if not _confirmed(
|
|
457
|
+
args,
|
|
458
|
+
f"Delete collection {args.id} and move its raindrops to Trash?",
|
|
459
|
+
):
|
|
460
|
+
output.error("aborted")
|
|
461
|
+
return 1
|
|
462
|
+
ok = client.delete_collection(args.id)
|
|
463
|
+
if args.json:
|
|
464
|
+
output.emit_json({"result": ok})
|
|
465
|
+
return 0 if ok else 1
|
|
466
|
+
if ok:
|
|
467
|
+
output.success(f"deleted collection {args.id}")
|
|
468
|
+
return 0
|
|
469
|
+
output.error(f"failed to delete collection {args.id}")
|
|
470
|
+
return 1
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def cmd_collections_merge(client: RaindropClient, args: Any) -> int:
|
|
474
|
+
ok = client.merge_collections(args.to, args.ids)
|
|
475
|
+
if args.json:
|
|
476
|
+
output.emit_json({"result": ok})
|
|
477
|
+
return 0 if ok else 1
|
|
478
|
+
output.success(f"merged {len(args.ids)} collection(s) into {args.to}")
|
|
479
|
+
return 0
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def cmd_collections_clean(client: RaindropClient, args: Any) -> int:
|
|
483
|
+
count = client.clean_collections()
|
|
484
|
+
if args.json:
|
|
485
|
+
output.emit_json({"removed": count})
|
|
486
|
+
return 0
|
|
487
|
+
output.success(f"removed {count} empty collection(s)")
|
|
488
|
+
return 0
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def cmd_collections_empty_trash(client: RaindropClient, args: Any) -> int:
|
|
492
|
+
if not _confirmed(
|
|
493
|
+
args,
|
|
494
|
+
"Permanently delete everything in Trash? This cannot be undone.",
|
|
495
|
+
):
|
|
496
|
+
output.error("aborted")
|
|
497
|
+
return 1
|
|
498
|
+
ok = client.empty_trash()
|
|
499
|
+
if args.json:
|
|
500
|
+
output.emit_json({"result": ok})
|
|
501
|
+
return 0 if ok else 1
|
|
502
|
+
output.success("emptied trash")
|
|
503
|
+
return 0
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def cmd_collections_reorder(client: RaindropClient, args: Any) -> int:
|
|
507
|
+
ok = client.reorder_collections(args.by)
|
|
508
|
+
if args.json:
|
|
509
|
+
output.emit_json({"result": ok})
|
|
510
|
+
return 0 if ok else 1
|
|
511
|
+
output.success(f"reordered all collections by {args.by}")
|
|
512
|
+
return 0
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def cmd_collections_cover(client: RaindropClient, args: Any) -> int:
|
|
516
|
+
name, content, mime = _read_file(args.file)
|
|
517
|
+
item = client.upload_collection_cover(args.id, name, content, mime)
|
|
518
|
+
if args.json:
|
|
519
|
+
output.emit_json(item)
|
|
520
|
+
return 0
|
|
521
|
+
output.success(f"set cover on collection {args.id}")
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def cmd_collections_covers(client: RaindropClient, args: Any) -> int:
|
|
526
|
+
groups = client.search_covers(args.text)
|
|
527
|
+
if args.json:
|
|
528
|
+
output.emit_json(groups)
|
|
529
|
+
return 0
|
|
530
|
+
for group in groups:
|
|
531
|
+
print(output.color(group.get("title", "?"), "title"))
|
|
532
|
+
for icon in group.get("icons", []):
|
|
533
|
+
url = icon.get("svg") or icon.get("png")
|
|
534
|
+
if url:
|
|
535
|
+
print(f" {url}")
|
|
536
|
+
return 0
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# -- tags ---------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def cmd_tags_list(client: RaindropClient, args: Any) -> int:
|
|
543
|
+
items = client.get_tags(args.collection)
|
|
544
|
+
if args.json:
|
|
545
|
+
output.emit_json(items)
|
|
546
|
+
return 0
|
|
547
|
+
if not items:
|
|
548
|
+
output.error("no tags found")
|
|
549
|
+
return 0
|
|
550
|
+
print(output.format_tags(items))
|
|
551
|
+
return 0
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def cmd_tags_rename(client: RaindropClient, args: Any) -> int:
|
|
555
|
+
ok = client.rename_tag(args.old, args.new, args.collection)
|
|
556
|
+
if args.json:
|
|
557
|
+
output.emit_json({"result": ok})
|
|
558
|
+
return 0 if ok else 1
|
|
559
|
+
output.success(f"renamed #{args.old} to #{args.new}")
|
|
560
|
+
return 0
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def cmd_tags_merge(client: RaindropClient, args: Any) -> int:
|
|
564
|
+
ok = client.merge_tags(args.tags, args.into, args.collection)
|
|
565
|
+
if args.json:
|
|
566
|
+
output.emit_json({"result": ok})
|
|
567
|
+
return 0 if ok else 1
|
|
568
|
+
output.success(f"merged {', '.join('#' + t for t in args.tags)} into #{args.into}")
|
|
569
|
+
return 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def cmd_tags_rm(client: RaindropClient, args: Any) -> int:
|
|
573
|
+
# Strips the tag from every raindrop carrying it; there is no undo and no
|
|
574
|
+
# way to enumerate what was touched afterwards.
|
|
575
|
+
scope = (
|
|
576
|
+
f" in collection {args.collection}"
|
|
577
|
+
if args.collection is not None
|
|
578
|
+
else " everywhere"
|
|
579
|
+
)
|
|
580
|
+
listed = ", ".join("#" + t for t in args.tags)
|
|
581
|
+
if not _confirmed(args, f"Delete {listed}{scope}? This cannot be undone."):
|
|
582
|
+
output.error("aborted")
|
|
583
|
+
return 1
|
|
584
|
+
ok = client.delete_tags(args.tags, args.collection)
|
|
585
|
+
if args.json:
|
|
586
|
+
output.emit_json({"result": ok})
|
|
587
|
+
return 0 if ok else 1
|
|
588
|
+
output.success(f"deleted tag(s): {', '.join('#' + t for t in args.tags)}")
|
|
589
|
+
return 0
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# -- highlights ---------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def cmd_highlights_list(client: RaindropClient, args: Any) -> int:
|
|
596
|
+
if args.raindrop:
|
|
597
|
+
items = client.get_raindrop_highlights(args.raindrop)
|
|
598
|
+
elif getattr(args, "all", False):
|
|
599
|
+
items = list(client.iter_highlights())
|
|
600
|
+
else:
|
|
601
|
+
items = client.get_all_highlights(page=args.page, perpage=args.perpage)
|
|
602
|
+
if args.json:
|
|
603
|
+
output.emit_json(items)
|
|
604
|
+
return 0
|
|
605
|
+
if not items:
|
|
606
|
+
output.error("no highlights found")
|
|
607
|
+
return 0
|
|
608
|
+
for hl in items:
|
|
609
|
+
print(output.format_highlight_line(hl))
|
|
610
|
+
return 0
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def cmd_highlights_add(client: RaindropClient, args: Any) -> int:
|
|
614
|
+
highlights = client.add_highlight(
|
|
615
|
+
args.raindrop, args.text, color=args.color, note=args.note
|
|
616
|
+
)
|
|
617
|
+
if args.json:
|
|
618
|
+
output.emit_json(highlights)
|
|
619
|
+
return 0
|
|
620
|
+
output.success(f"added highlight to raindrop {args.raindrop}")
|
|
621
|
+
return 0
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def cmd_highlights_edit(client: RaindropClient, args: Any) -> int:
|
|
625
|
+
highlights = client.update_highlight(
|
|
626
|
+
args.raindrop,
|
|
627
|
+
args.highlight,
|
|
628
|
+
text=args.text,
|
|
629
|
+
color=args.color,
|
|
630
|
+
note=args.note,
|
|
631
|
+
)
|
|
632
|
+
if args.json:
|
|
633
|
+
output.emit_json(highlights)
|
|
634
|
+
return 0
|
|
635
|
+
output.success(f"updated highlight {args.highlight}")
|
|
636
|
+
return 0
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def cmd_highlights_rm(client: RaindropClient, args: Any) -> int:
|
|
640
|
+
remaining = client.delete_highlight(args.raindrop, args.highlight)
|
|
641
|
+
if args.json:
|
|
642
|
+
output.emit_json(remaining)
|
|
643
|
+
return 0
|
|
644
|
+
output.success(f"deleted highlight {args.highlight}")
|
|
645
|
+
return 0
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# -- user / filters / suggest / exists ---------------------------------------
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def cmd_user(client: RaindropClient, args: Any) -> int:
|
|
652
|
+
user = client.get_user()
|
|
653
|
+
if args.json:
|
|
654
|
+
output.emit_json(user)
|
|
655
|
+
return 0
|
|
656
|
+
pro = "PRO" if user.get("pro") else "free"
|
|
657
|
+
print(output.color(user.get("fullName") or "(unknown)", "title"))
|
|
658
|
+
print(f" {output.color('id:', 'muted')} {user.get('_id')}")
|
|
659
|
+
print(f" {output.color('email:', 'muted')} {user.get('email')}")
|
|
660
|
+
print(f" {output.color('plan:', 'muted')} {pro}")
|
|
661
|
+
files = user.get("files") or {}
|
|
662
|
+
if files:
|
|
663
|
+
used = files.get("used", 0)
|
|
664
|
+
size = files.get("size", 0)
|
|
665
|
+
print(f" {output.color('files:', 'muted')} {used} / {size} bytes")
|
|
666
|
+
return 0
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def cmd_user_set(client: RaindropClient, args: Any) -> int:
|
|
670
|
+
config_updates: dict[str, str] = {}
|
|
671
|
+
for pair in args.config or []:
|
|
672
|
+
key, sep, value = pair.partition("=")
|
|
673
|
+
if not sep:
|
|
674
|
+
output.error(f"bad --config entry (want key=value): {pair}")
|
|
675
|
+
return 1
|
|
676
|
+
config_updates[key.strip()] = value.strip()
|
|
677
|
+
user = client.update_user(
|
|
678
|
+
fullName=args.name,
|
|
679
|
+
email=args.email,
|
|
680
|
+
newpassword=args.new_password,
|
|
681
|
+
oldpassword=args.old_password,
|
|
682
|
+
config=config_updates or None,
|
|
683
|
+
)
|
|
684
|
+
if args.json:
|
|
685
|
+
output.emit_json(user)
|
|
686
|
+
return 0
|
|
687
|
+
output.success("updated user settings")
|
|
688
|
+
return 0
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def cmd_cover(client: RaindropClient, args: Any) -> int:
|
|
692
|
+
name, content, mime = _read_file(args.file)
|
|
693
|
+
item = client.upload_cover(args.id, name, content, mime)
|
|
694
|
+
if args.json:
|
|
695
|
+
output.emit_json(item)
|
|
696
|
+
return 0
|
|
697
|
+
output.success(f"set cover on raindrop {args.id}")
|
|
698
|
+
return 0
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def cmd_import(client: RaindropClient, args: Any) -> int:
|
|
702
|
+
name, content, mime = _read_file(args.file)
|
|
703
|
+
groups = client.parse_import_file(name, content, mime)
|
|
704
|
+
if not args.create:
|
|
705
|
+
if args.json:
|
|
706
|
+
output.emit_json(groups)
|
|
707
|
+
return 0
|
|
708
|
+
bookmarks = _flatten_bookmarks(groups)
|
|
709
|
+
print(f"parsed {len(bookmarks)} bookmark(s) across {len(groups)} group(s)")
|
|
710
|
+
print("re-run with --create -c <collection> to import them")
|
|
711
|
+
return 0
|
|
712
|
+
bookmarks = _flatten_bookmarks(groups)
|
|
713
|
+
created: list[dict] = []
|
|
714
|
+
for chunk in _chunks(bookmarks, 100):
|
|
715
|
+
items = [
|
|
716
|
+
{
|
|
717
|
+
"link": b["link"],
|
|
718
|
+
"title": b.get("title", ""),
|
|
719
|
+
"excerpt": b.get("excerpt", ""),
|
|
720
|
+
"tags": b.get("tags", []),
|
|
721
|
+
"collection": {"$id": args.collection},
|
|
722
|
+
}
|
|
723
|
+
for b in chunk
|
|
724
|
+
if b.get("link")
|
|
725
|
+
]
|
|
726
|
+
created.extend(client.create_raindrops(items))
|
|
727
|
+
if args.json:
|
|
728
|
+
output.emit_json(created)
|
|
729
|
+
return 0
|
|
730
|
+
output.success(
|
|
731
|
+
f"imported {len(created)} bookmark(s) into collection {args.collection}"
|
|
732
|
+
)
|
|
733
|
+
return 0
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def cmd_stats(client: RaindropClient, args: Any) -> int:
|
|
737
|
+
stats = client.get_stats()
|
|
738
|
+
if args.json:
|
|
739
|
+
output.emit_json(stats)
|
|
740
|
+
return 0
|
|
741
|
+
names = {0: "All", -1: "Unsorted", -99: "Trash"}
|
|
742
|
+
for entry in stats.get("items", []):
|
|
743
|
+
name = names.get(entry.get("_id"), str(entry.get("_id")))
|
|
744
|
+
print(f" {output.color(name + ':', 'muted')} {entry.get('count', 0)}")
|
|
745
|
+
meta = stats.get("meta") or {}
|
|
746
|
+
if meta:
|
|
747
|
+
dups = (meta.get("duplicates") or {}).get("count", 0)
|
|
748
|
+
broken = (meta.get("broken") or {}).get("count", 0)
|
|
749
|
+
print(f" {output.color('duplicates:', 'muted')} {dups}")
|
|
750
|
+
print(f" {output.color('broken:', 'muted')} {broken}")
|
|
751
|
+
return 0
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def cmd_filters(client: RaindropClient, args: Any) -> int:
|
|
755
|
+
filters = client.get_filters(
|
|
756
|
+
args.collection, tags_sort=args.tags_sort, search=args.search
|
|
757
|
+
)
|
|
758
|
+
if args.json:
|
|
759
|
+
output.emit_json(filters)
|
|
760
|
+
return 0
|
|
761
|
+
for key in ("broken", "duplicates", "important", "notag"):
|
|
762
|
+
count = (filters.get(key) or {}).get("count")
|
|
763
|
+
if count is not None:
|
|
764
|
+
print(f" {output.color(key + ':', 'muted')} {count}")
|
|
765
|
+
types = filters.get("types") or []
|
|
766
|
+
if types:
|
|
767
|
+
print(output.color("types:", "muted"))
|
|
768
|
+
for t in types:
|
|
769
|
+
print(f" {t.get('_id')}: {t.get('count', 0)}")
|
|
770
|
+
tags = filters.get("tags") or []
|
|
771
|
+
if tags:
|
|
772
|
+
print(output.color("top tags:", "muted"))
|
|
773
|
+
for t in tags[:20]:
|
|
774
|
+
print(f" #{t.get('_id')}: {t.get('count', 0)}")
|
|
775
|
+
return 0
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def cmd_suggest(client: RaindropClient, args: Any) -> int:
|
|
779
|
+
if args.id is not None:
|
|
780
|
+
item = client.suggest_existing(args.id)
|
|
781
|
+
else:
|
|
782
|
+
item = client.suggest_new(args.url)
|
|
783
|
+
if args.json:
|
|
784
|
+
output.emit_json(item)
|
|
785
|
+
return 0
|
|
786
|
+
collections = [c.get("$id") for c in item.get("collections", [])]
|
|
787
|
+
tags = item.get("tags", [])
|
|
788
|
+
print(output.color("collections:", "muted"), ", ".join(map(str, collections)))
|
|
789
|
+
print(output.color("tags:", "muted"), " ".join(f"#{t}" for t in tags))
|
|
790
|
+
return 0
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def cmd_exists(client: RaindropClient, args: Any) -> int:
|
|
794
|
+
result = client.check_urls_exist(args.urls)
|
|
795
|
+
if args.json:
|
|
796
|
+
output.emit_json(result)
|
|
797
|
+
return 0
|
|
798
|
+
ids = result.get("ids", [])
|
|
799
|
+
if ids:
|
|
800
|
+
output.success(f"already saved (ids: {', '.join(map(str, ids))})")
|
|
801
|
+
else:
|
|
802
|
+
print("not saved")
|
|
803
|
+
return 0
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
# -- backups ------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def cmd_backups_list(client: RaindropClient, args: Any) -> int:
|
|
810
|
+
items = client.get_backups()
|
|
811
|
+
if args.json:
|
|
812
|
+
output.emit_json(items)
|
|
813
|
+
return 0
|
|
814
|
+
if not items:
|
|
815
|
+
output.error("no backups found")
|
|
816
|
+
return 0
|
|
817
|
+
for b in items:
|
|
818
|
+
print(f"{output.color(b.get('_id'), 'id')} {b.get('created')}")
|
|
819
|
+
return 0
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def cmd_backups_create(client: RaindropClient, args: Any) -> int:
|
|
823
|
+
client.generate_backup()
|
|
824
|
+
output.success("backup requested; Raindrop will email the export when ready")
|
|
825
|
+
return 0
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def cmd_backups_download(client: RaindropClient, args: Any) -> int:
|
|
829
|
+
data = client.download_backup(args.id, args.format)
|
|
830
|
+
path = args.output or f"raindrop-backup-{args.id}.{args.format}"
|
|
831
|
+
with open(path, "wb") as fh:
|
|
832
|
+
fh.write(data)
|
|
833
|
+
output.success(f"wrote {len(data)} bytes to {path}")
|
|
834
|
+
return 0
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# -- pinboard -----------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def cmd_pb_list(client: PinboardClient, args: Any) -> int:
|
|
841
|
+
tags = args.tag or None
|
|
842
|
+
if getattr(args, "all", False):
|
|
843
|
+
items = client.get_all(tags=tags)
|
|
844
|
+
else:
|
|
845
|
+
items = client.get_recent(tags=tags, count=args.count)
|
|
846
|
+
if args.toread:
|
|
847
|
+
items = [p for p in items if p.get("toread") == "yes"]
|
|
848
|
+
if args.json:
|
|
849
|
+
output.emit_json(items)
|
|
850
|
+
return 0
|
|
851
|
+
if not items:
|
|
852
|
+
output.error("no bookmarks found")
|
|
853
|
+
return 0
|
|
854
|
+
for post in items:
|
|
855
|
+
print(output.format_pinboard_post(post, detailed=args.detailed))
|
|
856
|
+
return 0
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def cmd_pb_get(client: PinboardClient, args: Any) -> int:
|
|
860
|
+
post = client.get_post(args.url)
|
|
861
|
+
if args.json:
|
|
862
|
+
output.emit_json(post or {})
|
|
863
|
+
return 0 if post else 1
|
|
864
|
+
if not post:
|
|
865
|
+
output.error(f"not saved: {args.url}")
|
|
866
|
+
return 1
|
|
867
|
+
print(output.format_pinboard_post(post, detailed=True))
|
|
868
|
+
return 0
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def cmd_pb_add(client: PinboardClient, args: Any) -> int:
|
|
872
|
+
client.add_post(
|
|
873
|
+
args.url,
|
|
874
|
+
args.title or args.url,
|
|
875
|
+
extended=args.extended or "",
|
|
876
|
+
tags=args.tags,
|
|
877
|
+
dt=args.dt or "",
|
|
878
|
+
replace=not args.no_replace,
|
|
879
|
+
shared=_tristate(args.shared, args.private),
|
|
880
|
+
toread=True if args.toread else None,
|
|
881
|
+
)
|
|
882
|
+
if args.json:
|
|
883
|
+
output.emit_json({"result": "done", "url": args.url})
|
|
884
|
+
return 0
|
|
885
|
+
output.success(f"added {args.url}")
|
|
886
|
+
return 0
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def cmd_pb_rm(client: PinboardClient, args: Any) -> int:
|
|
890
|
+
client.delete_post(args.url)
|
|
891
|
+
if args.json:
|
|
892
|
+
output.emit_json({"result": "done", "url": args.url})
|
|
893
|
+
return 0
|
|
894
|
+
output.success(f"deleted {args.url}")
|
|
895
|
+
return 0
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def cmd_pb_edit(client: PinboardClient, args: Any) -> int:
|
|
899
|
+
client.edit_post(
|
|
900
|
+
args.url,
|
|
901
|
+
title=args.title,
|
|
902
|
+
extended=args.extended,
|
|
903
|
+
tags=args.tags,
|
|
904
|
+
shared=_tristate(args.shared, args.private),
|
|
905
|
+
toread=_tristate(args.toread, args.not_toread),
|
|
906
|
+
)
|
|
907
|
+
if args.json:
|
|
908
|
+
output.emit_json({"result": "done", "url": args.url})
|
|
909
|
+
return 0
|
|
910
|
+
output.success(f"edited {args.url}")
|
|
911
|
+
return 0
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def cmd_pb_tag(client: PinboardClient, args: Any) -> int:
|
|
915
|
+
add = args.add or []
|
|
916
|
+
remove = set(args.remove or [])
|
|
917
|
+
if not (add or remove or args.clear):
|
|
918
|
+
output.error("nothing to do: pass --add, --remove, or --clear")
|
|
919
|
+
return 1
|
|
920
|
+
post = client.get_post(args.url)
|
|
921
|
+
if post is None:
|
|
922
|
+
output.error(f"not saved: {args.url}")
|
|
923
|
+
return 1
|
|
924
|
+
current = [] if args.clear else (post.get("tags") or "").split()
|
|
925
|
+
merged = [t for t in current if t not in remove]
|
|
926
|
+
for t in add:
|
|
927
|
+
if t not in merged:
|
|
928
|
+
merged.append(t)
|
|
929
|
+
client.edit_post(args.url, tags=merged)
|
|
930
|
+
if args.json:
|
|
931
|
+
output.emit_json({"result": "done", "tags": merged})
|
|
932
|
+
return 0
|
|
933
|
+
output.success(f"updated tags on {args.url}")
|
|
934
|
+
return 0
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def cmd_pb_suggest(client: PinboardClient, args: Any) -> int:
|
|
938
|
+
suggestions = client.suggest_tags(args.url)
|
|
939
|
+
if args.json:
|
|
940
|
+
output.emit_json(suggestions)
|
|
941
|
+
return 0
|
|
942
|
+
print(
|
|
943
|
+
output.color("popular:", "muted"),
|
|
944
|
+
" ".join(f"#{t}" for t in suggestions.get("popular", [])),
|
|
945
|
+
)
|
|
946
|
+
print(
|
|
947
|
+
output.color("recommended:", "muted"),
|
|
948
|
+
" ".join(f"#{t}" for t in suggestions.get("recommended", [])),
|
|
949
|
+
)
|
|
950
|
+
return 0
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def cmd_pb_tags_list(client: PinboardClient, args: Any) -> int:
|
|
954
|
+
tags = client.get_tags()
|
|
955
|
+
if args.json:
|
|
956
|
+
output.emit_json(tags)
|
|
957
|
+
return 0
|
|
958
|
+
if not tags:
|
|
959
|
+
output.error("no tags found")
|
|
960
|
+
return 0
|
|
961
|
+
print(output.format_pinboard_tags(tags))
|
|
962
|
+
return 0
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def cmd_pb_tags_rename(client: PinboardClient, args: Any) -> int:
|
|
966
|
+
client.rename_tag(args.old, args.new)
|
|
967
|
+
if args.json:
|
|
968
|
+
output.emit_json({"result": "done"})
|
|
969
|
+
return 0
|
|
970
|
+
output.success(f"renamed #{args.old} to #{args.new}")
|
|
971
|
+
return 0
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def cmd_pb_tags_rm(client: PinboardClient, args: Any) -> int:
|
|
975
|
+
for tag in args.tags:
|
|
976
|
+
client.delete_tag(tag)
|
|
977
|
+
if args.json:
|
|
978
|
+
output.emit_json({"result": "done"})
|
|
979
|
+
return 0
|
|
980
|
+
output.success(f"deleted tag(s): {', '.join('#' + t for t in args.tags)}")
|
|
981
|
+
return 0
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def cmd_pb_notes_list(client: PinboardClient, args: Any) -> int:
|
|
985
|
+
notes = client.list_notes()
|
|
986
|
+
if args.json:
|
|
987
|
+
output.emit_json(notes)
|
|
988
|
+
return 0
|
|
989
|
+
if not notes:
|
|
990
|
+
output.error("no notes found")
|
|
991
|
+
return 0
|
|
992
|
+
for note in notes:
|
|
993
|
+
print(output.format_note_line(note))
|
|
994
|
+
return 0
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def cmd_pb_notes_view(client: PinboardClient, args: Any) -> int:
|
|
998
|
+
note = client.get_note(args.id)
|
|
999
|
+
if args.json:
|
|
1000
|
+
output.emit_json(note)
|
|
1001
|
+
return 0
|
|
1002
|
+
print(output.color(note.get("title") or "(untitled)", "title"))
|
|
1003
|
+
print(note.get("text", ""))
|
|
1004
|
+
return 0
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def cfg_set_pinboard_token(client: Any, args: Any) -> int:
|
|
1008
|
+
path = config.write_pinboard_token(args.token)
|
|
1009
|
+
output.success(f"pinboard token saved to {path}")
|
|
1010
|
+
return 0
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
# -- sync (raindrop <-> pinboard) ---------------------------------------------
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def cmd_sync(client: Any, args: Any) -> int:
|
|
1017
|
+
"""Two-way additive sync. Reads both services, builds a plan, prints it, and
|
|
1018
|
+
(unless --dry-run) applies it. Needs both tokens."""
|
|
1019
|
+
rd = RaindropClient(config.resolve_token())
|
|
1020
|
+
pb = PinboardClient(config.resolve_pinboard_token())
|
|
1021
|
+
|
|
1022
|
+
raindrops = list(rd.iter_raindrops(0))
|
|
1023
|
+
pb_posts = pb.get_all()
|
|
1024
|
+
colls = rd.get_collections() + rd.get_child_collections()
|
|
1025
|
+
title_by_id = {c["_id"]: c.get("title", "") for c in colls}
|
|
1026
|
+
id_by_slug = {sync._slug(c.get("title", "")): c["_id"] for c in colls}
|
|
1027
|
+
|
|
1028
|
+
# Scope predicates: narrow WHAT is pushed; matching still uses the full sets.
|
|
1029
|
+
collections = set(args.collection or [])
|
|
1030
|
+
rd_tags = set(args.rd_tag or [])
|
|
1031
|
+
pb_tags = set(args.pb_tag or [])
|
|
1032
|
+
|
|
1033
|
+
def rd_keep(r: dict) -> bool:
|
|
1034
|
+
if collections and (r.get("collection") or {}).get("$id") not in collections:
|
|
1035
|
+
return False
|
|
1036
|
+
if rd_tags and not (rd_tags & set(r.get("tags") or [])):
|
|
1037
|
+
return False
|
|
1038
|
+
return True
|
|
1039
|
+
|
|
1040
|
+
def pb_keep(p: dict) -> bool:
|
|
1041
|
+
if pb_tags and not (pb_tags & set((p.get("tags") or "").split())):
|
|
1042
|
+
return False
|
|
1043
|
+
return True
|
|
1044
|
+
|
|
1045
|
+
plan = sync.plan_sync(
|
|
1046
|
+
raindrops, pb_posts, title_by_id, id_by_slug, rd_keep=rd_keep, pb_keep=pb_keep
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
# Direction limits which side actually gets written.
|
|
1050
|
+
if args.direction == "to-pinboard":
|
|
1051
|
+
plan.to_raindrop = []
|
|
1052
|
+
for mrg in plan.merges:
|
|
1053
|
+
mrg["rd_changed"] = False
|
|
1054
|
+
elif args.direction == "to-raindrop":
|
|
1055
|
+
plan.to_pinboard = []
|
|
1056
|
+
for mrg in plan.merges:
|
|
1057
|
+
mrg["pb_changed"] = False
|
|
1058
|
+
plan.merges = [m for m in plan.merges if m["rd_changed"] or m["pb_changed"]]
|
|
1059
|
+
|
|
1060
|
+
if args.json and args.dry_run:
|
|
1061
|
+
output.emit_json(
|
|
1062
|
+
{
|
|
1063
|
+
"to_pinboard": plan.to_pinboard,
|
|
1064
|
+
"to_raindrop": plan.to_raindrop,
|
|
1065
|
+
"merges": len(plan.merges),
|
|
1066
|
+
"rd_dupes": plan.rd_dupes,
|
|
1067
|
+
"pb_dupes": plan.pb_dupes,
|
|
1068
|
+
}
|
|
1069
|
+
)
|
|
1070
|
+
return 0
|
|
1071
|
+
|
|
1072
|
+
m = output.color
|
|
1073
|
+
print(f"{m('raindrop -> pinboard (new):', 'muted')} {len(plan.to_pinboard)}")
|
|
1074
|
+
print(f"{m('pinboard -> raindrop (new):', 'muted')} {len(plan.to_raindrop)}")
|
|
1075
|
+
print(f"{m('already on both (merge):', 'muted')} {len(plan.merges)}")
|
|
1076
|
+
if plan.rd_dupes or plan.pb_dupes:
|
|
1077
|
+
print(
|
|
1078
|
+
f"{m('near-dupes collapsed:', 'muted')} "
|
|
1079
|
+
f"raindrop {plan.rd_dupes}, pinboard {plan.pb_dupes}"
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
if args.dry_run:
|
|
1083
|
+
output.success(f"dry run: {plan.total} change(s) planned, none applied")
|
|
1084
|
+
return 0
|
|
1085
|
+
|
|
1086
|
+
counts = sync.apply_plan(plan, rd, pb)
|
|
1087
|
+
if args.json:
|
|
1088
|
+
output.emit_json(counts)
|
|
1089
|
+
return 0
|
|
1090
|
+
output.success(
|
|
1091
|
+
f"synced: +{counts['added_pinboard']} to pinboard, "
|
|
1092
|
+
f"+{counts['added_raindrop']} to raindrop, {counts['merged']} merged"
|
|
1093
|
+
)
|
|
1094
|
+
return 0
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
# -- config -------------------------------------------------------------------
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def cmd_completion(client: Any, args: Any) -> int:
|
|
1101
|
+
"""Print the completion script for a shell.
|
|
1102
|
+
|
|
1103
|
+
Imported lazily and built from the live parser, so the output can never drift
|
|
1104
|
+
from the commands this build actually has.
|
|
1105
|
+
"""
|
|
1106
|
+
from . import cli, completion
|
|
1107
|
+
|
|
1108
|
+
print(completion.generate(args.shell, cli.build_parser()), end="")
|
|
1109
|
+
return 0
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def cfg_path(client: Any, args: Any) -> int:
|
|
1113
|
+
print(config.config_path())
|
|
1114
|
+
return 0
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def cfg_show(client: Any, args: Any) -> int:
|
|
1118
|
+
data = dict(config.read_config())
|
|
1119
|
+
# Mask every secret, not just the Raindrop token (pinboard_token too).
|
|
1120
|
+
for key, value in data.items():
|
|
1121
|
+
if "token" in key and isinstance(value, str):
|
|
1122
|
+
data[key] = _mask(value)
|
|
1123
|
+
if args.json:
|
|
1124
|
+
output.emit_json(data)
|
|
1125
|
+
return 0
|
|
1126
|
+
if not data:
|
|
1127
|
+
print(f"(no config at {config.config_path()})")
|
|
1128
|
+
return 0
|
|
1129
|
+
for key, value in data.items():
|
|
1130
|
+
print(f"{key} = {value}")
|
|
1131
|
+
return 0
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def cfg_set_token(client: Any, args: Any) -> int:
|
|
1135
|
+
path = config.write_token(args.token)
|
|
1136
|
+
output.success(f"token saved to {path}")
|
|
1137
|
+
return 0
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
# -- helpers ------------------------------------------------------------------
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _tristate(true_flag: bool, false_flag: bool) -> bool | None:
|
|
1144
|
+
"""Map a pair of ``--x`` / ``--no-x`` flags to ``True``/``False``/``None``."""
|
|
1145
|
+
if true_flag:
|
|
1146
|
+
return True
|
|
1147
|
+
if false_flag:
|
|
1148
|
+
return False
|
|
1149
|
+
return None
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _mask(token: str) -> str:
|
|
1153
|
+
if len(token) <= 8:
|
|
1154
|
+
return "****"
|
|
1155
|
+
return f"{token[:4]}…{token[-4:]}"
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _chunks(items: list, size: int):
|
|
1159
|
+
for i in range(0, len(items), size):
|
|
1160
|
+
yield items[i : i + size]
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _read_file(path: str) -> tuple[str, bytes, str]:
|
|
1164
|
+
p = Path(path)
|
|
1165
|
+
mime = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
|
|
1166
|
+
return p.name, p.read_bytes(), mime
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _flatten_bookmarks(groups: list[dict]) -> list[dict]:
|
|
1170
|
+
"""Flatten the nested folders/bookmarks tree from ``parse_import_file``."""
|
|
1171
|
+
out: list[dict] = []
|
|
1172
|
+
|
|
1173
|
+
def walk(node: dict) -> None:
|
|
1174
|
+
out.extend(node.get("bookmarks") or [])
|
|
1175
|
+
for folder in node.get("folders") or []:
|
|
1176
|
+
walk(folder)
|
|
1177
|
+
|
|
1178
|
+
for group in groups:
|
|
1179
|
+
walk(group)
|
|
1180
|
+
return out
|