@smartcat/sanity-plugin 1.0.0 → 1.0.1
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 +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +168 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +311 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +169 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +313 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2110 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +287 -0
- package/dist/locjson.d.ts +287 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +262 -0
- package/dist/progress.d.ts +262 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +258 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +27 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +545 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +98 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +17 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +47 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +387 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +268 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +264 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +44 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +975 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +79 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +623 -0
- package/src/tool/processing.ts +766 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- package/src/types.ts +77 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {SmartcatClient, SmartcatError, SmartcatTimeoutError} from './client'
|
|
3
|
+
import {buildZip} from '../lib/zip.fixtures'
|
|
4
|
+
|
|
5
|
+
function mockFetch(status: number, body: unknown) {
|
|
6
|
+
return vi.fn(async () =>
|
|
7
|
+
new Response(typeof body === 'string' ? body : JSON.stringify(body), {status}),
|
|
8
|
+
) as unknown as typeof fetch
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const opts = {
|
|
12
|
+
server: 'us.smartcat.com',
|
|
13
|
+
workspaceId: '784fbe10-4ef5-4519-8acc-9a3cd956d614',
|
|
14
|
+
apiKey: 'test-key',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('SmartcatClient', () => {
|
|
18
|
+
it('sends Basic auth derived from workspaceId:apiKey', async () => {
|
|
19
|
+
const fetchImpl = mockFetch(200, {id: 'p1'})
|
|
20
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
21
|
+
await client.getProject('p1')
|
|
22
|
+
|
|
23
|
+
const [, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
24
|
+
const expected = `Basic ${btoa(`${opts.workspaceId}:${opts.apiKey}`)}`
|
|
25
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(expected)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('createProject posts multipart body and returns the project', async () => {
|
|
29
|
+
const fetchImpl = mockFetch(200, {id: 'guid-123', name: 'Demo'})
|
|
30
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
31
|
+
|
|
32
|
+
const project = await client.createProject({
|
|
33
|
+
name: 'Demo',
|
|
34
|
+
sourceLanguage: 'en',
|
|
35
|
+
targetLanguages: ['fr', 'zh-Hans'],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(project.id).toBe('guid-123')
|
|
39
|
+
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
40
|
+
expect(url).toBe('https://us.smartcat.com/api/integration/v1/project/create')
|
|
41
|
+
expect(init.method).toBe('POST')
|
|
42
|
+
expect(init.body).toBeInstanceOf(FormData)
|
|
43
|
+
expect((init.body as FormData).get('body')).toContain('"sourceLanguage":"en"')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('uploadDocument targets the project and normalizes the response to an array', async () => {
|
|
47
|
+
const fetchImpl = mockFetch(200, {id: 'doc1'})
|
|
48
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
49
|
+
|
|
50
|
+
const docs = await client.uploadDocument('guid-123', 'page/About-abc.locjson', '{}')
|
|
51
|
+
expect(docs).toEqual([{id: 'doc1'}])
|
|
52
|
+
|
|
53
|
+
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
54
|
+
expect(url).toBe(
|
|
55
|
+
'https://us.smartcat.com/api/integration/v1/project/document?projectId=guid-123',
|
|
56
|
+
)
|
|
57
|
+
expect((init.body as FormData).get('file')).toBeInstanceOf(Blob)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('uploadDocuments posts a multi-file body and normalizes the response to an array', async () => {
|
|
61
|
+
const fetchImpl = mockFetch(200, [{id: 'd1'}, {id: 'd2'}])
|
|
62
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
63
|
+
|
|
64
|
+
const docs = await client.uploadDocuments('guid-123', [
|
|
65
|
+
{filename: 'page/About-abc.locjson', content: '{"a":1}'},
|
|
66
|
+
{filename: 'page/Home-def.locjson', content: '{"b":2}'},
|
|
67
|
+
])
|
|
68
|
+
expect(docs).toEqual([{id: 'd1'}, {id: 'd2'}])
|
|
69
|
+
|
|
70
|
+
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
71
|
+
expect(url).toBe('https://us.smartcat.com/api/integration/v1/project/document?projectId=guid-123')
|
|
72
|
+
expect(init.method).toBe('POST')
|
|
73
|
+
const body = init.body as FormData
|
|
74
|
+
expect(body.getAll('file')).toHaveLength(2)
|
|
75
|
+
expect(body.get('model')).toBeInstanceOf(Blob)
|
|
76
|
+
expect(JSON.parse(await (body.get('model') as Blob).text())).toEqual([{}, {}])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('uploadDocuments normalizes a single-object response to an array', async () => {
|
|
80
|
+
const fetchImpl = mockFetch(200, {id: 'd1'})
|
|
81
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
82
|
+
const docs = await client.uploadDocuments('guid-123', [{filename: 'f.locjson', content: '{}'}])
|
|
83
|
+
expect(docs).toEqual([{id: 'd1'}])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('exportDocument requests a task then polls until 200', async () => {
|
|
87
|
+
const responses = [
|
|
88
|
+
{status: 200, text: async () => JSON.stringify({id: 'task-1'})}, // POST export
|
|
89
|
+
{status: 204, text: async () => ''}, // poll: not ready
|
|
90
|
+
{status: 200, text: async () => '{"units":[]}'}, // poll: ready
|
|
91
|
+
]
|
|
92
|
+
const fetchImpl = vi.fn(async () => responses.shift()!) as unknown as typeof fetch
|
|
93
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
94
|
+
|
|
95
|
+
const content = await client.exportDocument('doc-1', {sleep: async () => {}, pollIntervalMs: 0})
|
|
96
|
+
expect(content).toBe('{"units":[]}')
|
|
97
|
+
|
|
98
|
+
const calls = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls
|
|
99
|
+
expect(calls[0][0]).toContain('/document/export?documentIds=doc-1&mode=confirmed&type=target')
|
|
100
|
+
expect(calls[1][0]).toContain('/document/export/task-1')
|
|
101
|
+
expect(calls).toHaveLength(3)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('exportDocumentsBatch requests one task for all ids and unpacks the ZIP result', async () => {
|
|
105
|
+
const zip = await buildZip([
|
|
106
|
+
{name: 'page/About-abc12345(fr).locjson', content: '{"a":1}', method: 8},
|
|
107
|
+
{name: 'page/Home-def67890(fr).locjson', content: '{"b":2}'},
|
|
108
|
+
])
|
|
109
|
+
const responses = [
|
|
110
|
+
new Response(JSON.stringify({id: 'task-9'}), {status: 200}), // POST export
|
|
111
|
+
new Response(null, {status: 204}), // poll: not ready
|
|
112
|
+
new Response(zip.slice(), {status: 200}), // poll: ready (archive)
|
|
113
|
+
]
|
|
114
|
+
const fetchImpl = vi.fn(async () => responses.shift()!) as unknown as typeof fetch
|
|
115
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
116
|
+
|
|
117
|
+
const files = await client.exportDocumentsBatch(['g_25', 'h_25'], {sleep: async () => {}})
|
|
118
|
+
expect(files).toEqual([
|
|
119
|
+
{filename: 'page/About-abc12345(fr).locjson', content: '{"a":1}'},
|
|
120
|
+
{filename: 'page/Home-def67890(fr).locjson', content: '{"b":2}'},
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
const calls = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls
|
|
124
|
+
expect(calls[0][0]).toContain('/document/export?documentIds=g_25&documentIds=h_25&mode=confirmed&type=target')
|
|
125
|
+
expect(calls[1][0]).toContain('/document/export/task-9')
|
|
126
|
+
expect(calls).toHaveLength(3)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('exportDocumentsBatch handles a bare (non-ZIP) single-file result', async () => {
|
|
130
|
+
const responses = [
|
|
131
|
+
new Response(JSON.stringify({id: 'task-1'}), {status: 200}),
|
|
132
|
+
new Response('{"units":[]}', {status: 200}),
|
|
133
|
+
]
|
|
134
|
+
const fetchImpl = vi.fn(async () => responses.shift()!) as unknown as typeof fetch
|
|
135
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
136
|
+
|
|
137
|
+
const files = await client.exportDocumentsBatch(['g_25'], {sleep: async () => {}})
|
|
138
|
+
expect(files).toEqual([{filename: '', content: '{"units":[]}'}])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('exportDocumentsBatch surfaces poll errors as SmartcatError', async () => {
|
|
142
|
+
const responses = [
|
|
143
|
+
new Response(JSON.stringify({id: 'task-1'}), {status: 200}),
|
|
144
|
+
new Response('boom', {status: 500}),
|
|
145
|
+
]
|
|
146
|
+
const fetchImpl = vi.fn(async () => responses.shift()!) as unknown as typeof fetch
|
|
147
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
148
|
+
|
|
149
|
+
await expect(client.exportDocumentsBatch(['g_25', 'h_25'], {sleep: async () => {}})).rejects.toBeInstanceOf(
|
|
150
|
+
SmartcatError,
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('deleteDocument sends a DELETE with the document id as a query param', async () => {
|
|
155
|
+
// Smartcat returns 204 (no body); the Response ctor forbids a body for 204.
|
|
156
|
+
const fetchImpl = vi.fn(async () => new Response(null, {status: 204})) as unknown as typeof fetch
|
|
157
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
158
|
+
|
|
159
|
+
await client.deleteDocument('c11963b35c862b1c61a8c99a_12')
|
|
160
|
+
|
|
161
|
+
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
162
|
+
expect(url).toBe(
|
|
163
|
+
'https://us.smartcat.com/api/integration/v1/document?documentIds=c11963b35c862b1c61a8c99a_12',
|
|
164
|
+
)
|
|
165
|
+
expect(init.method).toBe('DELETE')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('throws SmartcatError on non-2xx', async () => {
|
|
169
|
+
const fetchImpl = mockFetch(401, 'Unauthorized')
|
|
170
|
+
const client = new SmartcatClient({...opts, fetchImpl})
|
|
171
|
+
await expect(client.getProject('p1')).rejects.toBeInstanceOf(SmartcatError)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('strips protocol/trailing slash from the server', async () => {
|
|
175
|
+
const fetchImpl = mockFetch(200, {id: 'p1'})
|
|
176
|
+
const client = new SmartcatClient({...opts, server: 'https://us.smartcat.com/', fetchImpl})
|
|
177
|
+
await client.getProject('p1')
|
|
178
|
+
const [url] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
179
|
+
expect(url).toBe('https://us.smartcat.com/api/integration/v1/project/p1')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('request timeout', () => {
|
|
183
|
+
it('passes an AbortSignal when requestTimeoutMs is set, and none when it is not', async () => {
|
|
184
|
+
const withTimeout = mockFetch(200, {id: 'p1'})
|
|
185
|
+
await new SmartcatClient({...opts, fetchImpl: withTimeout, requestTimeoutMs: 570_000}).getProject('p1')
|
|
186
|
+
const [, initA] = (withTimeout as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
187
|
+
expect(initA.signal).toBeInstanceOf(AbortSignal)
|
|
188
|
+
|
|
189
|
+
const noTimeout = mockFetch(200, {id: 'p1'})
|
|
190
|
+
await new SmartcatClient({...opts, fetchImpl: noTimeout}).getProject('p1')
|
|
191
|
+
const [, initB] = (noTimeout as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
192
|
+
expect(initB.signal).toBeUndefined()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('translates a fetch abort into a SmartcatTimeoutError and logs it', async () => {
|
|
196
|
+
const fetchImpl = vi.fn(async () => {
|
|
197
|
+
const err = new Error('aborted')
|
|
198
|
+
err.name = 'TimeoutError'
|
|
199
|
+
throw err
|
|
200
|
+
}) as unknown as typeof fetch
|
|
201
|
+
const logged: {status: number; body: string}[] = []
|
|
202
|
+
const client = new SmartcatClient({...opts, fetchImpl, requestTimeoutMs: 570_000})
|
|
203
|
+
client.setRequestLogger(({status, body}) => logged.push({status, body}))
|
|
204
|
+
|
|
205
|
+
const error = await client.getProject('p1').catch((e) => e)
|
|
206
|
+
expect(error).toBeInstanceOf(SmartcatTimeoutError)
|
|
207
|
+
expect(error.message).toContain('9m30s')
|
|
208
|
+
// The failed call is traced (synthetic status 0) so it reaches the log.
|
|
209
|
+
expect(logged).toEqual([{status: 0, body: expect.stringContaining('9m30s')}])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('leaves non-abort fetch errors unchanged', async () => {
|
|
213
|
+
const fetchImpl = vi.fn(async () => {
|
|
214
|
+
throw new Error('network down')
|
|
215
|
+
}) as unknown as typeof fetch
|
|
216
|
+
const client = new SmartcatClient({...opts, fetchImpl, requestTimeoutMs: 570_000})
|
|
217
|
+
const error = await client.getProject('p1').catch((e) => e)
|
|
218
|
+
expect(error).not.toBeInstanceOf(SmartcatTimeoutError)
|
|
219
|
+
expect((error as Error).message).toBe('network down')
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import {isZip, unzipText} from '../lib/zip'
|
|
2
|
+
import type {
|
|
3
|
+
CreateProjectInput,
|
|
4
|
+
RequestLogger,
|
|
5
|
+
SmartcatClientOptions,
|
|
6
|
+
SmartcatDocument,
|
|
7
|
+
SmartcatProject,
|
|
8
|
+
SmartcatTemplatesResponse,
|
|
9
|
+
} from './types'
|
|
10
|
+
|
|
11
|
+
const API_BASE = '/api/integration/v1'
|
|
12
|
+
|
|
13
|
+
/** One file from a (batch) export: archive entry name + LocJSON text. */
|
|
14
|
+
export interface ExportedFile {
|
|
15
|
+
/** Path inside the export archive; empty for a single-file response. */
|
|
16
|
+
filename: string
|
|
17
|
+
content: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Error thrown when the Smartcat API returns a non-2xx response. */
|
|
21
|
+
export class SmartcatError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
public readonly status: number,
|
|
24
|
+
public readonly body: string,
|
|
25
|
+
) {
|
|
26
|
+
super(`Smartcat API error ${status}: ${body}`)
|
|
27
|
+
this.name = 'SmartcatError'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Error thrown when a request is aborted by the configured `requestTimeoutMs`. */
|
|
32
|
+
export class SmartcatTimeoutError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
public readonly method: string,
|
|
35
|
+
public readonly path: string,
|
|
36
|
+
public readonly timeoutMs: number,
|
|
37
|
+
) {
|
|
38
|
+
super(`Smartcat did not respond within ${formatDuration(timeoutMs)} for ${method} ${path}`)
|
|
39
|
+
this.name = 'SmartcatTimeoutError'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Human-readable duration, e.g. 570000 → "9m30s", 30000 → "30s". */
|
|
44
|
+
function formatDuration(ms: number): string {
|
|
45
|
+
const totalSeconds = Math.round(ms / 1000)
|
|
46
|
+
const minutes = Math.floor(totalSeconds / 60)
|
|
47
|
+
const seconds = totalSeconds % 60
|
|
48
|
+
if (!minutes) return `${seconds}s`
|
|
49
|
+
return seconds ? `${minutes}m${seconds}s` : `${minutes}m`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True for an abort/timeout rejection from `fetch` (DOMException-style). */
|
|
53
|
+
function isAbortError(err: unknown): boolean {
|
|
54
|
+
const name = (err as {name?: string} | undefined)?.name
|
|
55
|
+
return name === 'TimeoutError' || name === 'AbortError'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Minimal client for the Smartcat integration API.
|
|
60
|
+
*
|
|
61
|
+
* Uses Basic auth (`base64(workspaceId:apiKey)`). Built on global `fetch`,
|
|
62
|
+
* `FormData` and `Blob` (Node 18+ / the Functions runtime); no Node-only APIs,
|
|
63
|
+
* so it is portable. Secrets are required, so this only runs server-side.
|
|
64
|
+
*/
|
|
65
|
+
export class SmartcatClient {
|
|
66
|
+
private readonly server: string
|
|
67
|
+
private readonly authHeader: string
|
|
68
|
+
private readonly fetchImpl: typeof fetch
|
|
69
|
+
private readonly requestTimeoutMs?: number
|
|
70
|
+
private requestLogger?: RequestLogger
|
|
71
|
+
|
|
72
|
+
constructor(options: SmartcatClientOptions) {
|
|
73
|
+
this.server = options.server.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
|
74
|
+
this.authHeader = `Basic ${btoa(`${options.workspaceId}:${options.apiKey}`)}`
|
|
75
|
+
this.fetchImpl = options.fetchImpl ?? fetch
|
|
76
|
+
this.requestTimeoutMs = options.requestTimeoutMs
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** An `AbortSignal` that fires after `requestTimeoutMs`, or undefined if unset. */
|
|
80
|
+
private timeoutSignal(): AbortSignal | undefined {
|
|
81
|
+
return this.requestTimeoutMs ? AbortSignal.timeout(this.requestTimeoutMs) : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Attach a sink that receives every HTTP call (method, path, status, body). */
|
|
85
|
+
setRequestLogger(logger: RequestLogger): void {
|
|
86
|
+
this.requestLogger = logger
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Creates a project and returns it (including its generated `id` GUID). */
|
|
90
|
+
async createProject(input: CreateProjectInput): Promise<SmartcatProject> {
|
|
91
|
+
const {templateId, ...rest} = input
|
|
92
|
+
// A template fully owns its workflow, MT/TM and pretranslation config, so we
|
|
93
|
+
// send only the project identity + template id. The standard flow sets the
|
|
94
|
+
// MT/TM/pretranslation flags here; `workflowStages` is supplied by the caller
|
|
95
|
+
// (it's what distinguishes AI+human from AI-only).
|
|
96
|
+
const body = templateId
|
|
97
|
+
? {assignToVendor: false, externalTag: 'smartcat-sanity-plugin', templateId, ...rest}
|
|
98
|
+
: {
|
|
99
|
+
assignToVendor: false,
|
|
100
|
+
externalTag: 'smartcat-sanity-plugin',
|
|
101
|
+
useMT: true,
|
|
102
|
+
useTranslationMemory: true,
|
|
103
|
+
pretranslate: true,
|
|
104
|
+
autoPropagateRepetitions: true,
|
|
105
|
+
minTranslationAssuranceLevel: 'regular',
|
|
106
|
+
...rest,
|
|
107
|
+
}
|
|
108
|
+
const form = new FormData()
|
|
109
|
+
form.append('body', JSON.stringify(body))
|
|
110
|
+
return this.request('POST', `${API_BASE}/project/create`, form)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Lists the workspace's project templates. */
|
|
114
|
+
async getTemplates(): Promise<SmartcatTemplatesResponse> {
|
|
115
|
+
return this.request('GET', `${API_BASE}/template`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Uploads one source file into a project. `filename` may include a folder path. */
|
|
119
|
+
async uploadDocument(
|
|
120
|
+
projectId: string,
|
|
121
|
+
filename: string,
|
|
122
|
+
content: string,
|
|
123
|
+
): Promise<SmartcatDocument[]> {
|
|
124
|
+
const form = new FormData()
|
|
125
|
+
// Use a generic content type: declaring application/json makes Smartcat try
|
|
126
|
+
// to map the file to an API model instead of importing it as a document.
|
|
127
|
+
form.append('file', new Blob([content], {type: 'application/octet-stream'}), filename)
|
|
128
|
+
const result = await this.request<SmartcatDocument[] | SmartcatDocument>(
|
|
129
|
+
'POST',
|
|
130
|
+
`${API_BASE}/project/document?projectId=${encodeURIComponent(projectId)}`,
|
|
131
|
+
form,
|
|
132
|
+
)
|
|
133
|
+
return Array.isArray(result) ? result : result ? [result] : []
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Uploads many source files into a project in a single request — the
|
|
138
|
+
* multi-file sibling of {@link uploadDocument}. Mirrors the reference
|
|
139
|
+
* `AddDocuments` multipart contract: one `model` part carrying a JSON array
|
|
140
|
+
* with one (default-settings) entry per file, plus one `file` part per file.
|
|
141
|
+
* Returns the created documents (files × target languages).
|
|
142
|
+
*/
|
|
143
|
+
async uploadDocuments(
|
|
144
|
+
projectId: string,
|
|
145
|
+
files: {filename: string; content: string}[],
|
|
146
|
+
): Promise<SmartcatDocument[]> {
|
|
147
|
+
const form = new FormData()
|
|
148
|
+
// One settings object per file, positional with the file parts (empty = default
|
|
149
|
+
// disassembly, as the single-file path). Correlation never relies on this order.
|
|
150
|
+
form.append('model', new Blob([JSON.stringify(files.map(() => ({})))], {type: 'application/json'}), 'model')
|
|
151
|
+
for (const f of files) {
|
|
152
|
+
form.append('file', new Blob([f.content], {type: 'application/octet-stream'}), f.filename)
|
|
153
|
+
}
|
|
154
|
+
const result = await this.request<SmartcatDocument[] | SmartcatDocument>(
|
|
155
|
+
'POST',
|
|
156
|
+
`${API_BASE}/project/document?projectId=${encodeURIComponent(projectId)}`,
|
|
157
|
+
form,
|
|
158
|
+
)
|
|
159
|
+
return Array.isArray(result) ? result : result ? [result] : []
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Updates an existing document's source content by re-uploading the file.
|
|
164
|
+
* Smartcat re-imports and merges, preserving translations of unchanged
|
|
165
|
+
* segments. `documentId` is a target document id (`<fileGuid>_<langNum>`).
|
|
166
|
+
*/
|
|
167
|
+
async updateDocument(
|
|
168
|
+
documentId: string,
|
|
169
|
+
filename: string,
|
|
170
|
+
content: string,
|
|
171
|
+
): Promise<SmartcatDocument[]> {
|
|
172
|
+
const form = new FormData()
|
|
173
|
+
form.append('file', new Blob([content], {type: 'application/octet-stream'}), filename)
|
|
174
|
+
const result = await this.request<SmartcatDocument[] | SmartcatDocument>(
|
|
175
|
+
'PUT',
|
|
176
|
+
`${API_BASE}/document/update?documentId=${encodeURIComponent(documentId)}`,
|
|
177
|
+
form,
|
|
178
|
+
)
|
|
179
|
+
return Array.isArray(result) ? result : result ? [result] : []
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Fetches a project (including its documents and per-stage progress). */
|
|
183
|
+
async getProject(projectId: string): Promise<SmartcatProject> {
|
|
184
|
+
return this.request('GET', `${API_BASE}/project/${encodeURIComponent(projectId)}`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Deletes a document from its project. The id is a target document id
|
|
189
|
+
* (`<fileGuid>_<langNum>`), but Smartcat removes the whole document — every
|
|
190
|
+
* target language — in a single call.
|
|
191
|
+
*/
|
|
192
|
+
async deleteDocument(documentId: string): Promise<void> {
|
|
193
|
+
await this.request('DELETE', `${API_BASE}/document?documentIds=${encodeURIComponent(documentId)}`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Builds the Smartcat web URL for a project's overview page. */
|
|
197
|
+
projectUrl(projectId: string): string {
|
|
198
|
+
return `https://${this.server}/projects/${projectId}/general`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Exports a document's target (translated) content and returns it as text.
|
|
203
|
+
* Two-step: request an export task, then poll until ready.
|
|
204
|
+
*
|
|
205
|
+
* `mode` defaults to `confirmed` — only segments confirmed at any workflow
|
|
206
|
+
* stage are returned (unconfirmed/untranslated segments fall back to source),
|
|
207
|
+
* so we never import unvetted machine-translation drafts. Use `current` to pull
|
|
208
|
+
* the live state including unconfirmed edits.
|
|
209
|
+
*/
|
|
210
|
+
async exportDocument(
|
|
211
|
+
documentId: string,
|
|
212
|
+
options: {
|
|
213
|
+
mode?: 'confirmed' | 'current'
|
|
214
|
+
pollIntervalMs?: number
|
|
215
|
+
maxAttempts?: number
|
|
216
|
+
sleep?: (ms: number) => Promise<void>
|
|
217
|
+
} = {},
|
|
218
|
+
): Promise<string> {
|
|
219
|
+
const {mode = 'confirmed', pollIntervalMs = 3000, maxAttempts = 20, sleep = defaultSleep} = options
|
|
220
|
+
|
|
221
|
+
const task = await this.request<{id: string}>(
|
|
222
|
+
'POST',
|
|
223
|
+
`${API_BASE}/document/export?documentIds=${encodeURIComponent(documentId)}&mode=${mode}&type=target`,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
227
|
+
const {status, text} = await this.requestRaw('GET', `${API_BASE}/document/export/${task.id}`)
|
|
228
|
+
if (status === 200) return text
|
|
229
|
+
if (status === 204) {
|
|
230
|
+
await sleep(pollIntervalMs)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
throw new SmartcatError(status, text)
|
|
234
|
+
}
|
|
235
|
+
throw new Error(`Smartcat export for ${documentId} timed out`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Exports many documents' target content in **one** export task. Smartcat
|
|
240
|
+
* fans the work out server-side and (for more than one document) returns a
|
|
241
|
+
* ZIP archive of the individual files, so the per-document request+poll
|
|
242
|
+
* round-trip is paid once per batch instead of once per document.
|
|
243
|
+
*
|
|
244
|
+
* Identity comes from each LocJSON's own `x-sanity` metadata, not the entry
|
|
245
|
+
* name, so callers should correlate on the parsed content.
|
|
246
|
+
*/
|
|
247
|
+
async exportDocumentsBatch(
|
|
248
|
+
documentIds: string[],
|
|
249
|
+
options: {
|
|
250
|
+
mode?: 'confirmed' | 'current'
|
|
251
|
+
pollIntervalMs?: number
|
|
252
|
+
maxAttempts?: number
|
|
253
|
+
sleep?: (ms: number) => Promise<void>
|
|
254
|
+
} = {},
|
|
255
|
+
): Promise<ExportedFile[]> {
|
|
256
|
+
if (documentIds.length === 0) return []
|
|
257
|
+
// Bigger batches take longer to assemble server-side; poll longer, not faster.
|
|
258
|
+
const {mode = 'confirmed', pollIntervalMs = 3000, maxAttempts = 60, sleep = defaultSleep} = options
|
|
259
|
+
|
|
260
|
+
const query = documentIds.map((id) => `documentIds=${encodeURIComponent(id)}`).join('&')
|
|
261
|
+
const task = await this.request<{id: string}>(
|
|
262
|
+
'POST',
|
|
263
|
+
`${API_BASE}/document/export?${query}&mode=${mode}&type=target`,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
267
|
+
const {status, bytes} = await this.requestBytes('GET', `${API_BASE}/document/export/${task.id}`)
|
|
268
|
+
if (status === 200) {
|
|
269
|
+
// A single-document task returns the bare file; multi-document tasks
|
|
270
|
+
// return a ZIP. Decide by content, not by count — Smartcat may drop
|
|
271
|
+
// documents it can't export from the archive.
|
|
272
|
+
if (isZip(bytes)) {
|
|
273
|
+
return (await unzipText(bytes)).map(({name, content}) => ({filename: name, content}))
|
|
274
|
+
}
|
|
275
|
+
return [{filename: '', content: new TextDecoder().decode(bytes)}]
|
|
276
|
+
}
|
|
277
|
+
if (status === 204) {
|
|
278
|
+
await sleep(pollIntervalMs)
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
throw new SmartcatError(status, new TextDecoder().decode(bytes))
|
|
282
|
+
}
|
|
283
|
+
throw new Error(`Smartcat batch export of ${documentIds.length} document(s) timed out`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async request<T = unknown>(method: string, path: string, body?: BodyInit): Promise<T> {
|
|
287
|
+
const {status, text} = await this.requestRaw(method, path, body)
|
|
288
|
+
if (status < 200 || status >= 300) throw new SmartcatError(status, text)
|
|
289
|
+
return (text ? JSON.parse(text) : null) as T
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Normalizes a `fetch` rejection. A timeout abort becomes a
|
|
294
|
+
* {@link SmartcatTimeoutError} — traced through the request logger first (with
|
|
295
|
+
* a synthetic status 0) so it surfaces in the same log as HTTP failures — while
|
|
296
|
+
* anything else is rethrown unchanged.
|
|
297
|
+
*/
|
|
298
|
+
private onFetchError(method: string, path: string, err: unknown): unknown {
|
|
299
|
+
if (isAbortError(err) && this.requestTimeoutMs) {
|
|
300
|
+
const timeout = new SmartcatTimeoutError(method, path, this.requestTimeoutMs)
|
|
301
|
+
this.requestLogger?.({method, path, status: 0, body: timeout.message})
|
|
302
|
+
return timeout
|
|
303
|
+
}
|
|
304
|
+
return err
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private async requestRaw(
|
|
308
|
+
method: string,
|
|
309
|
+
path: string,
|
|
310
|
+
body?: BodyInit,
|
|
311
|
+
): Promise<{status: number; text: string}> {
|
|
312
|
+
let res: Response
|
|
313
|
+
try {
|
|
314
|
+
res = await this.fetchImpl(`https://${this.server}${path}`, {
|
|
315
|
+
method,
|
|
316
|
+
headers: {Authorization: this.authHeader},
|
|
317
|
+
body,
|
|
318
|
+
signal: this.timeoutSignal(),
|
|
319
|
+
})
|
|
320
|
+
} catch (err) {
|
|
321
|
+
throw this.onFetchError(method, path, err)
|
|
322
|
+
}
|
|
323
|
+
const text = await res.text()
|
|
324
|
+
this.requestLogger?.({method, path, status: res.status, body: text})
|
|
325
|
+
return {status: res.status, text}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Like {@link requestRaw} but preserves the body as bytes (ZIP downloads). */
|
|
329
|
+
private async requestBytes(method: string, path: string): Promise<{status: number; bytes: Uint8Array}> {
|
|
330
|
+
let res: Response
|
|
331
|
+
try {
|
|
332
|
+
res = await this.fetchImpl(`https://${this.server}${path}`, {
|
|
333
|
+
method,
|
|
334
|
+
headers: {Authorization: this.authHeader},
|
|
335
|
+
signal: this.timeoutSignal(),
|
|
336
|
+
})
|
|
337
|
+
} catch (err) {
|
|
338
|
+
throw this.onFetchError(method, path, err)
|
|
339
|
+
}
|
|
340
|
+
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
341
|
+
// Log a size placeholder for successful binary payloads; error bodies are
|
|
342
|
+
// text (Smartcat's error detail) and stay readable in the trace.
|
|
343
|
+
const logBody = res.status >= 200 && res.status < 300 ? `<${bytes.length} bytes>` : new TextDecoder().decode(bytes)
|
|
344
|
+
this.requestLogger?.({method, path, status: res.status, body: logBody})
|
|
345
|
+
return {status: res.status, bytes}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
350
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
351
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {SmartcatClient, SmartcatError, SmartcatTimeoutError} from './client'
|
|
2
|
+
export type {
|
|
3
|
+
SmartcatClientOptions,
|
|
4
|
+
CreateProjectInput,
|
|
5
|
+
SmartcatProject,
|
|
6
|
+
SmartcatDocument,
|
|
7
|
+
SmartcatWorkflowStage,
|
|
8
|
+
SmartcatTemplate,
|
|
9
|
+
SmartcatTemplatesResponse,
|
|
10
|
+
} from './types'
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Credentials + server for a Smartcat workspace. */
|
|
2
|
+
export interface SmartcatClientOptions {
|
|
3
|
+
/** API server hostname, e.g. "us.smartcat.com". */
|
|
4
|
+
server: string
|
|
5
|
+
/** Workspace ID (GUID). */
|
|
6
|
+
workspaceId: string
|
|
7
|
+
/** API token. */
|
|
8
|
+
apiKey: string
|
|
9
|
+
/** Override fetch (defaults to global fetch). Useful for tests. */
|
|
10
|
+
fetchImpl?: typeof fetch
|
|
11
|
+
/**
|
|
12
|
+
* Per-request timeout in ms. When set, each HTTP call is aborted after this
|
|
13
|
+
* long (via `AbortSignal.timeout`) and rejected as a {@link SmartcatTimeoutError},
|
|
14
|
+
* so a stalled Smartcat response fails fast with a clear, loggable error instead
|
|
15
|
+
* of the whole Sanity Function being platform-killed. Unset ⇒ no timeout.
|
|
16
|
+
*/
|
|
17
|
+
requestTimeoutMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One Smartcat HTTP call, reported to a {@link RequestLogger} for tracing. */
|
|
21
|
+
export interface RequestLogInfo {
|
|
22
|
+
method: string
|
|
23
|
+
/** Request path incl. query string (no host, no secrets). */
|
|
24
|
+
path: string
|
|
25
|
+
status: number
|
|
26
|
+
/** Raw response body text — carries Smartcat's error detail on failures. */
|
|
27
|
+
body: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Sink for per-request tracing; attach with `SmartcatClient.setRequestLogger`. */
|
|
31
|
+
export type RequestLogger = (info: RequestLogInfo) => void
|
|
32
|
+
|
|
33
|
+
/** Input for creating a Smartcat project. */
|
|
34
|
+
export interface CreateProjectInput {
|
|
35
|
+
name: string
|
|
36
|
+
description?: string
|
|
37
|
+
/** Source language code (e.g. "en"). */
|
|
38
|
+
sourceLanguage: string
|
|
39
|
+
/** Target language codes (e.g. ["fr", "zh-Hans"]). */
|
|
40
|
+
targetLanguages: string[]
|
|
41
|
+
assignToVendor?: boolean
|
|
42
|
+
pretranslate?: boolean
|
|
43
|
+
autoPropagateRepetitions?: boolean
|
|
44
|
+
/** Workflow stages, e.g. ["translation", "postediting"]. */
|
|
45
|
+
workflowStages?: string[]
|
|
46
|
+
/** Create from a Smartcat project template; the template defines the workflow. */
|
|
47
|
+
templateId?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A Smartcat project template (from `GET /template`). */
|
|
51
|
+
export interface SmartcatTemplate {
|
|
52
|
+
id: string
|
|
53
|
+
name: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Response shape of `GET /template`. */
|
|
57
|
+
export interface SmartcatTemplatesResponse {
|
|
58
|
+
templates: SmartcatTemplate[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SmartcatWorkflowStage {
|
|
62
|
+
id?: string
|
|
63
|
+
/** Stage type/name, e.g. "translation" / "editing" (casing varies by API). */
|
|
64
|
+
stageType?: string
|
|
65
|
+
/** Human-readable stage name, when the API provides one. */
|
|
66
|
+
stageName?: string
|
|
67
|
+
/** Completion percentage for this stage, 0–100. */
|
|
68
|
+
progress?: number
|
|
69
|
+
/** Word counts, when present — useful for a weighted progress view. */
|
|
70
|
+
wordsTranslated?: number
|
|
71
|
+
totalWordsCount?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A document within a Smartcat project (one per source file × target language). */
|
|
75
|
+
export interface SmartcatDocument {
|
|
76
|
+
/** Target document id, formatted `<fileGuid>_<languageNumber>`. */
|
|
77
|
+
id: string
|
|
78
|
+
name?: string
|
|
79
|
+
fullPath?: string
|
|
80
|
+
filename?: string
|
|
81
|
+
/** Shared id of the uploaded source file (the `<fileGuid>` part of `id`). */
|
|
82
|
+
externalId?: string
|
|
83
|
+
sourceLanguage?: string
|
|
84
|
+
targetLanguage?: string
|
|
85
|
+
/** Per-stage progress. On documents these carry only `{id, progress}`; the
|
|
86
|
+
* stage names live on the project-level `workflowStages` (keyed by `id`). */
|
|
87
|
+
workflowStages?: SmartcatWorkflowStage[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A Smartcat project as returned by the API. */
|
|
91
|
+
export interface SmartcatProject {
|
|
92
|
+
id: string
|
|
93
|
+
name?: string
|
|
94
|
+
sourceLanguage?: string
|
|
95
|
+
targetLanguages?: string[]
|
|
96
|
+
/** Project-level stages, where each `id` maps to a `stageType` (e.g. "translation"). */
|
|
97
|
+
workflowStages?: SmartcatWorkflowStage[]
|
|
98
|
+
documents?: SmartcatDocument[]
|
|
99
|
+
}
|