@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.
Files changed (118) hide show
  1. package/README.md +25 -10
  2. package/bin/index.js +1362 -11796
  3. package/bin/mcp.js +751 -10328
  4. package/bin/serve.js +2252 -11829
  5. package/connectors/cloudflare-workers/.env.example +5 -0
  6. package/connectors/cloudflare-workers/AGENTS.md +11 -0
  7. package/connectors/cloudflare-workers/CLAUDE.md +11 -0
  8. package/connectors/cloudflare-workers/README.md +45 -0
  9. package/connectors/cloudflare-workers/package.json +47 -0
  10. package/connectors/cloudflare-workers/src/api/client.test.ts +144 -0
  11. package/connectors/cloudflare-workers/src/api/client.ts +386 -0
  12. package/connectors/cloudflare-workers/src/api/index.ts +1 -0
  13. package/connectors/cloudflare-workers/src/cli/index.ts +206 -0
  14. package/connectors/cloudflare-workers/src/index.ts +16 -0
  15. package/connectors/cloudflare-workers/src/types/index.ts +53 -0
  16. package/connectors/cloudflare-workers/src/utils/config.ts +61 -0
  17. package/connectors/cloudflare-workers/src/utils/output.ts +26 -0
  18. package/connectors/cloudflare-workers/tsconfig.build.json +4 -0
  19. package/connectors/cloudflare-workers/tsconfig.json +14 -0
  20. package/connectors/tidio/.env.example +3 -2
  21. package/connectors/tidio/CLAUDE.md +21 -96
  22. package/connectors/tidio/README.md +22 -18
  23. package/connectors/tidio/package.json +4 -2
  24. package/connectors/tidio/src/api/client.ts +26 -15
  25. package/connectors/tidio/src/api/index.ts +185 -128
  26. package/connectors/tidio/src/api/tidio.test.ts +175 -44
  27. package/connectors/tidio/src/cli/index.ts +154 -388
  28. package/connectors/tidio/src/index.ts +5 -3
  29. package/connectors/tidio/src/types/index.ts +135 -82
  30. package/connectors/tidio/src/utils/config.ts +90 -44
  31. package/connectors/wandb/.env.example +7 -0
  32. package/connectors/wandb/AGENTS.md +43 -0
  33. package/connectors/wandb/CLAUDE.md +67 -0
  34. package/connectors/wandb/README.md +65 -0
  35. package/connectors/wandb/package.json +52 -0
  36. package/connectors/wandb/src/api/client.test.ts +116 -0
  37. package/connectors/wandb/src/api/client.ts +83 -0
  38. package/connectors/wandb/src/api/graphql.ts +9 -0
  39. package/connectors/wandb/src/api/index.ts +36 -0
  40. package/connectors/wandb/src/api/projects.ts +36 -0
  41. package/connectors/wandb/src/api/viewer.ts +20 -0
  42. package/connectors/wandb/src/cli/index.ts +265 -0
  43. package/connectors/wandb/src/index.ts +22 -0
  44. package/connectors/wandb/src/types/index.ts +75 -0
  45. package/connectors/wandb/src/utils/config.ts +164 -0
  46. package/connectors/wandb/src/utils/output.ts +119 -0
  47. package/connectors/wandb/tsconfig.json +16 -0
  48. package/connectors/weights-biases/.env.example +7 -0
  49. package/connectors/weights-biases/CLAUDE.md +65 -0
  50. package/connectors/weights-biases/README.md +62 -0
  51. package/connectors/weights-biases/package.json +53 -0
  52. package/connectors/weights-biases/src/api/client.test.ts +46 -0
  53. package/connectors/weights-biases/src/api/client.ts +119 -0
  54. package/connectors/weights-biases/src/api/events.ts +18 -0
  55. package/connectors/weights-biases/src/api/index.ts +56 -0
  56. package/connectors/weights-biases/src/api/runs.ts +28 -0
  57. package/connectors/weights-biases/src/api/search.ts +10 -0
  58. package/connectors/weights-biases/src/cli/index.ts +289 -0
  59. package/connectors/weights-biases/src/index.ts +19 -0
  60. package/connectors/weights-biases/src/types/index.ts +92 -0
  61. package/connectors/weights-biases/src/utils/config.ts +168 -0
  62. package/connectors/weights-biases/src/utils/output.ts +116 -0
  63. package/connectors/weights-biases/tsconfig.json +16 -0
  64. package/connectors/x/src/api/oauth.test.ts +205 -0
  65. package/connectors/zoominfo/.env.example +4 -0
  66. package/connectors/zoominfo/AGENTS.md +26 -0
  67. package/connectors/zoominfo/CLAUDE.md +20 -0
  68. package/connectors/zoominfo/README.md +69 -0
  69. package/connectors/zoominfo/package.json +52 -0
  70. package/connectors/zoominfo/src/api/client.test.ts +140 -0
  71. package/connectors/zoominfo/src/api/client.ts +228 -0
  72. package/connectors/zoominfo/src/api/index.ts +1 -0
  73. package/connectors/zoominfo/src/cli/index.ts +179 -0
  74. package/connectors/zoominfo/src/index.ts +3 -0
  75. package/connectors/zoominfo/src/types/index.ts +39 -0
  76. package/connectors/zoominfo/src/utils/config.ts +61 -0
  77. package/connectors/zoominfo/src/utils/output.ts +27 -0
  78. package/connectors/zoominfo/tsconfig.json +20 -0
  79. package/connectors/zotero/.env.example +7 -0
  80. package/connectors/zotero/.npmrc.example +2 -0
  81. package/connectors/zotero/AGENTS.md +40 -0
  82. package/connectors/zotero/CLAUDE.md +69 -0
  83. package/connectors/zotero/README.md +91 -0
  84. package/connectors/zotero/package.json +43 -0
  85. package/connectors/zotero/src/api/attachments.ts +130 -0
  86. package/connectors/zotero/src/api/client.test.ts +166 -0
  87. package/connectors/zotero/src/api/client.ts +169 -0
  88. package/connectors/zotero/src/api/collections.ts +26 -0
  89. package/connectors/zotero/src/api/index.ts +45 -0
  90. package/connectors/zotero/src/api/items.ts +64 -0
  91. package/connectors/zotero/src/cli/index.ts +386 -0
  92. package/connectors/zotero/src/index.ts +28 -0
  93. package/connectors/zotero/src/types/index.ts +129 -0
  94. package/connectors/zotero/src/utils/config.ts +245 -0
  95. package/connectors/zotero/src/utils/output.ts +119 -0
  96. package/connectors/zotero/tsconfig.json +16 -0
  97. package/connectors/zymbly/.env.example +2 -0
  98. package/connectors/zymbly/AGENTS.md +11 -0
  99. package/connectors/zymbly/CLAUDE.md +5 -0
  100. package/connectors/zymbly/README.md +35 -0
  101. package/connectors/zymbly/package.json +52 -0
  102. package/connectors/zymbly/src/api/client.test.ts +110 -0
  103. package/connectors/zymbly/src/api/client.ts +166 -0
  104. package/connectors/zymbly/src/api/index.ts +1 -0
  105. package/connectors/zymbly/src/cli/index.ts +156 -0
  106. package/connectors/zymbly/src/index.ts +12 -0
  107. package/connectors/zymbly/src/types/index.ts +34 -0
  108. package/connectors/zymbly/src/utils/config.ts +52 -0
  109. package/connectors/zymbly/src/utils/output.ts +17 -0
  110. package/connectors/zymbly/tsconfig.json +13 -0
  111. package/dist/db/database.d.ts +2 -1
  112. package/dist/db/sqlite-adapter.d.ts +43 -0
  113. package/dist/index.js +172 -9653
  114. package/dist/lib/compact-output.d.ts +40 -0
  115. package/dist/mcp/tools/jobs.d.ts +32 -0
  116. package/dist/mcp/tools/jobs.test.d.ts +1 -0
  117. package/dist/no-cloud-boundary.test.d.ts +1 -0
  118. 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
+ }