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/output.py ADDED
@@ -0,0 +1,305 @@
1
+ """Terminal output helpers: colour, alignment, trees, and JSON emission.
2
+
3
+ Colour is TTY-aware and opt-out: it is disabled automatically when stdout is
4
+ not a terminal (so piped output stays clean), when ``NO_COLOR`` is set, or when
5
+ the CLI is passed ``--no-color``. The palette leans on the Kanagawa Dragon
6
+ family Brandon uses everywhere.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ from typing import Any
15
+
16
+ # ANSI SGR codes.
17
+ _CODES = {
18
+ "reset": "\033[0m",
19
+ "bold": "\033[1m",
20
+ "dim": "\033[2m",
21
+ "id": "\033[38;5;109m", # muted blue — identifiers
22
+ "title": "\033[1m", # bold — titles
23
+ "url": "\033[38;5;150m", # green — links
24
+ "tag": "\033[38;5;179m", # yellow — tags
25
+ "star": "\033[38;5;174m", # red/pink — important
26
+ "muted": "\033[2m", # dim — secondary text
27
+ "error": "\033[38;5;174m", # red — errors
28
+ "ok": "\033[38;5;150m", # green — success
29
+ }
30
+
31
+ _color_enabled = True
32
+
33
+
34
+ def configure(*, no_color: bool = False, stream=None) -> None:
35
+ """Decide whether colour is on, based on flags, env, and TTY status."""
36
+ global _color_enabled
37
+ stream = stream or sys.stdout
38
+ _color_enabled = not (
39
+ no_color
40
+ or os.environ.get("NO_COLOR") is not None
41
+ or not hasattr(stream, "isatty")
42
+ or not stream.isatty()
43
+ )
44
+
45
+
46
+ def color(text: str, name: str) -> str:
47
+ """Wrap ``text`` in the named colour when colour is enabled."""
48
+ if not _color_enabled:
49
+ return text
50
+ code = _CODES.get(name, "")
51
+ return f"{code}{text}{_CODES['reset']}" if code else text
52
+
53
+
54
+ def emit_json(data: Any) -> None:
55
+ """Print ``data`` as compact-but-readable UTF-8 JSON."""
56
+ print(json.dumps(data, ensure_ascii=False, indent=2))
57
+
58
+
59
+ def error(message: str) -> None:
60
+ print(color(f"error: {message}", "error"), file=sys.stderr)
61
+
62
+
63
+ def success(message: str) -> None:
64
+ print(color(message, "ok"))
65
+
66
+
67
+ def confirm(question: str, *, assume_yes: bool = False, stdin=None) -> bool:
68
+ """Ask ``question`` and return True only on an explicit yes.
69
+
70
+ The prompt goes to stderr, not stdout, so confirming does not pollute a
71
+ redirected or piped stdout. A non-interactive stdin refuses rather than
72
+ prompting: a blocked read would hang a script forever, and defaulting to
73
+ yes would delete things nobody agreed to. Callers pass ``assume_yes`` for
74
+ the ``--yes`` escape hatch.
75
+ """
76
+ if assume_yes:
77
+ return True
78
+ stdin = stdin or sys.stdin
79
+ if not hasattr(stdin, "isatty") or not stdin.isatty():
80
+ error(
81
+ "refusing to prompt with a non-interactive stdin; "
82
+ "pass --yes to confirm up front"
83
+ )
84
+ return False
85
+ print(color(question, "star") + " [y/N] ", end="", file=sys.stderr, flush=True)
86
+ try:
87
+ reply = stdin.readline()
88
+ except (EOFError, KeyboardInterrupt):
89
+ print(file=sys.stderr)
90
+ return False
91
+ if not reply: # EOF (Ctrl-D)
92
+ print(file=sys.stderr)
93
+ return False
94
+ return reply.strip().lower() in ("y", "yes")
95
+
96
+
97
+ # -- domain formatters --------------------------------------------------------
98
+
99
+
100
+ def format_raindrop_line(item: dict, *, detailed: bool = False) -> str:
101
+ """One-line (or multi-line when detailed) rendering of a raindrop."""
102
+ rid = color(f"[{item.get('_id')}]", "id")
103
+ title = color(item.get("title") or "(no title)", "title")
104
+ star = " " + color("★", "star") if item.get("important") else ""
105
+ lines = [f"{rid} {title}{star}"]
106
+ link = item.get("link")
107
+ if link:
108
+ lines.append(" " + color(link, "url"))
109
+ if detailed:
110
+ excerpt = (item.get("excerpt") or "").strip()
111
+ if excerpt:
112
+ lines.append(" " + color(_truncate(excerpt, 200), "muted"))
113
+ note = (item.get("note") or "").strip()
114
+ if note:
115
+ lines.append(" " + color("note: " + _truncate(note, 200), "muted"))
116
+ tags = item.get("tags") or []
117
+ if tags:
118
+ lines.append(" " + " ".join(color(f"#{t}", "tag") for t in tags))
119
+ return "\n".join(lines)
120
+
121
+
122
+ def format_raindrop_detail(item: dict) -> str:
123
+ rid = color(str(item.get("_id")), "id")
124
+ title = color(item.get("title") or "(no title)", "title")
125
+ lines = [f"{rid} {title}"]
126
+ if item.get("important"):
127
+ lines[0] += " " + color("★", "star")
128
+ fields = [
129
+ ("link", item.get("link")),
130
+ ("domain", item.get("domain")),
131
+ ("type", item.get("type")),
132
+ ("created", item.get("created")),
133
+ ("updated", item.get("lastUpdate")),
134
+ ]
135
+ collection_id = _collection_id_of(item)
136
+ if collection_id is not None:
137
+ fields.append(("collection", collection_id))
138
+ for label, value in fields:
139
+ if value:
140
+ lines.append(f" {color(label + ':', 'muted')} {value}")
141
+ excerpt = (item.get("excerpt") or "").strip()
142
+ if excerpt:
143
+ lines.append(f" {color('excerpt:', 'muted')} {excerpt}")
144
+ note = (item.get("note") or "").strip()
145
+ if note:
146
+ lines.append(f" {color('note:', 'muted')} {note}")
147
+ tags = item.get("tags") or []
148
+ if tags:
149
+ lines.append(" " + " ".join(color(f"#{t}", "tag") for t in tags))
150
+ highlights = item.get("highlights") or []
151
+ if highlights:
152
+ lines.append(f" {color('highlights:', 'muted')}")
153
+ for hl in highlights:
154
+ lines.append(f" {color('▍', hl.get('color', 'muted'))} {hl.get('text')}")
155
+ return "\n".join(lines)
156
+
157
+
158
+ def format_collections_flat(items: list[dict]) -> str:
159
+ rows = [
160
+ (
161
+ color(f"[{c.get('_id')}]", "id"),
162
+ c.get("title") or "(untitled)",
163
+ str(c.get("count", 0)),
164
+ )
165
+ for c in items
166
+ ]
167
+ return _columns(rows, headers=None)
168
+
169
+
170
+ def format_collection_tree(roots: list[dict], children: list[dict]) -> str:
171
+ """Render nested collections as an indented tree using ``parent.$id`` links."""
172
+ kids: dict[int, list[dict]] = {}
173
+ for child in children:
174
+ parent = (child.get("parent") or {}).get("$id")
175
+ if parent is not None:
176
+ kids.setdefault(parent, []).append(child)
177
+ for group in kids.values():
178
+ group.sort(key=lambda c: c.get("sort", 0))
179
+
180
+ lines: list[str] = []
181
+
182
+ def walk(node: dict, depth: int) -> None:
183
+ indent = " " * depth
184
+ rid = color(f"[{node.get('_id')}]", "id")
185
+ count = color(f"({node.get('count', 0)})", "muted")
186
+ lines.append(f"{indent}{rid} {node.get('title') or '(untitled)'} {count}")
187
+ for child in kids.get(node.get("_id", 0), []):
188
+ walk(child, depth + 1)
189
+
190
+ for root in roots:
191
+ walk(root, 0)
192
+ return "\n".join(lines)
193
+
194
+
195
+ def format_tags(items: list[dict]) -> str:
196
+ rows = [
197
+ (color(f"#{t.get('_id')}", "tag"), color(f"({t.get('count', 0)})", "muted"))
198
+ for t in items
199
+ ]
200
+ return _columns(rows, headers=None)
201
+
202
+
203
+ def format_highlight_line(hl: dict) -> str:
204
+ marker = color("▍", hl.get("color", "muted"))
205
+ ref = hl.get("raindropRef")
206
+ hid = color(f"[{hl.get('_id')}]", "id")
207
+ ref_str = color(f"rd:{ref}", "muted") if ref else ""
208
+ lines = [f"{hid} {ref_str} {marker} {hl.get('text', '')}".rstrip()]
209
+ note = (hl.get("note") or "").strip()
210
+ if note:
211
+ lines.append(" " + color("note: " + note, "muted"))
212
+ return "\n".join(lines)
213
+
214
+
215
+ # -- pinboard formatters ------------------------------------------------------
216
+
217
+
218
+ def format_pinboard_post(post: dict, *, detailed: bool = False) -> str:
219
+ """One-line (or multi-line when detailed) rendering of a Pinboard bookmark.
220
+ Pinboard keys bookmarks by URL, so the URL is the identifier here."""
221
+ title = color(post.get("description") or "(no title)", "title")
222
+ flags = ""
223
+ if post.get("toread") == "yes":
224
+ flags += " " + color("●unread", "star")
225
+ if post.get("shared") == "no":
226
+ flags += " " + color("🔒", "muted")
227
+ lines = [f"{title}{flags}"]
228
+ link = post.get("href")
229
+ if link:
230
+ lines.append(" " + color(link, "url"))
231
+ extended = (post.get("extended") or "").strip()
232
+ if detailed and extended:
233
+ lines.append(" " + color(_truncate(extended, 200), "muted"))
234
+ tags = (post.get("tags") or "").split()
235
+ if tags:
236
+ lines.append(" " + " ".join(color(f"#{t}", "tag") for t in tags))
237
+ return "\n".join(lines)
238
+
239
+
240
+ def format_pinboard_tags(tags: dict[str, int]) -> str:
241
+ rows = [
242
+ (color(f"#{tag}", "tag"), color(f"({count})", "muted"))
243
+ for tag, count in sorted(tags.items(), key=lambda kv: (-kv[1], kv[0]))
244
+ ]
245
+ return _columns(rows, headers=None)
246
+
247
+
248
+ def format_note_line(note: dict) -> str:
249
+ nid = color(f"[{note.get('id')}]", "id")
250
+ title = color(note.get("title") or "(untitled)", "title")
251
+ length = color(f"({note.get('length', 0)} chars)", "muted")
252
+ updated = color(note.get("updated_at") or "", "muted")
253
+ return f"{nid} {title} {length} {updated}".rstrip()
254
+
255
+
256
+ # -- generic helpers ----------------------------------------------------------
257
+
258
+
259
+ def _columns(rows: list[tuple[str, ...]], headers: tuple[str, ...] | None) -> str:
260
+ """Left-align rows into columns. Widths are computed on visible width so
261
+ ANSI codes do not throw off alignment."""
262
+ all_rows = ([headers] if headers else []) + rows
263
+ if not all_rows:
264
+ return ""
265
+ ncols = max(len(r) for r in all_rows)
266
+ widths = [0] * ncols
267
+ for row in all_rows:
268
+ for i, cell in enumerate(row):
269
+ widths[i] = max(widths[i], _visible_len(cell))
270
+ out = []
271
+ for row in all_rows:
272
+ cells = []
273
+ for i, cell in enumerate(row):
274
+ pad = widths[i] - _visible_len(cell)
275
+ cells.append(cell + " " * pad if i < ncols - 1 else cell)
276
+ out.append(" ".join(cells).rstrip())
277
+ return "\n".join(out)
278
+
279
+
280
+ def _visible_len(text: str) -> int:
281
+ """Length of ``text`` ignoring ANSI escape sequences."""
282
+ result = 0
283
+ i = 0
284
+ while i < len(text):
285
+ if text[i] == "\033":
286
+ end = text.find("m", i)
287
+ if end == -1:
288
+ break
289
+ i = end + 1
290
+ else:
291
+ result += 1
292
+ i += 1
293
+ return result
294
+
295
+
296
+ def _truncate(text: str, limit: int) -> str:
297
+ text = " ".join(text.split())
298
+ return text if len(text) <= limit else text[: limit - 1] + "…"
299
+
300
+
301
+ def _collection_id_of(item: dict) -> int | None:
302
+ collection = item.get("collection")
303
+ if isinstance(collection, dict):
304
+ return collection.get("$id")
305
+ return item.get("collectionId")
rd_cli/pinboard.py ADDED
@@ -0,0 +1,275 @@
1
+ """``PinboardClient`` — a stdlib-only wrapper over the Pinboard API v1
2
+ (https://api.pinboard.in/v1).
3
+
4
+ Sibling to :class:`rd_cli.client.RaindropClient`, and deliberately the same
5
+ shape (one ``_request`` chokepoint, injectable ``opener``/``sleep`` for tests,
6
+ the shared typed-error family), but adapted to Pinboard's realities:
7
+
8
+ - **Auth is a query parameter**, ``auth_token=user:HEX`` (not a Bearer header).
9
+ - **Every endpoint is a GET**, including the mutating ones, so writes are marked
10
+ with ``write=True`` rather than inferred from the HTTP method (that is what
11
+ ``--dry-run`` keys off).
12
+ - **JSON is opt-in** via ``format=json`` on every call.
13
+ - **The rate limit is strict** (one call per ~3s, ``posts/all`` once per 5 min),
14
+ so the client paces itself with a minimum inter-request interval on top of the
15
+ usual ``429`` backoff.
16
+
17
+ Pinboard's data model is flat: bookmarks keyed by URL (there are no numeric ids
18
+ and no collections), tags, and notes. There is no full-text search endpoint;
19
+ filtering is by tag and date only.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from typing import Any
30
+
31
+ from . import __version__
32
+ from .client import _backoff, _encode_params, _to_api_error
33
+ from .errors import APIError
34
+
35
+ BASE_URL = "https://api.pinboard.in/v1"
36
+ USER_AGENT = f"rd-cli/{__version__} (+https://github.com/VirInvictus/rd-cli)"
37
+
38
+ # Pinboard asks for at least three seconds between calls for most endpoints.
39
+ MIN_INTERVAL = 3.0
40
+
41
+
42
+ class PinboardClient:
43
+ def __init__(
44
+ self,
45
+ token: str,
46
+ *,
47
+ base_url: str = BASE_URL,
48
+ timeout: float = 30.0,
49
+ max_retries: int = 3,
50
+ min_interval: float = MIN_INTERVAL,
51
+ dry_run: bool = False,
52
+ opener: urllib.request.OpenerDirector | None = None,
53
+ sleep=time.sleep,
54
+ clock=time.monotonic,
55
+ ) -> None:
56
+ self.token = token
57
+ self.base_url = base_url.rstrip("/")
58
+ self.timeout = timeout
59
+ self.max_retries = max_retries
60
+ self.min_interval = min_interval
61
+ self.dry_run = dry_run
62
+ self._opener = opener or urllib.request.build_opener()
63
+ self._sleep = sleep
64
+ self._clock = clock
65
+ self._last_call: float | None = None
66
+
67
+ # -- core -----------------------------------------------------------------
68
+
69
+ def _request(
70
+ self,
71
+ path: str,
72
+ *,
73
+ params: dict[str, Any] | None = None,
74
+ write: bool = False,
75
+ ) -> Any:
76
+ query = dict(params or {})
77
+ query["auth_token"] = self.token
78
+ query["format"] = "json"
79
+
80
+ if self.dry_run and write:
81
+ shown = dict(params or {})
82
+ print(f"DRY RUN GET {path} {json.dumps(shown)}", file=sys.stderr)
83
+ return {"result_code": "done", "result": "done"}
84
+
85
+ url = f"{self.base_url}{path}?{_encode_params(query)}"
86
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
87
+ req = urllib.request.Request(url, headers=headers, method="GET")
88
+
89
+ attempt = 0
90
+ while True:
91
+ self._pace()
92
+ try:
93
+ with self._opener.open(req, timeout=self.timeout) as resp:
94
+ body = resp.read()
95
+ self._last_call = self._clock()
96
+ return json.loads(body) if body else {}
97
+ except urllib.error.HTTPError as exc:
98
+ self._last_call = self._clock()
99
+ if (
100
+ exc.code == 429 or 500 <= exc.code < 600
101
+ ) and attempt < self.max_retries:
102
+ self._sleep(_backoff(attempt))
103
+ attempt += 1
104
+ continue
105
+ raise _to_api_error(exc) from exc
106
+ except urllib.error.URLError as exc:
107
+ if attempt < self.max_retries:
108
+ self._sleep(_backoff(attempt))
109
+ attempt += 1
110
+ continue
111
+ raise APIError(f"Network error: {exc.reason}") from exc
112
+
113
+ def _pace(self) -> None:
114
+ """Sleep so consecutive calls are at least ``min_interval`` apart."""
115
+ if self._last_call is None or self.min_interval <= 0:
116
+ return
117
+ elapsed = self._clock() - self._last_call
118
+ if elapsed < self.min_interval:
119
+ self._sleep(self.min_interval - elapsed)
120
+
121
+ @staticmethod
122
+ def _check(data: dict) -> dict:
123
+ """Raise if a write response is not ``done``; else return it."""
124
+ code = data.get("result_code") or data.get("result")
125
+ if code and code != "done":
126
+ raise APIError(str(code))
127
+ return data
128
+
129
+ # -- posts ----------------------------------------------------------------
130
+
131
+ def last_update(self) -> str:
132
+ """Timestamp of the most recent bookmark change (cheap, for sync)."""
133
+ return self._request("/posts/update").get("update_time", "")
134
+
135
+ def get_all(
136
+ self,
137
+ *,
138
+ tags: list[str] | None = None,
139
+ start: int | None = None,
140
+ results: int | None = None,
141
+ fromdt: str = "",
142
+ todt: str = "",
143
+ ) -> list[dict]:
144
+ """All bookmarks (rate-limited to once per five minutes)."""
145
+ params: dict[str, Any] = {}
146
+ if tags:
147
+ params["tag"] = " ".join(tags)
148
+ if start is not None:
149
+ params["start"] = start
150
+ if results is not None:
151
+ params["results"] = results
152
+ if fromdt:
153
+ params["fromdt"] = fromdt
154
+ if todt:
155
+ params["todt"] = todt
156
+ data = self._request("/posts/all", params=params or None)
157
+ return data if isinstance(data, list) else data.get("posts", [])
158
+
159
+ def get_recent(
160
+ self, *, tags: list[str] | None = None, count: int = 15
161
+ ) -> list[dict]:
162
+ params: dict[str, Any] = {"count": min(count, 100)}
163
+ if tags:
164
+ params["tag"] = " ".join(tags)
165
+ return self._request("/posts/recent", params=params).get("posts", [])
166
+
167
+ def get_post(self, url: str, *, meta: bool = True) -> dict | None:
168
+ """The bookmark for ``url``, or ``None`` if it is not saved."""
169
+ params: dict[str, Any] = {"url": url}
170
+ if meta:
171
+ params["meta"] = "yes"
172
+ posts = self._request("/posts/get", params=params).get("posts", [])
173
+ return posts[0] if posts else None
174
+
175
+ def add_post(
176
+ self,
177
+ url: str,
178
+ title: str,
179
+ *,
180
+ extended: str = "",
181
+ tags: list[str] | None = None,
182
+ dt: str = "",
183
+ replace: bool = True,
184
+ shared: bool | None = None,
185
+ toread: bool | None = None,
186
+ ) -> dict:
187
+ params: dict[str, Any] = {"url": url, "description": title}
188
+ if extended:
189
+ params["extended"] = extended
190
+ if tags:
191
+ params["tags"] = " ".join(tags)
192
+ if dt:
193
+ params["dt"] = dt
194
+ params["replace"] = "yes" if replace else "no"
195
+ if shared is not None:
196
+ params["shared"] = "yes" if shared else "no"
197
+ if toread is not None:
198
+ params["toread"] = "yes" if toread else "no"
199
+ return self._check(self._request("/posts/add", params=params, write=True))
200
+
201
+ def delete_post(self, url: str) -> dict:
202
+ return self._check(
203
+ self._request("/posts/delete", params={"url": url}, write=True)
204
+ )
205
+
206
+ def suggest_tags(self, url: str) -> dict:
207
+ """``{"popular": [...], "recommended": [...]}`` for ``url``."""
208
+ raw = self._request("/posts/suggest", params={"url": url})
209
+ out: dict[str, list[str]] = {"popular": [], "recommended": []}
210
+ for group in raw if isinstance(raw, list) else []:
211
+ for key in out:
212
+ if key in group:
213
+ out[key] = group[key]
214
+ return out
215
+
216
+ # -- tags -----------------------------------------------------------------
217
+
218
+ def get_tags(self) -> dict[str, int]:
219
+ """Map of tag -> count (Pinboard returns the counts as strings)."""
220
+ raw = self._request("/tags/get")
221
+ return {tag: int(count) for tag, count in raw.items()}
222
+
223
+ def rename_tag(self, old: str, new: str) -> dict:
224
+ return self._check(
225
+ self._request("/tags/rename", params={"old": old, "new": new}, write=True)
226
+ )
227
+
228
+ def delete_tag(self, tag: str) -> dict:
229
+ return self._check(
230
+ self._request("/tags/delete", params={"tag": tag}, write=True)
231
+ )
232
+
233
+ # -- notes ----------------------------------------------------------------
234
+
235
+ def list_notes(self) -> list[dict]:
236
+ return self._request("/notes/list").get("notes", [])
237
+
238
+ def get_note(self, note_id: str) -> dict:
239
+ return self._request(f"/notes/{note_id}")
240
+
241
+ # -- convenience (Pinboard has no PUT; edits re-add with replace) ---------
242
+
243
+ def edit_post(self, url: str, **changes: Any) -> dict:
244
+ """Fetch ``url``, apply ``changes`` (title, extended, tags, shared,
245
+ toread), and re-add it with ``replace=yes``. Pinboard has no update
246
+ endpoint, so a partial edit is a read-modify-write."""
247
+ current = self.get_post(url)
248
+ if current is None:
249
+ raise APIError(f"not saved: {url}")
250
+ title = changes.get("title")
251
+ if title is None:
252
+ title = current.get("description", "")
253
+ extended = changes.get("extended")
254
+ if extended is None:
255
+ extended = current.get("extended", "")
256
+ tags: list[str]
257
+ if "tags" in changes and changes["tags"] is not None:
258
+ tags = list(changes["tags"])
259
+ else:
260
+ tags = str(current.get("tags") or "").split()
261
+ shared = changes.get("shared")
262
+ if shared is None:
263
+ shared = current.get("shared") == "yes"
264
+ toread = changes.get("toread")
265
+ if toread is None:
266
+ toread = current.get("toread") == "yes"
267
+ return self.add_post(
268
+ url,
269
+ title,
270
+ extended=extended,
271
+ tags=tags,
272
+ replace=True,
273
+ shared=shared,
274
+ toread=toread,
275
+ )