@smartcat/sanity-plugin 1.2.0 → 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.
- package/dist/_chunks-cjs/index.cjs +18 -5
- package/dist/_chunks-cjs/index.cjs.map +1 -1
- package/dist/_chunks-cjs/index2.cjs +8 -6
- package/dist/_chunks-cjs/index2.cjs.map +1 -1
- package/dist/_chunks-es/index.js +18 -5
- package/dist/_chunks-es/index.js.map +1 -1
- package/dist/_chunks-es/index2.js +8 -6
- package/dist/_chunks-es/index2.js.map +1 -1
- package/dist/export.cjs +21 -4
- package/dist/export.cjs.map +1 -1
- package/dist/export.d.cts +2 -0
- package/dist/export.d.ts +2 -0
- package/dist/export.js +22 -5
- package/dist/export.js.map +1 -1
- package/dist/index.cjs +156 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +160 -43
- package/dist/index.js.map +1 -1
- package/dist/locjson.d.cts +15 -0
- package/dist/locjson.d.ts +15 -0
- package/dist/progress.d.cts +0 -19
- package/dist/progress.d.ts +0 -19
- package/dist/smartcat.cjs +14 -2
- package/dist/smartcat.cjs.map +1 -1
- package/dist/smartcat.d.cts +10 -1
- package/dist/smartcat.d.ts +10 -1
- package/dist/smartcat.js +14 -2
- package/dist/smartcat.js.map +1 -1
- package/package.json +1 -1
- package/src/actions/AddToProjectAction.tsx +23 -2
- package/src/export/export.test.ts +42 -5
- package/src/export/index.ts +63 -8
- package/src/lib/locjson/filename.ts +7 -2
- package/src/lib/locjson/locjson.test.ts +64 -0
- package/src/lib/locjson/serialize.ts +38 -2
- package/src/progress/core.ts +44 -3
- package/src/progress/progress.test.ts +40 -0
- package/src/schema/translationProject.ts +19 -0
- package/src/smartcat/client.ts +15 -1
- package/src/tool/ProjectView.tsx +108 -44
- package/src/tool/data.ts +7 -0
- package/src/tool/processing.ts +110 -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
|
-
|
|
54
|
-
|
|
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',
|
package/src/export/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
273
|
+
isNew || stdBox.length === 0
|
|
262
274
|
? new Map<string, string>()
|
|
263
|
-
: existingDocIdsBySource(await smartcat.getProject(scProjectId as string),
|
|
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 =
|
|
276
|
-
const toCreate =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/progress/core.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
],
|
package/src/smartcat/client.ts
CHANGED
|
@@ -115,13 +115,27 @@ export class SmartcatClient {
|
|
|
115
115
|
return this.request('GET', `${API_BASE}/template`)
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/**
|
|
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)
|