@beomsukoh/zotero-cli 0.2.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/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/adapters/http/HttpZoteroAdapter.js +332 -0
- package/dist/adapters/output/ConsoleOutputAdapter.js +8 -0
- package/dist/application/errors.js +42 -0
- package/dist/application/ports.js +1 -0
- package/dist/application/types.js +1 -0
- package/dist/cli.js +11 -0
- package/dist/commands/CommandRegistry.js +44 -0
- package/dist/commands/attachCommand.js +49 -0
- package/dist/commands/collectionsCommand.js +52 -0
- package/dist/commands/createCollectionCommand.js +47 -0
- package/dist/commands/createItemCommand.js +91 -0
- package/dist/commands/deleteCommand.js +53 -0
- package/dist/commands/exportCommand.js +56 -0
- package/dist/commands/fulltextCommand.js +44 -0
- package/dist/commands/helpers.js +27 -0
- package/dist/commands/itemGetCommand.js +52 -0
- package/dist/commands/itemsCommand.js +62 -0
- package/dist/commands/librariesCommand.js +27 -0
- package/dist/commands/searchCommand.js +67 -0
- package/dist/commands/tagsCommand.js +39 -0
- package/dist/commands/templateCommand.js +38 -0
- package/dist/commands/types.js +1 -0
- package/dist/commands/typesInfoCommand.js +55 -0
- package/dist/compositionRoot.js +44 -0
- package/dist/infrastructure/httpClient.js +55 -0
- package/dist/utils/args.js +67 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Beomsu Koh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# zotero-cli
|
|
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).
|
|
4
|
+
|
|
5
|
+
Same architecture as [devonthink-cli](https://github.com/GoBeromsu/devonthink-cli): CommandModule pattern, port/adapter, typed error hierarchy. Zero runtime dependencies.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @goberomsu/zotero-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# List items (local Zotero API, no auth needed)
|
|
17
|
+
zt items --limit 10
|
|
18
|
+
|
|
19
|
+
# Search
|
|
20
|
+
zt search "machine learning"
|
|
21
|
+
|
|
22
|
+
# Get a specific item
|
|
23
|
+
zt get ABCD1234
|
|
24
|
+
|
|
25
|
+
# List collections
|
|
26
|
+
zt collections
|
|
27
|
+
|
|
28
|
+
# Export as BibTeX
|
|
29
|
+
zt export --format bibtex
|
|
30
|
+
|
|
31
|
+
# Get item template for creating a journal article
|
|
32
|
+
zt template journalArticle
|
|
33
|
+
|
|
34
|
+
# Create an item
|
|
35
|
+
zt create:item --type journalArticle --title "My Paper" --doi "10.1234/example"
|
|
36
|
+
|
|
37
|
+
# Attach a PDF to an item
|
|
38
|
+
zt attach ./paper.pdf --key ABCD1234
|
|
39
|
+
|
|
40
|
+
# Get full-text content
|
|
41
|
+
zt fulltext ABCD1234
|
|
42
|
+
|
|
43
|
+
# Show available item types
|
|
44
|
+
zt types
|
|
45
|
+
|
|
46
|
+
# Show fields for a specific type
|
|
47
|
+
zt types journalArticle
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
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) |
|
|
57
|
+
|
|
58
|
+
**Local mode** (reads only, Zotero desktop must be running):
|
|
59
|
+
```bash
|
|
60
|
+
zt items --limit 10
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Web mode** (full CRUD):
|
|
64
|
+
```bash
|
|
65
|
+
export ZOTERO_API_KEY=your_key_here
|
|
66
|
+
export ZOTERO_USER_ID=12345
|
|
67
|
+
export ZOTERO_BASE_URL=https://api.zotero.org
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
### Core
|
|
73
|
+
|
|
74
|
+
| Command | Description |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `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) |
|
|
79
|
+
| `zt search <query>` | Search items (`--qmode`, `--tag`, `--type`) |
|
|
80
|
+
| `zt tags` | List tags |
|
|
81
|
+
| `zt libraries` | List group libraries |
|
|
82
|
+
| `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 |
|
|
86
|
+
|
|
87
|
+
### Create
|
|
88
|
+
|
|
89
|
+
| Command | Description |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `zt create:item` | Create item from JSON or `--type`/`--title`/`--doi` options |
|
|
92
|
+
| `zt create:collection <name>` | Create collection (`--parent` for nesting) |
|
|
93
|
+
|
|
94
|
+
### Lookup
|
|
95
|
+
|
|
96
|
+
| Command | Description |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `zt types [itemType]` | List item types, or fields for a specific type |
|
|
99
|
+
| `zt template <itemType>` | Get JSON template for creating items |
|
|
100
|
+
|
|
101
|
+
### Export Formats
|
|
102
|
+
|
|
103
|
+
`bibtex`, `biblatex`, `ris`, `csljson`, `csv`, `tei`, `wikipedia`, `mods`, `refer`
|
|
104
|
+
|
|
105
|
+
## Architecture
|
|
106
|
+
|
|
107
|
+
```
|
|
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)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { NotFoundError, ConflictError, ExternalToolError, } from "../../application/errors.js";
|
|
5
|
+
export class HttpZoteroAdapter {
|
|
6
|
+
http;
|
|
7
|
+
baseUrl;
|
|
8
|
+
apiKey;
|
|
9
|
+
userId;
|
|
10
|
+
constructor(http, baseUrl, apiKey, userId) {
|
|
11
|
+
this.http = http;
|
|
12
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
13
|
+
this.apiKey = apiKey;
|
|
14
|
+
this.userId = userId;
|
|
15
|
+
}
|
|
16
|
+
// ── Query methods ──────────────────────────────────────────────
|
|
17
|
+
async listItems(library, collectionKey, options) {
|
|
18
|
+
const path = collectionKey
|
|
19
|
+
? `${this.libraryPrefix(library)}/collections/${collectionKey}/items`
|
|
20
|
+
: `${this.libraryPrefix(library)}/items`;
|
|
21
|
+
const url = this.buildUrl(path, this.searchParams(options));
|
|
22
|
+
return this.get(url);
|
|
23
|
+
}
|
|
24
|
+
async listTopItems(library, collectionKey, options) {
|
|
25
|
+
const path = collectionKey
|
|
26
|
+
? `${this.libraryPrefix(library)}/collections/${collectionKey}/items/top`
|
|
27
|
+
: `${this.libraryPrefix(library)}/items/top`;
|
|
28
|
+
const url = this.buildUrl(path, this.searchParams(options));
|
|
29
|
+
return this.get(url);
|
|
30
|
+
}
|
|
31
|
+
async getItem(library, itemKey) {
|
|
32
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}`);
|
|
33
|
+
return this.get(url);
|
|
34
|
+
}
|
|
35
|
+
async getItemChildren(library, itemKey, options) {
|
|
36
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}/children`, this.listParams(options));
|
|
37
|
+
return this.get(url);
|
|
38
|
+
}
|
|
39
|
+
async listCollections(library, parentKey, options) {
|
|
40
|
+
const path = parentKey
|
|
41
|
+
? `${this.libraryPrefix(library)}/collections/${parentKey}/collections`
|
|
42
|
+
: `${this.libraryPrefix(library)}/collections`;
|
|
43
|
+
const url = this.buildUrl(path, this.listParams(options));
|
|
44
|
+
return this.get(url);
|
|
45
|
+
}
|
|
46
|
+
async getCollection(library, collectionKey) {
|
|
47
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/collections/${collectionKey}`);
|
|
48
|
+
return this.get(url);
|
|
49
|
+
}
|
|
50
|
+
async listTags(library, options) {
|
|
51
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/tags`, this.listParams(options));
|
|
52
|
+
return this.get(url);
|
|
53
|
+
}
|
|
54
|
+
async listLibraries() {
|
|
55
|
+
const id = this.userId ?? "0";
|
|
56
|
+
const url = this.buildUrl(`/users/${id}/groups`);
|
|
57
|
+
return this.get(url);
|
|
58
|
+
}
|
|
59
|
+
async searchItems(library, query, options) {
|
|
60
|
+
const params = this.searchParams(options);
|
|
61
|
+
params.set("q", query);
|
|
62
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items`, params);
|
|
63
|
+
return this.get(url);
|
|
64
|
+
}
|
|
65
|
+
// ── Mutation methods ───────────────────────────────────────────
|
|
66
|
+
async createItem(library, data) {
|
|
67
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items`);
|
|
68
|
+
return this.post(url, data);
|
|
69
|
+
}
|
|
70
|
+
async updateItem(library, itemKey, data, version) {
|
|
71
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}`);
|
|
72
|
+
return this.patch(url, data, version);
|
|
73
|
+
}
|
|
74
|
+
async deleteItem(library, itemKey, version) {
|
|
75
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}`);
|
|
76
|
+
const response = await this.http.request("DELETE", url, {
|
|
77
|
+
headers: this.headers({ "If-Unmodified-Since-Version": String(version) }),
|
|
78
|
+
});
|
|
79
|
+
return this.parseResponse(response);
|
|
80
|
+
}
|
|
81
|
+
async createCollection(library, data) {
|
|
82
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/collections`);
|
|
83
|
+
return this.post(url, data);
|
|
84
|
+
}
|
|
85
|
+
async deleteCollection(library, collectionKey, version) {
|
|
86
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/collections/${collectionKey}`);
|
|
87
|
+
const response = await this.http.request("DELETE", url, {
|
|
88
|
+
headers: this.headers({ "If-Unmodified-Since-Version": String(version) }),
|
|
89
|
+
});
|
|
90
|
+
return this.parseResponse(response);
|
|
91
|
+
}
|
|
92
|
+
async addToCollection(library, itemKey, collectionKey) {
|
|
93
|
+
const item = (await this.getItem(library, itemKey));
|
|
94
|
+
const data = item["data"];
|
|
95
|
+
const version = item["version"] ?? 0;
|
|
96
|
+
const collections = Array.isArray(data?.["collections"])
|
|
97
|
+
? [...data["collections"]]
|
|
98
|
+
: [];
|
|
99
|
+
if (!collections.includes(collectionKey)) {
|
|
100
|
+
collections.push(collectionKey);
|
|
101
|
+
}
|
|
102
|
+
return this.updateItem(library, itemKey, { collections }, version);
|
|
103
|
+
}
|
|
104
|
+
async removeFromCollection(library, itemKey, collectionKey) {
|
|
105
|
+
const item = (await this.getItem(library, itemKey));
|
|
106
|
+
const data = item["data"];
|
|
107
|
+
const version = item["version"] ?? 0;
|
|
108
|
+
const collections = Array.isArray(data?.["collections"])
|
|
109
|
+
? data["collections"].filter((k) => k !== collectionKey)
|
|
110
|
+
: [];
|
|
111
|
+
return this.updateItem(library, itemKey, { collections }, version);
|
|
112
|
+
}
|
|
113
|
+
// ── Schema/template methods (global, not library-scoped) ─────
|
|
114
|
+
async getItemTypes() {
|
|
115
|
+
const url = "https://api.zotero.org/itemTypes";
|
|
116
|
+
return this.get(url);
|
|
117
|
+
}
|
|
118
|
+
async getItemTemplate(itemType) {
|
|
119
|
+
const url = `https://api.zotero.org/items/new?itemType=${encodeURIComponent(itemType)}`;
|
|
120
|
+
return this.get(url);
|
|
121
|
+
}
|
|
122
|
+
async getItemTypeFields(itemType) {
|
|
123
|
+
const url = `https://api.zotero.org/itemTypeFields?itemType=${encodeURIComponent(itemType)}`;
|
|
124
|
+
return this.get(url);
|
|
125
|
+
}
|
|
126
|
+
async getItemTypeCreatorTypes(itemType) {
|
|
127
|
+
const url = `https://api.zotero.org/itemTypeCreatorTypes?itemType=${encodeURIComponent(itemType)}`;
|
|
128
|
+
return this.get(url);
|
|
129
|
+
}
|
|
130
|
+
// ── Full-text ───────────────────────────────────────────────────
|
|
131
|
+
async getFullText(library, itemKey) {
|
|
132
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}/fulltext`);
|
|
133
|
+
return this.get(url);
|
|
134
|
+
}
|
|
135
|
+
// ── Export ──────────────────────────────────────────────────────
|
|
136
|
+
async exportItems(library, format, options) {
|
|
137
|
+
const params = this.searchParams(options);
|
|
138
|
+
params.set("format", format);
|
|
139
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items`, params);
|
|
140
|
+
const response = await this.http.request("GET", url, {
|
|
141
|
+
headers: this.headers(),
|
|
142
|
+
});
|
|
143
|
+
if (response.status >= 400) {
|
|
144
|
+
throw new ExternalToolError(`Zotero API error (${response.status}): ${response.body || "unknown error"}`);
|
|
145
|
+
}
|
|
146
|
+
return response.body;
|
|
147
|
+
}
|
|
148
|
+
// ── File operations ────────────────────────────────────────────
|
|
149
|
+
async uploadAttachment(library, parentItemKey, filePath, contentType) {
|
|
150
|
+
// 1. Get the attachment template
|
|
151
|
+
const template = (await this.getItemTemplate("attachment&linkMode=imported_file"));
|
|
152
|
+
// 2. Create the attachment item
|
|
153
|
+
const fileName = basename(filePath);
|
|
154
|
+
const attachmentData = {
|
|
155
|
+
...template,
|
|
156
|
+
parentItem: parentItemKey,
|
|
157
|
+
title: fileName,
|
|
158
|
+
contentType,
|
|
159
|
+
};
|
|
160
|
+
const createUrl = this.buildUrl(`${this.libraryPrefix(library)}/items`);
|
|
161
|
+
const createResponse = await this.http.request("POST", createUrl, {
|
|
162
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
163
|
+
body: JSON.stringify([attachmentData]),
|
|
164
|
+
});
|
|
165
|
+
const createResult = await this.parseResponse(createResponse);
|
|
166
|
+
const successful = createResult?.["successful"];
|
|
167
|
+
const created = successful?.["0"];
|
|
168
|
+
const attachmentKey = created?.["key"] ??
|
|
169
|
+
created?.["data"]?.["key"];
|
|
170
|
+
if (!attachmentKey) {
|
|
171
|
+
throw new ExternalToolError(`Failed to create attachment item: ${JSON.stringify(createResult)}`);
|
|
172
|
+
}
|
|
173
|
+
// 3. Read the file, compute md5 and size
|
|
174
|
+
const fileBuffer = await readFile(filePath);
|
|
175
|
+
const md5 = createHash("md5").update(fileBuffer).digest("hex");
|
|
176
|
+
const fileSize = fileBuffer.length;
|
|
177
|
+
const mtime = Date.now();
|
|
178
|
+
// 4. Get upload authorization
|
|
179
|
+
const authUrl = this.buildUrl(`${this.libraryPrefix(library)}/items/${attachmentKey}/file`);
|
|
180
|
+
const authBody = new URLSearchParams({
|
|
181
|
+
md5,
|
|
182
|
+
filename: fileName,
|
|
183
|
+
filesize: String(fileSize),
|
|
184
|
+
mtime: String(mtime),
|
|
185
|
+
}).toString();
|
|
186
|
+
const authResponse = await this.http.request("POST", authUrl, {
|
|
187
|
+
headers: this.headers({
|
|
188
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
189
|
+
"If-None-Match": "*",
|
|
190
|
+
}),
|
|
191
|
+
body: authBody,
|
|
192
|
+
});
|
|
193
|
+
const authResult = (await this.parseResponse(authResponse));
|
|
194
|
+
// 5. If file already exists on server, we're done
|
|
195
|
+
if (authResult["exists"] === 1) {
|
|
196
|
+
return { exists: true, key: attachmentKey };
|
|
197
|
+
}
|
|
198
|
+
// 6. Upload the file to the storage service
|
|
199
|
+
const uploadUrl = authResult["url"];
|
|
200
|
+
const prefix = authResult["prefix"] ?? "";
|
|
201
|
+
const suffix = authResult["suffix"] ?? "";
|
|
202
|
+
const uploadContentType = authResult["contentType"];
|
|
203
|
+
const uploadKey = authResult["uploadKey"];
|
|
204
|
+
// Build the upload body: prefix + file content + suffix
|
|
205
|
+
const prefixBuf = Buffer.from(prefix, "utf-8");
|
|
206
|
+
const suffixBuf = Buffer.from(suffix, "utf-8");
|
|
207
|
+
const uploadBody = Buffer.concat([prefixBuf, fileBuffer, suffixBuf]);
|
|
208
|
+
const uploadResponse = await this.http.request("POST", uploadUrl, {
|
|
209
|
+
headers: { "Content-Type": uploadContentType },
|
|
210
|
+
body: uploadBody.toString("binary"),
|
|
211
|
+
});
|
|
212
|
+
if (uploadResponse.status >= 400) {
|
|
213
|
+
throw new ExternalToolError(`File upload failed (${uploadResponse.status}): ${uploadResponse.body || "unknown error"}`);
|
|
214
|
+
}
|
|
215
|
+
// 7. Register the upload
|
|
216
|
+
const registerBody = new URLSearchParams({ upload: uploadKey }).toString();
|
|
217
|
+
const registerResponse = await this.http.request("POST", authUrl, {
|
|
218
|
+
headers: this.headers({
|
|
219
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
220
|
+
"If-None-Match": "*",
|
|
221
|
+
}),
|
|
222
|
+
body: registerBody,
|
|
223
|
+
});
|
|
224
|
+
await this.parseResponse(registerResponse);
|
|
225
|
+
return { success: true, key: attachmentKey };
|
|
226
|
+
}
|
|
227
|
+
async getFileUrl(library, itemKey) {
|
|
228
|
+
const url = this.buildUrl(`${this.libraryPrefix(library)}/items/${itemKey}/file`);
|
|
229
|
+
return url;
|
|
230
|
+
}
|
|
231
|
+
// ── Helpers ────────────────────────────────────────────────────
|
|
232
|
+
libraryPrefix(library) {
|
|
233
|
+
return `/${library.type === "group" ? "groups" : "users"}/${library.id}`;
|
|
234
|
+
}
|
|
235
|
+
buildUrl(path, params) {
|
|
236
|
+
const base = `${this.baseUrl}${path}`;
|
|
237
|
+
if (params && Array.from(params).length > 0) {
|
|
238
|
+
return `${base}?${params.toString()}`;
|
|
239
|
+
}
|
|
240
|
+
return base;
|
|
241
|
+
}
|
|
242
|
+
headers(extra) {
|
|
243
|
+
const h = {
|
|
244
|
+
"Zotero-API-Version": "3",
|
|
245
|
+
...extra,
|
|
246
|
+
};
|
|
247
|
+
if (this.apiKey) {
|
|
248
|
+
h["Zotero-API-Key"] = this.apiKey;
|
|
249
|
+
}
|
|
250
|
+
return h;
|
|
251
|
+
}
|
|
252
|
+
async parseResponse(response) {
|
|
253
|
+
if (response.status === 204) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (response.status === 404) {
|
|
257
|
+
throw new NotFoundError(`Resource not found: ${response.body || "unknown"}`);
|
|
258
|
+
}
|
|
259
|
+
if (response.status === 409 || response.status === 412) {
|
|
260
|
+
throw new ConflictError(`Conflict: ${response.body || "resource version mismatch"}`);
|
|
261
|
+
}
|
|
262
|
+
if (response.status >= 400) {
|
|
263
|
+
throw new ExternalToolError(`Zotero API error (${response.status}): ${response.body || "unknown error"}`);
|
|
264
|
+
}
|
|
265
|
+
if (!response.body) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(response.body);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
throw new ExternalToolError(`Failed to parse Zotero API response: ${response.body.slice(0, 200)}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
listParams(options) {
|
|
276
|
+
const params = new URLSearchParams();
|
|
277
|
+
if (!options)
|
|
278
|
+
return params;
|
|
279
|
+
if (options.limit !== undefined)
|
|
280
|
+
params.set("limit", String(options.limit));
|
|
281
|
+
if (options.start !== undefined)
|
|
282
|
+
params.set("start", String(options.start));
|
|
283
|
+
if (options.sort)
|
|
284
|
+
params.set("sort", options.sort);
|
|
285
|
+
if (options.direction)
|
|
286
|
+
params.set("direction", options.direction);
|
|
287
|
+
if (options.format)
|
|
288
|
+
params.set("format", options.format);
|
|
289
|
+
return params;
|
|
290
|
+
}
|
|
291
|
+
searchParams(options) {
|
|
292
|
+
const params = this.listParams(options);
|
|
293
|
+
if (!options)
|
|
294
|
+
return params;
|
|
295
|
+
if (options.query)
|
|
296
|
+
params.set("q", options.query);
|
|
297
|
+
if (options.qmode)
|
|
298
|
+
params.set("qmode", options.qmode);
|
|
299
|
+
if (options.tag) {
|
|
300
|
+
for (const t of options.tag) {
|
|
301
|
+
params.append("tag", t);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (options.itemType)
|
|
305
|
+
params.set("itemType", options.itemType);
|
|
306
|
+
return params;
|
|
307
|
+
}
|
|
308
|
+
// ── HTTP verb shortcuts ────────────────────────────────────────
|
|
309
|
+
async get(url) {
|
|
310
|
+
const response = await this.http.request("GET", url, {
|
|
311
|
+
headers: this.headers(),
|
|
312
|
+
});
|
|
313
|
+
return this.parseResponse(response);
|
|
314
|
+
}
|
|
315
|
+
async post(url, data) {
|
|
316
|
+
const response = await this.http.request("POST", url, {
|
|
317
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
318
|
+
body: JSON.stringify(data),
|
|
319
|
+
});
|
|
320
|
+
return this.parseResponse(response);
|
|
321
|
+
}
|
|
322
|
+
async patch(url, data, version) {
|
|
323
|
+
const response = await this.http.request("PATCH", url, {
|
|
324
|
+
headers: this.headers({
|
|
325
|
+
"Content-Type": "application/json",
|
|
326
|
+
"If-Unmodified-Since-Version": String(version),
|
|
327
|
+
}),
|
|
328
|
+
body: JSON.stringify(data),
|
|
329
|
+
});
|
|
330
|
+
return this.parseResponse(response);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
exitCode;
|
|
4
|
+
constructor(message, code, exitCode) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = new.target.name;
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class ValidationError extends AppError {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message, "VALIDATION_ERROR", 2);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class NotFoundError extends AppError {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message, "NOT_FOUND", 3);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class ConflictError extends AppError {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message, "CONFLICT", 4);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class ExternalToolError extends AppError {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message, "EXTERNAL_TOOL_ERROR", 5);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function toExitCode(error) {
|
|
32
|
+
if (error instanceof AppError) {
|
|
33
|
+
return error.exitCode;
|
|
34
|
+
}
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
export function toErrorMessage(error) {
|
|
38
|
+
if (error instanceof Error) {
|
|
39
|
+
return error.message;
|
|
40
|
+
}
|
|
41
|
+
return String(error);
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRuntime } from "./compositionRoot.js";
|
|
3
|
+
import { toErrorMessage, toExitCode } from "./application/errors.js";
|
|
4
|
+
export async function runCli(argv) {
|
|
5
|
+
const runtime = createRuntime();
|
|
6
|
+
await runtime.registry.run(argv, runtime);
|
|
7
|
+
}
|
|
8
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
9
|
+
process.stderr.write(`${toErrorMessage(error)}\n`);
|
|
10
|
+
process.exit(toExitCode(error));
|
|
11
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ValidationError } from "../application/errors.js";
|
|
2
|
+
const CATEGORY_ORDER = ["Core", "Property", "Create", "Lookup", "Other"];
|
|
3
|
+
export class CommandRegistry {
|
|
4
|
+
commands = new Map();
|
|
5
|
+
register(command) {
|
|
6
|
+
this.commands.set(command.name, command);
|
|
7
|
+
}
|
|
8
|
+
help() {
|
|
9
|
+
const lines = ["Usage: zt <command> [options]", ""];
|
|
10
|
+
const byCategory = new Map();
|
|
11
|
+
for (const command of this.commands.values()) {
|
|
12
|
+
const list = byCategory.get(command.category) ?? [];
|
|
13
|
+
list.push(command);
|
|
14
|
+
byCategory.set(command.category, list);
|
|
15
|
+
}
|
|
16
|
+
for (const category of CATEGORY_ORDER) {
|
|
17
|
+
const commands = byCategory.get(category);
|
|
18
|
+
if (!commands || commands.length === 0)
|
|
19
|
+
continue;
|
|
20
|
+
lines.push(`${category}:`);
|
|
21
|
+
for (const command of commands.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
22
|
+
lines.push(` ${command.name.padEnd(24)} ${command.description}`);
|
|
23
|
+
}
|
|
24
|
+
lines.push("");
|
|
25
|
+
}
|
|
26
|
+
return lines.join("\n").trimEnd();
|
|
27
|
+
}
|
|
28
|
+
async run(argv, context) {
|
|
29
|
+
const [commandName, ...rest] = argv;
|
|
30
|
+
if (!commandName || commandName === "help" || commandName === "--help") {
|
|
31
|
+
context.output.write(this.help());
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const command = this.commands.get(commandName);
|
|
35
|
+
if (!command)
|
|
36
|
+
throw new ValidationError(`Unknown command: ${commandName}`);
|
|
37
|
+
if (rest.includes("--help")) {
|
|
38
|
+
context.output.write(command.help(context));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const input = command.parse(rest, context);
|
|
42
|
+
await command.execute(input, context);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ValidationError } from "../application/errors.js";
|
|
2
|
+
import { assertNoMissingOptionValues, assertNoUnknownOptions, getOption, parseArgs } from "../utils/args.js";
|
|
3
|
+
import { renderJson, resolveLibrary } from "./helpers.js";
|
|
4
|
+
export class AttachCommand {
|
|
5
|
+
name = "attach";
|
|
6
|
+
category = "Core";
|
|
7
|
+
description = "Attach a file to an existing item.";
|
|
8
|
+
help() {
|
|
9
|
+
return [
|
|
10
|
+
"Usage:",
|
|
11
|
+
" zt attach <file-path> --key <parentItemKey>",
|
|
12
|
+
"",
|
|
13
|
+
"Upload and attach a file to an existing Zotero item.",
|
|
14
|
+
"",
|
|
15
|
+
"Options:",
|
|
16
|
+
" --key <parentItemKey> Parent item key (required)",
|
|
17
|
+
" --group <id> Library group ID",
|
|
18
|
+
" --content-type <mime> MIME type (default: application/pdf)",
|
|
19
|
+
"",
|
|
20
|
+
"Examples:",
|
|
21
|
+
' zt attach paper.pdf --key ABC12345',
|
|
22
|
+
' zt attach image.png --key ABC12345 --content-type image/png',
|
|
23
|
+
' zt attach paper.pdf --key ABC12345 --group 123456'
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
26
|
+
parse(argv, context) {
|
|
27
|
+
const parsed = parseArgs(argv);
|
|
28
|
+
assertNoUnknownOptions(parsed, ["key", "group", "content-type"]);
|
|
29
|
+
assertNoMissingOptionValues(parsed, ["key", "group", "content-type"]);
|
|
30
|
+
const library = resolveLibrary(parsed, context?.config ?? { baseUrl: "" });
|
|
31
|
+
const key = getOption(parsed, "key");
|
|
32
|
+
const filePath = parsed.positionals[0];
|
|
33
|
+
const contentType = getOption(parsed, "content-type") ?? "application/pdf";
|
|
34
|
+
if (!filePath) {
|
|
35
|
+
throw new ValidationError("attach requires a file path. Usage: zt attach <file-path> --key <parentItemKey>");
|
|
36
|
+
}
|
|
37
|
+
if (parsed.positionals.length > 1) {
|
|
38
|
+
throw new ValidationError("attach accepts at most one positional argument (file path).");
|
|
39
|
+
}
|
|
40
|
+
if (!key) {
|
|
41
|
+
throw new ValidationError("attach requires --key <parentItemKey>.");
|
|
42
|
+
}
|
|
43
|
+
return { library, key, filePath, contentType };
|
|
44
|
+
}
|
|
45
|
+
async execute(input, context) {
|
|
46
|
+
const result = await context.zotero.uploadAttachment(input.library, input.key, input.filePath, input.contentType);
|
|
47
|
+
context.output.write(renderJson(result));
|
|
48
|
+
}
|
|
49
|
+
}
|