@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 CHANGED
@@ -1,72 +1,111 @@
1
- # zotero-cli
1
+ # Zotero CLI
2
2
 
3
- Pure CLI wrapper for the Zotero API. Supports both the local desktop API (read-only, no auth) and the web API (full CRUD with API key).
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
- Same architecture as [devonthink-cli](https://github.com/GoBeromsu/devonthink-cli): CommandModule pattern, port/adapter, typed error hierarchy. Zero runtime dependencies.
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 @goberomsu/zotero-cli
25
+ npm install -g @beomsukoh/zotero-cli
11
26
  ```
12
27
 
13
- ## Quick Start
28
+ Verify the installation:
14
29
 
15
30
  ```bash
16
- # List items (local Zotero API, no auth needed)
17
- zt items --limit 10
31
+ zt items --limit 1
32
+ ```
18
33
 
19
- # Search
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
- # Get a specific item
23
- zt get ABCD1234
36
+ ## Quickstart
24
37
 
25
- # List collections
26
- zt collections
38
+ List items in your library:
27
39
 
28
- # Export as BibTeX
29
- zt export --format bibtex
40
+ ```bash
41
+ zt items --limit 10 --sort dateAdded
42
+ ```
30
43
 
31
- # Get item template for creating a journal article
32
- zt template journalArticle
44
+ Search across your library:
33
45
 
34
- # Create an item
35
- zt create:item --type journalArticle --title "My Paper" --doi "10.1234/example"
46
+ ```bash
47
+ zt search "attention is all you need"
48
+ ```
36
49
 
37
- # Attach a PDF to an item
38
- zt attach ./paper.pdf --key ABCD1234
50
+ Get a specific item by key:
39
51
 
40
- # Get full-text content
41
- zt fulltext ABCD1234
52
+ ```bash
53
+ zt get ABCD1234
54
+ ```
42
55
 
43
- # Show available item types
44
- zt types
56
+ List collections:
45
57
 
46
- # Show fields for a specific type
47
- zt types journalArticle
58
+ ```bash
59
+ zt collections
48
60
  ```
49
61
 
50
- ## Configuration
62
+ Import a paper by DOI (requires Translation Server):
51
63
 
52
- | Variable | Default | Description |
53
- |---|---|---|
54
- | `ZOTERO_BASE_URL` | `http://localhost:23119/api` | API base URL |
55
- | `ZOTERO_API_KEY` | — | API key (required for writes) |
56
- | `ZOTERO_USER_ID` | `0` | User ID (`0` = local API) |
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 items --limit 10
72
+ zt attach ./paper.pdf --key ABCD1234
61
73
  ```
62
74
 
63
- **Web mode** (full CRUD):
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 child items) |
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>` | Attach file to item (`--content-type`) |
84
- | `zt export --format <fmt>` | Export items (bibtex, ris, csljson, csv, tei, etc.) |
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 specific type |
99
- | `zt template <itemType>` | Get JSON template for creating items |
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
- ## Architecture
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
- zt <command> [options]
109
- -> CommandRegistry -> CommandModule.parse() -> CommandModule.execute()
110
- -> ZoteroPort (application boundary)
111
- -> HttpZoteroAdapter
112
- -> Zotero Local API (localhost:23119) or Web API (api.zotero.org)
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
- constructor(http, baseUrl, apiKey, userId) {
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
+ }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beomsukoh/zotero-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pure CLI wrapper for the Zotero API.",
5
5
  "type": "module",
6
6
  "bin": {