@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,766 @@
1
+ import {uuid} from '@sanity/uuid'
2
+ import {resolveDocumentTitle} from '../lib/documentTitle'
3
+ import {itemDocDeref} from '../lib/projectItems'
4
+ import {
5
+ enumerateLeaves,
6
+ getTranslatableFields,
7
+ innerValueType,
8
+ isInternationalizedArrayType,
9
+ localizationMode,
10
+ } from '../lib/locjson/fields'
11
+ import {getAtPath, setAtPath} from '../lib/locjson/paths'
12
+ import type {TranslatableField} from '../lib/locjson/types'
13
+ import {serializeToLocjson} from '../lib/locjson/serialize'
14
+ import {buildLocjsonFilename} from '../lib/locjson/filename'
15
+ import {deserializeFromLocjson} from '../lib/locjson/deserialize'
16
+ import {requireSourceLanguage} from '../lib/constants'
17
+ import {buildLanguageMappings, findDuplicateSmartcatLanguages} from '../lib/languageMap'
18
+ import type {LocalizationMode, LocJSON} from '../lib/locjson/types'
19
+ import {summarizeCompletion, type DocProgress} from '../progress/core'
20
+ import type {SmartcatLanguage} from '../types'
21
+ import type {LogFn} from '../lib/log'
22
+
23
+ /**
24
+ * Browser-side processing for the Smartcat workflow. All content transformation
25
+ * (Portable Text ⇄ HTML, field selection, variant creation) happens here in the
26
+ * Studio, where the real schema and a native DOM are available — so the Sanity
27
+ * Functions stay thin and dependency-free.
28
+ */
29
+
30
+ /** A loosely-typed Sanity client (the Studio's `useClient` result satisfies this). */
31
+ export interface ProcessingClient {
32
+ fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>
33
+ create(doc: Record<string, unknown>): Promise<{_id: string}>
34
+ createIfNotExists(doc: Record<string, unknown>): Promise<{_id: string}>
35
+ patch(id: string): ProcessingPatch
36
+ }
37
+ interface ProcessingPatch {
38
+ set(attrs: Record<string, unknown>): ProcessingPatch
39
+ setIfMissing(attrs: Record<string, unknown>): ProcessingPatch
40
+ insert(at: 'before' | 'after' | 'replace', selector: string, items: unknown[]): ProcessingPatch
41
+ unset(keys: string[]): ProcessingPatch
42
+ commit(): Promise<unknown>
43
+ }
44
+
45
+ /** A compiled schema (the Studio's `useSchema` result satisfies this). */
46
+ export interface ProcessingSchema {
47
+ get(typeName: string): unknown
48
+ }
49
+
50
+ const METADATA_TYPE = 'translation.metadata'
51
+ const REFERENCE_VALUE_TYPE = 'internationalizedArrayReferenceValue'
52
+
53
+ function parseHtml(html: string): Document {
54
+ return new DOMParser().parseFromString(html, 'text/html')
55
+ }
56
+
57
+ const VALUE_PREVIEW_CAP = 2000
58
+
59
+ /** A capped, display-safe string for a field value (source or translated). */
60
+ function valuePreview(value: unknown): string | undefined {
61
+ if (value === undefined || value === null) return undefined
62
+ const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
63
+ if (!text) return undefined
64
+ return text.length > VALUE_PREVIEW_CAP ? `${text.slice(0, VALUE_PREVIEW_CAP)}…` : text
65
+ }
66
+
67
+ function errorMessage(err: unknown): string {
68
+ return err instanceof Error ? err.message : String(err)
69
+ }
70
+
71
+ /** Top-level, non-system schema field names for a type (for "not translatable" reporting). */
72
+ function schemaFieldNames(schema: ProcessingSchema, type: string): string[] {
73
+ const compiled = schema.get(type) as {fields?: {name?: string}[]} | undefined
74
+ return (compiled?.fields ?? [])
75
+ .map((f) => f?.name)
76
+ .filter((n): n is string => typeof n === 'string' && !n.startsWith('_'))
77
+ }
78
+
79
+ function blockContentTypeFor(schema: ProcessingSchema, type: string, fieldPath: string): unknown {
80
+ const compiled = schema.get(type) as {fields?: {name: string; type: unknown}[]} | undefined
81
+ const fieldType = compiled?.fields?.find((f) => f.name === fieldPath)?.type
82
+ // Field-level Portable Text lives under the array member's `value`; resolve the
83
+ // inner block-content type rather than the internationalized-array wrapper.
84
+ if (isInternationalizedArrayType(fieldType as never)) {
85
+ return innerValueType(fieldType as never)
86
+ }
87
+ return fieldType
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Export: serialize project items → outbox (LocJSON), set status = queued
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** An item the editor marked for deletion; carried to the export Function. */
95
+ export interface PendingDeletion {
96
+ itemKey: string
97
+ sourceDocId: string
98
+ smartcatDocumentId?: string
99
+ }
100
+
101
+ export interface PrepareExportArgs {
102
+ client: ProcessingClient
103
+ schema: ProcessingSchema
104
+ projectId: string
105
+ targetLanguages: string[]
106
+ sourceLanguage: string
107
+ /** Configured languages, used to resolve each id → its Smartcat code. */
108
+ languages: SmartcatLanguage[]
109
+ exclude?: string[]
110
+ /** How internationalized-array members identify their locale (field name or `'_key'`). */
111
+ fieldLanguageKey: string
112
+ /** Items to delete from Smartcat (and the project) on this export. */
113
+ deletions?: PendingDeletion[]
114
+ /** Streams per-document/per-field progress to the UI; no-op if omitted. */
115
+ onLog?: LogFn
116
+ }
117
+
118
+ const ITEMS_QUERY = `*[_id == $id][0]{ sourceLanguage, items[]{ "doc": ${itemDocDeref()} } }`
119
+
120
+ export async function prepareExport({
121
+ client,
122
+ schema,
123
+ projectId,
124
+ targetLanguages,
125
+ sourceLanguage,
126
+ languages,
127
+ exclude,
128
+ fieldLanguageKey,
129
+ deletions = [],
130
+ onLog,
131
+ }: PrepareExportArgs): Promise<{prepared: number; deleting: number}> {
132
+ const log: LogFn = onLog ?? (() => {})
133
+ const project = await client.fetch<{
134
+ sourceLanguage?: string
135
+ items?: {doc: (Record<string, unknown> & {_id: string; _type: string}) | null}[]
136
+ } | null>(ITEMS_QUERY, {id: projectId})
137
+
138
+ const source = project?.sourceLanguage || sourceLanguage
139
+
140
+ // Resolve the id → Smartcat-code mapping for this project's languages and
141
+ // stamp it on the project, so the Functions can map to/from Smartcat codes.
142
+ // Two targets resolving to the same code can't be routed back, so reject it.
143
+ const smartcatLanguages = buildLanguageMappings(languages, [source, ...targetLanguages])
144
+ const dups = findDuplicateSmartcatLanguages(
145
+ smartcatLanguages.filter((m) => targetLanguages.includes(m.sanityId)),
146
+ )
147
+ if (dups.length > 0) {
148
+ const detail = dups.map((d) => `${d.sanityIds.join(', ')} → ${d.smartcatLanguage}`).join('; ')
149
+ throw new Error(`Multiple target languages map to the same Smartcat language: ${detail}`)
150
+ }
151
+ // Don't re-upload documents that are being deleted this round.
152
+ const deletedDocIds = new Set(deletions.map((d) => d.sourceDocId))
153
+ const docs = (project?.items ?? [])
154
+ .map((i) => i?.doc)
155
+ .filter((d): d is Record<string, unknown> & {_id: string; _type: string} => Boolean(d))
156
+ .filter((d) => !deletedDocIds.has(d._id))
157
+
158
+ // The translatable field list is per-type, so compute it once per type even
159
+ // when a project holds many documents of the same type.
160
+ const fieldsByType = new Map<string, ReturnType<typeof getTranslatableFields>>()
161
+ const fieldsFor = (type: string) => {
162
+ let fields = fieldsByType.get(type)
163
+ if (!fields) {
164
+ fields = getTranslatableFields(schema.get(type), {exclude})
165
+ fieldsByType.set(type, fields)
166
+ }
167
+ return fields
168
+ }
169
+
170
+ log({level: 'info', message: `Export — ${docs.length} document(s) → ${targetLanguages.join(', ') || '(no targets)'}`})
171
+
172
+ const outbox: {_key: string; sourceDocId: string; title?: string; filename: string; locjson: string}[] = []
173
+ for (const doc of docs) {
174
+ const name = resolveDocumentTitle(schema, doc)
175
+ log({level: 'info', message: `${name || doc._id} · ${doc._type}`})
176
+ try {
177
+ const fields = fieldsFor(doc._type)
178
+ // Fields skipped because they hold a block type that can't be serialized
179
+ // (e.g. a custom `table`): left untranslated on purpose, reported as errors
180
+ // below so the editor knows to act rather than lose content silently.
181
+ const skippedBlockTypes = new Map<string, string[]>()
182
+ const locjson = serializeToLocjson(doc as never, fields, {
183
+ sourceLanguage: source,
184
+ fieldLanguageKey,
185
+ onSkippedField: ({fieldPath, types}) => skippedBlockTypes.set(fieldPath, types),
186
+ })
187
+ // A field produced a unit ⇒ serialized; otherwise its source was empty.
188
+ const unitByKey = new Map(locjson.units.map((u) => [u.key, u]))
189
+ for (const field of fields) {
190
+ const unit = unitByKey.get(field.path)
191
+ const skippedTypes = skippedBlockTypes.get(field.path)
192
+ if (unit) {
193
+ log({level: 'success', indent: true, message: `${field.title || field.path}: serialized (${field.kind})`, value: unit.source.join('')})
194
+ } else if (skippedTypes) {
195
+ log({
196
+ level: 'error',
197
+ indent: true,
198
+ message: `${field.title || field.path}: not translated — contains block type(s) that can't be translated: ${skippedTypes.join(', ')}. The whole field was left untranslated to avoid losing this content.`,
199
+ })
200
+ } else {
201
+ log({level: 'skip', indent: true, message: `${field.title || field.path}: skipped — no source content`})
202
+ }
203
+ }
204
+ // Surface the rest of the type's fields too, so a missing translation is
205
+ // explained ("not translatable") rather than silently absent.
206
+ const selected = new Set(fields.map((f) => f.path))
207
+ const excluded = new Set(exclude ?? [])
208
+ for (const fieldName of schemaFieldNames(schema, doc._type)) {
209
+ if (selected.has(fieldName) || excluded.has(fieldName)) continue
210
+ log({level: 'skip', indent: true, message: `${fieldName}: skipped — not translatable`})
211
+ }
212
+ if (locjson.units.length === 0) {
213
+ // Distinguish "nothing to translate" from "everything translatable was
214
+ // held back": the latter means the whole document goes untranslated, so
215
+ // flag it as an error rather than a benign skip.
216
+ if (skippedBlockTypes.size > 0) {
217
+ log({
218
+ level: 'error',
219
+ indent: true,
220
+ message: 'not sent — its only translatable content is in field(s) with block type(s) that can’t be translated (see above)',
221
+ })
222
+ } else {
223
+ log({level: 'skip', indent: true, message: 'no translatable content — not sent'})
224
+ }
225
+ continue
226
+ }
227
+ outbox.push({
228
+ _key: uuid(),
229
+ sourceDocId: doc._id,
230
+ title: name,
231
+ filename: buildLocjsonFilename(doc._type, doc._id, name),
232
+ locjson: JSON.stringify(locjson),
233
+ })
234
+ } catch (err) {
235
+ log({level: 'error', indent: true, message: `failed to serialize: ${errorMessage(err)}`})
236
+ }
237
+ }
238
+ log({level: 'success', message: `Queued ${outbox.length} file(s)${deletions.length ? ` · ${deletions.length} for deletion` : ''}`})
239
+
240
+ await client
241
+ .patch(projectId)
242
+ .set({
243
+ outbox,
244
+ pendingDeletions: deletions,
245
+ targetLanguages,
246
+ sourceLanguage: source,
247
+ smartcatLanguages,
248
+ status: 'queued',
249
+ lastError: null,
250
+ })
251
+ .commit()
252
+
253
+ return {prepared: outbox.length, deleting: deletions.length}
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Import: inbox (translated LocJSON) → locale variants + metadata, status = completed
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export interface ApplyImportArgs {
261
+ client: ProcessingClient
262
+ schema: ProcessingSchema
263
+ projectId: string
264
+ /** Document-level locale field name (document-internationalization `languageField`). */
265
+ documentLanguageField: string
266
+ /** Field-level locale key (internationalized-array): field name or `'_key'`. */
267
+ fieldLanguageKey: string
268
+ /** Streams per-document/per-field progress to the UI; no-op if omitted. */
269
+ onLog?: LogFn
270
+ }
271
+
272
+ const INBOX_QUERY = `*[_id == $id][0]{
273
+ sourceLanguage,
274
+ inbox[]{ sourceDocId, targetLanguage, smartcatDocumentId, locjson },
275
+ progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
276
+ }`
277
+
278
+ export async function applyImportedTranslations({
279
+ client,
280
+ schema,
281
+ projectId,
282
+ documentLanguageField,
283
+ fieldLanguageKey,
284
+ onLog,
285
+ }: ApplyImportArgs): Promise<{imported: number; status: 'completed' | 'translating'}> {
286
+ const log: LogFn = onLog ?? (() => {})
287
+ const project = await client.fetch<{
288
+ sourceLanguage?: string
289
+ inbox?: {sourceDocId?: string; targetLanguage: string; smartcatDocumentId?: string; locjson: string}[]
290
+ progress?: DocProgress[]
291
+ } | null>(INBOX_QUERY, {id: projectId})
292
+
293
+ const sourceLanguage = requireSourceLanguage(project?.sourceLanguage)
294
+ const inbox = project?.inbox ?? []
295
+ // Resolved titles per source doc, from the stored progress (the same source the
296
+ // import Function log uses) — so the log shows names, not raw document ids.
297
+ const titleByDoc = new Map<string, string>()
298
+ for (const p of project?.progress ?? []) {
299
+ if (p.sourceDocId && p.title) titleByDoc.set(p.sourceDocId, p.title)
300
+ }
301
+ let imported = 0
302
+ // (sourceDocId, targetLanguage) pairs we successfully imported.
303
+ const importedKeys = new Set<string>()
304
+
305
+ // Localization mode is per-type; derive once per type from the schema.
306
+ const modeByType = new Map<string, LocalizationMode>()
307
+ const modeFor = (type: string): LocalizationMode => {
308
+ let mode = modeByType.get(type)
309
+ if (!mode) {
310
+ mode = localizationMode(schema.get(type))
311
+ modeByType.set(type, mode)
312
+ }
313
+ return mode
314
+ }
315
+
316
+ // Translatable-field descriptors per type (shapes + nesting), for reconstructing
317
+ // nested/array member values on write-back. Same exclude as export.
318
+ const descriptorsByType = new Map<string, ReturnType<typeof getTranslatableFields>>()
319
+ const descriptorsFor = (type: string) => {
320
+ let d = descriptorsByType.get(type)
321
+ if (!d) {
322
+ d = getTranslatableFields(schema.get(type), {exclude: [documentLanguageField]})
323
+ descriptorsByType.set(type, d)
324
+ }
325
+ return d
326
+ }
327
+
328
+ log({level: 'info', message: `Import — ${inbox.length} translated file(s)`})
329
+
330
+ for (const item of inbox) {
331
+ let parsed: LocJSON
332
+ try {
333
+ parsed = JSON.parse(item.locjson) as LocJSON
334
+ } catch {
335
+ log({level: 'error', message: `${item.sourceDocId || 'unknown'} → ${item.targetLanguage}: invalid LocJSON, skipped`})
336
+ continue
337
+ }
338
+ const type = parsed.properties?.['x-sanity']?.documentType
339
+ const result = deserializeFromLocjson(parsed, {
340
+ parseHtml,
341
+ getBlockContentType: (fieldPath) => (type ? blockContentTypeFor(schema, type, fieldPath) : undefined),
342
+ })
343
+ if (!result.documentId || !result.documentType) {
344
+ log({level: 'error', message: `${item.sourceDocId || 'unknown'} → ${item.targetLanguage}: missing document id/type, skipped`})
345
+ continue
346
+ }
347
+
348
+ const mode = modeFor(result.documentType)
349
+ const title = titleByDoc.get(result.documentId)
350
+ const label = title ? `${title} (Sanity ID: ${result.documentId})` : result.documentId
351
+ log({level: 'info', message: `${label} → ${item.targetLanguage} · ${result.documentType} (${mode} mode)`})
352
+
353
+ let ok = false
354
+ // Each item is isolated: a failure is logged and skipped, so one bad
355
+ // document never aborts the rest of the batch.
356
+ try {
357
+ ok =
358
+ mode === 'field'
359
+ ? await applyFieldLevelTranslations(client, {
360
+ schema,
361
+ type: result.documentType,
362
+ docId: result.documentId,
363
+ locale: item.targetLanguage,
364
+ sourceLanguage: result.sourceLanguage || sourceLanguage,
365
+ descriptors: descriptorsFor(result.documentType),
366
+ translations: result.fields,
367
+ sources: result.sources,
368
+ languageKey: fieldLanguageKey,
369
+ }, log)
370
+ : await upsertVariant(client, {
371
+ sourceId: result.documentId,
372
+ type: result.documentType,
373
+ sourceLanguage,
374
+ locale: item.targetLanguage,
375
+ fields: result.fields,
376
+ sources: result.sources,
377
+ languageField: documentLanguageField,
378
+ }, log)
379
+ } catch (err) {
380
+ log({level: 'error', indent: true, message: `failed: ${errorMessage(err)}`})
381
+ }
382
+ if (ok) {
383
+ imported++
384
+ importedKeys.add(`${result.documentId}__${item.targetLanguage}`)
385
+ }
386
+ }
387
+
388
+ // Mark the imported targets, then decide completion: the project is "completed"
389
+ // only when every target is complete AND imported. Otherwise it stays
390
+ // "translating" so the remaining targets can be imported once they finish.
391
+ const progress = (project?.progress ?? []).map((doc) => ({
392
+ ...doc,
393
+ targets: doc.targets.map((target) =>
394
+ importedKeys.has(`${doc.sourceDocId}__${target.language}`)
395
+ ? {...target, imported: true}
396
+ : target,
397
+ ),
398
+ }))
399
+ const status = summarizeCompletion(progress).allDone ? 'completed' : 'translating'
400
+
401
+ await client
402
+ .patch(projectId)
403
+ .set({progress, status, lastImportAt: new Date().toISOString()})
404
+ .unset(['inbox'])
405
+ .commit()
406
+
407
+ return {imported, status}
408
+ }
409
+
410
+ interface ApplyFieldLevelArgs {
411
+ schema: ProcessingSchema
412
+ type: string
413
+ docId: string
414
+ locale: string
415
+ /** Source language — its array member is the structural template for reconstruction. */
416
+ sourceLanguage: string
417
+ /** Translatable-field descriptors for `type` (shapes + nesting). */
418
+ descriptors: TranslatableField[]
419
+ /** Translated leaf values keyed by full unit path. */
420
+ translations: Record<string, unknown>
421
+ /** Source (pre-translation) text per unit path, for the JSON well-formedness check. */
422
+ sources: Record<string, string>
423
+ /** How members identify their locale: a field name (`'language'`) or `'_key'`. */
424
+ languageKey: string
425
+ }
426
+
427
+ /** A value is "potential JSON" if it's a string whose first non-blank char is `{`. */
428
+ function isPotentialJson(value: unknown): value is string {
429
+ return typeof value === 'string' && value.trimStart().startsWith('{')
430
+ }
431
+
432
+ /**
433
+ * Guards a field whose **source** is JSON: the translated value must also parse.
434
+ * Returns the parse error message when the translation is malformed (caller logs
435
+ * it and skips the field); `null` when there's nothing to enforce — the source
436
+ * isn't confirmed JSON — or the translation is valid JSON.
437
+ */
438
+ function malformedJsonError(source: unknown, translated: unknown): string | null {
439
+ // Only enforce when the source is confirmed JSON (starts with `{` and parses).
440
+ if (!isPotentialJson(source)) return null
441
+ try {
442
+ JSON.parse(source)
443
+ } catch {
444
+ return null // source isn't really JSON, so don't hold the translation to it
445
+ }
446
+ if (typeof translated !== 'string') return 'translated value is not a string'
447
+ try {
448
+ JSON.parse(translated)
449
+ return null
450
+ } catch (err) {
451
+ return err instanceof Error ? err.message : String(err)
452
+ }
453
+ }
454
+
455
+ const DRAFTS_PREFIX = 'drafts.'
456
+
457
+ /** The published (bare) form of a document id. */
458
+ function publishedId(id: string): string {
459
+ return id.startsWith(DRAFTS_PREFIX) ? id.slice(DRAFTS_PREFIX.length) : id
460
+ }
461
+
462
+ /** The draft form of a document id. */
463
+ function draftId(id: string): string {
464
+ return id.startsWith(DRAFTS_PREFIX) ? id : DRAFTS_PREFIX + id
465
+ }
466
+
467
+ interface EditableDraft {
468
+ draftId: string
469
+ /** Content edits are computed against: the draft if one exists, else published. */
470
+ base: Record<string, unknown>
471
+ /** Whether a draft already exists (so we don't clone published over it). */
472
+ exists: boolean
473
+ }
474
+
475
+ /**
476
+ * Resolves the **draft** to write imported translations into, so they accumulate
477
+ * for editorial review instead of landing on the live document (where they'd be
478
+ * invisible behind an existing draft and overwritten on the next publish).
479
+ *
480
+ * Read-only: returns the existing draft if there is one (so a concurrent draft
481
+ * isn't clobbered), otherwise the published document as the base to seed from.
482
+ * `null` if neither exists. Use {@link commitToDraft} to actually write.
483
+ */
484
+ async function loadEditableDraft(client: ProcessingClient, id: string): Promise<EditableDraft | null> {
485
+ const dId = draftId(id)
486
+ const draft = await client.fetch<Record<string, unknown> | null>(`*[_id == $id][0]`, {id: dId})
487
+ if (draft) return {draftId: dId, base: draft, exists: true}
488
+
489
+ const published = await client.fetch<Record<string, unknown> | null>(`*[_id == $id][0]`, {id: publishedId(id)})
490
+ if (!published) return null
491
+ return {draftId: dId, base: published, exists: false}
492
+ }
493
+
494
+ /**
495
+ * Writes `attrs` onto the draft, first cloning the published document into a new
496
+ * draft if none exists yet. Only call when there is something to write, so an
497
+ * unchanged import never leaves a spurious empty draft behind.
498
+ */
499
+ async function commitToDraft(client: ProcessingClient, editable: EditableDraft, attrs: Record<string, unknown>): Promise<void> {
500
+ if (!editable.exists) {
501
+ await client.createIfNotExists({...stripSystemFields(editable.base), _id: editable.draftId, _type: editable.base._type})
502
+ }
503
+ await client.patch(editable.draftId).set(attrs).commit()
504
+ }
505
+
506
+ /**
507
+ * Field-level (internationalized-array) import: writes each translated field's
508
+ * value into the **same** document's **draft**, as the array member for `locale`
509
+ * — updating the existing member (preserving its `_key`) or appending a new one.
510
+ * No locale variant document and no translation.metadata.
511
+ */
512
+ async function applyFieldLevelTranslations(
513
+ client: ProcessingClient,
514
+ {docId, locale, sourceLanguage, descriptors, translations, sources, languageKey}: ApplyFieldLevelArgs,
515
+ log: LogFn,
516
+ ): Promise<boolean> {
517
+ const editable = await loadEditableDraft(client, docId)
518
+ if (!editable) {
519
+ log({level: 'error', indent: true, message: `source document not found: ${docId}`})
520
+ return false
521
+ }
522
+ const doc = editable.base
523
+ const byKey = languageKey === '_key'
524
+ const patchSet: Record<string, unknown> = {}
525
+
526
+ // One patch entry per (nested) i18n field. The target member value is rebuilt
527
+ // from the SOURCE member as a template — preserving `_key`s and non-text fields
528
+ // — with each translated leaf overlaid by its valuePath.
529
+ for (const field of descriptors) {
530
+ if (field.localization !== 'field') continue
531
+ const existing = (Array.isArray(getAtPath(doc, field.path)) ? getAtPath(doc, field.path) : []) as Record<string, unknown>[]
532
+ const template = (existing.find((m) => m?.[languageKey] === sourceLanguage) ?? existing.find((m) => m?.value !== undefined))?.value
533
+
534
+ let target: unknown = structuredClone(template)
535
+ let changed = false
536
+ for (const leaf of enumerateLeaves(field, template)) {
537
+ let value = translations[leaf.fullKey]
538
+ if (value === undefined) continue
539
+ if (leaf.kind === 'portableText' && Array.isArray(value)) value = value.map((b) => keyBlock(b as Record<string, unknown>))
540
+ // A JSON source ⇒ its translation must parse too, or we'd write broken JSON.
541
+ const jsonError = malformedJsonError(sources[leaf.fullKey], value)
542
+ if (jsonError) {
543
+ log({level: 'error', indent: true, message: `${leaf.fullKey}: '${locale}' translation is not valid JSON — skipped`, value: valuePreview(value), error: jsonError})
544
+ continue
545
+ }
546
+ target = leaf.valuePath ? setAtPath(target, leaf.valuePath, value) : value
547
+ changed = true
548
+ }
549
+ if (!changed) continue
550
+
551
+ const idx = existing.findIndex((m) => m?.[languageKey] === locale)
552
+ if (idx >= 0 && sameValue(existing[idx]?.value, target)) {
553
+ log({level: 'skip', indent: true, message: `${field.path}: '${locale}' unchanged — skipped`})
554
+ continue
555
+ }
556
+ if (idx >= 0) {
557
+ patchSet[field.path] = existing.map((m, i) => (i === idx ? {...m, value: target} : m))
558
+ log({level: 'update', indent: true, message: `${field.path}: updating '${locale}' field variant`, before: valuePreview(existing[idx]?.value), value: valuePreview(target)})
559
+ } else {
560
+ // v4.x keys members by `_key`; v5+ uses a uuid `_key` plus a language field.
561
+ const _type = existing.find((m) => m?._type)?._type
562
+ patchSet[field.path] = [
563
+ ...existing,
564
+ byKey ? {_key: locale, _type, value: target} : {_key: uuid(), _type, [languageKey]: locale, value: target},
565
+ ]
566
+ log({level: 'info', indent: true, message: `${field.path}: creating '${locale}' field variant`, value: valuePreview(target)})
567
+ }
568
+ }
569
+
570
+ if (Object.keys(patchSet).length === 0) {
571
+ log({level: 'success', indent: true, message: `${docId}: no field changes`})
572
+ return true
573
+ }
574
+ await commitToDraft(client, editable, patchSet)
575
+ log({level: 'success', indent: true, message: `saved ${Object.keys(patchSet).length} field(s) on draft of ${docId}`})
576
+ return true
577
+ }
578
+
579
+ interface UpsertVariantArgs {
580
+ sourceId: string
581
+ type: string
582
+ sourceLanguage: string
583
+ locale: string
584
+ fields: Record<string, unknown>
585
+ /** Source (pre-translation) text per field, for the JSON well-formedness check. */
586
+ sources: Record<string, string>
587
+ /** Document locale field name (document-internationalization `languageField`). */
588
+ languageField: string
589
+ }
590
+
591
+ interface MetaTranslation {
592
+ language?: string
593
+ value?: {_ref?: string}
594
+ }
595
+
596
+ async function upsertVariant(
597
+ client: ProcessingClient,
598
+ {sourceId, type, sourceLanguage, locale, fields, sources, languageField}: UpsertVariantArgs,
599
+ log: LogFn,
600
+ ): Promise<boolean> {
601
+ const source = await client.fetch<Record<string, unknown> | null>(`*[_id == $id][0]`, {id: sourceId})
602
+ if (!source) {
603
+ log({level: 'error', indent: true, message: `source document not found: ${sourceId}`})
604
+ return false
605
+ }
606
+
607
+ const translatedFields = keyPortableText(fields)
608
+
609
+ const metadata = await client.fetch<{_id: string; translations?: MetaTranslation[]} | null>(
610
+ `*[_type == $metaType && references($sourceId)][0]{_id, translations}`,
611
+ {metaType: METADATA_TYPE, sourceId},
612
+ )
613
+ const existingVariantId = metadata?.translations?.find((t) => t.language === locale)?.value?._ref
614
+
615
+ // Log the intent before each write so a failure mid-op is still traceable.
616
+ // All writes target the variant's draft, so translations accumulate for review.
617
+ let variantId: string
618
+ if (existingVariantId) {
619
+ // Edit the variant's draft (seeded from its published version if needed), so
620
+ // imported fields are reviewable and don't get clobbered by an open draft.
621
+ const editable = await loadEditableDraft(client, existingVariantId)
622
+ if (!editable) {
623
+ log({level: 'error', indent: true, message: `'${locale}' document variant not found: ${existingVariantId}`})
624
+ return false
625
+ }
626
+ const variant = editable.base
627
+ // Only write fields whose translated value actually differs from the variant.
628
+ const changed: Record<string, unknown> = {}
629
+ for (const [path, value] of Object.entries(translatedFields)) {
630
+ if (sameValue(variant[path], value)) {
631
+ log({level: 'skip', indent: true, message: `${path}: unchanged — skipped`})
632
+ continue
633
+ }
634
+ const jsonError = malformedJsonError(sources[path], value)
635
+ if (jsonError) {
636
+ log({
637
+ level: 'error',
638
+ indent: true,
639
+ message: `${path}: translation is not valid JSON — skipped`,
640
+ before: valuePreview(variant[path]),
641
+ value: valuePreview(value),
642
+ error: jsonError,
643
+ })
644
+ continue
645
+ }
646
+ changed[path] = value
647
+ log({
648
+ level: 'update',
649
+ indent: true,
650
+ message: `${path}: updating`,
651
+ before: valuePreview(variant[path]),
652
+ value: valuePreview(value),
653
+ })
654
+ }
655
+ if (Object.keys(changed).length > 0) {
656
+ await commitToDraft(client, editable, changed)
657
+ log({level: 'success', indent: true, message: `updated ${Object.keys(changed).length} field(s) on '${locale}' document variant draft`})
658
+ } else {
659
+ log({level: 'success', indent: true, message: `'${locale}' document variant already up to date`})
660
+ }
661
+ variantId = publishedId(existingVariantId)
662
+ } else {
663
+ // Drop any field whose source is JSON but whose translation doesn't parse,
664
+ // so a malformed value never lands on the new variant.
665
+ const writableFields: Record<string, unknown> = {}
666
+ for (const [path, value] of Object.entries(translatedFields)) {
667
+ const jsonError = malformedJsonError(sources[path], value)
668
+ if (jsonError) {
669
+ log({
670
+ level: 'error',
671
+ indent: true,
672
+ message: `${path}: translation is not valid JSON — skipped`,
673
+ value: valuePreview(value),
674
+ error: jsonError,
675
+ })
676
+ continue
677
+ }
678
+ writableFields[path] = value
679
+ log({level: 'info', indent: true, message: `${path}`, value: valuePreview(value)})
680
+ }
681
+ log({level: 'info', indent: true, message: `creating new '${locale}' document variant draft`})
682
+ // Create the variant as a draft; metadata references its published (bare) id.
683
+ variantId = uuid()
684
+ await client.create({...stripSystemFields(source), _id: draftId(variantId), [languageField]: locale, ...writableFields})
685
+ }
686
+
687
+ if (!metadata?._id) {
688
+ log({level: 'info', indent: true, message: 'creating translation metadata'})
689
+ await client.create({
690
+ _type: METADATA_TYPE,
691
+ schemaTypes: [type],
692
+ translations: [translationEntry(sourceLanguage, sourceId), translationEntry(locale, variantId)],
693
+ })
694
+ } else if (!existingVariantId) {
695
+ log({level: 'info', indent: true, message: 'linking variant into translation metadata'})
696
+ await client
697
+ .patch(metadata._id)
698
+ .setIfMissing({translations: []})
699
+ .insert('after', 'translations[-1]', [translationEntry(locale, variantId)])
700
+ .commit()
701
+ }
702
+
703
+ log({level: 'success', indent: true, message: `'${locale}' document variant ready: ${variantId}`})
704
+ return true
705
+ }
706
+
707
+ function translationEntry(language: string, ref: string) {
708
+ return {
709
+ _key: uuid(),
710
+ _type: REFERENCE_VALUE_TYPE,
711
+ language,
712
+ value: {_type: 'reference', _ref: ref, _weak: true},
713
+ }
714
+ }
715
+
716
+ function stripSystemFields(doc: Record<string, unknown>): Record<string, unknown> {
717
+ const {_id, _rev, _createdAt, _updatedAt, ...rest} = doc
718
+ void _id
719
+ void _rev
720
+ void _createdAt
721
+ void _updatedAt
722
+ return rest
723
+ }
724
+
725
+ function keyPortableText(fields: Record<string, unknown>): Record<string, unknown> {
726
+ const out: Record<string, unknown> = {}
727
+ for (const [key, value] of Object.entries(fields)) {
728
+ if (Array.isArray(value) && value.some((v) => v && typeof v === 'object' && '_type' in v)) {
729
+ out[key] = value.map((b) => keyBlock(b as Record<string, unknown>))
730
+ } else {
731
+ out[key] = value
732
+ }
733
+ }
734
+ return out
735
+ }
736
+
737
+ function keyBlock(block: Record<string, unknown>): Record<string, unknown> {
738
+ const keyed: Record<string, unknown> = {_key: (block._key as string) || uuid(), ...block}
739
+ if (Array.isArray(keyed.children)) {
740
+ keyed.children = (keyed.children as Record<string, unknown>[]).map((child) => ({
741
+ _key: (child._key as string) || uuid(),
742
+ ...child,
743
+ }))
744
+ }
745
+ return keyed
746
+ }
747
+
748
+ /**
749
+ * Deep value equality that ignores volatile `_key`s and object-key order, so an
750
+ * unchanged translation — whose Portable Text gets freshly re-keyed on each
751
+ * deserialize — isn't mistaken for a change.
752
+ */
753
+ function sameValue(a: unknown, b: unknown): boolean {
754
+ return stableString(a) === stableString(b)
755
+ }
756
+
757
+ function stableString(value: unknown): string {
758
+ if (Array.isArray(value)) return `[${value.map(stableString).join(',')}]`
759
+ if (value && typeof value === 'object') {
760
+ const entries = Object.entries(value as Record<string, unknown>)
761
+ .filter(([k]) => k !== '_key')
762
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
763
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableString(v)}`).join(',')}}`
764
+ }
765
+ return JSON.stringify(value) ?? 'undefined'
766
+ }