@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.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,393 @@
1
+ import type {CreateProjectInput, RequestLogger, SmartcatDocument, SmartcatProject} from '../smartcat/types'
2
+ import {SmartcatError} from '../smartcat/client'
3
+ import {buildProgress, sourceIdPrefix, type DocProgress, type ProjectItem} from '../progress/core'
4
+ import {toSmartcatLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'
5
+ import {resolveWorkflowCreateParams} from '../lib/workflow'
6
+ import type {LogLine} from '../lib/log'
7
+
8
+ /** Source files per multi-file upload request (first-sync create path). */
9
+ const UPLOAD_CHUNK_SIZE = 50
10
+ /** Delay before the single batch retry, ms. */
11
+ const BATCH_RETRY_DELAY_MS = 1000
12
+ /** Create-path chunks uploaded concurrently. A saturation study found upload
13
+ * throughput scales ~linearly with concurrency only up to ~C=40, where the
14
+ * Smartcat API's per-workspace rate limit trips (HTTP 429) and effective
15
+ * throughput collapses. Raising this above ~30 is pointless — the gains are
16
+ * erased by rate-limiting, and it eats into the shared rate budget of any other
17
+ * exports running concurrently. 10 is a safe, near-linear default with headroom. */
18
+ export const UPLOAD_CHUNK_CONCURRENCY = 10
19
+
20
+ function chunk<T>(items: T[], size: number): T[][] {
21
+ const out: T[][] = []
22
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
23
+ return out
24
+ }
25
+
26
+ /** Runs `worker` over `items` with at most `limit` in flight concurrently. */
27
+ async function runWithConcurrency<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
28
+ let next = 0
29
+ async function runNext(): Promise<void> {
30
+ const i = next++
31
+ if (i >= items.length) return
32
+ await worker(items[i])
33
+ return runNext()
34
+ }
35
+ await Promise.all(Array.from({length: Math.min(limit, items.length)}, () => runNext()))
36
+ }
37
+
38
+ /** Minimal structural subset of @sanity/client used by the export. */
39
+ export interface SanityLikeClient {
40
+ fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>
41
+ patch(id: string): {
42
+ set(attrs: Record<string, unknown>): {
43
+ unset(keys: string[]): {commit(): Promise<unknown>}
44
+ commit(): Promise<unknown>
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Structural subset of SmartcatClient the export needs. */
50
+ export interface SmartcatExportClient {
51
+ createProject(input: CreateProjectInput): Promise<{id: string}>
52
+ uploadDocument(projectId: string, filename: string, content: string): Promise<SmartcatDocument[]>
53
+ uploadDocuments(projectId: string, files: {filename: string; content: string}[]): Promise<SmartcatDocument[]>
54
+ updateDocument(documentId: string, filename: string, content: string): Promise<SmartcatDocument[]>
55
+ deleteDocument(documentId: string): Promise<void>
56
+ getProject(projectId: string): Promise<SmartcatProject>
57
+ projectUrl(projectId: string): string
58
+ /** Optional: receive a trace of every HTTP call for the client-facing log. */
59
+ setRequestLogger?(logger: RequestLogger): void
60
+ }
61
+
62
+ interface PendingDeletion {
63
+ itemKey?: string
64
+ sourceDocId?: string
65
+ smartcatDocumentId?: string
66
+ }
67
+
68
+ export interface RunExportUploadOptions {
69
+ sanity: SanityLikeClient
70
+ smartcat: SmartcatExportClient
71
+ /** `_id` of the `smartcat.translationProject` to export. */
72
+ projectId: string
73
+ now?: () => string
74
+ sleep?: (ms: number) => Promise<void>
75
+ }
76
+
77
+ export interface RunExportUploadResult {
78
+ smartcatProjectId: string
79
+ /** Source files newly created in Smartcat this run. */
80
+ created: number
81
+ /** Source files updated in place this run. */
82
+ updated: number
83
+ /** Documents deleted from Smartcat this run. */
84
+ deleted: number
85
+ /** Documents whose upload/update failed (e.g. Smartcat lock); the rest still synced. */
86
+ failed: number
87
+ }
88
+
89
+ const PROJECT_QUERY = `*[_id == $id][0]{
90
+ _id,
91
+ name,
92
+ sourceLanguage,
93
+ targetLanguages,
94
+ workflow,
95
+ smartcatProjectId,
96
+ smartcatProjectUrl,
97
+ smartcatLanguages[]{sanityId, smartcatLanguage},
98
+ outbox[]{sourceDocId, title, filename, locjson},
99
+ pendingDeletions[]{itemKey, sourceDocId, smartcatDocumentId},
100
+ progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
101
+ }`
102
+
103
+ interface OutboxItem {
104
+ sourceDocId?: string
105
+ title?: string
106
+ filename: string
107
+ locjson: string
108
+ }
109
+
110
+ interface ProjectData {
111
+ _id: string
112
+ name?: string
113
+ sourceLanguage?: string
114
+ targetLanguages?: string[]
115
+ workflow?: string
116
+ smartcatProjectId?: string
117
+ smartcatProjectUrl?: string
118
+ smartcatLanguages?: SmartcatLanguageMapping[]
119
+ outbox?: OutboxItem[]
120
+ pendingDeletions?: PendingDeletion[]
121
+ progress?: DocProgress[]
122
+ }
123
+
124
+ /** A source file maps to one Smartcat document id we can update in place. */
125
+ function existingDocIdsBySource(scProject: SmartcatProject, outbox: OutboxItem[]): Map<string, string> {
126
+ // Index Smartcat documents by the source-id prefix in their filename, keeping
127
+ // one representative id per source file (all target-language docs share a source).
128
+ const idByPrefix = new Map<string, string>()
129
+ for (const doc of scProject.documents ?? []) {
130
+ const prefix = sourceIdPrefix(doc)
131
+ if (prefix && doc.id && !idByPrefix.has(prefix)) idByPrefix.set(prefix, doc.id)
132
+ }
133
+ const result = new Map<string, string>()
134
+ for (const item of outbox) {
135
+ if (!item.sourceDocId) continue
136
+ const id = idByPrefix.get(stripDraft(item.sourceDocId).slice(0, 8))
137
+ if (id) result.set(item.sourceDocId, id)
138
+ }
139
+ return result
140
+ }
141
+
142
+ function stripDraft(id: string): string {
143
+ return id.replace(/^drafts\./, '')
144
+ }
145
+
146
+ /**
147
+ * Merges the documents Smartcat returned from this run's writes into the
148
+ * authoritative `getProject` list. The fetched docs win (they carry workflow
149
+ * stages); written docs are added only when the fetch hasn't indexed them yet,
150
+ * so a freshly-created document — and its title — isn't dropped this run.
151
+ */
152
+ function mergeWrittenDocs(authoritative: SmartcatDocument[], written: SmartcatDocument[]): SmartcatDocument[] {
153
+ const byId = new Map(authoritative.filter((d) => d.id).map((d) => [d.id, d]))
154
+ for (const d of written) if (d.id && !byId.has(d.id)) byId.set(d.id, d)
155
+ return [...byId.values()]
156
+ }
157
+
158
+ /**
159
+ * Thin export step run by a Sanity Function: takes the LocJSON files the browser
160
+ * prepared (in `outbox`) and syncs them to Smartcat.
161
+ *
162
+ * - **First sync** (no `smartcatProjectId`): creates the Smartcat project and
163
+ * uploads each file, then mirrors Smartcat's (possibly de-duplicated) project
164
+ * name back onto the Sanity project.
165
+ * - **Re-sync** (project already exists): reuses it — updating documents that are
166
+ * already there (Smartcat merges changes, preserving existing translations) and
167
+ * creating any newly-added items. Never creates a second Smartcat project.
168
+ *
169
+ * Does no content processing, so it depends only on the Smartcat client.
170
+ */
171
+ export async function runExportUpload(options: RunExportUploadOptions): Promise<RunExportUploadResult> {
172
+ const {
173
+ sanity,
174
+ smartcat,
175
+ projectId,
176
+ now = () => new Date().toISOString(),
177
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
178
+ } = options
179
+ const log: LogLine[] = []
180
+
181
+ // Trace every Smartcat HTTP call into the client-facing log. Successful calls
182
+ // are a single info line; failures show "Error <status> while performing …"
183
+ // (red) with the raw response body on the line below.
184
+ smartcat.setRequestLogger?.(({method, path, status, body}) => {
185
+ if (status >= 200 && status < 300) {
186
+ log.push({level: 'info', message: `${method} ${path} → ${status}`})
187
+ } else {
188
+ log.push({level: 'error', message: `Error ${status} while performing ${method} ${path}`})
189
+ if (body) log.push({level: 'error', indent: true, message: body})
190
+ }
191
+ })
192
+
193
+ const project = await sanity.fetch<ProjectData | null>(PROJECT_QUERY, {id: projectId})
194
+ if (!project) throw new Error(`Translation project ${projectId} not found`)
195
+
196
+ try {
197
+ const outbox = project.outbox ?? []
198
+ const deletions = project.pendingDeletions ?? []
199
+ if (outbox.length === 0 && deletions.length === 0) {
200
+ // Not an error: every queued document resolved to zero translatable units
201
+ // (e.g. empty source content), so there's nothing to send. Settle back to a
202
+ // resting status and explain why, rather than failing the project.
203
+ const status = project.smartcatProjectId ? 'sent' : 'draft'
204
+ log.push({level: 'skip', message: 'Nothing to sync — no documents with translatable content'})
205
+ await sanity
206
+ .patch(projectId)
207
+ .set({status, lastExportAt: now(), lastError: null, functionLog: JSON.stringify(log)})
208
+ .unset(['outbox', 'pendingDeletions'])
209
+ .commit()
210
+ return {smartcatProjectId: project.smartcatProjectId ?? '', created: 0, updated: 0, deleted: 0, failed: 0}
211
+ }
212
+ log.push({level: 'info', message: `Syncing ${outbox.length} file(s)${deletions.length ? ` · ${deletions.length} deletion(s)` : ''} to Smartcat`})
213
+
214
+ const isNew = !project.smartcatProjectId
215
+ let scProjectId = project.smartcatProjectId
216
+ let scProjectUrl = project.smartcatProjectUrl
217
+
218
+ if (isNew) {
219
+ const sourceLanguage = project.sourceLanguage
220
+ if (!sourceLanguage) throw new Error('No source language set on the project')
221
+ const targetLanguages = project.targetLanguages ?? []
222
+ if (targetLanguages.length === 0) throw new Error('No target languages set on the project')
223
+ // Send Smartcat its own language codes (the project carries the mapping).
224
+ const map = project.smartcatLanguages
225
+ log.push({level: 'info', message: `Creating Smartcat project "${project.name || 'Sanity translation project'}"`})
226
+ const createdProject = await smartcat.createProject({
227
+ name: project.name || 'Sanity translation project',
228
+ sourceLanguage: toSmartcatLanguage(map, sourceLanguage),
229
+ targetLanguages: targetLanguages.map((id) => toSmartcatLanguage(map, id)),
230
+ ...resolveWorkflowCreateParams(project.workflow),
231
+ })
232
+ scProjectId = createdProject.id
233
+ scProjectUrl = smartcat.projectUrl(createdProject.id)
234
+ log.push({level: 'success', message: `Created Smartcat project ${scProjectId}`})
235
+ // Persist the new project id immediately, before uploads. If a later step
236
+ // fails (or the event is re-run), the next run reuses this project instead
237
+ // of creating a duplicate in Smartcat.
238
+ await sanity.patch(projectId).set({smartcatProjectId: scProjectId, smartcatProjectUrl: scProjectUrl}).commit()
239
+ }
240
+
241
+ // Delete marked documents first (one id removes the whole document). Best-effort:
242
+ // an item is removed from the project only if its Smartcat delete succeeded (or
243
+ // it had no linked document).
244
+ const deletedItemKeys: string[] = []
245
+ for (const del of deletions) {
246
+ try {
247
+ if (del.smartcatDocumentId) {
248
+ await smartcat.deleteDocument(del.smartcatDocumentId)
249
+ log.push({level: 'success', message: `Deleted document ${del.smartcatDocumentId}`})
250
+ }
251
+ if (del.itemKey) deletedItemKeys.push(del.itemKey)
252
+ } catch (e) {
253
+ // Leave the item in place; the mark is cleared so the editor can retry.
254
+ log.push({level: 'error', message: `Failed to delete document ${del.smartcatDocumentId}: ${e instanceof Error ? e.message : String(e)}`})
255
+ }
256
+ }
257
+
258
+ // For an existing project, find which source files are already in Smartcat so
259
+ // we update them in place rather than creating duplicates.
260
+ const existing =
261
+ isNew || outbox.length === 0
262
+ ? new Map<string, string>()
263
+ : existingDocIdsBySource(await smartcat.getProject(scProjectId as string), outbox)
264
+
265
+ let created = 0
266
+ let updated = 0
267
+ let failed = 0
268
+ // Capture the documents Smartcat returns from each write. The post-upload
269
+ // getProject can lag on freshly-created docs (indexing delay); merging these
270
+ // in guarantees buildProgress sees them this run, so the title — which lives
271
+ // only on the in-memory outbox — is captured now instead of being lost until
272
+ // a re-export (see the field-level "Untitled" progress bug).
273
+ const writtenDocs: SmartcatDocument[] = []
274
+
275
+ const toUpdate = outbox.filter((item) => item.sourceDocId && existing.get(item.sourceDocId))
276
+ const toCreate = outbox.filter((item) => !(item.sourceDocId && existing.get(item.sourceDocId)))
277
+
278
+ // Updates stay per-item (no multi-file equivalent) — today's behavior.
279
+ for (const item of toUpdate) {
280
+ const docId = existing.get(item.sourceDocId as string) as string
281
+ try {
282
+ const returned = await smartcat.updateDocument(docId, item.filename, item.locjson)
283
+ // Tag with the filename we uploaded so correlation works even when the
284
+ // write response omits the document name.
285
+ for (const d of returned) writtenDocs.push({...d, name: d.name || item.filename})
286
+ updated++
287
+ log.push({level: 'success', message: `Updated ${item.filename}`})
288
+ } catch {
289
+ // The HTTP error + body were already logged in red by the request logger.
290
+ // One document failing (e.g. a Smartcat "document locked" / update-in-
291
+ // progress 400) must not abort the batch or fail the whole project — record
292
+ // it and move on so the rest still sync.
293
+ failed++
294
+ log.push({level: 'error', message: `Skipped ${item.filename} — update failed (see error above)`})
295
+ }
296
+ }
297
+
298
+ // Per-chunk upload: batch → retry once as batch → per-file fallback. Mutates the
299
+ // enclosing `created`/`failed`/`writtenDocs`/`log` state; never throws for a
300
+ // normal upload failure (the per-file fallback swallows those), so it's safe to
301
+ // run many of these concurrently below.
302
+ async function processChunk(group: OutboxItem[]): Promise<void> {
303
+ const payload = group.map((i) => ({filename: i.filename, content: i.locjson}))
304
+ try {
305
+ const returned = await smartcat.uploadDocuments(scProjectId as string, payload)
306
+ // Batch correlation relies on the response carrying a per-doc name/fullPath (spike-confirmed).
307
+ writtenDocs.push(...returned)
308
+ created += group.length
309
+ log.push({level: 'success', message: `Uploaded ${group.length} file(s)`})
310
+ } catch {
311
+ await sleep(BATCH_RETRY_DELAY_MS)
312
+ try {
313
+ const returned = await smartcat.uploadDocuments(scProjectId as string, payload)
314
+ writtenDocs.push(...returned)
315
+ created += group.length
316
+ log.push({level: 'success', message: `Uploaded ${group.length} file(s) (retry)`})
317
+ } catch {
318
+ log.push({level: 'info', message: `Batch upload failed, retrying ${group.length} file(s) individually`})
319
+ for (const item of group) {
320
+ try {
321
+ const returned = await smartcat.uploadDocument(scProjectId as string, item.filename, item.locjson)
322
+ // Tag with the filename we uploaded so correlation works even when the
323
+ // write response omits the document name.
324
+ for (const d of returned) writtenDocs.push({...d, name: d.name || item.filename})
325
+ created++
326
+ log.push({level: 'success', message: `Uploaded ${item.filename}`})
327
+ } catch (e) {
328
+ // A 409 here means the batch attempt(s) above actually committed this
329
+ // file before throwing (HTTP error masking a successful create); the
330
+ // re-POST fails because Smartcat rejects a duplicate filename. The
331
+ // document exists in the project, so it's synced, not failed —
332
+ // getProject/buildProgress remain authoritative for its content.
333
+ if (e instanceof SmartcatError && e.status === 409) {
334
+ created++
335
+ log.push({level: 'success', message: `Already synced ${item.filename}`})
336
+ } else {
337
+ failed++
338
+ log.push({level: 'error', message: `Skipped ${item.filename} — upload failed (see error above)`})
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ // Creates are uploaded in chunks, with bounded concurrency across chunks —
347
+ // log-line order becomes non-deterministic, but the mutations above are safe
348
+ // under JS's single-threaded async interleaving.
349
+ await runWithConcurrency(chunk(toCreate, UPLOAD_CHUNK_SIZE), UPLOAD_CHUNK_CONCURRENCY, processChunk)
350
+
351
+ // Re-fetch for authoritative ids/stages, and mirror the name back on first
352
+ // creation (Smartcat may have de-duplicated it, e.g. "My test (1)"). Then add
353
+ // any just-written docs it hasn't indexed yet so none are dropped this run.
354
+ const scProject = await smartcat.getProject(scProjectId as string)
355
+ const documents = mergeWrittenDocs(scProject.documents ?? [], writtenDocs)
356
+ const items: ProjectItem[] = outbox.map((o) => ({_id: o.sourceDocId || o.filename, title: o.title}))
357
+ const progress = buildProgress(items, {...scProject, documents}, project.progress, now(), project.smartcatLanguages)
358
+
359
+ log.push({
360
+ level: failed ? 'error' : 'info',
361
+ message: `Synced ${created} created, ${updated} updated${failed ? `, ${failed} failed` : ''}${deletedItemKeys.length ? `, ${deletedItemKeys.length} deleted` : ''}`,
362
+ })
363
+
364
+ const patch: Record<string, unknown> = {
365
+ smartcatProjectId: scProjectId,
366
+ smartcatProjectUrl: scProjectUrl,
367
+ status: 'sent',
368
+ progress,
369
+ lastExportAt: now(),
370
+ lastError: null,
371
+ functionLog: JSON.stringify(log),
372
+ }
373
+ // Mirror Smartcat's project name back on every sync, so the two stay in step
374
+ // (Smartcat may de-duplicate it, e.g. "My test (1)"). Note: this means a local
375
+ // rename in the Studio is overwritten on the next sync unless it's also pushed
376
+ // to Smartcat — Smartcat is the source of truth for the linked project's name.
377
+ if (scProject.name) patch.name = scProject.name
378
+
379
+ const unset = ['outbox', 'pendingDeletions', ...deletedItemKeys.map((k) => `items[_key=="${k}"]`)]
380
+ await sanity.patch(projectId).set(patch).unset(unset).commit()
381
+
382
+ return {smartcatProjectId: scProjectId as string, created, updated, deleted: deletedItemKeys.length, failed}
383
+ } catch (err) {
384
+ const message = err instanceof Error ? err.message : String(err)
385
+ log.push({level: 'error', message})
386
+ await sanity
387
+ .patch(projectId)
388
+ .set({status: 'error', lastError: message, lastExportAt: now(), functionLog: JSON.stringify(log)})
389
+ .commit()
390
+ .catch(() => {})
391
+ throw err
392
+ }
393
+ }
@@ -0,0 +1,212 @@
1
+ import {Fragment, useCallback, useEffect, useState, type MouseEvent} from 'react'
2
+ import {useClient, useFormValue, useWorkspace, type InputProps} from 'sanity'
3
+ import {Card, Stack, Text, type CardTone} from '@sanity/ui'
4
+ import {useRouter} from 'sanity/router'
5
+ import {API_VERSION, TRANSLATION_PROJECT_TYPE} from '../lib/constants'
6
+ import {ITEM_ID} from '../lib/projectItems'
7
+ import {localizationMode} from '../lib/locjson/fields'
8
+ import {TRANSLATION_TOOL_NAME} from '../tool'
9
+ import type {LocalizationMode} from '../lib/locjson/types'
10
+ import type {ResolvedSmartcatConfig} from '../lib/resolveConfig'
11
+
12
+ interface ProjectRef {
13
+ _id: string
14
+ name?: string
15
+ /** True when this document is itself in the project's items (the source it was
16
+ * added as); false when it matched via a sibling variant (a translation target). */
17
+ isSource: boolean
18
+ /** True when the project actually targets this document's language — i.e. this
19
+ * variant is a real translation target of it, not just a sibling of its source. */
20
+ targetsThisLanguage: boolean
21
+ }
22
+
23
+ // Match projects whose items reference this document OR one of its sibling
24
+ // language variants — so the banner shows on both the source and its translated
25
+ // (document-level i18n) variants. The sibling ids come from the
26
+ // translation.metadata group that references this document. Per project we also
27
+ // record which role this document plays: `isSource` (the doc is itself an item)
28
+ // and `targetsThisLanguage` (the project targets this doc's language, so this
29
+ // variant is a genuine target — a project that references the source but doesn't
30
+ // target this language is not). The same document can be a source in one project
31
+ // and a target in another (e.g. pivot-language translation).
32
+ const QUERY = `*[_type == "${TRANSLATION_PROJECT_TYPE}" && count(items[
33
+ ${ITEM_ID} in [$id] + *[_type == "translation.metadata" && references($id)].translations[].value._ref
34
+ ]) > 0] | order(_updatedAt desc){
35
+ _id,
36
+ name,
37
+ "isSource": count(items[${ITEM_ID} == $id]) > 0,
38
+ "targetsThisLanguage": $language in targetLanguages
39
+ }`
40
+
41
+ /** A link to a project's page in the Translations tool, with SPA navigation. */
42
+ function ProjectLink({basePath, project}: {basePath: string; project: ProjectRef}) {
43
+ const router = useRouter()
44
+ // basePath is "/" by default or a configured prefix; join without doubling slashes.
45
+ const href = `${basePath.replace(/\/$/, '')}/${TRANSLATION_TOOL_NAME}/project/${project._id}`
46
+ const onClick = useCallback(
47
+ (e: MouseEvent) => {
48
+ // Let modifier-clicks (new tab/window) use the href; otherwise navigate in-app.
49
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
50
+ e.preventDefault()
51
+ router.navigateUrl({path: href})
52
+ },
53
+ [router, href],
54
+ )
55
+ return (
56
+ <a href={href} onClick={onClick}>
57
+ {project.name || 'Untitled project'}
58
+ </a>
59
+ )
60
+ }
61
+
62
+ /** A self-contained card listing same-role projects. `lead` is the full text before the links. */
63
+ function RoleCard({
64
+ lead,
65
+ tone = 'primary',
66
+ warning,
67
+ basePath,
68
+ projects,
69
+ }: {
70
+ lead: string
71
+ tone?: CardTone
72
+ warning?: string
73
+ basePath: string
74
+ projects: ProjectRef[]
75
+ }) {
76
+ return (
77
+ <Card tone={tone} padding={3} radius={2} border>
78
+ <Stack space={3}>
79
+ <Text size={1}>
80
+ {lead}
81
+ {projects.map((p, i) => (
82
+ <Fragment key={p._id}>
83
+ {i > 0 && ', '}
84
+ <ProjectLink basePath={basePath} project={p} />
85
+ </Fragment>
86
+ ))}
87
+ </Text>
88
+ {warning && (
89
+ <Text size={1} weight="semibold">
90
+ {warning}
91
+ </Text>
92
+ )}
93
+ </Stack>
94
+ </Card>
95
+ )
96
+ }
97
+
98
+ /**
99
+ * Status banner naming the project(s) this document belongs to, each linking to
100
+ * its page in the tool.
101
+ *
102
+ * For document-level localization a document can play both roles across projects
103
+ * (source in one, target in another), so source and variant memberships get
104
+ * their own lines. For field-level localization the single document holds every
105
+ * language, so there is no source/variant distinction — it's just "this document".
106
+ */
107
+ function TranslationStatusLine({mode}: {mode: LocalizationMode}) {
108
+ const client = useClient({apiVersion: API_VERSION})
109
+ const {basePath} = useWorkspace()
110
+ const docId = (useFormValue(['_id']) as string | undefined)?.replace(/^drafts\./, '')
111
+ const language = (useFormValue(['language']) as string | undefined) ?? null
112
+ const [projects, setProjects] = useState<ProjectRef[]>([])
113
+
114
+ useEffect(() => {
115
+ if (!docId) return undefined
116
+ let cancelled = false
117
+ const params = {id: docId, language}
118
+ const load = () =>
119
+ client
120
+ .fetch<ProjectRef[]>(QUERY, params)
121
+ .then((res) => !cancelled && setProjects(res))
122
+ .catch(() => !cancelled && setProjects([]))
123
+ load()
124
+ const sub = client.listen(QUERY, params, {visibility: 'query'}).subscribe({next: load, error: () => {}})
125
+ return () => {
126
+ cancelled = true
127
+ sub.unsubscribe()
128
+ }
129
+ }, [client, docId, language])
130
+
131
+ if (projects.length === 0) return null
132
+
133
+ // Field-level docs hold all languages in one document — neutral, single card.
134
+ if (mode === 'field') {
135
+ return (
136
+ <Stack space={3}>
137
+ <RoleCard
138
+ lead={
139
+ projects.length === 1
140
+ ? 'This document is part of a translation project: '
141
+ : 'This document is part of multiple translation projects: '
142
+ }
143
+ basePath={basePath}
144
+ projects={projects}
145
+ />
146
+ </Stack>
147
+ )
148
+ }
149
+
150
+ const asSource = projects.filter((p) => p.isSource)
151
+ // A genuine target only if the project actually targets this variant's
152
+ // language — a project that merely includes the source (but targets other
153
+ // languages) doesn't translate into this variant, so it's not a collision.
154
+ const asTarget = projects.filter((p) => !p.isSource && p.targetsThisLanguage)
155
+
156
+ return (
157
+ <Stack space={3}>
158
+ {asSource.length > 0 && (
159
+ <RoleCard
160
+ lead={
161
+ asSource.length === 1
162
+ ? 'This source document is part of a translation project: '
163
+ : 'This source document is part of multiple translation projects: '
164
+ }
165
+ basePath={basePath}
166
+ projects={asSource}
167
+ />
168
+ )}
169
+ {asTarget.length > 0 && (
170
+ <RoleCard
171
+ // A document being a translation target in more than one project means
172
+ // multiple projects can write back to the same variant — flag it red.
173
+ tone={asTarget.length > 1 ? 'critical' : 'primary'}
174
+ lead={
175
+ asTarget.length === 1
176
+ ? 'This document variant is part of a translation project: '
177
+ : 'This document variant is part of multiple translation projects: '
178
+ }
179
+ warning={
180
+ asTarget.length > 1
181
+ ? 'Importing translations from more than one project can overwrite each other on this document.'
182
+ : undefined
183
+ }
184
+ basePath={basePath}
185
+ projects={asTarget}
186
+ />
187
+ )}
188
+ </Stack>
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Wraps the document form for translatable types, rendering a status line above
194
+ * the fields when the document belongs to one or more Smartcat translation
195
+ * projects. Resolves live, so it updates as projects change.
196
+ */
197
+ export function createTranslationStatusInput(config: ResolvedSmartcatConfig) {
198
+ return function TranslationStatusInput(props: InputProps) {
199
+ // Only the document root of a translatable type — not nested fields. This
200
+ // input is registered globally, so the type check also keeps it off the
201
+ // plugin's own `smartcat.*` documents.
202
+ const isDocumentRoot = props.path.length === 0 && config.isTranslatableType(props.schemaType.name)
203
+ if (!isDocumentRoot) return props.renderDefault(props)
204
+
205
+ return (
206
+ <Stack space={5}>
207
+ <TranslationStatusLine mode={localizationMode(props.schemaType)} />
208
+ {props.renderDefault(props)}
209
+ </Stack>
210
+ )
211
+ }
212
+ }