@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.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. 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
+ }