@smartcat/sanity-plugin 1.2.1 → 1.3.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 (42) hide show
  1. package/dist/_chunks-cjs/index.cjs +18 -5
  2. package/dist/_chunks-cjs/index.cjs.map +1 -1
  3. package/dist/_chunks-cjs/index2.cjs +8 -6
  4. package/dist/_chunks-cjs/index2.cjs.map +1 -1
  5. package/dist/_chunks-es/index.js +18 -5
  6. package/dist/_chunks-es/index.js.map +1 -1
  7. package/dist/_chunks-es/index2.js +8 -6
  8. package/dist/_chunks-es/index2.js.map +1 -1
  9. package/dist/export.cjs +21 -4
  10. package/dist/export.cjs.map +1 -1
  11. package/dist/export.d.cts +2 -0
  12. package/dist/export.d.ts +2 -0
  13. package/dist/export.js +22 -5
  14. package/dist/export.js.map +1 -1
  15. package/dist/index.cjs +156 -39
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.js +160 -43
  18. package/dist/index.js.map +1 -1
  19. package/dist/locjson.d.cts +15 -0
  20. package/dist/locjson.d.ts +15 -0
  21. package/dist/progress.d.cts +0 -19
  22. package/dist/progress.d.ts +0 -19
  23. package/dist/smartcat.cjs +14 -2
  24. package/dist/smartcat.cjs.map +1 -1
  25. package/dist/smartcat.d.cts +10 -1
  26. package/dist/smartcat.d.ts +10 -1
  27. package/dist/smartcat.js +14 -2
  28. package/dist/smartcat.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/actions/AddToProjectAction.tsx +23 -2
  31. package/src/export/export.test.ts +42 -5
  32. package/src/export/index.ts +63 -8
  33. package/src/lib/locjson/filename.ts +7 -2
  34. package/src/lib/locjson/locjson.test.ts +64 -0
  35. package/src/lib/locjson/serialize.ts +38 -2
  36. package/src/progress/core.ts +44 -3
  37. package/src/progress/progress.test.ts +40 -0
  38. package/src/schema/translationProject.ts +19 -0
  39. package/src/smartcat/client.ts +15 -1
  40. package/src/tool/ProjectView.tsx +108 -44
  41. package/src/tool/data.ts +7 -0
  42. package/src/tool/processing.ts +110 -4
@@ -17,6 +17,9 @@ import {
17
17
  Flex,
18
18
  Heading,
19
19
  Inline,
20
+ Menu,
21
+ MenuButton,
22
+ MenuItem,
20
23
  Spinner,
21
24
  Stack,
22
25
  Text,
@@ -33,6 +36,7 @@ import {
33
36
  SyncIcon,
34
37
  RemoveCircleIcon,
35
38
  JsonIcon,
39
+ ChevronDownIcon,
36
40
  } from '@sanity/icons'
37
41
  import styled, {css, keyframes} from 'styled-components'
38
42
  import {API_VERSION} from '../lib/constants'
@@ -158,10 +162,16 @@ const CodeArea = styled.textarea`
158
162
  tab-size: 2;
159
163
  `
160
164
 
165
+ /** Target-locale siblings of a document-level item, via translation.metadata. */
166
+ const VIEW_SIBLINGS_QUERY = `*[_type == "translation.metadata" && references($id)][0].translations[]{"language": _key, "doc": value->}`
167
+
161
168
  /**
162
- * Builds the LocJSON the export would produce for an item — same field selection
163
- * and serialize options as {@link prepareExport} — and shows it (pretty-printed)
164
- * in a read-only modal, for inspecting exactly what gets sent to Smartcat.
169
+ * Dropdown that builds the LocJSON the export would produce for an item — same
170
+ * field selection and serialize options as {@link prepareExport} — and shows it
171
+ * (pretty-printed) in a read-only modal. Offers "Source only" (what a plain
172
+ * export sends) plus "Source + <language>" for each target language: the
173
+ * bilingual file the send-existing flow would upload for that language, so you
174
+ * can inspect exactly what goes to Smartcat per language.
165
175
  */
166
176
  function ViewLocjsonButton(props: {
167
177
  docId: string
@@ -173,45 +183,80 @@ function ViewLocjsonButton(props: {
173
183
  sourceLanguage: string
174
184
  documentI18nLanguageField: string
175
185
  fieldI18nLanguageField: string
186
+ /** Project target languages (Sanity locale ids). */
187
+ targetLanguages: string[]
188
+ /** Configured languages (for titles + Smartcat codes). */
189
+ languages: SmartcatLanguage[]
176
190
  }) {
177
- const {docId, type, sourceIsPublished, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField} = props
191
+ const {docId, type, sourceIsPublished, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField, targetLanguages, languages} = props
178
192
  const [open, setOpen] = useState(false)
179
193
  const [content, setContent] = useState<string | null>(null)
180
194
  const [filename, setFilename] = useState<string | null>(null)
195
+ const [title, setTitle] = useState<string>('Source only')
181
196
  const [error, setError] = useState<string | null>(null)
182
197
 
183
- const build = useCallback(async () => {
184
- setOpen(true)
185
- setContent(null)
186
- setFilename(null)
187
- setError(null)
188
- try {
189
- // Resolve the same version the export will send: the draft (fallback to
190
- // published) unless the item was added from the published perspective.
191
- const raw = (await client.fetch(
192
- sourceIsPublished
193
- ? '*[_id == $id][0]'
194
- : 'coalesce(*[_id == "drafts." + $id][0], *[_id == $id][0])',
195
- {id: docId},
196
- )) as (Record<string, unknown> & {_id: string}) | null
197
- if (!raw) throw new Error(`Document not found: ${docId}`)
198
- const isDraft = typeof raw._id === 'string' && raw._id.startsWith('drafts.')
199
- // Serialize under the canonical (bare) id, matching the export.
200
- const doc = isDraft ? {...raw, _id: docId} : raw
201
- const fields = getTranslatableFields(schema.get(type), {exclude: [documentI18nLanguageField]})
202
- const locjson = serializeToLocjson(doc as never, fields, {
203
- sourceLanguage,
204
- fieldLanguageKey: fieldI18nLanguageField,
205
- })
206
- // Same name the export sends to Smartcat; the last path segment is the
207
- // file's basename (the Smartcat "folder/" prefix can't be a local filename).
208
- const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never), {draft: isDraft})
209
- setFilename(smartcatName.split('/').pop() || smartcatName)
210
- setContent(JSON.stringify(locjson, null, 2))
211
- } catch (err) {
212
- setError(err instanceof Error ? err.message : String(err))
213
- }
214
- }, [client, schema, docId, type, sourceIsPublished, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField])
198
+ // Build the LocJSON for one variant: `targetLanguage` undefined ⇒ source-only
199
+ // (a plain export), otherwise the bilingual file for that Sanity locale.
200
+ const build = useCallback(
201
+ async (targetLanguage?: string) => {
202
+ setOpen(true)
203
+ setContent(null)
204
+ setFilename(null)
205
+ setError(null)
206
+ setTitle(targetLanguage ? `Source + ${languageTitle(languages, targetLanguage)}` : 'Source only')
207
+ try {
208
+ // Resolve the same version the export will send: the draft (fallback to
209
+ // published) unless the item was added from the published perspective.
210
+ const raw = (await client.fetch(
211
+ sourceIsPublished
212
+ ? '*[_id == $id][0]'
213
+ : 'coalesce(*[_id == "drafts." + $id][0], *[_id == $id][0])',
214
+ {id: docId},
215
+ )) as (Record<string, unknown> & {_id: string}) | null
216
+ if (!raw) throw new Error(`Document not found: ${docId}`)
217
+ const isDraft = typeof raw._id === 'string' && raw._id.startsWith('drafts.')
218
+ // Serialize under the canonical (bare) id, matching the export.
219
+ const doc = isDraft ? {...raw, _id: docId} : raw
220
+ const fields = getTranslatableFields(schema.get(type), {exclude: [documentI18nLanguageField]})
221
+
222
+ // For a bilingual variant, resolve the existing translation the same way
223
+ // the export does: field-level from the inline member, document-level from
224
+ // the target-locale sibling document.
225
+ let target: {language: string; doc?: Record<string, unknown> & {_id: string; _type: string}} | undefined
226
+ if (targetLanguage) {
227
+ if (localizationMode(schema.get(type)) === 'document') {
228
+ const variants = (await client.fetch(VIEW_SIBLINGS_QUERY, {id: docId})) as
229
+ | {language: string; doc: (Record<string, unknown> & {_id: string; _type: string}) | null}[]
230
+ | null
231
+ const sibling = (variants ?? []).find((v) => v.language === targetLanguage)?.doc ?? undefined
232
+ target = {language: targetLanguage, doc: sibling}
233
+ } else {
234
+ target = {language: targetLanguage}
235
+ }
236
+ }
237
+
238
+ const locjson = serializeToLocjson(doc as never, fields, {
239
+ sourceLanguage,
240
+ fieldLanguageKey: fieldI18nLanguageField,
241
+ target,
242
+ })
243
+ // Same name the export sends to Smartcat; the last path segment is the
244
+ // file's basename (the Smartcat "folder/" prefix can't be a local filename).
245
+ const smartcatCode = targetLanguage
246
+ ? languages.find((l) => l.id === targetLanguage)?.smartcatLanguage || targetLanguage
247
+ : undefined
248
+ const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never), {
249
+ draft: isDraft,
250
+ language: smartcatCode,
251
+ })
252
+ setFilename(smartcatName.split('/').pop() || smartcatName)
253
+ setContent(JSON.stringify(locjson, null, 2))
254
+ } catch (err) {
255
+ setError(err instanceof Error ? err.message : String(err))
256
+ }
257
+ },
258
+ [client, schema, docId, type, sourceIsPublished, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField, languages],
259
+ )
215
260
 
216
261
  const download = useCallback(() => {
217
262
  if (content == null || !filename) return
@@ -225,20 +270,25 @@ function ViewLocjsonButton(props: {
225
270
 
226
271
  return (
227
272
  <>
228
- <Button
229
- mode="bleed"
230
- icon={JsonIcon}
231
- title="View LocJSON"
232
- fontSize={0}
233
- padding={2}
234
- onClick={build}
273
+ <MenuButton
274
+ id={`locjson-menu-${docId}`}
275
+ button={<Button mode="bleed" icon={JsonIcon} iconRight={ChevronDownIcon} title="View LocJSON" fontSize={0} padding={2} />}
276
+ menu={
277
+ <Menu>
278
+ <MenuItem text="Source only" onClick={() => build()} />
279
+ {targetLanguages.map((lang) => (
280
+ <MenuItem key={lang} text={`Source + ${languageTitle(languages, lang)}`} onClick={() => build(lang)} />
281
+ ))}
282
+ </Menu>
283
+ }
284
+ popover={{portal: true}}
235
285
  />
236
286
  {open && (
237
287
  <Dialog
238
288
  id={`locjson-${docId}`}
239
289
  header={
240
290
  <Flex align="center" gap={3}>
241
- <Text weight="semibold">LocJSON · {type}</Text>
291
+ <Text weight="semibold">LocJSON · {type} · {title}</Text>
242
292
  <Button
243
293
  icon={DownloadIcon}
244
294
  text="Download"
@@ -912,6 +962,18 @@ export function ProjectView({
912
962
  {modeByType.get(item.doc._type) === 'field' ? 'fields' : 'document'}
913
963
  </Badge>
914
964
  )}
965
+ <Badge
966
+ tone={item.sendExistingTranslations ? 'primary' : 'default'}
967
+ fontSize={0}
968
+ paddingX={2}
969
+ title={
970
+ item.sendExistingTranslations
971
+ ? 'Existing translations are uploaded to Smartcat (one bilingual file per language) and marked confirmed'
972
+ : 'Only source text is sent; Smartcat translates from scratch'
973
+ }
974
+ >
975
+ {item.sendExistingTranslations ? 'source + translations' : 'source only'}
976
+ </Badge>
915
977
  {item.doc && <OpenInStudioButton id={item.doc._id} type={item.doc._type} />}
916
978
  {item.doc && (
917
979
  <ViewLocjsonButton
@@ -923,6 +985,8 @@ export function ProjectView({
923
985
  sourceLanguage={sourceLanguage}
924
986
  documentI18nLanguageField={documentI18nLanguageField}
925
987
  fieldI18nLanguageField={fieldI18nLanguageField}
988
+ targetLanguages={targets}
989
+ languages={languages}
926
990
  />
927
991
  )}
928
992
  </Flex>
package/src/tool/data.ts CHANGED
@@ -24,6 +24,12 @@ export interface ProjectItemRef {
24
24
  * and which version the export serializes. Null on legacy items (pre-flag).
25
25
  */
26
26
  sourceIsPublished: boolean | null
27
+ /**
28
+ * Whether the export sends this item's existing translations (one bilingual
29
+ * file per target language) or only its source text. Null/false on legacy
30
+ * items (pre-flag) ⇒ source-only, today's behaviour.
31
+ */
32
+ sendExistingTranslations: boolean | null
27
33
  doc: {_id: string; _type: string} | null
28
34
  /** Locale variants linked via translation.metadata: language -> variant doc id. */
29
35
  translations: {language: string; id: string}[] | null
@@ -73,6 +79,7 @@ export const PROJECT_DETAIL_QUERY = `*[_id == $id][0]{
73
79
  _key,
74
80
  "docId": ${ITEM_ID},
75
81
  sourceIsPublished,
82
+ sendExistingTranslations,
76
83
  "doc": ${itemDocDeref('{_id, _type}')},
77
84
  "translations": *[_type == "translation.metadata" && references(${ITEM_ID_FROM_SUBQUERY})][0]
78
85
  .translations[]{language, "id": value._ref}
@@ -14,7 +14,7 @@ import {serializeToLocjson} from '../lib/locjson/serialize'
14
14
  import {buildLocjsonFilename} from '../lib/locjson/filename'
15
15
  import {deserializeFromLocjson} from '../lib/locjson/deserialize'
16
16
  import {requireSourceLanguage} from '../lib/constants'
17
- import {buildLanguageMappings, findDuplicateSmartcatLanguages} from '../lib/languageMap'
17
+ import {buildLanguageMappings, findDuplicateSmartcatLanguages, toSmartcatLanguage} from '../lib/languageMap'
18
18
  import type {LocalizationMode, LocJSON} from '../lib/locjson/types'
19
19
  import {summarizeCompletion, type DocProgress} from '../progress/core'
20
20
  import type {SmartcatLanguage} from '../types'
@@ -142,10 +142,22 @@ const ITEMS_QUERY = `*[_id == $id][0]{
142
142
  items[]{
143
143
  "docId": ${ITEM_ID},
144
144
  "docType": docType,
145
+ sendExistingTranslations,
145
146
  "doc": ${itemDocDerefRespectingDraft()}
146
147
  }
147
148
  }`
148
149
 
150
+ /**
151
+ * Target-locale sibling documents for a set of source ids, via
152
+ * document-internationalization's `translation.metadata`. Returns, per metadata
153
+ * group, every locale variant's `{language, doc}` — the send-existing flow reads
154
+ * each target language's sibling as the translation to upload. Document-level
155
+ * only; field-level types keep all languages inline on the same document.
156
+ */
157
+ const SIBLINGS_QUERY = `*[_type == "translation.metadata" && references($ids)]{
158
+ "variants": translations[]{ "language": _key, "doc": value-> }
159
+ }`
160
+
149
161
  export async function prepareExport({
150
162
  client,
151
163
  schema,
@@ -165,6 +177,7 @@ export async function prepareExport({
165
177
  items?: {
166
178
  docId?: string
167
179
  docType?: string
180
+ sendExistingTranslations?: boolean
168
181
  doc: (Record<string, unknown> & {_id: string; _type: string}) | null
169
182
  }[]
170
183
  } | null>(ITEMS_QUERY, {id: projectId})
@@ -195,6 +208,37 @@ export async function prepareExport({
195
208
  .map((item) => item.doc)
196
209
  .filter((d): d is Record<string, unknown> & {_id: string; _type: string} => Boolean(d))
197
210
 
211
+ // For "send existing translations" document-level items, fetch each source's
212
+ // target-locale sibling documents up front (one query), keyed by
213
+ // bare-source-id → (Sanity locale → sibling doc). Field-level types need none —
214
+ // their translations live inline on the same document.
215
+ type TargetDoc = Record<string, unknown> & {_id: string; _type: string}
216
+ const siblingsByDocId = new Map<string, Map<string, TargetDoc>>()
217
+ const flaggedDocLevelIds = items
218
+ .filter(
219
+ (item) =>
220
+ item.doc && item.sendExistingTranslations === true && localizationMode(schema.get(item.doc._type)) === 'document',
221
+ )
222
+ .map((item) => publishedId((item.doc as {_id: string})._id))
223
+ if (flaggedDocLevelIds.length > 0) {
224
+ const groups = await client.fetch<{variants: {language: string; doc: TargetDoc | null}[]}[]>(
225
+ SIBLINGS_QUERY,
226
+ {ids: flaggedDocLevelIds},
227
+ )
228
+ for (const group of groups ?? []) {
229
+ const byLang = new Map<string, TargetDoc>()
230
+ const groupSourceIds: string[] = []
231
+ for (const variant of group.variants ?? []) {
232
+ if (!variant.doc) continue
233
+ byLang.set(variant.language, variant.doc)
234
+ groupSourceIds.push(publishedId(variant.doc._id))
235
+ }
236
+ // Every member of a translation group can reach the same sibling set, so key
237
+ // the map by each variant's id — the source doc looks up its own group.
238
+ for (const sid of groupSourceIds) siblingsByDocId.set(sid, byLang)
239
+ }
240
+ }
241
+
198
242
  // The translatable field list is per-type, so compute it once per type even
199
243
  // when a project holds many documents of the same type.
200
244
  const fieldsByType = new Map<string, ReturnType<typeof getTranslatableFields>>()
@@ -228,17 +272,79 @@ export async function prepareExport({
228
272
  })
229
273
  }
230
274
 
231
- const outbox: {_key: string; sourceDocId: string; title?: string; filename: string; locjson: string}[] = []
232
- for (const rawDoc of docs) {
275
+ const outbox: {
276
+ _key: string
277
+ sourceDocId: string
278
+ title?: string
279
+ filename: string
280
+ locjson: string
281
+ /** Present for per-language bilingual files (send-existing flow). */
282
+ targetLanguage?: string
283
+ }[] = []
284
+
285
+ /**
286
+ * Queue one bilingual LocJSON per target language for a "send existing
287
+ * translations" item. Each file carries the source plus that language's
288
+ * existing translation (field-level: the inline member; document-level: the
289
+ * locale-sibling document), is named `…_{smartcatCode}.locjson` so Smartcat
290
+ * groups the languages as one document, and records its single target language.
291
+ */
292
+ const queueBilingual = (
293
+ doc: Record<string, unknown> & {_id: string; _type: string},
294
+ name: string | undefined,
295
+ fields: TranslatableField[],
296
+ isDraft: boolean,
297
+ ): void => {
298
+ const mode = localizationMode(schema.get(doc._type))
299
+ const byLang = mode === 'document' ? siblingsByDocId.get(doc._id) : undefined
300
+ let queued = 0
301
+ for (const targetSanityId of targetLanguages) {
302
+ const smartcatCode = toSmartcatLanguage(smartcatLanguages, targetSanityId)
303
+ const target =
304
+ mode === 'field' ? {language: targetSanityId} : {language: targetSanityId, doc: byLang?.get(targetSanityId)}
305
+ const locjson = serializeToLocjson(doc as never, fields, {sourceLanguage: source, fieldLanguageKey, target})
306
+ if (locjson.units.length === 0) {
307
+ log({level: 'skip', indent: true, message: `${targetSanityId}: no translatable content — not sent`})
308
+ continue
309
+ }
310
+ const prefilled = locjson.units.filter((u) => u.target.length > 0).length
311
+ outbox.push({
312
+ _key: uuid(),
313
+ sourceDocId: doc._id,
314
+ title: name,
315
+ filename: buildLocjsonFilename(doc._type, doc._id, name, {draft: isDraft, language: smartcatCode}),
316
+ locjson: JSON.stringify(locjson),
317
+ targetLanguage: smartcatCode,
318
+ })
319
+ queued++
320
+ log({
321
+ level: 'success',
322
+ indent: true,
323
+ message: `${targetSanityId} → ${smartcatCode}: queued ${locjson.units.length} unit(s), ${prefilled} with existing translation`,
324
+ })
325
+ }
326
+ if (queued === 0) log({level: 'skip', indent: true, message: 'no translatable content — not sent'})
327
+ }
328
+ for (const item of items) {
329
+ const rawDoc = item.doc
330
+ if (!rawDoc) continue
233
331
  // Serialize under the canonical (bare) id so identity, metadata and progress
234
332
  // stay stable, even when the *content* came from the draft. Mark draft-sourced
235
333
  // files so they're recognizable in Smartcat.
236
334
  const isDraft = rawDoc._id.startsWith('drafts.')
237
335
  const doc = isDraft ? {...rawDoc, _id: publishedId(rawDoc._id)} : rawDoc
238
336
  const name = resolveDocumentTitle(schema, doc)
239
- log({level: 'info', message: `${name || doc._id} · ${doc._type}${isDraft ? ' (draft)' : ''}`})
337
+ const sendExisting = item.sendExistingTranslations === true
338
+ log({
339
+ level: 'info',
340
+ message: `${name || doc._id} · ${doc._type}${isDraft ? ' (draft)' : ''}${sendExisting ? ' · sending existing translations' : ''}`,
341
+ })
240
342
  try {
241
343
  const fields = fieldsFor(doc._type)
344
+ if (sendExisting) {
345
+ queueBilingual(doc, name, fields, isDraft)
346
+ continue
347
+ }
242
348
  // Fields skipped because they hold a block type that can't be serialized
243
349
  // (e.g. a custom `table`): left untranslated on purpose, reported as errors
244
350
  // below so the editor knows to act rather than lose content silently.