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