@hasna/connectors 1.3.44 → 1.3.46
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 -0
- package/bin/index.js +792 -130
- package/bin/mcp.js +438 -52
- package/bin/serve.js +438 -52
- 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/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/index.js +45 -2
- 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/package.json +3 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateItemInput,
|
|
3
|
+
CreateItemsResponse,
|
|
4
|
+
ListItemsOptions,
|
|
5
|
+
UpdateItemInput,
|
|
6
|
+
ZoteroItem,
|
|
7
|
+
} from '../types';
|
|
8
|
+
import { encodePathSegment, ZoteroClient } from './client';
|
|
9
|
+
|
|
10
|
+
export class ItemsApi {
|
|
11
|
+
constructor(private readonly client: ZoteroClient) {}
|
|
12
|
+
|
|
13
|
+
private itemsPath(collectionKey?: string): string {
|
|
14
|
+
const prefix = this.client.libraryPrefix();
|
|
15
|
+
if (collectionKey) {
|
|
16
|
+
return `${prefix}/collections/${encodePathSegment(collectionKey)}/items`;
|
|
17
|
+
}
|
|
18
|
+
return `${prefix}/items`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async list(options: ListItemsOptions = {}): Promise<ZoteroItem[]> {
|
|
22
|
+
const { collectionKey, ...params } = options;
|
|
23
|
+
return this.client.get<ZoteroItem[]>(this.itemsPath(collectionKey), params);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async search(query: string, options: Omit<ListItemsOptions, 'q'> = {}): Promise<ZoteroItem[]> {
|
|
27
|
+
return this.list({ ...options, q: query });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async get(itemKey: string): Promise<ZoteroItem> {
|
|
31
|
+
const prefix = this.client.libraryPrefix();
|
|
32
|
+
return this.client.get<ZoteroItem>(`${prefix}/items/${encodePathSegment(itemKey)}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async create(items: CreateItemInput | CreateItemInput[]): Promise<CreateItemsResponse> {
|
|
36
|
+
const payload = Array.isArray(items) ? items : [items];
|
|
37
|
+
return this.client.post<CreateItemsResponse>(this.itemsPath(), payload);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async update(
|
|
41
|
+
itemKey: string,
|
|
42
|
+
item: UpdateItemInput,
|
|
43
|
+
version?: number | string,
|
|
44
|
+
method: 'PATCH' | 'PUT' = 'PATCH'
|
|
45
|
+
): Promise<CreateItemsResponse> {
|
|
46
|
+
const prefix = this.client.libraryPrefix();
|
|
47
|
+
const path = `${prefix}/items/${encodePathSegment(itemKey)}`;
|
|
48
|
+
|
|
49
|
+
if (method === 'PUT') {
|
|
50
|
+
return this.client.request<CreateItemsResponse>(path, {
|
|
51
|
+
method: 'PUT',
|
|
52
|
+
body: item,
|
|
53
|
+
version,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return this.client.patch<CreateItemsResponse>(path, item, { version });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async delete(itemKey: string, version: number | string): Promise<void> {
|
|
61
|
+
const prefix = this.client.libraryPrefix();
|
|
62
|
+
await this.client.delete(`${prefix}/items/${encodePathSegment(itemKey)}`, { version });
|
|
63
|
+
}
|
|
64
|
+
}
|