@beomsukoh/zotero-cli 0.2.0 → 0.4.0
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.
- package/README.md +180 -49
- package/dist/adapters/http/HttpZoteroAdapter.js +93 -1
- package/dist/commands/bbtCommand.js +85 -0
- package/dist/commands/downloadCommand.js +59 -0
- package/dist/commands/importCommand.js +127 -0
- package/dist/commands/reparentCommand.js +45 -0
- package/dist/compositionRoot.js +11 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,72 +1,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Zotero CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Control Zotero from the command line. `zotero-cli` wraps the Zotero Web API v3 and Local API through shell-friendly verbs, JSON output, and a consistent option grammar.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```bash
|
|
6
|
+
# Search your library
|
|
7
|
+
zt search "machine learning" --limit 5
|
|
8
|
+
|
|
9
|
+
# Import a paper by DOI
|
|
10
|
+
zt import --doi "10.1145/3025453.3025912"
|
|
11
|
+
|
|
12
|
+
# Export as BibTeX
|
|
13
|
+
zt export --format bibtex --collection ABCD1234
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Node.js 20+
|
|
19
|
+
- Zotero 7+ (for local API reads) or a Zotero API key (for writes)
|
|
20
|
+
- [Zotero Translation Server](https://github.com/zotero/translation-server) (optional, for `zt import`)
|
|
6
21
|
|
|
7
22
|
## Install
|
|
8
23
|
|
|
9
24
|
```bash
|
|
10
|
-
npm install -g @
|
|
25
|
+
npm install -g @beomsukoh/zotero-cli
|
|
11
26
|
```
|
|
12
27
|
|
|
13
|
-
|
|
28
|
+
Verify the installation:
|
|
14
29
|
|
|
15
30
|
```bash
|
|
16
|
-
|
|
17
|
-
|
|
31
|
+
zt items --limit 1
|
|
32
|
+
```
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
zt search "machine learning"
|
|
34
|
+
This prints a JSON array of items from your local Zotero library. If Zotero desktop is not running, the command exits with an error.
|
|
21
35
|
|
|
22
|
-
|
|
23
|
-
zt get ABCD1234
|
|
36
|
+
## Quickstart
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
zt collections
|
|
38
|
+
List items in your library:
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
zt
|
|
40
|
+
```bash
|
|
41
|
+
zt items --limit 10 --sort dateAdded
|
|
42
|
+
```
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
zt template journalArticle
|
|
44
|
+
Search across your library:
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
zt
|
|
46
|
+
```bash
|
|
47
|
+
zt search "attention is all you need"
|
|
48
|
+
```
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
zt attach ./paper.pdf --key ABCD1234
|
|
50
|
+
Get a specific item by key:
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
zt
|
|
52
|
+
```bash
|
|
53
|
+
zt get ABCD1234
|
|
54
|
+
```
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
zt types
|
|
56
|
+
List collections:
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
zt
|
|
58
|
+
```bash
|
|
59
|
+
zt collections
|
|
48
60
|
```
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
Import a paper by DOI (requires Translation Server):
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
```bash
|
|
65
|
+
docker run -d -p 1969:1969 zotero/translation-server
|
|
66
|
+
zt import --doi "10.1145/3025453.3025912"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Attach a PDF to an existing item:
|
|
57
70
|
|
|
58
|
-
**Local mode** (reads only, Zotero desktop must be running):
|
|
59
71
|
```bash
|
|
60
|
-
zt
|
|
72
|
+
zt attach ./paper.pdf --key ABCD1234
|
|
61
73
|
```
|
|
62
74
|
|
|
63
|
-
|
|
75
|
+
Export your library as BibTeX:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
zt export --format bibtex
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Dual-Mode Architecture
|
|
82
|
+
|
|
83
|
+
Zotero CLI supports two modes of operation:
|
|
84
|
+
|
|
85
|
+
| Mode | Base URL | Auth | Capabilities |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| **Local** (default) | `http://localhost:23119/api` | None | Read-only; Zotero desktop must be running |
|
|
88
|
+
| **Web** | `https://api.zotero.org` | API key | Full CRUD; works remotely |
|
|
89
|
+
|
|
90
|
+
**Local mode** requires no configuration — just have Zotero desktop running.
|
|
91
|
+
|
|
92
|
+
**Web mode** requires an API key from [zotero.org/settings/keys](https://www.zotero.org/settings/keys/):
|
|
93
|
+
|
|
64
94
|
```bash
|
|
65
95
|
export ZOTERO_API_KEY=your_key_here
|
|
66
96
|
export ZOTERO_USER_ID=12345
|
|
67
97
|
export ZOTERO_BASE_URL=https://api.zotero.org
|
|
68
98
|
```
|
|
69
99
|
|
|
100
|
+
### Configuration
|
|
101
|
+
|
|
102
|
+
| Variable | Default | Description |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `ZOTERO_BASE_URL` | `http://localhost:23119/api` | API base URL |
|
|
105
|
+
| `ZOTERO_API_KEY` | — | API key (required for writes) |
|
|
106
|
+
| `ZOTERO_USER_ID` | `0` | User ID (`0` = local) |
|
|
107
|
+
| `ZOTERO_TRANSLATION_SERVER` | `http://localhost:1969` | Translation Server URL |
|
|
108
|
+
|
|
70
109
|
## Commands
|
|
71
110
|
|
|
72
111
|
### Core
|
|
@@ -74,15 +113,16 @@ export ZOTERO_BASE_URL=https://api.zotero.org
|
|
|
74
113
|
| Command | Description |
|
|
75
114
|
|---|---|
|
|
76
115
|
| `zt items` | List items (`--collection`, `--top`, `--tag`, `--type`, `--limit`, `--sort`) |
|
|
77
|
-
| `zt collections` | List collections (`--parent`) |
|
|
78
|
-
| `zt get <key>` | Get item by key (`--children` for
|
|
116
|
+
| `zt collections` | List collections (`--parent` for subcollections) |
|
|
117
|
+
| `zt get <key>` | Get item by key (`--children` for attachments/notes) |
|
|
79
118
|
| `zt search <query>` | Search items (`--qmode`, `--tag`, `--type`) |
|
|
80
119
|
| `zt tags` | List tags |
|
|
81
120
|
| `zt libraries` | List group libraries |
|
|
82
121
|
| `zt delete --key <key>` | Delete item or collection (`--collection` flag) |
|
|
83
|
-
| `zt attach <file> --key <key>` |
|
|
84
|
-
| `zt export --format <fmt>` | Export items
|
|
85
|
-
| `zt fulltext <key>` | Get full-text content |
|
|
122
|
+
| `zt attach <file> --key <key>` | Upload and attach file to item |
|
|
123
|
+
| `zt export --format <fmt>` | Export items in citation formats |
|
|
124
|
+
| `zt fulltext <key>` | Get full-text content of an item |
|
|
125
|
+
| `zt import` | Import from DOI, ISBN, arXiv, URL, BibTeX, or PDF |
|
|
86
126
|
|
|
87
127
|
### Create
|
|
88
128
|
|
|
@@ -95,21 +135,112 @@ export ZOTERO_BASE_URL=https://api.zotero.org
|
|
|
95
135
|
|
|
96
136
|
| Command | Description |
|
|
97
137
|
|---|---|
|
|
98
|
-
| `zt types [itemType]` | List item types, or fields for a
|
|
99
|
-
| `zt template <itemType>` | Get JSON template for
|
|
138
|
+
| `zt types [itemType]` | List item types, or fields/creators for a type |
|
|
139
|
+
| `zt template <itemType>` | Get JSON template for item creation |
|
|
140
|
+
|
|
141
|
+
Run `zt --help` or `zt <command> --help` for full usage.
|
|
142
|
+
|
|
143
|
+
## Examples
|
|
144
|
+
|
|
145
|
+
### Import a paper by DOI and attach its PDF
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Start translation server (one-time)
|
|
149
|
+
docker run -d -p 1969:1969 zotero/translation-server
|
|
150
|
+
|
|
151
|
+
# Import: resolves metadata via Translation Server, creates item, attaches PDF
|
|
152
|
+
zt import ./paper.pdf --doi "10.1145/3025453.3025912" --collection COLL1234
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Import from arXiv
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
zt import --arxiv "2301.01234"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Import from a BibTeX file
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
zt import --bibtex ./references.bib
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Create a journal article manually
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Get the template first
|
|
171
|
+
zt template journalArticle
|
|
172
|
+
|
|
173
|
+
# Create with specific fields
|
|
174
|
+
zt create:item --type journalArticle \
|
|
175
|
+
--title "My Research Paper" \
|
|
176
|
+
--doi "10.1234/example" \
|
|
177
|
+
--date "2026" \
|
|
178
|
+
--collection COLL1234
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Create from raw JSON
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
zt create:item '{"itemType":"book","title":"Clean Code","creators":[{"creatorType":"author","firstName":"Robert","lastName":"Martin"}]}'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Browse item type schema
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# List all 42 item types
|
|
191
|
+
zt types
|
|
192
|
+
|
|
193
|
+
# Show fields for journal articles
|
|
194
|
+
zt types journalArticle
|
|
195
|
+
|
|
196
|
+
# Show creator types for books
|
|
197
|
+
zt types book --creators
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Export a collection as CSL-JSON
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
zt export --format csljson --collection ABCD1234
|
|
204
|
+
```
|
|
100
205
|
|
|
101
206
|
### Export Formats
|
|
102
207
|
|
|
103
208
|
`bibtex`, `biblatex`, `ris`, `csljson`, `csv`, `tei`, `wikipedia`, `mods`, `refer`
|
|
104
209
|
|
|
105
|
-
|
|
210
|
+
### Work with group libraries
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
# List groups
|
|
214
|
+
zt libraries
|
|
215
|
+
|
|
216
|
+
# List items in a group
|
|
217
|
+
zt items --group 12345
|
|
218
|
+
|
|
219
|
+
# Search within a group
|
|
220
|
+
zt search "deep learning" --group 12345
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Philosophy
|
|
224
|
+
|
|
225
|
+
`zotero-cli` exposes what the Zotero API provides. It does not add citation management workflows, sync logic, bibliography generation pipelines, or PDF management beyond attachment upload. If the Zotero API does not offer a behavior natively, this package does not invent one.
|
|
106
226
|
|
|
227
|
+
The `import` command integrates with the official [Zotero Translation Server](https://github.com/zotero/translation-server) for DOI/ISBN/URL resolution — the same engine that powers the Zotero Connector browser extension.
|
|
228
|
+
|
|
229
|
+
## Development
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
pnpm install
|
|
233
|
+
pnpm check # TypeScript + doc validation
|
|
234
|
+
pnpm test # Unit tests
|
|
235
|
+
pnpm build
|
|
236
|
+
pnpm pack:check # Package integrity
|
|
107
237
|
```
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
238
|
+
|
|
239
|
+
## Publishing
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
pnpm release:check
|
|
243
|
+
npm publish --access public
|
|
113
244
|
```
|
|
114
245
|
|
|
115
246
|
## License
|
|
@@ -7,11 +7,13 @@ export class HttpZoteroAdapter {
|
|
|
7
7
|
baseUrl;
|
|
8
8
|
apiKey;
|
|
9
9
|
userId;
|
|
10
|
-
|
|
10
|
+
translationServerUrl;
|
|
11
|
+
constructor(http, baseUrl, apiKey, userId, translationServerUrl) {
|
|
11
12
|
this.http = http;
|
|
12
13
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
13
14
|
this.apiKey = apiKey;
|
|
14
15
|
this.userId = userId;
|
|
16
|
+
this.translationServerUrl = translationServerUrl ?? "http://localhost:1969";
|
|
15
17
|
}
|
|
16
18
|
// ── Query methods ──────────────────────────────────────────────
|
|
17
19
|
async listItems(library, collectionKey, options) {
|
|
@@ -224,10 +226,100 @@ export class HttpZoteroAdapter {
|
|
|
224
226
|
await this.parseResponse(registerResponse);
|
|
225
227
|
return { success: true, key: attachmentKey };
|
|
226
228
|
}
|
|
229
|
+
async downloadAttachment(library, itemKey) {
|
|
230
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}/file`);
|
|
231
|
+
const response = await this.http.requestRaw("GET", url, {
|
|
232
|
+
headers: this.headers(),
|
|
233
|
+
});
|
|
234
|
+
if (response.status === 404) {
|
|
235
|
+
throw new NotFoundError(`Attachment not found: ${itemKey}`);
|
|
236
|
+
}
|
|
237
|
+
if (response.status >= 400) {
|
|
238
|
+
throw new ExternalToolError(`Failed to download attachment (${response.status})`);
|
|
239
|
+
}
|
|
240
|
+
return response.data;
|
|
241
|
+
}
|
|
227
242
|
async getFileUrl(library, itemKey) {
|
|
228
243
|
const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}/file`);
|
|
229
244
|
return url;
|
|
230
245
|
}
|
|
246
|
+
// ── Reparent ────────────────────────────────────────────────────
|
|
247
|
+
async reparentItem(library, itemKey, newParentKey) {
|
|
248
|
+
const item = (await this.getItem(library, itemKey));
|
|
249
|
+
const version = item["version"] ?? 0;
|
|
250
|
+
return this.updateItem(library, itemKey, { parentItem: newParentKey }, version);
|
|
251
|
+
}
|
|
252
|
+
// ── Better BibTeX ───────────────────────────────────────────────
|
|
253
|
+
get bbtUrl() {
|
|
254
|
+
const base = this.baseUrl.replace(/\/api\/?$/, "");
|
|
255
|
+
return `${base}/better-bibtex/json-rpc`;
|
|
256
|
+
}
|
|
257
|
+
async bbtRpc(method, params) {
|
|
258
|
+
const response = await this.http.request("POST", this.bbtUrl, {
|
|
259
|
+
headers: { "Content-Type": "application/json" },
|
|
260
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }),
|
|
261
|
+
});
|
|
262
|
+
if (response.status >= 400) {
|
|
263
|
+
throw new ExternalToolError(`Better BibTeX RPC error (${response.status}): ${response.body}`);
|
|
264
|
+
}
|
|
265
|
+
const parsed = JSON.parse(response.body);
|
|
266
|
+
if (parsed["error"]) {
|
|
267
|
+
throw new ExternalToolError(`BBT error: ${JSON.stringify(parsed["error"])}`);
|
|
268
|
+
}
|
|
269
|
+
return parsed["result"] ?? null;
|
|
270
|
+
}
|
|
271
|
+
async bbtCiteKeys(itemKeys) {
|
|
272
|
+
return this.bbtRpc("item.citationkey", [itemKeys]);
|
|
273
|
+
}
|
|
274
|
+
async bbtExport(itemKeys, translator) {
|
|
275
|
+
const result = await this.bbtRpc("item.export", [itemKeys, translator]);
|
|
276
|
+
return String(result);
|
|
277
|
+
}
|
|
278
|
+
async bbtSearch(query) {
|
|
279
|
+
return this.bbtRpc("item.search", [query]);
|
|
280
|
+
}
|
|
281
|
+
async bbtProbe() {
|
|
282
|
+
try {
|
|
283
|
+
const base = this.baseUrl.replace(/\/api\/?$/, "");
|
|
284
|
+
const url = `${base}/better-bibtex/cayw?probe=true`;
|
|
285
|
+
const response = await this.http.request("GET", url, {});
|
|
286
|
+
return response.status === 200;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// ── Translation Server methods ────────────────────────────────
|
|
293
|
+
async resolveIdentifier(identifier) {
|
|
294
|
+
const url = `${this.translationServerUrl}/search`;
|
|
295
|
+
const response = await this.http.request("POST", url, {
|
|
296
|
+
headers: { "Content-Type": "text/plain" },
|
|
297
|
+
body: identifier,
|
|
298
|
+
});
|
|
299
|
+
if (response.status === 501) {
|
|
300
|
+
throw new ExternalToolError("No translator available for this identifier.");
|
|
301
|
+
}
|
|
302
|
+
return this.parseResponse(response);
|
|
303
|
+
}
|
|
304
|
+
async scrapeUrl(url) {
|
|
305
|
+
const tsUrl = `${this.translationServerUrl}/web`;
|
|
306
|
+
const response = await this.http.request("POST", tsUrl, {
|
|
307
|
+
headers: { "Content-Type": "text/plain" },
|
|
308
|
+
body: url,
|
|
309
|
+
});
|
|
310
|
+
if (response.status === 501) {
|
|
311
|
+
throw new ExternalToolError("No translator available for this URL.");
|
|
312
|
+
}
|
|
313
|
+
return this.parseResponse(response);
|
|
314
|
+
}
|
|
315
|
+
async importBibliography(text) {
|
|
316
|
+
const url = `${this.translationServerUrl}/import`;
|
|
317
|
+
const response = await this.http.request("POST", url, {
|
|
318
|
+
headers: { "Content-Type": "text/plain" },
|
|
319
|
+
body: text,
|
|
320
|
+
});
|
|
321
|
+
return this.parseResponse(response);
|
|
322
|
+
}
|
|
231
323
|
// ── Helpers ────────────────────────────────────────────────────
|
|
232
324
|
libraryPrefix(library) {
|
|
233
325
|
return `/${library.type === "group" ? "groups" : "users"}/${library.id}`;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ValidationError } from "../application/errors.js";
|
|
2
|
+
import { assertNoMissingOptionValues, assertNoUnknownOptions, getOption, parseArgs } from "../utils/args.js";
|
|
3
|
+
import { renderJson } from "./helpers.js";
|
|
4
|
+
const VALID_SUBCOMMANDS = new Set(["status", "cite", "export", "search"]);
|
|
5
|
+
export class BbtCommand {
|
|
6
|
+
name = "bbt";
|
|
7
|
+
category = "Lookup";
|
|
8
|
+
description = "Better BibTeX operations: cite keys, export, search, status.";
|
|
9
|
+
help() {
|
|
10
|
+
return [
|
|
11
|
+
"Usage:",
|
|
12
|
+
" zt bbt status",
|
|
13
|
+
" zt bbt cite <key> [<key>...]",
|
|
14
|
+
" zt bbt export <key> [<key>...] --translator better-bibtex",
|
|
15
|
+
" zt bbt search <query>",
|
|
16
|
+
"",
|
|
17
|
+
"Better BibTeX operations.",
|
|
18
|
+
"",
|
|
19
|
+
"Subcommands:",
|
|
20
|
+
" status Check if BBT is available",
|
|
21
|
+
" cite <key> [<key>...] Get citation keys for items",
|
|
22
|
+
" export <key> [...] Export items via BBT",
|
|
23
|
+
" search <query> Search via BBT",
|
|
24
|
+
"",
|
|
25
|
+
"Options:",
|
|
26
|
+
" --translator <name> Translator for export (default: better-bibtex)",
|
|
27
|
+
"",
|
|
28
|
+
"Examples:",
|
|
29
|
+
" zt bbt status",
|
|
30
|
+
" zt bbt cite ABC12345 DEF67890",
|
|
31
|
+
" zt bbt export ABC12345 --translator better-biblatex",
|
|
32
|
+
" zt bbt search \"attention is all you need\""
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|
|
35
|
+
parse(argv) {
|
|
36
|
+
const parsed = parseArgs(argv);
|
|
37
|
+
assertNoUnknownOptions(parsed, ["translator"]);
|
|
38
|
+
assertNoMissingOptionValues(parsed, ["translator"]);
|
|
39
|
+
const subcommand = parsed.positionals[0];
|
|
40
|
+
if (!subcommand) {
|
|
41
|
+
throw new ValidationError("bbt requires a subcommand: status, cite, export, search");
|
|
42
|
+
}
|
|
43
|
+
if (!VALID_SUBCOMMANDS.has(subcommand)) {
|
|
44
|
+
throw new ValidationError(`Unknown bbt subcommand: ${subcommand}. Valid: status, cite, export, search`);
|
|
45
|
+
}
|
|
46
|
+
const args = parsed.positionals.slice(1);
|
|
47
|
+
const translator = getOption(parsed, "translator") ?? "better-bibtex";
|
|
48
|
+
// Validate args per subcommand
|
|
49
|
+
if (subcommand === "cite" && args.length === 0) {
|
|
50
|
+
throw new ValidationError("bbt cite requires at least one item key.");
|
|
51
|
+
}
|
|
52
|
+
if (subcommand === "export" && args.length === 0) {
|
|
53
|
+
throw new ValidationError("bbt export requires at least one item key.");
|
|
54
|
+
}
|
|
55
|
+
if (subcommand === "search" && args.length === 0) {
|
|
56
|
+
throw new ValidationError("bbt search requires a query.");
|
|
57
|
+
}
|
|
58
|
+
return { subcommand: subcommand, args, translator };
|
|
59
|
+
}
|
|
60
|
+
async execute(input, context) {
|
|
61
|
+
switch (input.subcommand) {
|
|
62
|
+
case "status": {
|
|
63
|
+
const available = await context.zotero.bbtProbe();
|
|
64
|
+
context.output.write(renderJson({ available }));
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "cite": {
|
|
68
|
+
const result = await context.zotero.bbtCiteKeys(input.args);
|
|
69
|
+
context.output.write(renderJson(result));
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "export": {
|
|
73
|
+
const result = await context.zotero.bbtExport(input.args, input.translator);
|
|
74
|
+
context.output.write(result);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "search": {
|
|
78
|
+
const query = input.args.join(" ");
|
|
79
|
+
const result = await context.zotero.bbtSearch(query);
|
|
80
|
+
context.output.write(renderJson(result));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { ValidationError } from "../application/errors.js";
|
|
4
|
+
import { assertNoMissingOptionValues, assertNoUnknownOptions, getOption, parseArgs } from "../utils/args.js";
|
|
5
|
+
import { renderJson, resolveLibrary } from "./helpers.js";
|
|
6
|
+
export class DownloadCommand {
|
|
7
|
+
name = "download";
|
|
8
|
+
category = "Core";
|
|
9
|
+
description = "Download an attachment file to disk.";
|
|
10
|
+
help() {
|
|
11
|
+
return [
|
|
12
|
+
"Usage:",
|
|
13
|
+
" zt download <key>",
|
|
14
|
+
" zt download <key> --output ./paper.pdf",
|
|
15
|
+
"",
|
|
16
|
+
"Download an attachment file to disk.",
|
|
17
|
+
"",
|
|
18
|
+
"Options:",
|
|
19
|
+
" --group <id> Library group ID",
|
|
20
|
+
" --output <path> Output file path (default: current dir + filename from item)",
|
|
21
|
+
"",
|
|
22
|
+
"Examples:",
|
|
23
|
+
" zt download ABC12345",
|
|
24
|
+
" zt download ABC12345 --output ./paper.pdf",
|
|
25
|
+
" zt download ABC12345 --group 123456"
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
parse(argv, context) {
|
|
29
|
+
const parsed = parseArgs(argv);
|
|
30
|
+
assertNoUnknownOptions(parsed, ["group", "output"]);
|
|
31
|
+
assertNoMissingOptionValues(parsed, ["group", "output"]);
|
|
32
|
+
if (parsed.positionals.length > 1) {
|
|
33
|
+
throw new ValidationError("download accepts at most one positional argument (item key).");
|
|
34
|
+
}
|
|
35
|
+
const key = parsed.positionals[0];
|
|
36
|
+
if (!key) {
|
|
37
|
+
throw new ValidationError("download requires an item key. Usage: zt download <key>");
|
|
38
|
+
}
|
|
39
|
+
const library = resolveLibrary(parsed, context?.config ?? { baseUrl: "" });
|
|
40
|
+
const output = getOption(parsed, "output");
|
|
41
|
+
return { library, key, output };
|
|
42
|
+
}
|
|
43
|
+
async execute(input, context) {
|
|
44
|
+
// Get item metadata to determine filename if output path not specified
|
|
45
|
+
const item = await context.zotero.getItem(input.library, input.key);
|
|
46
|
+
const data = item.data;
|
|
47
|
+
const filename = data?.filename ?? `${input.key}`;
|
|
48
|
+
const outputPath = input.output
|
|
49
|
+
? resolve(input.output)
|
|
50
|
+
: resolve(process.cwd(), filename);
|
|
51
|
+
const buffer = await context.zotero.downloadAttachment(input.library, input.key);
|
|
52
|
+
await writeFile(outputPath, buffer);
|
|
53
|
+
context.output.write(renderJson({
|
|
54
|
+
success: true,
|
|
55
|
+
path: outputPath,
|
|
56
|
+
size: buffer.length
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { ValidationError, NotFoundError } from "../application/errors.js";
|
|
4
|
+
import { assertNoMissingOptionValues, assertNoUnknownOptions, getOption, parseArgs, } from "../utils/args.js";
|
|
5
|
+
import { renderJson, resolveLibrary } from "./helpers.js";
|
|
6
|
+
export class ImportCommand {
|
|
7
|
+
name = "import";
|
|
8
|
+
category = "Core";
|
|
9
|
+
description = "Import items from DOI, ISBN, arXiv ID, URL, or BibTeX.";
|
|
10
|
+
help() {
|
|
11
|
+
return [
|
|
12
|
+
"Usage:",
|
|
13
|
+
' zt import --doi "10.1234/example"',
|
|
14
|
+
' zt import --isbn "978-0-123456-78-9"',
|
|
15
|
+
' zt import --arxiv "2301.01234"',
|
|
16
|
+
' zt import --url "https://arxiv.org/abs/2301.01234"',
|
|
17
|
+
" zt import --bibtex ./references.bib",
|
|
18
|
+
" zt import ./paper.pdf",
|
|
19
|
+
' zt import ./paper.pdf --doi "10.1234/example"',
|
|
20
|
+
"",
|
|
21
|
+
"Import bibliographic metadata via the Zotero Translation Server",
|
|
22
|
+
"and create items in Zotero. Optionally attach a PDF.",
|
|
23
|
+
"",
|
|
24
|
+
"Options:",
|
|
25
|
+
" --doi <doi> DOI to resolve",
|
|
26
|
+
" --isbn <isbn> ISBN to resolve",
|
|
27
|
+
" --arxiv <id> arXiv ID to resolve",
|
|
28
|
+
" --url <url> URL to scrape",
|
|
29
|
+
" --bibtex <file> BibTeX file to import",
|
|
30
|
+
" --collection <key> Add to collection after import",
|
|
31
|
+
" --group <id> Library group ID",
|
|
32
|
+
"",
|
|
33
|
+
"Positional:",
|
|
34
|
+
" <file> PDF file to attach after creating item",
|
|
35
|
+
"",
|
|
36
|
+
"Examples:",
|
|
37
|
+
' zt import --doi "10.1038/s41586-021-03819-2"',
|
|
38
|
+
' zt import --arxiv "2301.01234" --collection ABC12345',
|
|
39
|
+
" zt import ./paper.pdf --doi 10.1234/example",
|
|
40
|
+
" zt import --bibtex refs.bib --group 123456",
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
parse(argv, context) {
|
|
44
|
+
const parsed = parseArgs(argv);
|
|
45
|
+
assertNoUnknownOptions(parsed, [
|
|
46
|
+
"doi", "isbn", "arxiv", "url", "bibtex", "collection", "group",
|
|
47
|
+
]);
|
|
48
|
+
assertNoMissingOptionValues(parsed, [
|
|
49
|
+
"doi", "isbn", "arxiv", "url", "bibtex", "collection", "group",
|
|
50
|
+
]);
|
|
51
|
+
const library = resolveLibrary(parsed, context?.config ?? { baseUrl: "" });
|
|
52
|
+
const doi = getOption(parsed, "doi");
|
|
53
|
+
const isbn = getOption(parsed, "isbn");
|
|
54
|
+
const arxiv = getOption(parsed, "arxiv");
|
|
55
|
+
const url = getOption(parsed, "url");
|
|
56
|
+
const bibtexFile = getOption(parsed, "bibtex");
|
|
57
|
+
const collection = getOption(parsed, "collection");
|
|
58
|
+
const filePath = parsed.positionals[0];
|
|
59
|
+
if (parsed.positionals.length > 1) {
|
|
60
|
+
throw new ValidationError("import accepts at most one positional argument (file path).");
|
|
61
|
+
}
|
|
62
|
+
return { library, doi, isbn, arxiv, url, bibtexFile, collection, filePath };
|
|
63
|
+
}
|
|
64
|
+
async execute(input, context) {
|
|
65
|
+
const library = input.library;
|
|
66
|
+
let metadata;
|
|
67
|
+
// Step 1: Resolve metadata
|
|
68
|
+
if (input.doi) {
|
|
69
|
+
metadata = await context.zotero.resolveIdentifier(input.doi);
|
|
70
|
+
}
|
|
71
|
+
else if (input.isbn) {
|
|
72
|
+
metadata = await context.zotero.resolveIdentifier(input.isbn);
|
|
73
|
+
}
|
|
74
|
+
else if (input.arxiv) {
|
|
75
|
+
metadata = await context.zotero.resolveIdentifier(`arXiv:${input.arxiv}`);
|
|
76
|
+
}
|
|
77
|
+
else if (input.url) {
|
|
78
|
+
metadata = await context.zotero.scrapeUrl(input.url);
|
|
79
|
+
}
|
|
80
|
+
else if (input.bibtexFile) {
|
|
81
|
+
const content = await readFile(input.bibtexFile, "utf8");
|
|
82
|
+
metadata = await context.zotero.importBibliography(content);
|
|
83
|
+
}
|
|
84
|
+
else if (input.filePath) {
|
|
85
|
+
// Try to extract DOI from filename (pattern: 10.xxxx/yyyy)
|
|
86
|
+
const doiMatch = basename(input.filePath).match(/10\.\d{4,}[^\s]*/);
|
|
87
|
+
if (doiMatch) {
|
|
88
|
+
metadata = await context.zotero.resolveIdentifier(doiMatch[0]);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
throw new ValidationError("Cannot determine metadata source. Provide --doi, --url, --isbn, or --arxiv.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
throw new ValidationError("import requires --doi, --url, --isbn, --arxiv, --bibtex, or a file path.");
|
|
96
|
+
}
|
|
97
|
+
// Step 2: Create the item
|
|
98
|
+
// Translation server returns array of items or single item
|
|
99
|
+
const items = Array.isArray(metadata) ? metadata : [metadata];
|
|
100
|
+
if (items.length === 0) {
|
|
101
|
+
throw new NotFoundError("No items found for the given identifier.");
|
|
102
|
+
}
|
|
103
|
+
// Add to collection if specified
|
|
104
|
+
if (input.collection) {
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
if (typeof item === "object" && item !== null) {
|
|
107
|
+
item["collections"] = [input.collection];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const created = await context.zotero.createItem(library, items);
|
|
112
|
+
// Step 3: Attach PDF if file path provided
|
|
113
|
+
if (input.filePath && input.filePath.endsWith(".pdf")) {
|
|
114
|
+
const createdData = created;
|
|
115
|
+
const successful = (createdData?.["successful"] ?? createdData?.["success"]);
|
|
116
|
+
if (successful) {
|
|
117
|
+
const firstKey = Object.values(successful)[0];
|
|
118
|
+
const itemKey = firstKey?.["key"] ??
|
|
119
|
+
firstKey?.["data"]?.["key"];
|
|
120
|
+
if (itemKey) {
|
|
121
|
+
await context.zotero.uploadAttachment(library, itemKey, input.filePath, "application/pdf");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
context.output.write(renderJson(created));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ValidationError } from "../application/errors.js";
|
|
2
|
+
import { assertNoMissingOptionValues, assertNoUnknownOptions, getOption, parseArgs } from "../utils/args.js";
|
|
3
|
+
import { ensureNoPositionals, renderJson, resolveLibrary } from "./helpers.js";
|
|
4
|
+
export class ReparentCommand {
|
|
5
|
+
name = "reparent";
|
|
6
|
+
category = "Core";
|
|
7
|
+
description = "Change the parent of an attachment or note item.";
|
|
8
|
+
help() {
|
|
9
|
+
return [
|
|
10
|
+
"Usage:",
|
|
11
|
+
" zt reparent --key <attachmentKey> --parent <newParentKey>",
|
|
12
|
+
"",
|
|
13
|
+
"Change the parent of an attachment or note item.",
|
|
14
|
+
"",
|
|
15
|
+
"Options:",
|
|
16
|
+
" --key <key> Attachment or note key (required)",
|
|
17
|
+
" --parent <key> New parent item key (required)",
|
|
18
|
+
" --group <id> Library group ID",
|
|
19
|
+
"",
|
|
20
|
+
"Examples:",
|
|
21
|
+
" zt reparent --key ATT12345 --parent ABC12345",
|
|
22
|
+
" zt reparent --key ATT12345 --parent ABC12345 --group 123456"
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
parse(argv, context) {
|
|
26
|
+
const parsed = parseArgs(argv);
|
|
27
|
+
assertNoUnknownOptions(parsed, ["key", "parent", "group"]);
|
|
28
|
+
assertNoMissingOptionValues(parsed, ["key", "parent", "group"]);
|
|
29
|
+
ensureNoPositionals(parsed, "reparent");
|
|
30
|
+
const key = getOption(parsed, "key");
|
|
31
|
+
if (!key) {
|
|
32
|
+
throw new ValidationError("reparent requires --key <attachmentKey>.");
|
|
33
|
+
}
|
|
34
|
+
const parentKey = getOption(parsed, "parent");
|
|
35
|
+
if (!parentKey) {
|
|
36
|
+
throw new ValidationError("reparent requires --parent <newParentKey>.");
|
|
37
|
+
}
|
|
38
|
+
const library = resolveLibrary(parsed, context?.config ?? { baseUrl: "" });
|
|
39
|
+
return { library, key, parentKey };
|
|
40
|
+
}
|
|
41
|
+
async execute(input, context) {
|
|
42
|
+
const result = await context.zotero.reparentItem(input.library, input.key, input.parentKey);
|
|
43
|
+
context.output.write(renderJson(result));
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/compositionRoot.js
CHANGED
|
@@ -16,14 +16,19 @@ import { ExportCommand } from "./commands/exportCommand.js";
|
|
|
16
16
|
import { FulltextCommand } from "./commands/fulltextCommand.js";
|
|
17
17
|
import { TemplateCommand } from "./commands/templateCommand.js";
|
|
18
18
|
import { TypesInfoCommand } from "./commands/typesInfoCommand.js";
|
|
19
|
+
import { ImportCommand } from "./commands/importCommand.js";
|
|
20
|
+
import { DownloadCommand } from "./commands/downloadCommand.js";
|
|
21
|
+
import { ReparentCommand } from "./commands/reparentCommand.js";
|
|
22
|
+
import { BbtCommand } from "./commands/bbtCommand.js";
|
|
19
23
|
export function createRuntime() {
|
|
20
24
|
const config = {
|
|
21
25
|
apiKey: process.env["ZOTERO_API_KEY"],
|
|
22
26
|
userId: process.env["ZOTERO_USER_ID"] ?? "0",
|
|
23
|
-
baseUrl: process.env["ZOTERO_BASE_URL"] ?? "http://localhost:23119/api"
|
|
27
|
+
baseUrl: process.env["ZOTERO_BASE_URL"] ?? "http://localhost:23119/api",
|
|
28
|
+
translationServerUrl: process.env["ZOTERO_TRANSLATION_SERVER"] ?? "http://localhost:1969",
|
|
24
29
|
};
|
|
25
30
|
const http = new NodeHttpClient();
|
|
26
|
-
const zotero = new HttpZoteroAdapter(http, config.baseUrl, config.apiKey, config.userId);
|
|
31
|
+
const zotero = new HttpZoteroAdapter(http, config.baseUrl, config.apiKey, config.userId, config.translationServerUrl);
|
|
27
32
|
const output = new ConsoleOutputAdapter();
|
|
28
33
|
const registry = new CommandRegistry();
|
|
29
34
|
registry.register(new LibrariesCommand());
|
|
@@ -40,5 +45,9 @@ export function createRuntime() {
|
|
|
40
45
|
registry.register(new FulltextCommand());
|
|
41
46
|
registry.register(new TemplateCommand());
|
|
42
47
|
registry.register(new TypesInfoCommand());
|
|
48
|
+
registry.register(new ImportCommand());
|
|
49
|
+
registry.register(new DownloadCommand());
|
|
50
|
+
registry.register(new ReparentCommand());
|
|
51
|
+
registry.register(new BbtCommand());
|
|
43
52
|
return { output, zotero, registry, config };
|
|
44
53
|
}
|