@smartcat/sanity-plugin 1.2.1 → 1.4.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 (46) 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 +477 -124
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.js +482 -129
  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/AddItemsSearch.tsx +462 -0
  41. package/src/tool/DocTitle.tsx +23 -0
  42. package/src/tool/ProjectView.tsx +29 -187
  43. package/src/tool/data.ts +7 -0
  44. package/src/tool/itemActions.tsx +216 -0
  45. package/src/tool/processing.test.ts +11 -1
  46. package/src/tool/processing.ts +116 -4
@@ -33,7 +33,7 @@ function fakeSanity(project: unknown) {
33
33
  // Stateful Smartcat fake. `getProject` reflects documents created so far; names
34
34
  // encode the source-id prefix exactly as buildLocjsonFilename does.
35
35
  function fakeSmartcat(initialDocs: SmartcatDocument[] = []) {
36
- const uploads: {filename: string; body: string}[] = []
36
+ const uploads: {filename: string; body: string; targetLanguage?: string}[] = []
37
37
  const updates: {documentId: string; filename: string; body: string}[] = []
38
38
  const deletes: string[] = []
39
39
  const docs = [...initialDocs]
@@ -46,12 +46,14 @@ function fakeSmartcat(initialDocs: SmartcatDocument[] = []) {
46
46
  }
47
47
  const client: SmartcatExportClient = {
48
48
  createProject: vi.fn(async () => ({id: 'guid-1'})),
49
- uploadDocument: vi.fn(async (_p: string, filename: string, body: string) => {
50
- uploads.push({filename, body})
49
+ uploadDocument: vi.fn(async (_p: string, filename: string, body: string, targetLanguage?: string) => {
50
+ uploads.push({filename, body, targetLanguage})
51
51
  const leaf = filename.split('/').pop()!.replace(/\.[^.]+$/, '')
52
52
  const id = `gNew${++guid}_12`
53
- docs.push({id, name: leaf, targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 0}]})
54
- return [{id, targetLanguage: 'fr'}]
53
+ // A per-language upload reports that one language; else default to fr.
54
+ const lang = targetLanguage || 'fr'
55
+ docs.push({id, name: leaf, targetLanguage: lang, workflowStages: [{id: 'st-tr', progress: 0}]})
56
+ return [{id, name: leaf, targetLanguage: lang}]
55
57
  }),
56
58
  uploadDocuments: vi.fn(async (_p: string, files: {filename: string; content: string}[]) => {
57
59
  const out: SmartcatDocument[] = []
@@ -116,6 +118,41 @@ describe('runExportUpload — first sync', () => {
116
118
  expect(progress[0].targets[0]).toMatchObject({language: 'fr', smartcatDocumentId: 'gNew1_12'})
117
119
  })
118
120
 
121
+ it('uploads per-language bilingual files with a pinned target language, and records their ids', async () => {
122
+ // Two languages of one source: each outbox entry names `_<lang>` and carries a
123
+ // targetLanguage → uploaded via uploadDocument with that language pinned.
124
+ const project = {
125
+ _id: 'p1',
126
+ name: 'Spring',
127
+ sourceLanguage: 'en',
128
+ targetLanguages: ['fr', 'de'],
129
+ smartcatLanguages: [
130
+ {sanityId: 'fr', smartcatLanguage: 'fr'},
131
+ {sanityId: 'de', smartcatLanguage: 'de'},
132
+ ],
133
+ outbox: [
134
+ {sourceDocId: 'about123-aaaa', title: 'About', filename: 'page/About-about123_fr.locjson', locjson: '{"units":[]}', targetLanguage: 'fr'},
135
+ {sourceDocId: 'about123-aaaa', title: 'About', filename: 'page/About-about123_de.locjson', locjson: '{"units":[]}', targetLanguage: 'de'},
136
+ ],
137
+ }
138
+ const {client: sanity, sets} = fakeSanity(project)
139
+ const {client: smartcat, uploads} = fakeSmartcat()
140
+
141
+ const result = await runExportUpload({sanity, smartcat, projectId: 'p1', now: () => 'T'})
142
+
143
+ expect(uploads).toEqual([
144
+ {filename: 'page/About-about123_fr.locjson', body: '{"units":[]}', targetLanguage: 'fr'},
145
+ {filename: 'page/About-about123_de.locjson', body: '{"units":[]}', targetLanguage: 'de'},
146
+ ])
147
+ expect(result).toMatchObject({created: 2, updated: 0, failed: 0})
148
+ // Both languages correlate back to the one source, each with its own doc id.
149
+ const progress = sets.at(-1)!.progress as {sourceDocId: string; targets: {language: string; smartcatDocumentId?: string}[]}[]
150
+ expect(progress).toHaveLength(1)
151
+ expect(progress[0].sourceDocId).toBe('about123-aaaa')
152
+ expect(progress[0].targets.map((t) => t.language).sort()).toEqual(['de', 'fr'])
153
+ expect(progress[0].targets.every((t) => t.smartcatDocumentId)).toBe(true)
154
+ })
155
+
119
156
  it('maps Sanity ids to Smartcat codes for createProject', async () => {
120
157
  const project = {
121
158
  _id: 'p1',
@@ -1,7 +1,7 @@
1
1
  import type {CreateProjectInput, RequestLogger, SmartcatDocument, SmartcatProject} from '../smartcat/types'
2
2
  import {SmartcatError} from '../smartcat/client'
3
3
  import {buildProgress, sourceIdPrefix, type DocProgress, type ProjectItem} from '../progress/core'
4
- import {toSmartcatLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'
4
+ import {toSmartcatLanguage, toSanityLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'
5
5
  import {resolveWorkflowCreateParams} from '../lib/workflow'
6
6
  import type {LogLine} from '../lib/log'
7
7
 
@@ -49,7 +49,8 @@ export interface SanityLikeClient {
49
49
  /** Structural subset of SmartcatClient the export needs. */
50
50
  export interface SmartcatExportClient {
51
51
  createProject(input: CreateProjectInput): Promise<{id: string}>
52
- uploadDocument(projectId: string, filename: string, content: string): Promise<SmartcatDocument[]>
52
+ /** `targetLanguage` (Smartcat code) restricts a bilingual file to one language. */
53
+ uploadDocument(projectId: string, filename: string, content: string, targetLanguage?: string): Promise<SmartcatDocument[]>
53
54
  uploadDocuments(projectId: string, files: {filename: string; content: string}[]): Promise<SmartcatDocument[]>
54
55
  updateDocument(documentId: string, filename: string, content: string): Promise<SmartcatDocument[]>
55
56
  deleteDocument(documentId: string): Promise<void>
@@ -95,7 +96,7 @@ const PROJECT_QUERY = `*[_id == $id][0]{
95
96
  smartcatProjectId,
96
97
  smartcatProjectUrl,
97
98
  smartcatLanguages[]{sanityId, smartcatLanguage},
98
- outbox[]{sourceDocId, title, filename, locjson},
99
+ outbox[]{sourceDocId, title, filename, locjson, targetLanguage},
99
100
  pendingDeletions[]{itemKey, sourceDocId, smartcatDocumentId},
100
101
  progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
101
102
  }`
@@ -105,6 +106,9 @@ interface OutboxItem {
105
106
  title?: string
106
107
  filename: string
107
108
  locjson: string
109
+ /** Present for per-language bilingual files (send-existing flow): the single
110
+ * Smartcat target-language code this file carries. Absent ⇒ target all langs. */
111
+ targetLanguage?: string
108
112
  }
109
113
 
110
114
  interface ProjectData {
@@ -255,12 +259,20 @@ export async function runExportUpload(options: RunExportUploadOptions): Promise<
255
259
  }
256
260
  }
257
261
 
262
+ // Per-language bilingual files (send-existing flow) upload one document per
263
+ // (source × language); the rest target all languages with one file. They take
264
+ // different upload/update paths, so split them here.
265
+ const perLangBox = outbox.filter((item) => item.targetLanguage)
266
+ const stdBox = outbox.filter((item) => !item.targetLanguage)
267
+
258
268
  // For an existing project, find which source files are already in Smartcat so
259
- // we update them in place rather than creating duplicates.
269
+ // we update them in place rather than creating duplicates. Only the all-language
270
+ // files use this source-prefix map; per-language files resolve their existing
271
+ // document id per (source × language) from stored progress below.
260
272
  const existing =
261
- isNew || outbox.length === 0
273
+ isNew || stdBox.length === 0
262
274
  ? new Map<string, string>()
263
- : existingDocIdsBySource(await smartcat.getProject(scProjectId as string), outbox)
275
+ : existingDocIdsBySource(await smartcat.getProject(scProjectId as string), stdBox)
264
276
 
265
277
  let created = 0
266
278
  let updated = 0
@@ -272,8 +284,23 @@ export async function runExportUpload(options: RunExportUploadOptions): Promise<
272
284
  // a re-export (see the field-level "Untitled" progress bug).
273
285
  const writtenDocs: SmartcatDocument[] = []
274
286
 
275
- const toUpdate = outbox.filter((item) => item.sourceDocId && existing.get(item.sourceDocId))
276
- const toCreate = outbox.filter((item) => !(item.sourceDocId && existing.get(item.sourceDocId)))
287
+ const toUpdate = stdBox.filter((item) => item.sourceDocId && existing.get(item.sourceDocId))
288
+ const toCreate = stdBox.filter((item) => !(item.sourceDocId && existing.get(item.sourceDocId)))
289
+
290
+ // Existing per-language document ids, keyed `${sourceDocId}__${sanityLocale}`
291
+ // from stored progress — the target that re-sync updates in place. Progress
292
+ // stores the Sanity locale, so map the outbox's Smartcat code back to it.
293
+ const perLangDocId = new Map<string, string>()
294
+ for (const doc of project.progress ?? []) {
295
+ for (const t of doc.targets) {
296
+ if (t.smartcatDocumentId) perLangDocId.set(`${doc.sourceDocId}__${t.language}`, t.smartcatDocumentId)
297
+ }
298
+ }
299
+ const existingPerLangId = (item: OutboxItem): string | undefined => {
300
+ if (!item.sourceDocId || !item.targetLanguage) return undefined
301
+ const locale = toSanityLanguage(project.smartcatLanguages, item.targetLanguage)
302
+ return perLangDocId.get(`${item.sourceDocId}__${locale}`)
303
+ }
277
304
 
278
305
  // Updates stay per-item (no multi-file equivalent) — today's behavior.
279
306
  for (const item of toUpdate) {
@@ -348,6 +375,34 @@ export async function runExportUpload(options: RunExportUploadOptions): Promise<
348
375
  // under JS's single-threaded async interleaving.
349
376
  await runWithConcurrency(chunk(toCreate, UPLOAD_CHUNK_SIZE), UPLOAD_CHUNK_CONCURRENCY, processChunk)
350
377
 
378
+ // Per-language bilingual files: one document per (source × language). Update in
379
+ // place when a stored document id exists for that pair (rename-proof: keyed by
380
+ // the stable Smartcat id, not the filename), otherwise create with the target
381
+ // language pinned via the upload's `documentModel`. Tag written docs with the
382
+ // filename so buildProgress correlates them, and capture the id for next time.
383
+ await runWithConcurrency(perLangBox, UPLOAD_CHUNK_CONCURRENCY, async (item) => {
384
+ const existingId = existingPerLangId(item)
385
+ try {
386
+ const returned = existingId
387
+ ? await smartcat.updateDocument(existingId, item.filename, item.locjson)
388
+ : await smartcat.uploadDocument(scProjectId as string, item.filename, item.locjson, item.targetLanguage)
389
+ for (const d of returned) writtenDocs.push({...d, name: d.name || item.filename})
390
+ if (existingId) updated++
391
+ else created++
392
+ log.push({level: 'success', message: `${existingId ? 'Updated' : 'Uploaded'} ${item.filename}`})
393
+ } catch (e) {
394
+ // A 409 means a prior attempt already created this file (HTTP error masking
395
+ // a successful create) — it's synced, not failed.
396
+ if (e instanceof SmartcatError && e.status === 409) {
397
+ created++
398
+ log.push({level: 'success', message: `Already synced ${item.filename}`})
399
+ } else {
400
+ failed++
401
+ log.push({level: 'error', message: `Skipped ${item.filename} — upload failed (see error above)`})
402
+ }
403
+ }
404
+ })
405
+
351
406
  // Re-fetch for authoritative ids/stages, and mirror the name back on first
352
407
  // creation (Smartcat may have de-duplicated it, e.g. "My test (1)"). Then add
353
408
  // any just-written docs it hasn't indexed yet so none are dropped this run.
@@ -12,13 +12,18 @@ export function buildLocjsonFilename(
12
12
  documentType: string,
13
13
  documentId: string,
14
14
  title?: string,
15
- options: {draft?: boolean} = {},
15
+ options: {draft?: boolean; language?: string} = {},
16
16
  ): string {
17
17
  const idPrefix = stripDraft(documentId).slice(0, 8) || 'unknown'
18
18
  const safeTitle = sanitizeSegment(title || 'Untitled')
19
19
  const safeType = sanitizeSegment(documentType) || 'document'
20
20
  const draftMarker = options.draft ? '-draft' : ''
21
- return `${safeType}/${safeTitle}-${idPrefix}${draftMarker}.locjson`
21
+ // Per-language bilingual files (send-existing flow) append `_<lang>` so Smartcat
22
+ // groups the languages of one source as a single multilingual document, and so
23
+ // `sourceIdPrefix` can strip the marker back off. The language sits after the
24
+ // draft marker; correlation strips them in reverse.
25
+ const langMarker = options.language ? `_${sanitizeSegment(options.language)}` : ''
26
+ return `${safeType}/${safeTitle}-${idPrefix}${draftMarker}${langMarker}.locjson`
22
27
  }
23
28
 
24
29
  function stripDraft(id: string): string {
@@ -344,6 +344,39 @@ describe('serializeToLocjson', () => {
344
344
  expect(body.source[0]).toContain('<td>Region</td>')
345
345
  })
346
346
 
347
+ it('bilingual (document-level): fills each unit target from the target-locale sibling doc', () => {
348
+ const result = serializeToLocjson(
349
+ {_id: 'x', _type: 'page', title: 'About Us', excerpt: 'Line one\nLine two', body: [makeBlock('We build things.')]},
350
+ fields,
351
+ {
352
+ sourceLanguage: 'en',
353
+ fieldLanguageKey: 'language',
354
+ target: {
355
+ language: 'fr',
356
+ doc: {_id: 'x-fr', _type: 'page', title: 'À propos', excerpt: 'Ligne un\nLigne deux', body: [makeBlock('Nous créons des choses.')]},
357
+ },
358
+ },
359
+ )
360
+ const title = result.units.find((u) => u.key === 'title')!
361
+ expect(title.source.join('')).toBe('About Us')
362
+ expect(title.target.join('')).toBe('À propos')
363
+ const excerpt = result.units.find((u) => u.key === 'excerpt')!
364
+ expect(excerpt.target).toEqual(['Ligne un\n', 'Ligne deux'])
365
+ const body = result.units.find((u) => u.key === 'body')!
366
+ expect(body.target[0]).toContain('Nous créons des choses.')
367
+ })
368
+
369
+ it('bilingual: leaves target empty when the target-locale field has no content', () => {
370
+ const result = serializeToLocjson(
371
+ {_id: 'x', _type: 'page', title: 'About Us', body: []},
372
+ fields,
373
+ {sourceLanguage: 'en', fieldLanguageKey: 'language', target: {language: 'fr', doc: {_id: 'x-fr', _type: 'page'}}},
374
+ )
375
+ const title = result.units.find((u) => u.key === 'title')!
376
+ expect(title.source.join('')).toBe('About Us')
377
+ expect(title.target).toEqual([]) // no French title → Smartcat translates it
378
+ })
379
+
347
380
  it('skips empty fields', () => {
348
381
  const empty = serializeToLocjson(
349
382
  {_id: 'x', _type: 'page', title: '', body: []},
@@ -538,6 +571,28 @@ describe('field-level localization', () => {
538
571
  expect(answer.source[0]).toContain('Like this.')
539
572
  })
540
573
 
574
+ it('bilingual (field-level): fills target from the target-language member on the same doc', () => {
575
+ const doc = {
576
+ _id: 'faq-1',
577
+ _type: 'faq',
578
+ question: [member('en', 'How?'), member('fr', 'Comment ?')],
579
+ answer: [
580
+ member('en', [makeBlock('Like this.')], 'internationalizedArraySimpleBlockContentValue'),
581
+ member('fr', [makeBlock('Comme ceci.')], 'internationalizedArraySimpleBlockContentValue'),
582
+ ],
583
+ }
584
+ const locjson = serializeToLocjson(doc, getTranslatableFields(faqType), {
585
+ sourceLanguage: 'en',
586
+ fieldLanguageKey: 'language',
587
+ target: {language: 'fr'},
588
+ })
589
+ const question = locjson.units.find((u) => u.key === 'question')!
590
+ expect(question.source.join('')).toBe('How?')
591
+ expect(question.target.join('')).toBe('Comment ?')
592
+ const answer = locjson.units.find((u) => u.key === 'answer')!
593
+ expect(answer.target[0]).toContain('Comme ceci.')
594
+ })
595
+
541
596
  it('reads the source member by _key when fieldLanguageKey is "_key" (v4)', () => {
542
597
  const doc = {
543
598
  _id: 'faq-1',
@@ -612,4 +667,13 @@ describe('buildLocjsonFilename', () => {
612
667
  'page/About Us-3f14f092-draft.locjson',
613
668
  )
614
669
  })
670
+
671
+ it('appends a _<lang> marker for per-language bilingual files (after the draft marker)', () => {
672
+ expect(buildLocjsonFilename('page', '3f14f092-3475', 'About Us', {language: 'fr'})).toBe(
673
+ 'page/About Us-3f14f092_fr.locjson',
674
+ )
675
+ expect(buildLocjsonFilename('page', 'drafts.3f14f092-3475', 'About Us', {draft: true, language: 'de'})).toBe(
676
+ 'page/About Us-3f14f092-draft_de.locjson',
677
+ )
678
+ })
615
679
  })
@@ -23,6 +23,20 @@ export interface SerializeOptions {
23
23
  * round-trip; the caller surfaces this to the user's log.
24
24
  */
25
25
  onSkippedField?: (info: {fieldPath: string; title?: string; types: string[]}) => void
26
+ /**
27
+ * When set, produces a **bilingual** file for a single target language: each
28
+ * unit's `target` is populated from the existing translation, in parallel with
29
+ * its `source`. Used by the "send existing translations" flow. The target value
30
+ * of a field is read the same way as the source, but for the target language:
31
+ * - field-level: the target-language member's `value` on the same document;
32
+ * - document-level: the same field path on `doc` (the target-locale sibling).
33
+ * `language` is the Sanity locale (matches internationalized-array members).
34
+ */
35
+ target?: {
36
+ language: string
37
+ /** The target-locale sibling document (document-level only). */
38
+ doc?: SerializableDocument
39
+ }
26
40
  }
27
41
 
28
42
  /** A minimal view of a Sanity document needed for serialization. */
@@ -55,10 +69,22 @@ export function serializeToLocjson(
55
69
  ? memberValue(getAtPath(doc, field.path), options.sourceLanguage, options.fieldLanguageKey)
56
70
  : getAtPath(doc, field.path)
57
71
 
72
+ // The existing translation for this field (bilingual mode only): read the same
73
+ // way as the source, but for the target language / target-locale document.
74
+ const targetOpt = options.target
75
+ const targetSource =
76
+ !targetOpt
77
+ ? undefined
78
+ : field.localization === 'field'
79
+ ? memberValue(getAtPath(doc, field.path), targetOpt.language, options.fieldLanguageKey)
80
+ : getAtPath(targetOpt.doc, field.path)
81
+
58
82
  // A scalar field yields one leaf; array/object member values yield one leaf
59
83
  // per translatable string inside (addressed by valuePath).
60
84
  for (const leaf of enumerateLeaves(field, source)) {
61
85
  const value = leaf.valuePath ? getAtPath(source, leaf.valuePath) : source
86
+ const targetValue =
87
+ targetSource === undefined ? undefined : leaf.valuePath ? getAtPath(targetSource, leaf.valuePath) : targetSource
62
88
  const comments = leaf.title ? [leaf.title] : undefined
63
89
 
64
90
  if (leaf.kind === 'plain') {
@@ -71,7 +97,8 @@ export function serializeToLocjson(
71
97
  // Split into line segments but keep the trailing newline on each, so
72
98
  // concatenating the segments (LocJSON's '' join) reconstructs the original.
73
99
  source: value.split(/(?<=\n)/),
74
- target: [],
100
+ // Existing translation (parallel to source), or empty for Smartcat to fill.
101
+ target: typeof targetValue === 'string' && targetValue.length > 0 ? targetValue.split(/(?<=\n)/) : [],
75
102
  })
76
103
  } else {
77
104
  const blocks = value as PortableTextBlock[] | undefined
@@ -86,11 +113,20 @@ export function serializeToLocjson(
86
113
  // `<br/>`), so also require actual visible text before emitting a unit.
87
114
  const html = portableTextToHtml(blocks)
88
115
  if (!html || portableTextToPlainText(blocks).trim().length === 0) continue
116
+ // Existing translation as HTML — only when the target-locale value is itself
117
+ // serializable and carries visible text; otherwise leave it for Smartcat.
118
+ const targetBlocks = targetValue as PortableTextBlock[] | undefined
119
+ const targetHtml =
120
+ targetSource === undefined ||
121
+ findUnserializableBlockTypes(targetBlocks).length > 0 ||
122
+ portableTextToPlainText(targetBlocks).trim().length === 0
123
+ ? ''
124
+ : portableTextToHtml(targetBlocks)
89
125
  units.push({
90
126
  key: leaf.fullKey,
91
127
  properties: {comments, 'x-smartcat-format': 'html', 'x-smartcat-split': 'full', 'x-sanity': {fieldPath: leaf.fullKey}},
92
128
  source: [html],
93
- target: [],
129
+ target: targetHtml ? [targetHtml] : [],
94
130
  })
95
131
  }
96
132
  }
@@ -138,10 +138,26 @@ export interface ProjectItem {
138
138
  * — leaving the wrong 8 chars, so those documents silently never correlate,
139
139
  * never import, and re-syncs duplicate them instead of updating.
140
140
  */
141
+ function escapeRegExp(value: string): string {
142
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
143
+ }
144
+
141
145
  export function sourceIdPrefix(doc: SmartcatDocument): string {
142
146
  const base = doc.name || doc.filename || doc.fullPath || ''
143
147
  const leaf = base.split('/').pop() ?? ''
144
- const noExt = leaf.replace(/\.locjson$/i, '')
148
+ let noExt = leaf.replace(/\.locjson$/i, '')
149
+ // Per-language bilingual files (send-existing flow) are named
150
+ // `<title>-<idPrefix>_<lang>.locjson`, and Smartcat's export echoes a trailing
151
+ // `(<lang>)`. Strip both — keyed to *this* document's known target language, so
152
+ // the language marker doesn't occupy the id-prefix tail. Legacy source-only
153
+ // names (`<title>-<idPrefix>.locjson`) have neither, so are left untouched; the
154
+ // key guard also stops a title that legitimately ends in `_xx`/`(xx)` from being
155
+ // mangled unless `xx` is exactly this doc's target language.
156
+ const lang = doc.targetLanguage
157
+ if (lang) {
158
+ noExt = noExt.replace(new RegExp(`\\(${escapeRegExp(lang)}\\)$`), '')
159
+ noExt = noExt.replace(new RegExp(`_${escapeRegExp(lang)}$`), '')
160
+ }
145
161
  // Draft-sourced files carry a trailing `-draft` marker (informational only, so
146
162
  // a project never holds both a document's draft and its published version).
147
163
  // Strip it before taking the tail so a draft file correlates to the same source
@@ -186,12 +202,31 @@ export function buildProgress(
186
202
  }
187
203
 
188
204
  const itemByPrefix = new Map<string, ProjectItem>()
189
- for (const item of items) itemByPrefix.set(stripDraft(item._id).slice(0, 8), item)
205
+ const itemById = new Map<string, ProjectItem>()
206
+ for (const item of items) {
207
+ itemByPrefix.set(stripDraft(item._id).slice(0, 8), item)
208
+ itemById.set(stripDraft(item._id), item)
209
+ }
210
+
211
+ // Rename-proof correlation: recover a document's item from a previously stored
212
+ // `smartcatDocumentId`. The Smartcat document id (`<fileGuid>_<langNum>`) is
213
+ // stable across file renames in Smartcat, whereas its filename (which
214
+ // `sourceIdPrefix` parses) is not. Prefer the stored id; fall back to the
215
+ // filename prefix for documents not yet stored (first sync) or legacy projects.
216
+ const sourceDocIdByScId = new Map<string, string>()
217
+ for (const doc of previous ?? []) {
218
+ for (const target of doc.targets) {
219
+ if (target.smartcatDocumentId) sourceDocIdByScId.set(target.smartcatDocumentId, doc.sourceDocId)
220
+ }
221
+ }
190
222
 
191
223
  const byDoc = new Map<string, DocProgress>()
192
224
  for (const scDoc of scProject.documents ?? []) {
193
225
  if (!scDoc.targetLanguage) continue
194
- const item = itemByPrefix.get(sourceIdPrefix(scDoc))
226
+ const storedSourceDocId = scDoc.id ? sourceDocIdByScId.get(scDoc.id) : undefined
227
+ const item =
228
+ (storedSourceDocId ? itemById.get(stripDraft(storedSourceDocId)) : undefined) ??
229
+ itemByPrefix.get(sourceIdPrefix(scDoc))
195
230
  if (!item) continue
196
231
 
197
232
  // Store the Sanity locale tag, not Smartcat's code.
@@ -216,8 +251,14 @@ export function buildProgress(
216
251
  }
217
252
 
218
253
  // Preserve item order; sort each doc's targets by language for stable display.
254
+ // Dedupe by id: the send-existing flow yields one item per (source × language),
255
+ // so `items` can list a source more than once — its single grouped docProgress
256
+ // must still appear only once.
219
257
  const result: DocProgress[] = []
258
+ const seen = new Set<string>()
220
259
  for (const item of items) {
260
+ if (seen.has(item._id)) continue
261
+ seen.add(item._id)
221
262
  const docProgress = byDoc.get(item._id)
222
263
  if (!docProgress) continue
223
264
  docProgress.targets.sort((a, b) => a.language.localeCompare(b.language))
@@ -59,6 +59,19 @@ describe('stage helpers', () => {
59
59
  expect(sourceIdPrefix({id: 'g_1', name: 'v2.5 Landing Page-ab12cd34'})).toBe('ab12cd34')
60
60
  })
61
61
 
62
+ it('strips a per-language `_<lang>` suffix (and Smartcat `(<lang>)` echo) keyed to the target language', () => {
63
+ // Send-existing flow names files `<title>-<idPrefix>_<lang>.locjson`; the id
64
+ // prefix must survive the language suffix.
65
+ expect(sourceIdPrefix({id: 'g_1', name: 'About Us-f18f0d41_fr', targetLanguage: 'fr'})).toBe('f18f0d41')
66
+ expect(sourceIdPrefix({id: 'g_1', filename: 'About Us-f18f0d41_de.locjson', targetLanguage: 'de'})).toBe('f18f0d41')
67
+ // Smartcat's export echoes a trailing `(<lang>)` — strip that too.
68
+ expect(sourceIdPrefix({id: 'g_1', name: 'About Us-f18f0d41_fr(fr)', targetLanguage: 'fr'})).toBe('f18f0d41')
69
+ // Draft marker sits before the language suffix.
70
+ expect(sourceIdPrefix({id: 'g_1', name: 'About Us-f18f0d41-draft_fr', targetLanguage: 'fr'})).toBe('f18f0d41')
71
+ // Key guard: a title ending in `_xx` is untouched unless xx is the target language.
72
+ expect(sourceIdPrefix({id: 'g_1', name: 'Report_v2-f18f0d41', targetLanguage: 'fr'})).toBe('f18f0d41')
73
+ })
74
+
62
75
  it('correlates a draft-sourced file to the same id as its published counterpart', () => {
63
76
  // Draft files carry a trailing `-draft` marker; it must be stripped so both
64
77
  // resolve to the same source id (a project holds one version, never both).
@@ -155,6 +168,33 @@ describe('buildProgress', () => {
155
168
  })
156
169
  })
157
170
 
171
+ describe('buildProgress rename-proof correlation', () => {
172
+ it('recovers a renamed document via the stored smartcatDocumentId (not its filename)', () => {
173
+ // A user renamed the file in Smartcat, so its name no longer carries the id
174
+ // prefix — filename correlation would drop it. The stored id from the previous
175
+ // sync recovers the item.
176
+ const renamed: SmartcatProject = {
177
+ id: 'p1',
178
+ workflowStages: STAGE_NAMES,
179
+ documents: [
180
+ {id: '817_12', name: 'Totally Renamed', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}, {id: 'st-ed', progress: 100}]},
181
+ ],
182
+ }
183
+ const previous = [
184
+ {
185
+ _key: 'k',
186
+ sourceDocId: 'f18f0d41-aaaa-bbbb',
187
+ title: 'About Us',
188
+ targets: [{_key: 't', language: 'fr', smartcatDocumentId: '817_12', stages: [], complete: true, imported: false}],
189
+ },
190
+ ]
191
+ const progress = buildProgress(items, renamed, previous, 'now')
192
+ expect(progress).toHaveLength(1)
193
+ expect(progress[0].sourceDocId).toBe('f18f0d41-aaaa-bbbb')
194
+ expect(progress[0].targets[0]).toMatchObject({language: 'fr', smartcatDocumentId: '817_12'})
195
+ })
196
+ })
197
+
158
198
  describe('summarizeCompletion', () => {
159
199
  it('flags allDone only when every target is complete AND imported', () => {
160
200
  const progress: DocProgress[] = [
@@ -101,6 +101,18 @@ export function createTranslationProjectType(): SchemaTypeDefinition {
101
101
  // export translates the published document. Absent/false (incl. all
102
102
  // legacy items) prefers the draft — see itemDocDerefRespectingDraft.
103
103
  defineField({name: 'sourceIsPublished', title: 'Source is published', type: 'boolean'}),
104
+ // When true, the export sends the item's *existing* translations to
105
+ // Smartcat: one bilingual LocJSON per target language (populated from
106
+ // field/linked-doc translations), grouped via `{name}_{lang}.locjson`.
107
+ // Absent/false (incl. all legacy items) ⇒ today's behaviour: a single
108
+ // source-only file targeting all languages. Note: imported bilingual
109
+ // targets land confirmed at every workflow stage (Smartcat ignores
110
+ // per-segment status on LocJSON import), i.e. they skip human review.
111
+ defineField({
112
+ name: 'sendExistingTranslations',
113
+ title: 'Send existing translations',
114
+ type: 'boolean',
115
+ }),
104
116
  ],
105
117
  preview: {select: {title: 'docId', subtitle: 'docType'}},
106
118
  }),
@@ -210,8 +222,15 @@ export function createTranslationProjectType(): SchemaTypeDefinition {
210
222
  type: 'object',
211
223
  name: 'smartcatOutboxItem',
212
224
  fields: [
225
+ defineField({name: 'sourceDocId', type: 'string'}),
226
+ defineField({name: 'title', type: 'string'}),
213
227
  defineField({name: 'filename', type: 'string'}),
214
228
  defineField({name: 'locjson', type: 'text'}),
229
+ // Present only for per-language bilingual files (send-existing flow):
230
+ // the single target language this file carries, so the export Function
231
+ // uploads it with `documentModel targetLanguages:[lang]`. Absent ⇒
232
+ // upload targeting all project languages (the source-only default).
233
+ defineField({name: 'targetLanguage', type: 'string'}),
215
234
  ],
216
235
  }),
217
236
  ],
@@ -115,13 +115,27 @@ export class SmartcatClient {
115
115
  return this.request('GET', `${API_BASE}/template`)
116
116
  }
117
117
 
118
- /** Uploads one source file into a project. `filename` may include a folder path. */
118
+ /**
119
+ * Uploads one source file into a project. `filename` may include a folder path.
120
+ *
121
+ * When `targetLanguage` is given (a Smartcat language code), the file is
122
+ * restricted to that single target language via the `documentModel` settings
123
+ * part — used by the "send existing translations" flow, which uploads one
124
+ * bilingual LocJSON per language. Omitted ⇒ the document targets every project
125
+ * language (today's source-only default).
126
+ */
119
127
  async uploadDocument(
120
128
  projectId: string,
121
129
  filename: string,
122
130
  content: string,
131
+ targetLanguage?: string,
123
132
  ): Promise<SmartcatDocument[]> {
124
133
  const form = new FormData()
134
+ if (targetLanguage) {
135
+ // `documentModel` is a JSON array (one entry per file); a bare object 400s.
136
+ const model = [{targetLanguages: [targetLanguage]}]
137
+ form.append('documentModel', new Blob([JSON.stringify(model)], {type: 'application/json'}), 'documentModel')
138
+ }
125
139
  // Use a generic content type: declaring application/json makes Smartcat try
126
140
  // to map the file to an API model instead of importing it as a document.
127
141
  form.append('file', new Blob([content], {type: 'application/octet-stream'}), filename)