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/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