@smartcat/sanity-plugin 1.0.0 → 1.1.0

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