zop-cli 0.2.0__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.
- zop/__init__.py +5 -0
- zop/__main__.py +6 -0
- zop/_version.py +5 -0
- zop/adapters/__init__.py +6 -0
- zop/adapters/sqlite_reader.py +476 -0
- zop/adapters/zotero_api.py +315 -0
- zop/cli.py +96 -0
- zop/commands/__init__.py +21 -0
- zop/commands/collection.py +221 -0
- zop/commands/export.py +71 -0
- zop/commands/item.py +176 -0
- zop/commands/library.py +63 -0
- zop/commands/note.py +68 -0
- zop/commands/pdf.py +71 -0
- zop/commands/tag.py +94 -0
- zop/core/__init__.py +5 -0
- zop/core/concurrency.py +38 -0
- zop/core/config.py +66 -0
- zop/core/envelope.py +71 -0
- zop/core/errors.py +92 -0
- zop/models/__init__.py +15 -0
- zop/models/collection.py +45 -0
- zop/models/common.py +30 -0
- zop/models/envelope.py +58 -0
- zop/models/item.py +34 -0
- zop/services/__init__.py +19 -0
- zop/services/collections.py +326 -0
- zop/services/export.py +187 -0
- zop/services/items.py +142 -0
- zop/services/library.py +30 -0
- zop/services/notes.py +47 -0
- zop/services/pdf.py +130 -0
- zop/services/tags.py +99 -0
- zop_cli-0.2.0.dist-info/METADATA +96 -0
- zop_cli-0.2.0.dist-info/RECORD +38 -0
- zop_cli-0.2.0.dist-info/WHEEL +4 -0
- zop_cli-0.2.0.dist-info/entry_points.txt +2 -0
- zop_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Async Zotero Web API client (batch-capable).
|
|
2
|
+
|
|
3
|
+
Differences from pyzotero-based tools (e.g. zot):
|
|
4
|
+
- Real batch POST for collection creation
|
|
5
|
+
- Bounded-concurrency PATCH for item moves (pyzotero's addto_collection is single-item per call)
|
|
6
|
+
- True reparent via PATCH /collections/{key} with parentCollection=false (pyzotero doesn't expose this)
|
|
7
|
+
- Per-item error isolation: one failure doesn't abort the batch
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from zop.core.concurrency import chunked
|
|
20
|
+
from zop.core.errors import ApiError, AuthError, ConflictError, NotFoundError
|
|
21
|
+
|
|
22
|
+
_SENTINEL = object()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ApiCreds:
|
|
27
|
+
library_id: str
|
|
28
|
+
api_key: str
|
|
29
|
+
library_type: str = "user" # "user" or "group"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ZoteroApi:
|
|
33
|
+
"""Thin async wrapper over the Zotero Web API."""
|
|
34
|
+
|
|
35
|
+
BASE_URL = "https://api.zotero.org"
|
|
36
|
+
USER_AGENT = "zop/0.1 (+https://github.com/anomalyco/zop)"
|
|
37
|
+
|
|
38
|
+
# API limits per Zotero docs
|
|
39
|
+
BATCH_WRITE_LIMIT = 50 # max objects per POST/PATCH
|
|
40
|
+
BATCH_READ_LIMIT = 100 # max items per GET
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
creds: ApiCreds,
|
|
45
|
+
*,
|
|
46
|
+
concurrency: int = 8,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
if not creds.library_id or not creds.api_key:
|
|
51
|
+
raise AuthError("library_id and api_key are required")
|
|
52
|
+
self.creds = creds
|
|
53
|
+
self.concurrency = concurrency
|
|
54
|
+
self._client = httpx.AsyncClient(
|
|
55
|
+
base_url=self.BASE_URL,
|
|
56
|
+
headers={
|
|
57
|
+
"Zotero-API-Key": creds.api_key,
|
|
58
|
+
"Zotero-API-Version": "3",
|
|
59
|
+
"User-Agent": self.USER_AGENT,
|
|
60
|
+
},
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
transport=transport,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def __aenter__(self) -> ZoteroApi:
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
69
|
+
await self.close()
|
|
70
|
+
|
|
71
|
+
async def close(self) -> None:
|
|
72
|
+
await self._client.aclose()
|
|
73
|
+
|
|
74
|
+
# ---- URL helpers ----
|
|
75
|
+
|
|
76
|
+
def _root(self) -> str:
|
|
77
|
+
return f"/{self.creds.library_type}s/{self.creds.library_id}"
|
|
78
|
+
|
|
79
|
+
def _coll_url(self, key: str | None = None) -> str:
|
|
80
|
+
base = f"{self._root()}/collections"
|
|
81
|
+
return f"{base}/{key}" if key else base
|
|
82
|
+
|
|
83
|
+
def _items_url(self, key: str | None = None) -> str:
|
|
84
|
+
base = f"{self._root()}/items"
|
|
85
|
+
return f"{base}/{key}" if key else base
|
|
86
|
+
|
|
87
|
+
# ---- Response handling ----
|
|
88
|
+
|
|
89
|
+
def _check(self, resp: httpx.Response) -> Any:
|
|
90
|
+
"""Parse response or raise structured error."""
|
|
91
|
+
if resp.status_code == 404:
|
|
92
|
+
raise NotFoundError(f"Not found: {resp.url}")
|
|
93
|
+
if resp.status_code in (409, 412):
|
|
94
|
+
raise ConflictError(f"Conflict (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
95
|
+
if resp.status_code in (401, 403):
|
|
96
|
+
raise AuthError(f"Auth failed (HTTP {resp.status_code})")
|
|
97
|
+
if resp.status_code >= 400:
|
|
98
|
+
raise ApiError(resp.status_code, resp.text[:200])
|
|
99
|
+
if not resp.content:
|
|
100
|
+
return None
|
|
101
|
+
return resp.json()
|
|
102
|
+
|
|
103
|
+
# ---- Collections ----
|
|
104
|
+
|
|
105
|
+
async def list_collections(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
|
106
|
+
"""Fetch all collections for the library (paginated)."""
|
|
107
|
+
out: list[dict[str, Any]] = []
|
|
108
|
+
start = 0
|
|
109
|
+
while True:
|
|
110
|
+
resp = await self._client.get(
|
|
111
|
+
self._coll_url(),
|
|
112
|
+
params={"limit": min(limit, self.BATCH_READ_LIMIT), "start": start},
|
|
113
|
+
)
|
|
114
|
+
data = self._check(resp)
|
|
115
|
+
if not data:
|
|
116
|
+
break
|
|
117
|
+
out.extend(data)
|
|
118
|
+
if len(data) < self.BATCH_READ_LIMIT:
|
|
119
|
+
break
|
|
120
|
+
start += len(data)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
async def create_collections(
|
|
124
|
+
self, payloads: Sequence[dict[str, Any]]
|
|
125
|
+
) -> list[dict[str, Any]]:
|
|
126
|
+
"""Create up to BATCH_WRITE_LIMIT collections per request.
|
|
127
|
+
|
|
128
|
+
Returns the created collection objects (each with `key`, `version`, etc.)
|
|
129
|
+
in the same order as the input. The Zotero API returns:
|
|
130
|
+
`{"successful": {...}, "unchanged": {...}, "failed": {...}, "success": {...}}`
|
|
131
|
+
"""
|
|
132
|
+
if not payloads:
|
|
133
|
+
return []
|
|
134
|
+
results: list[dict[str, Any]] = []
|
|
135
|
+
for batch in chunked(list(payloads), self.BATCH_WRITE_LIMIT):
|
|
136
|
+
resp = await self._client.post(self._coll_url(), json=list(batch))
|
|
137
|
+
data = self._check(resp)
|
|
138
|
+
if isinstance(data, dict):
|
|
139
|
+
# data has keys: successful (dict by index), unchanged, failed, success
|
|
140
|
+
# Reconstruct ordered list of created collections.
|
|
141
|
+
created = data.get("successful", {})
|
|
142
|
+
if isinstance(created, dict):
|
|
143
|
+
results.extend(created.values())
|
|
144
|
+
else:
|
|
145
|
+
results.extend(created)
|
|
146
|
+
elif isinstance(data, list):
|
|
147
|
+
results.extend(data)
|
|
148
|
+
return results
|
|
149
|
+
|
|
150
|
+
async def update_collection(
|
|
151
|
+
self,
|
|
152
|
+
key: str,
|
|
153
|
+
*,
|
|
154
|
+
name: str | None = None,
|
|
155
|
+
parent_key: str | None | object = _SENTINEL,
|
|
156
|
+
version: int | None = None,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Update a collection.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
key: Collection key.
|
|
162
|
+
name: New name (optional).
|
|
163
|
+
parent_key: New parent key. Pass ``None`` or ``False`` to detach to
|
|
164
|
+
top-level. Default (sentinel) leaves parent unchanged.
|
|
165
|
+
version: If-Unmodified-Since-Version for optimistic locking.
|
|
166
|
+
"""
|
|
167
|
+
payload: dict[str, Any] = {}
|
|
168
|
+
if name is not None:
|
|
169
|
+
payload["name"] = name
|
|
170
|
+
if parent_key is not _SENTINEL:
|
|
171
|
+
payload["parentCollection"] = parent_key if parent_key else False
|
|
172
|
+
headers: dict[str, str] = {}
|
|
173
|
+
if version is not None:
|
|
174
|
+
headers["If-Unmodified-Since-Version"] = str(version)
|
|
175
|
+
resp = await self._client.patch(self._coll_url(key), json=payload, headers=headers)
|
|
176
|
+
return cast("dict[str, Any]", self._check(resp))
|
|
177
|
+
|
|
178
|
+
async def delete_collection(self, key: str, *, version: int | None = None) -> None:
|
|
179
|
+
"""Delete a collection. CASCADE: deletes all subcollections."""
|
|
180
|
+
headers: dict[str, str] = {}
|
|
181
|
+
if version is not None:
|
|
182
|
+
headers["If-Unmodified-Since-Version"] = str(version)
|
|
183
|
+
resp = await self._client.delete(self._coll_url(key), headers=headers)
|
|
184
|
+
self._check(resp)
|
|
185
|
+
|
|
186
|
+
# ---- Items ----
|
|
187
|
+
|
|
188
|
+
async def get_item(self, key: str) -> dict[str, Any]:
|
|
189
|
+
resp = await self._client.get(self._items_url(key))
|
|
190
|
+
return cast("dict[str, Any]", self._check(resp))
|
|
191
|
+
|
|
192
|
+
async def update_item_collections(
|
|
193
|
+
self, key: str, collections: list[str], *, version: int | None = None
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""Set an item's collection membership (replaces existing)."""
|
|
196
|
+
headers: dict[str, str] = {}
|
|
197
|
+
if version is not None:
|
|
198
|
+
headers["If-Unmodified-Since-Version"] = str(version)
|
|
199
|
+
resp = await self._client.patch(
|
|
200
|
+
self._items_url(key), json={"collections": collections}, headers=headers
|
|
201
|
+
)
|
|
202
|
+
return cast("dict[str, Any]", self._check(resp))
|
|
203
|
+
|
|
204
|
+
async def batch_update_item_collections(
|
|
205
|
+
self,
|
|
206
|
+
updates: Sequence[tuple[str, list[str], int | None]],
|
|
207
|
+
*,
|
|
208
|
+
concurrency: int | None = None,
|
|
209
|
+
) -> tuple[list[str], list[tuple[str, Exception]]]:
|
|
210
|
+
"""Move many items concurrently with bounded parallelism.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
updates: Sequence of ``(item_key, target_collections, version)``.
|
|
214
|
+
concurrency: Override default concurrency.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
``(success_keys, failures)`` where ``failures`` is a list of
|
|
218
|
+
``(item_key, exception)`` pairs.
|
|
219
|
+
"""
|
|
220
|
+
if not updates:
|
|
221
|
+
return [], []
|
|
222
|
+
sem = asyncio.Semaphore(concurrency or self.concurrency)
|
|
223
|
+
|
|
224
|
+
async def _one(item_key: str, colls: list[str], ver: int | None) -> str:
|
|
225
|
+
async with sem:
|
|
226
|
+
await self.update_item_collections(item_key, colls, version=ver)
|
|
227
|
+
return item_key
|
|
228
|
+
|
|
229
|
+
results = await asyncio.gather(
|
|
230
|
+
*(_one(k, c, v) for k, c, v in updates), return_exceptions=True
|
|
231
|
+
)
|
|
232
|
+
successes: list[str] = []
|
|
233
|
+
failures: list[tuple[str, Exception]] = []
|
|
234
|
+
for (k, _, _), r in zip(updates, results, strict=True):
|
|
235
|
+
if isinstance(r, Exception):
|
|
236
|
+
failures.append((k, r))
|
|
237
|
+
else:
|
|
238
|
+
successes.append(cast(str, r))
|
|
239
|
+
return successes, failures
|
|
240
|
+
|
|
241
|
+
async def update_item(
|
|
242
|
+
self,
|
|
243
|
+
key: str,
|
|
244
|
+
data: dict[str, Any],
|
|
245
|
+
*,
|
|
246
|
+
version: int | None = None,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
"""Patch an item's fields.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
key: Item key.
|
|
252
|
+
data: Partial item payload (e.g. ``{"tags": [...]}`` or
|
|
253
|
+
``{"title": ...}``). Replaces the listed fields server-side.
|
|
254
|
+
version: ``If-Unmodified-Since-Version`` for optimistic locking.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
The updated item dict, or an empty dict on an empty response.
|
|
258
|
+
"""
|
|
259
|
+
headers: dict[str, str] = {}
|
|
260
|
+
if version is not None:
|
|
261
|
+
headers["If-Unmodified-Since-Version"] = str(version)
|
|
262
|
+
resp = await self._client.patch(self._items_url(key), json=data, headers=headers)
|
|
263
|
+
result = self._check(resp)
|
|
264
|
+
return result if isinstance(result, dict) else {}
|
|
265
|
+
|
|
266
|
+
async def delete_item(self, key: str, *, version: int | None = None) -> None:
|
|
267
|
+
"""Delete an item.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
key: Item key.
|
|
271
|
+
version: ``If-Unmodified-Since-Version``. Zotero requires this for
|
|
272
|
+
item DELETE in practice.
|
|
273
|
+
"""
|
|
274
|
+
headers: dict[str, str] = {}
|
|
275
|
+
if version is not None:
|
|
276
|
+
headers["If-Unmodified-Since-Version"] = str(version)
|
|
277
|
+
resp = await self._client.delete(self._items_url(key), headers=headers)
|
|
278
|
+
self._check(resp)
|
|
279
|
+
|
|
280
|
+
async def create_items(
|
|
281
|
+
self, payloads: Sequence[dict[str, Any]]
|
|
282
|
+
) -> list[dict[str, Any]]:
|
|
283
|
+
"""Create items via batch POST /items.
|
|
284
|
+
|
|
285
|
+
Sends payloads as a single POST body (list), chunked at
|
|
286
|
+
``BATCH_WRITE_LIMIT``. Returns the created item dicts extracted from
|
|
287
|
+
the server's ``successful`` envelope (each contains ``key``,
|
|
288
|
+
``version``, etc.), in the order the server reports them.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
payloads: Item template dicts (e.g.
|
|
292
|
+
``[{"itemType": "journalArticle", "DOI": "..."}]``).
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
List of created items. Empty if the server rejected all entries;
|
|
296
|
+
the caller decides whether empty means an error.
|
|
297
|
+
"""
|
|
298
|
+
if not payloads:
|
|
299
|
+
return []
|
|
300
|
+
results: list[dict[str, Any]] = []
|
|
301
|
+
for batch in chunked(list(payloads), self.BATCH_WRITE_LIMIT):
|
|
302
|
+
resp = await self._client.post(self._items_url(), json=list(batch))
|
|
303
|
+
data = self._check(resp)
|
|
304
|
+
if isinstance(data, dict):
|
|
305
|
+
created = data.get("successful", {})
|
|
306
|
+
if isinstance(created, dict):
|
|
307
|
+
results.extend(created.values())
|
|
308
|
+
else:
|
|
309
|
+
results.extend(created)
|
|
310
|
+
elif isinstance(data, list):
|
|
311
|
+
results.extend(data)
|
|
312
|
+
return results
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
__all__ = ["ApiCreds", "ZoteroApi"]
|
zop/cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import NoReturn
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from zop import __version__
|
|
11
|
+
from zop.commands.collection import collection as collection_cmd
|
|
12
|
+
from zop.commands.export import export_cmd
|
|
13
|
+
from zop.commands.item import item as item_cmd
|
|
14
|
+
from zop.commands.library import duplicates_cmd, recent_cmd, stats_cmd
|
|
15
|
+
from zop.commands.note import note as note_cmd
|
|
16
|
+
from zop.commands.pdf import pdf as pdf_cmd
|
|
17
|
+
from zop.commands.tag import tag as tag_cmd
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group(
|
|
21
|
+
name="zop",
|
|
22
|
+
help="High-throughput Zotero CLI. Reads from local SQLite; writes via Web API.",
|
|
23
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
24
|
+
)
|
|
25
|
+
@click.version_option(__version__, "-V", "--version")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--json",
|
|
28
|
+
"force_json",
|
|
29
|
+
is_flag=True,
|
|
30
|
+
default=False,
|
|
31
|
+
help="Force JSON envelope output.",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--human",
|
|
35
|
+
"force_human",
|
|
36
|
+
is_flag=True,
|
|
37
|
+
default=False,
|
|
38
|
+
help="Force human-readable output.",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"-q",
|
|
42
|
+
"--quiet",
|
|
43
|
+
is_flag=True,
|
|
44
|
+
default=False,
|
|
45
|
+
help="Suppress non-essential output (errors only).",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"-v",
|
|
49
|
+
"--verbose",
|
|
50
|
+
count=True,
|
|
51
|
+
help="Increase verbosity (-v, -vv).",
|
|
52
|
+
)
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def main(
|
|
55
|
+
ctx: click.Context,
|
|
56
|
+
force_json: bool,
|
|
57
|
+
force_human: bool,
|
|
58
|
+
quiet: bool,
|
|
59
|
+
verbose: int,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Entry point."""
|
|
62
|
+
if force_json and force_human:
|
|
63
|
+
raise click.UsageError("--json and --human are mutually exclusive")
|
|
64
|
+
if force_json:
|
|
65
|
+
human = False
|
|
66
|
+
elif force_human:
|
|
67
|
+
human = True
|
|
68
|
+
else:
|
|
69
|
+
human = sys.stdout.isatty()
|
|
70
|
+
ctx.ensure_object(dict)
|
|
71
|
+
ctx.obj["human"] = human
|
|
72
|
+
ctx.obj["quiet"] = quiet
|
|
73
|
+
ctx.obj["verbose"] = verbose
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
main.add_command(collection_cmd, name="collection")
|
|
77
|
+
main.add_command(item_cmd, name="item")
|
|
78
|
+
main.add_command(tag_cmd, name="tag")
|
|
79
|
+
main.add_command(note_cmd, name="note")
|
|
80
|
+
main.add_command(pdf_cmd, name="pdf")
|
|
81
|
+
main.add_command(export_cmd, name="export")
|
|
82
|
+
main.add_command(stats_cmd, name="stats")
|
|
83
|
+
main.add_command(recent_cmd, name="recent")
|
|
84
|
+
main.add_command(duplicates_cmd, name="duplicates")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run() -> NoReturn:
|
|
88
|
+
"""Entry point for setuptools/uv script wrapper."""
|
|
89
|
+
try:
|
|
90
|
+
main(standalone_mode=True)
|
|
91
|
+
except click.ClickException as exc:
|
|
92
|
+
exc.show()
|
|
93
|
+
sys.exit(exc.exit_code)
|
|
94
|
+
except click.exceptions.Abort:
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
sys.exit(0)
|
zop/commands/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""CLI command groups."""
|
|
2
|
+
|
|
3
|
+
from zop.commands.collection import collection
|
|
4
|
+
from zop.commands.export import export_cmd
|
|
5
|
+
from zop.commands.item import item
|
|
6
|
+
from zop.commands.library import duplicates_cmd, recent_cmd, stats_cmd
|
|
7
|
+
from zop.commands.note import note
|
|
8
|
+
from zop.commands.pdf import pdf
|
|
9
|
+
from zop.commands.tag import tag
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"collection",
|
|
13
|
+
"duplicates_cmd",
|
|
14
|
+
"export_cmd",
|
|
15
|
+
"item",
|
|
16
|
+
"note",
|
|
17
|
+
"pdf",
|
|
18
|
+
"recent_cmd",
|
|
19
|
+
"stats_cmd",
|
|
20
|
+
"tag",
|
|
21
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Collection CLI subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from zop.adapters.zotero_api import ApiCreds
|
|
13
|
+
from zop.core.config import load_config
|
|
14
|
+
from zop.core.envelope import emit, emit_batch, emit_error
|
|
15
|
+
from zop.core.errors import ZopError
|
|
16
|
+
from zop.services.collections import CollectionsService, PlanNode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _service(ctx: click.Context) -> CollectionsService:
|
|
20
|
+
cfg = load_config()
|
|
21
|
+
creds: ApiCreds | None = None
|
|
22
|
+
if cfg.has_write_credentials:
|
|
23
|
+
creds = ApiCreds(library_id=cfg.library_id, api_key=cfg.api_key)
|
|
24
|
+
if not cfg.data_dir:
|
|
25
|
+
raise click.UsageError(
|
|
26
|
+
"data_dir not configured. Run 'zop config init' or set in "
|
|
27
|
+
"~/.config/zop/config.toml"
|
|
28
|
+
)
|
|
29
|
+
return CollectionsService(db_path=Path(cfg.data_dir) / "zotero.sqlite", creds=creds)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _human() -> bool:
|
|
33
|
+
return sys.stdout.isatty()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.group(name="collection")
|
|
37
|
+
def collection() -> None:
|
|
38
|
+
"""Manage Zotero collections."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@collection.command("list")
|
|
42
|
+
@click.option("--tree", is_flag=True, help="Show as parent/child tree.")
|
|
43
|
+
@click.option("--flat", is_flag=True, help="Show as flat list (default).")
|
|
44
|
+
@click.pass_context
|
|
45
|
+
def list_cmd(ctx: click.Context, tree: bool, flat: bool) -> None:
|
|
46
|
+
"""List all collections."""
|
|
47
|
+
try:
|
|
48
|
+
svc = _service(ctx)
|
|
49
|
+
if tree:
|
|
50
|
+
nodes = svc.list_tree()
|
|
51
|
+
data = [n.model_dump() for n in nodes]
|
|
52
|
+
else:
|
|
53
|
+
data = [c.model_dump() for c in svc.list_all()]
|
|
54
|
+
emit(data, human=_human(), count=len(data))
|
|
55
|
+
except ZopError as e:
|
|
56
|
+
emit_error(e, human=_human())
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@collection.command("items")
|
|
61
|
+
@click.argument("key")
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def items_cmd(ctx: click.Context, key: str) -> None:
|
|
64
|
+
"""List item keys in a collection (by KEY)."""
|
|
65
|
+
try:
|
|
66
|
+
svc = _service(ctx)
|
|
67
|
+
data = svc.items(key)
|
|
68
|
+
emit(data, human=_human(), count=len(data))
|
|
69
|
+
except ZopError as e:
|
|
70
|
+
emit_error(e, human=_human())
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@collection.command("create")
|
|
75
|
+
@click.argument("name")
|
|
76
|
+
@click.option("--parent", "parent_name", default=None, help="Parent collection NAME.")
|
|
77
|
+
@click.pass_context
|
|
78
|
+
def create_cmd(ctx: click.Context, name: str, parent_name: str | None) -> None:
|
|
79
|
+
"""Create a collection (NAME). Requires API key."""
|
|
80
|
+
try:
|
|
81
|
+
svc = _service(ctx)
|
|
82
|
+
result = asyncio.run(svc.create(name, parent=parent_name))
|
|
83
|
+
emit([result.model_dump()], human=_human(), count=1)
|
|
84
|
+
except ZopError as e:
|
|
85
|
+
emit_error(e, human=_human())
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@collection.command("delete")
|
|
90
|
+
@click.argument("key")
|
|
91
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
|
92
|
+
@click.pass_context
|
|
93
|
+
def delete_cmd(ctx: click.Context, key: str, yes: bool) -> None:
|
|
94
|
+
"""Delete a collection (cascades to subcollections). Requires API key."""
|
|
95
|
+
if not yes:
|
|
96
|
+
click.confirm(f"Delete collection {key} (and all subcollections)?", abort=True)
|
|
97
|
+
try:
|
|
98
|
+
svc = _service(ctx)
|
|
99
|
+
asyncio.run(svc.delete(key))
|
|
100
|
+
emit({"deleted": key}, human=_human())
|
|
101
|
+
except ZopError as e:
|
|
102
|
+
emit_error(e, human=_human())
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@collection.command("reparent")
|
|
107
|
+
@click.argument("key")
|
|
108
|
+
@click.option("--parent", "parent_name", default=None, help="New parent NAME (omit for top-level).")
|
|
109
|
+
@click.pass_context
|
|
110
|
+
def reparent_cmd(ctx: click.Context, key: str, parent_name: str | None) -> None:
|
|
111
|
+
"""Move a collection under a new parent (or to top-level). Requires API key."""
|
|
112
|
+
try:
|
|
113
|
+
svc = _service(ctx)
|
|
114
|
+
result = asyncio.run(svc.reparent(key, parent_name))
|
|
115
|
+
emit([result.model_dump()], human=_human(), count=1)
|
|
116
|
+
except ZopError as e:
|
|
117
|
+
emit_error(e, human=_human())
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@collection.command("move")
|
|
122
|
+
@click.argument("item_keys", nargs=-1, required=True)
|
|
123
|
+
@click.option("--to", "to_collection_key", required=True, help="Target collection KEY.")
|
|
124
|
+
@click.pass_context
|
|
125
|
+
def move_cmd(
|
|
126
|
+
ctx: click.Context,
|
|
127
|
+
item_keys: tuple[str, ...],
|
|
128
|
+
to_collection_key: str,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Move items into a collection. Bounded-concurrent PATCH."""
|
|
131
|
+
try:
|
|
132
|
+
svc = _service(ctx)
|
|
133
|
+
ok, fail = asyncio.run(svc.move_items(list(item_keys), to_collection_key))
|
|
134
|
+
emit_batch(
|
|
135
|
+
[{"key": k} for k in ok],
|
|
136
|
+
[(k, _wrapped(e)) for k, e in fail],
|
|
137
|
+
human=_human(),
|
|
138
|
+
)
|
|
139
|
+
if fail:
|
|
140
|
+
sys.exit(2)
|
|
141
|
+
except ZopError as e:
|
|
142
|
+
emit_error(e, human=_human())
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@collection.command("plan")
|
|
147
|
+
@click.argument("plan_file", type=click.Path(exists=True, path_type=Path))
|
|
148
|
+
@click.option("--dry-run", is_flag=True, help="Validate without executing.")
|
|
149
|
+
@click.option("--execute", "do_execute", is_flag=True, help="Actually execute.")
|
|
150
|
+
@click.pass_context
|
|
151
|
+
def plan_cmd(
|
|
152
|
+
ctx: click.Context,
|
|
153
|
+
plan_file: Path,
|
|
154
|
+
dry_run: bool,
|
|
155
|
+
do_execute: bool,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Batch reorg from a plan JSON file.
|
|
158
|
+
|
|
159
|
+
Plan format::
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
"collections": [
|
|
163
|
+
{"name": "Topic A", "parent": "ExistingParent", "items": ["KEY1", "KEY2"]},
|
|
164
|
+
{"name": "Topic B", "items": []}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
"""
|
|
168
|
+
if dry_run == do_execute:
|
|
169
|
+
raise click.UsageError("Specify exactly one of --dry-run or --execute")
|
|
170
|
+
try:
|
|
171
|
+
with plan_file.open("r", encoding="utf-8") as f:
|
|
172
|
+
raw = json.load(f)
|
|
173
|
+
plan = [
|
|
174
|
+
PlanNode(
|
|
175
|
+
name=p["name"],
|
|
176
|
+
parent=p.get("parent"),
|
|
177
|
+
items=p.get("items", []),
|
|
178
|
+
)
|
|
179
|
+
for p in raw.get("collections", [])
|
|
180
|
+
]
|
|
181
|
+
svc = _service(ctx)
|
|
182
|
+
report = svc.validate_plan(plan)
|
|
183
|
+
out = {
|
|
184
|
+
"to_create": [
|
|
185
|
+
{"name": n.name, "parent": n.parent, "items": n.items}
|
|
186
|
+
for n in report.to_create
|
|
187
|
+
],
|
|
188
|
+
"assignments": [{"item": k, "collection": c} for k, c in report.item_assignments],
|
|
189
|
+
"conflicts": report.conflicts,
|
|
190
|
+
"unresolved_parents": report.unresolved_parents,
|
|
191
|
+
"ok": report.ok,
|
|
192
|
+
}
|
|
193
|
+
if dry_run:
|
|
194
|
+
emit(out, human=_human())
|
|
195
|
+
if not report.ok:
|
|
196
|
+
sys.exit(2)
|
|
197
|
+
return
|
|
198
|
+
# execute
|
|
199
|
+
if not report.ok:
|
|
200
|
+
emit(out, human=_human())
|
|
201
|
+
sys.exit(2)
|
|
202
|
+
created = asyncio.run(svc.create_many(plan))
|
|
203
|
+
emit(
|
|
204
|
+
{
|
|
205
|
+
"created": [c.model_dump() for c in created],
|
|
206
|
+
"assignments_pending": report.item_assignments,
|
|
207
|
+
},
|
|
208
|
+
human=_human(),
|
|
209
|
+
count=len(created),
|
|
210
|
+
)
|
|
211
|
+
# Note: item assignment to newly-created collections would go here
|
|
212
|
+
# in a follow-up step (separate API call after collections exist).
|
|
213
|
+
except ZopError as e:
|
|
214
|
+
emit_error(e, human=_human())
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _wrapped(e: BaseException) -> ZopError:
|
|
219
|
+
if isinstance(e, ZopError):
|
|
220
|
+
return e
|
|
221
|
+
return ZopError(str(e))
|