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