@hasna/connectors 1.3.45 → 1.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 +25 -10
- package/bin/index.js +1362 -11796
- package/bin/mcp.js +751 -10328
- package/bin/serve.js +2252 -11829
- package/connectors/cloudflare-workers/.env.example +5 -0
- package/connectors/cloudflare-workers/AGENTS.md +11 -0
- package/connectors/cloudflare-workers/CLAUDE.md +11 -0
- package/connectors/cloudflare-workers/README.md +45 -0
- package/connectors/cloudflare-workers/package.json +47 -0
- package/connectors/cloudflare-workers/src/api/client.test.ts +144 -0
- package/connectors/cloudflare-workers/src/api/client.ts +386 -0
- package/connectors/cloudflare-workers/src/api/index.ts +1 -0
- package/connectors/cloudflare-workers/src/cli/index.ts +206 -0
- package/connectors/cloudflare-workers/src/index.ts +16 -0
- package/connectors/cloudflare-workers/src/types/index.ts +53 -0
- package/connectors/cloudflare-workers/src/utils/config.ts +61 -0
- package/connectors/cloudflare-workers/src/utils/output.ts +26 -0
- package/connectors/cloudflare-workers/tsconfig.build.json +4 -0
- package/connectors/cloudflare-workers/tsconfig.json +14 -0
- package/connectors/tidio/.env.example +3 -2
- package/connectors/tidio/CLAUDE.md +21 -96
- package/connectors/tidio/README.md +22 -18
- package/connectors/tidio/package.json +4 -2
- package/connectors/tidio/src/api/client.ts +26 -15
- package/connectors/tidio/src/api/index.ts +185 -128
- package/connectors/tidio/src/api/tidio.test.ts +175 -44
- package/connectors/tidio/src/cli/index.ts +154 -388
- package/connectors/tidio/src/index.ts +5 -3
- package/connectors/tidio/src/types/index.ts +135 -82
- package/connectors/tidio/src/utils/config.ts +90 -44
- package/connectors/wandb/.env.example +7 -0
- package/connectors/wandb/AGENTS.md +43 -0
- package/connectors/wandb/CLAUDE.md +67 -0
- package/connectors/wandb/README.md +65 -0
- package/connectors/wandb/package.json +52 -0
- package/connectors/wandb/src/api/client.test.ts +116 -0
- package/connectors/wandb/src/api/client.ts +83 -0
- package/connectors/wandb/src/api/graphql.ts +9 -0
- package/connectors/wandb/src/api/index.ts +36 -0
- package/connectors/wandb/src/api/projects.ts +36 -0
- package/connectors/wandb/src/api/viewer.ts +20 -0
- package/connectors/wandb/src/cli/index.ts +265 -0
- package/connectors/wandb/src/index.ts +22 -0
- package/connectors/wandb/src/types/index.ts +75 -0
- package/connectors/wandb/src/utils/config.ts +164 -0
- package/connectors/wandb/src/utils/output.ts +119 -0
- package/connectors/wandb/tsconfig.json +16 -0
- package/connectors/weights-biases/.env.example +7 -0
- package/connectors/weights-biases/CLAUDE.md +65 -0
- package/connectors/weights-biases/README.md +62 -0
- package/connectors/weights-biases/package.json +53 -0
- package/connectors/weights-biases/src/api/client.test.ts +46 -0
- package/connectors/weights-biases/src/api/client.ts +119 -0
- package/connectors/weights-biases/src/api/events.ts +18 -0
- package/connectors/weights-biases/src/api/index.ts +56 -0
- package/connectors/weights-biases/src/api/runs.ts +28 -0
- package/connectors/weights-biases/src/api/search.ts +10 -0
- package/connectors/weights-biases/src/cli/index.ts +289 -0
- package/connectors/weights-biases/src/index.ts +19 -0
- package/connectors/weights-biases/src/types/index.ts +92 -0
- package/connectors/weights-biases/src/utils/config.ts +168 -0
- package/connectors/weights-biases/src/utils/output.ts +116 -0
- package/connectors/weights-biases/tsconfig.json +16 -0
- package/connectors/x/src/api/oauth.test.ts +205 -0
- package/connectors/zoominfo/.env.example +4 -0
- package/connectors/zoominfo/AGENTS.md +26 -0
- package/connectors/zoominfo/CLAUDE.md +20 -0
- package/connectors/zoominfo/README.md +69 -0
- package/connectors/zoominfo/package.json +52 -0
- package/connectors/zoominfo/src/api/client.test.ts +140 -0
- package/connectors/zoominfo/src/api/client.ts +228 -0
- package/connectors/zoominfo/src/api/index.ts +1 -0
- package/connectors/zoominfo/src/cli/index.ts +179 -0
- package/connectors/zoominfo/src/index.ts +3 -0
- package/connectors/zoominfo/src/types/index.ts +39 -0
- package/connectors/zoominfo/src/utils/config.ts +61 -0
- package/connectors/zoominfo/src/utils/output.ts +27 -0
- package/connectors/zoominfo/tsconfig.json +20 -0
- package/connectors/zotero/.env.example +7 -0
- package/connectors/zotero/.npmrc.example +2 -0
- package/connectors/zotero/AGENTS.md +40 -0
- package/connectors/zotero/CLAUDE.md +69 -0
- package/connectors/zotero/README.md +91 -0
- package/connectors/zotero/package.json +43 -0
- package/connectors/zotero/src/api/attachments.ts +130 -0
- package/connectors/zotero/src/api/client.test.ts +166 -0
- package/connectors/zotero/src/api/client.ts +169 -0
- package/connectors/zotero/src/api/collections.ts +26 -0
- package/connectors/zotero/src/api/index.ts +45 -0
- package/connectors/zotero/src/api/items.ts +64 -0
- package/connectors/zotero/src/cli/index.ts +386 -0
- package/connectors/zotero/src/index.ts +28 -0
- package/connectors/zotero/src/types/index.ts +129 -0
- package/connectors/zotero/src/utils/config.ts +245 -0
- package/connectors/zotero/src/utils/output.ts +119 -0
- package/connectors/zotero/tsconfig.json +16 -0
- package/connectors/zymbly/.env.example +2 -0
- package/connectors/zymbly/AGENTS.md +11 -0
- package/connectors/zymbly/CLAUDE.md +5 -0
- package/connectors/zymbly/README.md +35 -0
- package/connectors/zymbly/package.json +52 -0
- package/connectors/zymbly/src/api/client.test.ts +110 -0
- package/connectors/zymbly/src/api/client.ts +166 -0
- package/connectors/zymbly/src/api/index.ts +1 -0
- package/connectors/zymbly/src/cli/index.ts +156 -0
- package/connectors/zymbly/src/index.ts +12 -0
- package/connectors/zymbly/src/types/index.ts +34 -0
- package/connectors/zymbly/src/utils/config.ts +52 -0
- package/connectors/zymbly/src/utils/output.ts +17 -0
- package/connectors/zymbly/tsconfig.json +13 -0
- package/dist/db/database.d.ts +2 -1
- package/dist/db/sqlite-adapter.d.ts +43 -0
- package/dist/index.js +172 -9653
- package/dist/lib/compact-output.d.ts +40 -0
- package/dist/mcp/tools/jobs.d.ts +32 -0
- package/dist/mcp/tools/jobs.test.d.ts +1 -0
- package/dist/no-cloud-boundary.test.d.ts +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @hasna/connect-zotero
|
|
2
|
+
|
|
3
|
+
Zotero Web API v3 connector with CLI and library support. Manage bibliography items, collections, and file attachments via the official Zotero REST API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @hasna/connect-zotero
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For CLI usage:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun install -g @hasna/connect-zotero
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
### Environment Variables
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export ZOTERO_API_KEY="your-api-key"
|
|
23
|
+
export ZOTERO_LIBRARY_ID="your-library-id"
|
|
24
|
+
export ZOTERO_LIBRARY_TYPE="users" # or groups
|
|
25
|
+
# export ZOTERO_BASE_URL="https://api.zotero.org"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### CLI Configuration
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
connect-zotero config set-api-key <key>
|
|
32
|
+
connect-zotero config set-library-id <id>
|
|
33
|
+
connect-zotero config set-library-type users
|
|
34
|
+
connect-zotero config show
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Configuration is stored in `~/.hasna/connectors/connect-zotero/`.
|
|
38
|
+
|
|
39
|
+
## CLI Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Test authentication
|
|
43
|
+
connect-zotero test
|
|
44
|
+
|
|
45
|
+
# List items
|
|
46
|
+
connect-zotero items list
|
|
47
|
+
connect-zotero items list --collection <key> --limit 25
|
|
48
|
+
|
|
49
|
+
# Search items
|
|
50
|
+
connect-zotero items search "machine learning"
|
|
51
|
+
|
|
52
|
+
# Get, create, update, delete items
|
|
53
|
+
connect-zotero items get <itemKey>
|
|
54
|
+
connect-zotero items create --json ./item.json
|
|
55
|
+
connect-zotero items update <itemKey> --json ./patch.json --version 12
|
|
56
|
+
connect-zotero items delete <itemKey> --version 12
|
|
57
|
+
|
|
58
|
+
# Collections
|
|
59
|
+
connect-zotero collections list
|
|
60
|
+
connect-zotero collections create --json ./collection.json
|
|
61
|
+
|
|
62
|
+
# Attachments
|
|
63
|
+
connect-zotero attachments create --json ./attachment.json
|
|
64
|
+
connect-zotero attachments upload --file ./paper.pdf --parent <itemKey>
|
|
65
|
+
|
|
66
|
+
# Raw API escape hatch
|
|
67
|
+
connect-zotero raw /users/1234567/items -X GET
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Library Usage
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { Zotero } from '@hasna/connect-zotero';
|
|
74
|
+
|
|
75
|
+
const zotero = new Zotero({
|
|
76
|
+
apiKey: process.env.ZOTERO_API_KEY!,
|
|
77
|
+
libraryId: process.env.ZOTERO_LIBRARY_ID!,
|
|
78
|
+
libraryType: 'users',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const items = await zotero.items.list({ limit: 10 });
|
|
82
|
+
const results = await zotero.items.search('neural networks');
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API Reference
|
|
86
|
+
|
|
87
|
+
- [Zotero Web API v3](https://www.zotero.org/support/dev/web_api/v3/start)
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
Apache-2.0
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasna/connect-zotero",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Zotero Web API v3 connector with CLI and library support",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"connect-zotero": "bin/index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "bun run ./src/cli/index.ts",
|
|
13
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun && bun build ./src/cli/index.ts --outdir ./bin --target bun",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "bun test",
|
|
16
|
+
"prepublishOnly": "bun run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"zotero",
|
|
20
|
+
"api",
|
|
21
|
+
"cli",
|
|
22
|
+
"connector",
|
|
23
|
+
"bibliography",
|
|
24
|
+
"citations"
|
|
25
|
+
],
|
|
26
|
+
"author": "Hasna",
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"chalk": "^5.3.0",
|
|
30
|
+
"commander": "^12.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/bun": "latest",
|
|
34
|
+
"typescript": "^5.0.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"bin",
|
|
39
|
+
"src",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CLAUDE.md"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
CreateAttachmentInput,
|
|
4
|
+
CreateItemsResponse,
|
|
5
|
+
UploadAuthResponse,
|
|
6
|
+
UploadFileInput,
|
|
7
|
+
} from '../types';
|
|
8
|
+
import { ZoteroApiError } from '../types';
|
|
9
|
+
import { encodePathSegment, ZoteroClient } from './client';
|
|
10
|
+
import { ItemsApi } from './items';
|
|
11
|
+
|
|
12
|
+
export class AttachmentsApi {
|
|
13
|
+
private readonly items: ItemsApi;
|
|
14
|
+
|
|
15
|
+
constructor(private readonly client: ZoteroClient) {
|
|
16
|
+
this.items = new ItemsApi(client);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async create(input: CreateAttachmentInput): Promise<CreateItemsResponse> {
|
|
20
|
+
const attachment = {
|
|
21
|
+
itemType: 'attachment',
|
|
22
|
+
linkMode: input.linkMode ?? 'imported_url',
|
|
23
|
+
parentItem: input.parentItem,
|
|
24
|
+
title: input.title ?? input.filename ?? input.url,
|
|
25
|
+
url: input.url,
|
|
26
|
+
accessDate: input.accessDate,
|
|
27
|
+
contentType: input.contentType,
|
|
28
|
+
filename: input.filename,
|
|
29
|
+
tags: input.tags ?? [],
|
|
30
|
+
collections: input.collections,
|
|
31
|
+
relations: input.relations ?? {},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return this.items.create(attachment);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async uploadFile(input: UploadFileInput): Promise<{
|
|
38
|
+
attachmentKey: string;
|
|
39
|
+
filename: string;
|
|
40
|
+
uploaded?: boolean;
|
|
41
|
+
exists?: boolean;
|
|
42
|
+
md5?: string;
|
|
43
|
+
}> {
|
|
44
|
+
const filename = input.filename;
|
|
45
|
+
const contentType = input.contentType ?? 'application/octet-stream';
|
|
46
|
+
const file = Buffer.from(input.content);
|
|
47
|
+
const mtime = String(input.mtime ?? Date.now());
|
|
48
|
+
const md5 = createHash('md5').update(file).digest('hex');
|
|
49
|
+
|
|
50
|
+
let attachmentKey = input.attachmentKey ?? '';
|
|
51
|
+
|
|
52
|
+
if (!attachmentKey) {
|
|
53
|
+
if (!input.parentItem) {
|
|
54
|
+
throw new ZoteroApiError('parentItem is required when attachmentKey is not provided', 'missing_parent');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const created = await this.create({
|
|
58
|
+
parentItem: input.parentItem,
|
|
59
|
+
linkMode: 'imported_file',
|
|
60
|
+
contentType,
|
|
61
|
+
filename,
|
|
62
|
+
title: filename,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
attachmentKey = created.success?.['0'] ?? created.successful?.['0'] ?? '';
|
|
66
|
+
if (!attachmentKey) {
|
|
67
|
+
throw new ZoteroApiError('Attachment item was not created', 'create_failed');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const prefix = this.client.libraryPrefix();
|
|
72
|
+
const filePath = `${prefix}/items/${encodePathSegment(attachmentKey)}/file`;
|
|
73
|
+
|
|
74
|
+
const uploadAuthText = await this.client.requestText(filePath, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
78
|
+
'If-None-Match': '*',
|
|
79
|
+
},
|
|
80
|
+
body: new URLSearchParams({
|
|
81
|
+
md5,
|
|
82
|
+
filename,
|
|
83
|
+
filesize: String(file.byteLength),
|
|
84
|
+
mtime,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const uploadAuth = JSON.parse(uploadAuthText || '{}') as UploadAuthResponse;
|
|
89
|
+
|
|
90
|
+
if (uploadAuth.exists) {
|
|
91
|
+
return { attachmentKey, filename, exists: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!uploadAuth.url || !uploadAuth.contentType || !uploadAuth.uploadKey) {
|
|
95
|
+
throw new ZoteroApiError('File upload authorization response was incomplete', 'upload_auth_incomplete');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const uploadBody = Buffer.concat([
|
|
99
|
+
Buffer.from(uploadAuth.prefix ?? ''),
|
|
100
|
+
file,
|
|
101
|
+
Buffer.from(uploadAuth.suffix ?? ''),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const uploadResponse = await fetch(uploadAuth.url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'Content-Type': uploadAuth.contentType },
|
|
107
|
+
body: uploadBody,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!uploadResponse.ok) {
|
|
111
|
+
const text = await uploadResponse.text();
|
|
112
|
+
throw new ZoteroApiError(
|
|
113
|
+
`File upload failed (${uploadResponse.status}): ${text.slice(0, 500)}`,
|
|
114
|
+
'upload_failed',
|
|
115
|
+
uploadResponse.status
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await this.client.requestText(filePath, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
123
|
+
'If-None-Match': '*',
|
|
124
|
+
},
|
|
125
|
+
body: new URLSearchParams({ upload: uploadAuth.uploadKey }),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return { attachmentKey, filename, uploaded: true, md5 };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
ZoteroClient,
|
|
4
|
+
normalizeLibraryType,
|
|
5
|
+
buildLibraryPrefix,
|
|
6
|
+
buildZoteroUrl,
|
|
7
|
+
DEFAULT_BASE_URL,
|
|
8
|
+
} from './client';
|
|
9
|
+
import { ZoteroApiError } from '../types';
|
|
10
|
+
|
|
11
|
+
describe('ZoteroClient helpers', () => {
|
|
12
|
+
test('normalizeLibraryType maps group to groups', () => {
|
|
13
|
+
expect(normalizeLibraryType('users')).toBe('users');
|
|
14
|
+
expect(normalizeLibraryType('groups')).toBe('groups');
|
|
15
|
+
expect(normalizeLibraryType('group')).toBe('groups');
|
|
16
|
+
expect(normalizeLibraryType(undefined)).toBe('users');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('buildLibraryPrefix encodes library id and type', () => {
|
|
20
|
+
expect(buildLibraryPrefix('12345', 'users')).toBe('/users/12345');
|
|
21
|
+
expect(buildLibraryPrefix('abc/def', 'groups')).toBe('/groups/abc%2Fdef');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('buildZoteroUrl appends query params and strips trailing slash', () => {
|
|
25
|
+
const url = buildZoteroUrl(`${DEFAULT_BASE_URL}/`, '/users/1/items', {
|
|
26
|
+
limit: 10,
|
|
27
|
+
q: 'test query',
|
|
28
|
+
empty: '',
|
|
29
|
+
skip: undefined,
|
|
30
|
+
});
|
|
31
|
+
expect(url).toBe('https://api.zotero.org/users/1/items?limit=10&q=test+query');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('ZoteroClient', () => {
|
|
36
|
+
const mockConfig = {
|
|
37
|
+
apiKey: 'test-api-key',
|
|
38
|
+
libraryId: '1234567',
|
|
39
|
+
libraryType: 'users' as const,
|
|
40
|
+
baseUrl: 'https://api.zotero.org',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('constructor', () => {
|
|
44
|
+
test('throws when api key is missing', () => {
|
|
45
|
+
expect(() => new ZoteroClient({ apiKey: '', libraryId: '1' })).toThrow('Zotero API key is required');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('throws when library id is missing', () => {
|
|
49
|
+
expect(() => new ZoteroClient({ apiKey: 'key', libraryId: '' })).toThrow('Zotero library ID is required');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('creates client with valid config', () => {
|
|
53
|
+
const client = new ZoteroClient(mockConfig);
|
|
54
|
+
expect(client.libraryPrefix()).toBe('/users/1234567');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('normalizes group library type', () => {
|
|
58
|
+
const client = new ZoteroClient({ ...mockConfig, libraryType: 'group' });
|
|
59
|
+
expect(client.libraryPrefix()).toBe('/groups/1234567');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('request', () => {
|
|
64
|
+
let client: ZoteroClient;
|
|
65
|
+
let originalFetch: typeof global.fetch;
|
|
66
|
+
let fetchMock: ReturnType<typeof mock>;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
client = new ZoteroClient(mockConfig);
|
|
70
|
+
originalFetch = global.fetch;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
global.fetch = originalFetch;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('sends required Zotero headers on GET', async () => {
|
|
78
|
+
fetchMock = mock(() =>
|
|
79
|
+
Promise.resolve({
|
|
80
|
+
ok: true,
|
|
81
|
+
status: 200,
|
|
82
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
83
|
+
text: () => Promise.resolve('[]'),
|
|
84
|
+
} as Response)
|
|
85
|
+
);
|
|
86
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
87
|
+
|
|
88
|
+
await client.get('/users/1234567/items', { limit: 1 });
|
|
89
|
+
|
|
90
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
91
|
+
const [url, options] = fetchMock.mock.calls[0];
|
|
92
|
+
expect(url).toBe('https://api.zotero.org/users/1234567/items?limit=1');
|
|
93
|
+
expect(options.method).toBe('GET');
|
|
94
|
+
expect(options.headers['Zotero-API-Key']).toBe('test-api-key');
|
|
95
|
+
expect(options.headers['Zotero-API-Version']).toBe('3');
|
|
96
|
+
expect(options.headers.Accept).toBe('application/json');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('includes version header on PATCH', async () => {
|
|
100
|
+
fetchMock = mock(() =>
|
|
101
|
+
Promise.resolve({
|
|
102
|
+
ok: true,
|
|
103
|
+
status: 200,
|
|
104
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
105
|
+
text: () => Promise.resolve('{}'),
|
|
106
|
+
} as Response)
|
|
107
|
+
);
|
|
108
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
109
|
+
|
|
110
|
+
await client.patch('/users/1234567/items/ABC123', { title: 'Updated' }, { version: 42 });
|
|
111
|
+
|
|
112
|
+
const [, options] = fetchMock.mock.calls[0];
|
|
113
|
+
expect(options.method).toBe('PATCH');
|
|
114
|
+
expect(options.headers['If-Unmodified-Since-Version']).toBe('42');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('includes version header on DELETE', async () => {
|
|
118
|
+
fetchMock = mock(() =>
|
|
119
|
+
Promise.resolve({
|
|
120
|
+
ok: true,
|
|
121
|
+
status: 204,
|
|
122
|
+
headers: new Headers({}),
|
|
123
|
+
text: () => Promise.resolve(''),
|
|
124
|
+
} as Response)
|
|
125
|
+
);
|
|
126
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
127
|
+
|
|
128
|
+
await client.delete('/users/1234567/items/ABC123', { version: 7 });
|
|
129
|
+
|
|
130
|
+
const [, options] = fetchMock.mock.calls[0];
|
|
131
|
+
expect(options.method).toBe('DELETE');
|
|
132
|
+
expect(options.headers['If-Unmodified-Since-Version']).toBe('7');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('throws ZoteroApiError on failed response', async () => {
|
|
136
|
+
fetchMock = mock(() =>
|
|
137
|
+
Promise.resolve({
|
|
138
|
+
ok: false,
|
|
139
|
+
status: 403,
|
|
140
|
+
statusText: 'Forbidden',
|
|
141
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
142
|
+
text: () => Promise.resolve(JSON.stringify({ message: 'Invalid key' })),
|
|
143
|
+
} as Response)
|
|
144
|
+
);
|
|
145
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
146
|
+
|
|
147
|
+
await expect(client.get('/users/1234567/items')).rejects.toThrow(ZoteroApiError);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('upload auth parsing', () => {
|
|
153
|
+
test('parses upload authorization response shape', () => {
|
|
154
|
+
const response = {
|
|
155
|
+
url: 'https://upload.example.com/',
|
|
156
|
+
contentType: 'multipart/form-data',
|
|
157
|
+
uploadKey: 'abc123',
|
|
158
|
+
prefix: '--boundary\r\n',
|
|
159
|
+
suffix: '\r\n--boundary--',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
expect(response.url).toBeTruthy();
|
|
163
|
+
expect(response.contentType).toBeTruthy();
|
|
164
|
+
expect(response.uploadKey).toBeTruthy();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { LibraryType, RequestOptions, ZoteroConfig } from '../types';
|
|
2
|
+
import { ZoteroApiError } from '../types';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_BASE_URL = 'https://api.zotero.org';
|
|
5
|
+
export const ZOTERO_API_VERSION = '3';
|
|
6
|
+
|
|
7
|
+
export function normalizeLibraryType(libraryType?: LibraryType | 'group'): LibraryType {
|
|
8
|
+
return libraryType === 'groups' || libraryType === 'group' ? 'groups' : 'users';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function encodePathSegment(value: string): string {
|
|
12
|
+
return encodeURIComponent(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildLibraryPrefix(libraryId: string, libraryType?: LibraryType | 'group'): string {
|
|
16
|
+
const normalizedType = normalizeLibraryType(libraryType);
|
|
17
|
+
return `/${normalizedType}/${encodePathSegment(libraryId)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildZoteroUrl(
|
|
21
|
+
baseUrl: string,
|
|
22
|
+
path: string,
|
|
23
|
+
params?: Record<string, string | number | boolean | undefined>
|
|
24
|
+
): string {
|
|
25
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
26
|
+
const url = new URL(`${baseUrl.replace(/\/$/, '')}${normalizedPath}`);
|
|
27
|
+
|
|
28
|
+
if (params) {
|
|
29
|
+
for (const [key, value] of Object.entries(params)) {
|
|
30
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
31
|
+
url.searchParams.append(key, String(value));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return url.toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ZoteroClient {
|
|
40
|
+
private readonly apiKey: string;
|
|
41
|
+
private readonly libraryId: string;
|
|
42
|
+
private readonly libraryType: LibraryType;
|
|
43
|
+
private readonly baseUrl: string;
|
|
44
|
+
|
|
45
|
+
constructor(config: ZoteroConfig) {
|
|
46
|
+
if (!config.apiKey) {
|
|
47
|
+
throw new Error('Zotero API key is required');
|
|
48
|
+
}
|
|
49
|
+
if (!config.libraryId) {
|
|
50
|
+
throw new Error('Zotero library ID is required');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.apiKey = config.apiKey;
|
|
54
|
+
this.libraryId = config.libraryId;
|
|
55
|
+
this.libraryType = normalizeLibraryType(config.libraryType);
|
|
56
|
+
this.baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getBaseUrl(): string {
|
|
60
|
+
return this.baseUrl;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
libraryPrefix(): string {
|
|
64
|
+
return buildLibraryPrefix(this.libraryId, this.libraryType);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
|
68
|
+
return buildZoteroUrl(this.baseUrl, path, params);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
defaultHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
72
|
+
return {
|
|
73
|
+
'Zotero-API-Key': this.apiKey,
|
|
74
|
+
'Zotero-API-Version': ZOTERO_API_VERSION,
|
|
75
|
+
Accept: 'application/json',
|
|
76
|
+
...extra,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
81
|
+
const { method = 'GET', params, body, headers = {}, version } = options;
|
|
82
|
+
|
|
83
|
+
const url = this.buildUrl(path, params);
|
|
84
|
+
const requestHeaders = this.defaultHeaders(headers);
|
|
85
|
+
|
|
86
|
+
if (version !== undefined) {
|
|
87
|
+
requestHeaders['If-Unmodified-Since-Version'] = String(version);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fetchOptions: RequestInit = {
|
|
91
|
+
method,
|
|
92
|
+
headers: requestHeaders,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
96
|
+
if (body instanceof URLSearchParams) {
|
|
97
|
+
fetchOptions.body = body;
|
|
98
|
+
} else if (body instanceof Buffer || body instanceof Uint8Array) {
|
|
99
|
+
fetchOptions.body = body as BodyInit;
|
|
100
|
+
} else if (typeof body === 'string') {
|
|
101
|
+
fetchOptions.body = body;
|
|
102
|
+
} else {
|
|
103
|
+
requestHeaders['Content-Type'] = requestHeaders['Content-Type'] || 'application/json';
|
|
104
|
+
fetchOptions.body = JSON.stringify(body);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const response = await fetch(url, fetchOptions);
|
|
109
|
+
|
|
110
|
+
if (response.status === 204) {
|
|
111
|
+
return {} as T;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const contentType = response.headers.get('content-type') || '';
|
|
115
|
+
let data: unknown;
|
|
116
|
+
|
|
117
|
+
if (contentType.includes('application/json')) {
|
|
118
|
+
const text = await response.text();
|
|
119
|
+
data = text ? JSON.parse(text) : undefined;
|
|
120
|
+
} else {
|
|
121
|
+
data = await response.text();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
const message = typeof data === 'string'
|
|
126
|
+
? data.slice(0, 500)
|
|
127
|
+
: (data as { message?: string })?.message || response.statusText;
|
|
128
|
+
throw new ZoteroApiError(
|
|
129
|
+
`HTTP ${response.status}: ${message}`,
|
|
130
|
+
'http_error',
|
|
131
|
+
response.status
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return data as T;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
|
139
|
+
return this.request<T>(path, { method: 'GET', params });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async post<T>(
|
|
143
|
+
path: string,
|
|
144
|
+
body?: unknown,
|
|
145
|
+
options: Omit<RequestOptions, 'method' | 'body'> = {}
|
|
146
|
+
): Promise<T> {
|
|
147
|
+
return this.request<T>(path, { ...options, method: 'POST', body });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async patch<T>(
|
|
151
|
+
path: string,
|
|
152
|
+
body?: unknown,
|
|
153
|
+
options: Omit<RequestOptions, 'method' | 'body'> = {}
|
|
154
|
+
): Promise<T> {
|
|
155
|
+
return this.request<T>(path, { ...options, method: 'PATCH', body });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async delete<T>(
|
|
159
|
+
path: string,
|
|
160
|
+
options: Omit<RequestOptions, 'method'> = {}
|
|
161
|
+
): Promise<T> {
|
|
162
|
+
return this.request<T>(path, { ...options, method: 'DELETE' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async requestText(path: string, options: RequestOptions = {}): Promise<string> {
|
|
166
|
+
const result = await this.request<string>(path, options);
|
|
167
|
+
return typeof result === 'string' ? result : JSON.stringify(result);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CreateCollectionInput, ZoteroCollection } from '../types';
|
|
2
|
+
import { encodePathSegment, ZoteroClient } from './client';
|
|
3
|
+
|
|
4
|
+
export class CollectionsApi {
|
|
5
|
+
constructor(private readonly client: ZoteroClient) {}
|
|
6
|
+
|
|
7
|
+
private collectionsPath(): string {
|
|
8
|
+
return `${this.client.libraryPrefix()}/collections`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async list(): Promise<ZoteroCollection[]> {
|
|
12
|
+
return this.client.get<ZoteroCollection[]>(this.collectionsPath());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async get(collectionKey: string): Promise<ZoteroCollection> {
|
|
16
|
+
const prefix = this.client.libraryPrefix();
|
|
17
|
+
return this.client.get<ZoteroCollection>(
|
|
18
|
+
`${prefix}/collections/${encodePathSegment(collectionKey)}`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async create(collections: CreateCollectionInput | CreateCollectionInput[]): Promise<unknown> {
|
|
23
|
+
const payload = Array.isArray(collections) ? collections : [collections];
|
|
24
|
+
return this.client.post(this.collectionsPath(), payload);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ZoteroConfig, RequestOptions } from '../types';
|
|
2
|
+
import { ZoteroClient } from './client';
|
|
3
|
+
import { ItemsApi } from './items';
|
|
4
|
+
import { CollectionsApi } from './collections';
|
|
5
|
+
import { AttachmentsApi } from './attachments';
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
ZoteroClient,
|
|
9
|
+
DEFAULT_BASE_URL,
|
|
10
|
+
ZOTERO_API_VERSION,
|
|
11
|
+
normalizeLibraryType,
|
|
12
|
+
buildLibraryPrefix,
|
|
13
|
+
buildZoteroUrl,
|
|
14
|
+
} from './client';
|
|
15
|
+
export { ItemsApi } from './items';
|
|
16
|
+
export { CollectionsApi } from './collections';
|
|
17
|
+
export { AttachmentsApi } from './attachments';
|
|
18
|
+
|
|
19
|
+
export class Zotero {
|
|
20
|
+
private readonly client: ZoteroClient;
|
|
21
|
+
|
|
22
|
+
public readonly items: ItemsApi;
|
|
23
|
+
public readonly collections: CollectionsApi;
|
|
24
|
+
public readonly attachments: AttachmentsApi;
|
|
25
|
+
|
|
26
|
+
constructor(config: ZoteroConfig) {
|
|
27
|
+
this.client = new ZoteroClient(config);
|
|
28
|
+
this.items = new ItemsApi(this.client);
|
|
29
|
+
this.collections = new CollectionsApi(this.client);
|
|
30
|
+
this.attachments = new AttachmentsApi(this.client);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async test(): Promise<unknown[]> {
|
|
34
|
+
const prefix = this.client.libraryPrefix();
|
|
35
|
+
return this.client.get(`${prefix}/items`, { limit: 1 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async rawRequest<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
39
|
+
return this.client.request<T>(path, options);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getClient(): ZoteroClient {
|
|
43
|
+
return this.client;
|
|
44
|
+
}
|
|
45
|
+
}
|