@smartcat/sanity-plugin 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +168 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +311 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +169 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +313 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2110 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +287 -0
- package/dist/locjson.d.ts +287 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +262 -0
- package/dist/progress.d.ts +262 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +258 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +27 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +545 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +98 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +17 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +47 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +387 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +268 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +264 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +44 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +975 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +79 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +623 -0
- package/src/tool/processing.ts +766 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- package/src/types.ts +77 -0
|
@@ -0,0 +1,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
|
+
}
|