@smartcat/sanity-plugin 1.0.0 → 1.1.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/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 +174 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +338 -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 +175 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +340 -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 +2172 -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 +2179 -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 +291 -0
- package/dist/locjson.d.ts +291 -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 +277 -0
- package/dist/progress.d.ts +277 -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 +274 -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 +34 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +615 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +157 -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 +31 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +54 -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 +409 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +290 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +268 -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 +46 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +1004 -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 +86 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +698 -0
- package/src/tool/processing.ts +839 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- package/src/types.ts +77 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type {SmartcatDocument, SmartcatProject} from '../smartcat/types'
|
|
2
|
+
import {buildProgress, hasConfirmedContent, itemsFromProject, type DocProgress} from '../progress/core'
|
|
3
|
+
import {ITEM_ID, itemDocDerefRespectingDraft} from '../lib/projectItems'
|
|
4
|
+
import type {SmartcatLanguageMapping} from '../lib/languageMap'
|
|
5
|
+
import type {LogLine} from '../lib/log'
|
|
6
|
+
|
|
7
|
+
/** The Smartcat document's full path (folder + filename), extension stripped. */
|
|
8
|
+
function smartcatDocPath(scDoc: SmartcatDocument | undefined): string {
|
|
9
|
+
const raw = scDoc?.fullPath || scDoc?.name || scDoc?.filename || ''
|
|
10
|
+
return raw.replace(/\.[^./]+$/, '')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Minimal structural subset of @sanity/client used by the import download. */
|
|
14
|
+
export interface SanityLikeClient {
|
|
15
|
+
fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>
|
|
16
|
+
patch(id: string): {
|
|
17
|
+
set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Structural subset of SmartcatClient the import download needs. */
|
|
22
|
+
export interface SmartcatImportClient {
|
|
23
|
+
getProject(projectId: string): Promise<SmartcatProject>
|
|
24
|
+
exportDocument(documentId: string): Promise<string>
|
|
25
|
+
/**
|
|
26
|
+
* Batch export: one Smartcat task for many documents (ZIP result). Optional
|
|
27
|
+
* so custom/legacy clients keep working — without it every document falls
|
|
28
|
+
* back to a single {@link exportDocument} round-trip.
|
|
29
|
+
*/
|
|
30
|
+
exportDocumentsBatch?(documentIds: string[]): Promise<{filename: string; content: string}[]>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RunImportDownloadOptions {
|
|
34
|
+
sanity: SanityLikeClient
|
|
35
|
+
smartcat: SmartcatImportClient
|
|
36
|
+
/** `_id` of the `smartcat.translationProject` to import. */
|
|
37
|
+
projectId: string
|
|
38
|
+
now?: () => string
|
|
39
|
+
/** Documents per Smartcat batch-export task. */
|
|
40
|
+
batchSize?: number
|
|
41
|
+
/**
|
|
42
|
+
* Soft cap on the total LocJSON bytes stored in the project's `inbox` per
|
|
43
|
+
* run. The inbox lives on the Sanity project document, which has a practical
|
|
44
|
+
* size limit — targets beyond the cap are reported as `remaining` and picked
|
|
45
|
+
* up by the next import run.
|
|
46
|
+
*/
|
|
47
|
+
maxInboxBytes?: number
|
|
48
|
+
/** How many batch-export tasks run against Smartcat concurrently. */
|
|
49
|
+
concurrency?: number
|
|
50
|
+
/**
|
|
51
|
+
* Soft time budget for the download phase, in ms. Once exceeded, batches not
|
|
52
|
+
* yet started are deferred as `remaining` and the run exits cleanly — so the
|
|
53
|
+
* Function always finishes well before its hard platform timeout instead of
|
|
54
|
+
* being killed mid-run (which would leave the project stuck in "importing").
|
|
55
|
+
*/
|
|
56
|
+
timeBudgetMs?: number
|
|
57
|
+
/** Clock override for tests (ms). */
|
|
58
|
+
nowMs?: () => number
|
|
59
|
+
/**
|
|
60
|
+
* Receives every log line as it is emitted (in addition to the functionLog
|
|
61
|
+
* stored on the project document) — the Function forwards these to console
|
|
62
|
+
* so `sanity functions logs` shows the run without reading the document.
|
|
63
|
+
*/
|
|
64
|
+
onLog?: (line: LogLine) => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RunImportDownloadResult {
|
|
68
|
+
/** Number of complete targets downloaded into the inbox. */
|
|
69
|
+
downloaded: number
|
|
70
|
+
/** Number of targets skipped because they are not yet fully translated. */
|
|
71
|
+
skipped: number
|
|
72
|
+
/** Targets ready to download but deferred to the next run (inbox size cap). */
|
|
73
|
+
remaining: number
|
|
74
|
+
/** Targets already imported in a previous run and therefore not re-downloaded. */
|
|
75
|
+
alreadyImported: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_BATCH_SIZE = 50
|
|
79
|
+
/** Keeps the project document comfortably below Sanity's document size limits. */
|
|
80
|
+
const DEFAULT_MAX_INBOX_BYTES = 1_500_000
|
|
81
|
+
/**
|
|
82
|
+
* Parallel Smartcat export tasks. High enough to divide the download time ~4×,
|
|
83
|
+
* low enough not to crowd the account's export-task queue.
|
|
84
|
+
*/
|
|
85
|
+
const DEFAULT_CONCURRENCY = 4
|
|
86
|
+
/** Exit-early margin against the 15-minute Sanity Function timeout. */
|
|
87
|
+
const DEFAULT_TIME_BUDGET_MS = 10 * 60_000
|
|
88
|
+
|
|
89
|
+
const PROJECT_QUERY = `*[_id == $id][0]{
|
|
90
|
+
_id,
|
|
91
|
+
smartcatProjectId,
|
|
92
|
+
smartcatLanguages[]{sanityId, smartcatLanguage},
|
|
93
|
+
"items": items[]{ "_id": ${ITEM_ID}, "title": ${itemDocDerefRespectingDraft('.title')} },
|
|
94
|
+
progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
|
|
95
|
+
}`
|
|
96
|
+
|
|
97
|
+
interface ProjectData {
|
|
98
|
+
_id: string
|
|
99
|
+
smartcatProjectId?: string
|
|
100
|
+
smartcatLanguages?: SmartcatLanguageMapping[]
|
|
101
|
+
items?: {_id?: string; title?: string}[]
|
|
102
|
+
progress?: DocProgress[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface InboxItem {
|
|
106
|
+
_key: string
|
|
107
|
+
sourceDocId: string
|
|
108
|
+
targetLanguage: string
|
|
109
|
+
smartcatDocumentId: string
|
|
110
|
+
locjson: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A (document × language) target selected for download this run. */
|
|
114
|
+
interface ReadyTarget {
|
|
115
|
+
key: string
|
|
116
|
+
sourceDocId: string
|
|
117
|
+
language: string
|
|
118
|
+
smartcatDocumentId: string
|
|
119
|
+
label: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stripDraft(id: string): string {
|
|
123
|
+
return id.replace(/^drafts\./, '')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The Sanity source-document id a LocJSON carries in its `x-sanity` metadata. */
|
|
127
|
+
function sanityDocIdOf(locjson: string): string | undefined {
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(locjson) as {properties?: {'x-sanity'?: {documentId?: string}}}
|
|
130
|
+
return parsed.properties?.['x-sanity']?.documentId
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface BatchDownloadResult {
|
|
137
|
+
files: {target: ReadyTarget; locjson: string}[]
|
|
138
|
+
/** Targets fetched one-by-one because the batched call failed or its archive missed them. */
|
|
139
|
+
fallbackCount: number
|
|
140
|
+
/** The batched export call itself failed (the error line is already emitted). */
|
|
141
|
+
batchFailed: boolean
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Downloads a batch of same-language targets. Uses one batched export task
|
|
146
|
+
* (ZIP) when the client supports it, correlating each returned file back to
|
|
147
|
+
* its target via the LocJSON's own `x-sanity.documentId`. Targets the archive
|
|
148
|
+
* didn't cover — and the whole batch when the batched call fails — fall back
|
|
149
|
+
* to per-document exports, so a batch problem degrades to the old (slow but
|
|
150
|
+
* proven) path instead of failing the run. Every degradation is logged loudly:
|
|
151
|
+
* a silent fallback looks exactly like "batching deployed but nothing faster".
|
|
152
|
+
*/
|
|
153
|
+
async function downloadBatch(
|
|
154
|
+
smartcat: SmartcatImportClient,
|
|
155
|
+
batch: ReadyTarget[],
|
|
156
|
+
emit: (line: LogLine) => void,
|
|
157
|
+
): Promise<BatchDownloadResult> {
|
|
158
|
+
const contentByTarget = new Map<ReadyTarget, string>()
|
|
159
|
+
const batchAttempted = Boolean(smartcat.exportDocumentsBatch && batch.length > 1)
|
|
160
|
+
let batchFailed = false
|
|
161
|
+
|
|
162
|
+
if (batchAttempted) {
|
|
163
|
+
try {
|
|
164
|
+
const files = await smartcat.exportDocumentsBatch!(batch.map((t) => t.smartcatDocumentId))
|
|
165
|
+
const targetByDocId = new Map(batch.map((t) => [stripDraft(t.sourceDocId), t]))
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
const docId = sanityDocIdOf(file.content)
|
|
168
|
+
const target = docId ? targetByDocId.get(stripDraft(docId)) : undefined
|
|
169
|
+
if (target && !contentByTarget.has(target)) contentByTarget.set(target, file.content)
|
|
170
|
+
}
|
|
171
|
+
if (contentByTarget.size < batch.length) {
|
|
172
|
+
emit({
|
|
173
|
+
level: 'info',
|
|
174
|
+
message: `${batch.length - contentByTarget.size} of ${batch.length} file(s) missing from the batch archive — fetching them per-document`,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
batchFailed = true
|
|
179
|
+
emit({
|
|
180
|
+
level: 'error',
|
|
181
|
+
message: `Batch export of ${batch.length} document(s) failed — falling back to per-document export: ${err instanceof Error ? err.message : String(err)}`,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const files: {target: ReadyTarget; locjson: string}[] = []
|
|
187
|
+
let fallbackCount = 0
|
|
188
|
+
for (const target of batch) {
|
|
189
|
+
let locjson = contentByTarget.get(target)
|
|
190
|
+
if (locjson === undefined) {
|
|
191
|
+
locjson = await smartcat.exportDocument(target.smartcatDocumentId)
|
|
192
|
+
if (batchAttempted) fallbackCount++
|
|
193
|
+
}
|
|
194
|
+
files.push({target, locjson})
|
|
195
|
+
}
|
|
196
|
+
return {files, fallbackCount, batchFailed}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Thin import step run by a Sanity Function: refreshes per-stage progress from
|
|
201
|
+
* Smartcat, then downloads the ready targets into the project's `inbox` and
|
|
202
|
+
* sets status to `downloaded`. The browser turns the inbox into locale
|
|
203
|
+
* variants and decides final completion.
|
|
204
|
+
*
|
|
205
|
+
* Downloads happen in **batches** (one Smartcat export task per ~50 documents,
|
|
206
|
+
* returned as a ZIP), so run time no longer scales with per-document polling —
|
|
207
|
+
* large projects fit comfortably inside the Function timeout. The inbox is
|
|
208
|
+
* size-capped per run; overflow targets are reported as `remaining` and the
|
|
209
|
+
* next import run picks them up (already-imported targets are skipped).
|
|
210
|
+
*
|
|
211
|
+
* Targets with nothing confirmed are skipped, so no untranslated variants are
|
|
212
|
+
* created. Does no content processing, so it depends only on the Smartcat client.
|
|
213
|
+
*/
|
|
214
|
+
export async function runImportDownload(
|
|
215
|
+
options: RunImportDownloadOptions,
|
|
216
|
+
): Promise<RunImportDownloadResult> {
|
|
217
|
+
const {
|
|
218
|
+
sanity,
|
|
219
|
+
smartcat,
|
|
220
|
+
projectId,
|
|
221
|
+
now = () => new Date().toISOString(),
|
|
222
|
+
batchSize = DEFAULT_BATCH_SIZE,
|
|
223
|
+
maxInboxBytes = DEFAULT_MAX_INBOX_BYTES,
|
|
224
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
225
|
+
timeBudgetMs = DEFAULT_TIME_BUDGET_MS,
|
|
226
|
+
nowMs = Date.now,
|
|
227
|
+
onLog,
|
|
228
|
+
} = options
|
|
229
|
+
const log: LogLine[] = []
|
|
230
|
+
const emit = (line: LogLine) => {
|
|
231
|
+
log.push(line)
|
|
232
|
+
onLog?.(line)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const project = await sanity.fetch<ProjectData | null>(PROJECT_QUERY, {id: projectId})
|
|
236
|
+
if (!project) throw new Error(`Translation project ${projectId} not found`)
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (!project.smartcatProjectId) {
|
|
240
|
+
throw new Error('Project has no smartcatProjectId — export it first')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const timestamp = now()
|
|
244
|
+
const scProject = await smartcat.getProject(project.smartcatProjectId)
|
|
245
|
+
emit({level: 'info', message: 'Checking Smartcat for completed translations'})
|
|
246
|
+
// Look up each target's Smartcat document by its id, for full-path logging.
|
|
247
|
+
const scDocById = new Map((scProject.documents ?? []).filter((d) => d.id).map((d) => [d.id, d]))
|
|
248
|
+
|
|
249
|
+
// Rebuild progress from Smartcat + the project's items, so the dashboard
|
|
250
|
+
// reflects the latest state even for targets we don't import this round.
|
|
251
|
+
const progress = buildProgress(
|
|
252
|
+
itemsFromProject(project),
|
|
253
|
+
scProject,
|
|
254
|
+
project.progress,
|
|
255
|
+
timestamp,
|
|
256
|
+
project.smartcatLanguages,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
// Collect every target that has confirmed content (at any stage); targets
|
|
260
|
+
// with nothing confirmed are skipped. Targets already imported in an
|
|
261
|
+
// earlier run are not re-downloaded, so capped runs make forward progress.
|
|
262
|
+
const ready: ReadyTarget[] = []
|
|
263
|
+
let skipped = 0
|
|
264
|
+
let alreadyImported = 0
|
|
265
|
+
for (const docProgress of progress) {
|
|
266
|
+
for (const target of docProgress.targets) {
|
|
267
|
+
const id = target.smartcatDocumentId
|
|
268
|
+
// Prefer the Smartcat full path; fall back to the resolved title / source id.
|
|
269
|
+
const label =
|
|
270
|
+
(id && smartcatDocPath(scDocById.get(id))) || docProgress.title || docProgress.sourceDocId
|
|
271
|
+
if (!id || !hasConfirmedContent(target.stages)) {
|
|
272
|
+
if (id && !target.imported) {
|
|
273
|
+
skipped++
|
|
274
|
+
emit({level: 'skip', message: `${label} (Smartcat ID: ${id}) → ${target.language}: not ready, skipped`})
|
|
275
|
+
}
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (target.imported) {
|
|
279
|
+
alreadyImported++
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
ready.push({
|
|
283
|
+
key: target._key,
|
|
284
|
+
sourceDocId: docProgress.sourceDocId,
|
|
285
|
+
language: target.language,
|
|
286
|
+
smartcatDocumentId: id,
|
|
287
|
+
label,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Download in batches: one Smartcat export task per batch (ZIP result)
|
|
293
|
+
// instead of a request+poll round-trip per document. Batches are grouped
|
|
294
|
+
// by language so each source document appears at most once per archive.
|
|
295
|
+
const inbox: InboxItem[] = []
|
|
296
|
+
let inboxBytes = 0
|
|
297
|
+
let remaining = 0
|
|
298
|
+
const byLanguage = new Map<string, ReadyTarget[]>()
|
|
299
|
+
for (const target of ready) {
|
|
300
|
+
const group = byLanguage.get(target.language) ?? []
|
|
301
|
+
group.push(target)
|
|
302
|
+
byLanguage.set(target.language, group)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const batches: ReadyTarget[][] = []
|
|
306
|
+
for (const group of byLanguage.values()) {
|
|
307
|
+
for (let i = 0; i < group.length; i += batchSize) {
|
|
308
|
+
batches.push(group.slice(i, i + batchSize))
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Worker pool: up to `concurrency` batch-export tasks in flight at once —
|
|
313
|
+
// Smartcat assembles the archives in parallel, dividing the download time.
|
|
314
|
+
// Each worker re-checks the caps before claiming the next batch:
|
|
315
|
+
// - inbox byte cap: the inbox lives on the project document, which must
|
|
316
|
+
// stay below Sanity's document size limit;
|
|
317
|
+
// - time budget: exit cleanly before the platform kills the Function, so a
|
|
318
|
+
// slow Smartcat day degrades to "continue in the next run", never to a
|
|
319
|
+
// project stuck in "importing".
|
|
320
|
+
const effectiveConcurrency = Math.max(1, Math.min(concurrency, batches.length))
|
|
321
|
+
if (batches.length > 0) {
|
|
322
|
+
emit({
|
|
323
|
+
level: 'info',
|
|
324
|
+
message: `Downloading ${ready.length} target(s) in ${batches.length} batch(es) of up to ${batchSize}, ${effectiveConcurrency} in parallel`,
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
const startedAtMs = nowMs()
|
|
328
|
+
let deferredByBytes = 0
|
|
329
|
+
let deferredByTime = 0
|
|
330
|
+
let fallbackDownloads = 0
|
|
331
|
+
let failedBatches = 0
|
|
332
|
+
let nextBatch = 0
|
|
333
|
+
const worker = async () => {
|
|
334
|
+
while (nextBatch < batches.length) {
|
|
335
|
+
const index = nextBatch++
|
|
336
|
+
const batch = batches[index]
|
|
337
|
+
if (inboxBytes >= maxInboxBytes) {
|
|
338
|
+
deferredByBytes += batch.length
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (nowMs() - startedAtMs >= timeBudgetMs) {
|
|
342
|
+
deferredByTime += batch.length
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
const batchStartMs = nowMs()
|
|
346
|
+
const {files, fallbackCount, batchFailed} = await downloadBatch(smartcat, batch, emit)
|
|
347
|
+
fallbackDownloads += fallbackCount
|
|
348
|
+
if (batchFailed) failedBatches++
|
|
349
|
+
for (const {target, locjson} of files) {
|
|
350
|
+
inbox.push({
|
|
351
|
+
_key: target.key,
|
|
352
|
+
sourceDocId: target.sourceDocId,
|
|
353
|
+
targetLanguage: target.language,
|
|
354
|
+
smartcatDocumentId: target.smartcatDocumentId,
|
|
355
|
+
locjson,
|
|
356
|
+
})
|
|
357
|
+
inboxBytes += locjson.length
|
|
358
|
+
}
|
|
359
|
+
emit({
|
|
360
|
+
level: 'success',
|
|
361
|
+
message:
|
|
362
|
+
`Batch ${index + 1}/${batches.length} (${batch[0].language}): ${files.length} doc(s) in ` +
|
|
363
|
+
`${((nowMs() - batchStartMs) / 1000).toFixed(1)}s` +
|
|
364
|
+
`${fallbackCount ? ` — ${fallbackCount} via per-document fallback` : ''}`,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
await Promise.all(Array.from({length: effectiveConcurrency}, worker))
|
|
369
|
+
remaining = deferredByBytes + deferredByTime
|
|
370
|
+
|
|
371
|
+
// One line that answers "where did the time go and why was anything left
|
|
372
|
+
// behind" — the silent-degradation postmortems start here.
|
|
373
|
+
if (ready.length > 0) {
|
|
374
|
+
emit({
|
|
375
|
+
level: failedBatches || fallbackDownloads ? 'error' : 'info',
|
|
376
|
+
message:
|
|
377
|
+
`Download phase: ${((nowMs() - startedAtMs) / 1000).toFixed(1)}s — ` +
|
|
378
|
+
`${batches.length} batch(es), ${failedBatches} failed, ${fallbackDownloads} per-document fallback download(s); ` +
|
|
379
|
+
`deferred ${deferredByBytes} (inbox size cap) + ${deferredByTime} (time budget)`,
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
emit({
|
|
384
|
+
level: 'info',
|
|
385
|
+
message:
|
|
386
|
+
`Downloaded ${inbox.length} target(s)` +
|
|
387
|
+
`${skipped ? `, skipped ${skipped} not ready` : ''}` +
|
|
388
|
+
`${alreadyImported ? `, ${alreadyImported} already imported` : ''}` +
|
|
389
|
+
`${remaining ? `, ${remaining} deferred — run Import again to continue` : ''}`,
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
await sanity
|
|
393
|
+
.patch(projectId)
|
|
394
|
+
.set({
|
|
395
|
+
inbox,
|
|
396
|
+
progress,
|
|
397
|
+
progressSyncedAt: timestamp,
|
|
398
|
+
status: 'downloaded',
|
|
399
|
+
// Deferred-target count for the dashboard: after applying this batch it
|
|
400
|
+
// auto-continues the import instead of asking for another click.
|
|
401
|
+
importRemaining: remaining,
|
|
402
|
+
lastError: null,
|
|
403
|
+
functionLog: JSON.stringify(log),
|
|
404
|
+
})
|
|
405
|
+
.commit()
|
|
406
|
+
|
|
407
|
+
return {downloaded: inbox.length, skipped, remaining, alreadyImported}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
410
|
+
emit({level: 'error', message})
|
|
411
|
+
await sanity
|
|
412
|
+
.patch(projectId)
|
|
413
|
+
.set({status: 'error', lastError: message, lastImportAt: now(), functionLog: JSON.stringify(log)})
|
|
414
|
+
.commit()
|
|
415
|
+
.catch(() => {})
|
|
416
|
+
throw err
|
|
417
|
+
}
|
|
418
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {definePlugin} from 'sanity'
|
|
2
|
+
import {createTranslationProjectType} from './schema/translationProject'
|
|
3
|
+
import {createSettingsType} from './schema/settings'
|
|
4
|
+
import {createAddToProjectAction} from './actions/AddToProjectAction'
|
|
5
|
+
import {createTranslationStatusInput} from './form/TranslationStatusInput'
|
|
6
|
+
import {createTranslationsTool} from './tool'
|
|
7
|
+
import {StudioInit} from './tool/StudioInit'
|
|
8
|
+
import {resolveConfig} from './lib/resolveConfig'
|
|
9
|
+
import type {SmartcatPluginConfig} from './types'
|
|
10
|
+
|
|
11
|
+
export type {SmartcatPluginConfig, SmartcatLanguage, TranslationProjectStatus} from './types'
|
|
12
|
+
// Re-exported so the `declare module 'sanity'` augmentation (adding
|
|
13
|
+
// `options.smartcatTranslation`) is loaded for consumers of the plugin types.
|
|
14
|
+
export type {SmartcatTranslationSchemaOptions} from './schema/translatableOptions'
|
|
15
|
+
export {TRANSLATION_PROJECT_TYPE} from './lib/constants'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Smartcat translation plugin for Sanity Studio.
|
|
19
|
+
*
|
|
20
|
+
* - Adds a `smartcat.translationProject` document type (a named set of items).
|
|
21
|
+
* - Adds an "Add to translation project" document action on the configured
|
|
22
|
+
* `translatableTypes`.
|
|
23
|
+
* - Shows a status line on those types when the document belongs to one or more
|
|
24
|
+
* translation projects.
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
export const smartcatTranslation = definePlugin<SmartcatPluginConfig>((config) => {
|
|
29
|
+
// Resolve all config defaults once, here at the boundary; everything below
|
|
30
|
+
// receives concrete values.
|
|
31
|
+
const resolved = resolveConfig(config)
|
|
32
|
+
const projectType = createTranslationProjectType()
|
|
33
|
+
const settingsType = createSettingsType()
|
|
34
|
+
const addToProjectAction = createAddToProjectAction(resolved)
|
|
35
|
+
const translationStatusInput = createTranslationStatusInput(resolved)
|
|
36
|
+
const translationsTool = createTranslationsTool(resolved)
|
|
37
|
+
|
|
38
|
+
// The plugin's customizations apply to translatable types (all types by
|
|
39
|
+
// default), but never to the plugin's own `smartcat.*` documents.
|
|
40
|
+
const isTranslatable = resolved.isTranslatableType
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
name: 'sanity-plugin-smartcat-translation',
|
|
44
|
+
schema: {
|
|
45
|
+
types: [projectType, settingsType],
|
|
46
|
+
},
|
|
47
|
+
studio: {
|
|
48
|
+
components: {
|
|
49
|
+
// Bootstraps the template cache on first Studio load.
|
|
50
|
+
layout: StudioInit,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
tools: [translationsTool],
|
|
54
|
+
document: {
|
|
55
|
+
actions: (prev, context) => (isTranslatable(context.schemaType) ? [...prev, addToProjectAction] : prev),
|
|
56
|
+
},
|
|
57
|
+
form: {
|
|
58
|
+
components: {
|
|
59
|
+
input: translationStatusInput,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document type for a translation project (a named set of items to translate).
|
|
3
|
+
*
|
|
4
|
+
* @public
|
|
5
|
+
*/
|
|
6
|
+
export const TRANSLATION_PROJECT_TYPE = 'smartcat.translationProject'
|
|
7
|
+
|
|
8
|
+
/** Singleton document holding workspace-wide Smartcat settings (template cache). */
|
|
9
|
+
export const SETTINGS_TYPE = 'smartcat.settings'
|
|
10
|
+
|
|
11
|
+
/** Fixed `_id` of the settings singleton. */
|
|
12
|
+
export const SETTINGS_DOC_ID = 'smartcat.settings'
|
|
13
|
+
|
|
14
|
+
/** Sanity API version used by the plugin's client calls. */
|
|
15
|
+
export const API_VERSION = '2025-02-01'
|
|
16
|
+
|
|
17
|
+
/** Resolves the configured source language, failing if it's not set. */
|
|
18
|
+
export function requireSourceLanguage(sourceLanguage: string | undefined): string {
|
|
19
|
+
if (!sourceLanguage) {
|
|
20
|
+
throw new Error('Smartcat plugin: `sourceLanguage` is not configured')
|
|
21
|
+
}
|
|
22
|
+
return sourceLanguage
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import {Schema} from '@sanity/schema'
|
|
3
|
+
import {resolveDocumentTitle} from './documentTitle'
|
|
4
|
+
|
|
5
|
+
// A `page` whose title is a field-level internationalized array and whose preview
|
|
6
|
+
// selects a plain-string `internalName` — the shape that crashed React #31.
|
|
7
|
+
const schema = Schema.compile({
|
|
8
|
+
name: 'test',
|
|
9
|
+
types: [
|
|
10
|
+
{name: 'internationalizedArrayStringValue', type: 'object', fields: [{name: 'value', type: 'string'}]},
|
|
11
|
+
{name: 'internationalizedArrayString', type: 'array', of: [{type: 'internationalizedArrayStringValue'}]},
|
|
12
|
+
{
|
|
13
|
+
name: 'page',
|
|
14
|
+
type: 'document',
|
|
15
|
+
fields: [
|
|
16
|
+
{name: 'internalName', type: 'string'},
|
|
17
|
+
{name: 'title', type: 'internationalizedArrayString'},
|
|
18
|
+
],
|
|
19
|
+
preview: {select: {title: 'internalName'}},
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const localizedTitle = [
|
|
25
|
+
{_key: 'en', _type: 'internationalizedArrayStringValue', language: 'en', value: 'About us'},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
describe('resolveDocumentTitle', () => {
|
|
29
|
+
it('returns the schema preview title, never the localized array', () => {
|
|
30
|
+
const title = resolveDocumentTitle(schema, {_type: 'page', internalName: 'About', title: localizedTitle})
|
|
31
|
+
expect(title).toBe('About')
|
|
32
|
+
expect(typeof title).toBe('string')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('returns undefined (not the array object) when no string title resolves', () => {
|
|
36
|
+
const title = resolveDocumentTitle(schema, {_type: 'page', title: localizedTitle})
|
|
37
|
+
expect(title).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns undefined for an unknown type', () => {
|
|
41
|
+
expect(resolveDocumentTitle(schema, {_type: 'nope'})).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {prepareForPreview} from 'sanity'
|
|
2
|
+
|
|
3
|
+
/** Fallback label for a document whose schema preview yields no title. */
|
|
4
|
+
export const UNTITLED = 'Untitled'
|
|
5
|
+
|
|
6
|
+
/** Minimal schema shape: just enough to look a type up by name. */
|
|
7
|
+
export interface TitleSchema {
|
|
8
|
+
get(typeName: string): unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Document display title, resolved the Sanity-native way — by running the schema
|
|
13
|
+
* type's `preview.prepare` (same as the Studio list/structure tree) — so types
|
|
14
|
+
* without a literal `title` field (e.g. field-level i18n) get a real label. Always
|
|
15
|
+
* returns a string or `undefined`, never a raw field value (an internationalized
|
|
16
|
+
* array is an object that crashes React #31 if rendered as a child).
|
|
17
|
+
*/
|
|
18
|
+
export function resolveDocumentTitle(
|
|
19
|
+
schema: TitleSchema,
|
|
20
|
+
doc: Record<string, unknown> & {_type: string},
|
|
21
|
+
): string | undefined {
|
|
22
|
+
const type = schema.get(doc._type)
|
|
23
|
+
if (!type) return undefined
|
|
24
|
+
try {
|
|
25
|
+
const preview = prepareForPreview(doc as never, type as never)
|
|
26
|
+
return typeof preview?.title === 'string' ? preview.title : undefined
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildLanguageMappings,
|
|
4
|
+
findDuplicateSmartcatLanguages,
|
|
5
|
+
toSanityLanguage,
|
|
6
|
+
toSmartcatLanguage,
|
|
7
|
+
} from './languageMap'
|
|
8
|
+
|
|
9
|
+
const LANGUAGES = [
|
|
10
|
+
{id: 'en', title: 'English'},
|
|
11
|
+
{id: 'fr', title: 'French'},
|
|
12
|
+
{id: 'PL-PL', title: 'Polish', smartcatLanguage: 'pl'},
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
describe('buildLanguageMappings', () => {
|
|
16
|
+
it('resolves each id to its smartcatLanguage, defaulting to the id', () => {
|
|
17
|
+
expect(buildLanguageMappings(LANGUAGES, ['en', 'PL-PL'])).toEqual([
|
|
18
|
+
{sanityId: 'en', smartcatLanguage: 'en'},
|
|
19
|
+
{sanityId: 'PL-PL', smartcatLanguage: 'pl'},
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('falls back to the id for languages not in the config', () => {
|
|
24
|
+
expect(buildLanguageMappings(LANGUAGES, ['de'])).toEqual([{sanityId: 'de', smartcatLanguage: 'de'}])
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('toSmartcatLanguage / toSanityLanguage', () => {
|
|
29
|
+
const mappings = buildLanguageMappings(LANGUAGES, ['en', 'PL-PL'])
|
|
30
|
+
|
|
31
|
+
it('maps forward and inverse', () => {
|
|
32
|
+
expect(toSmartcatLanguage(mappings, 'PL-PL')).toBe('pl')
|
|
33
|
+
expect(toSanityLanguage(mappings, 'pl')).toBe('PL-PL')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('is identity for unmapped values or no mapping', () => {
|
|
37
|
+
expect(toSmartcatLanguage(mappings, 'de')).toBe('de')
|
|
38
|
+
expect(toSanityLanguage(mappings, 'de')).toBe('de')
|
|
39
|
+
expect(toSmartcatLanguage(undefined, 'fr')).toBe('fr')
|
|
40
|
+
expect(toSanityLanguage(undefined, 'fr')).toBe('fr')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('findDuplicateSmartcatLanguages', () => {
|
|
45
|
+
it('returns nothing when all codes are distinct', () => {
|
|
46
|
+
expect(findDuplicateSmartcatLanguages(buildLanguageMappings(LANGUAGES, ['en', 'fr', 'PL-PL']))).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('groups Sanity ids that collapse to the same code', () => {
|
|
50
|
+
const dups = findDuplicateSmartcatLanguages([
|
|
51
|
+
{sanityId: 'PL-PL', smartcatLanguage: 'pl'},
|
|
52
|
+
{sanityId: 'pl', smartcatLanguage: 'pl'},
|
|
53
|
+
])
|
|
54
|
+
expect(dups).toEqual([{smartcatLanguage: 'pl', sanityIds: ['PL-PL', 'pl']}])
|
|
55
|
+
})
|
|
56
|
+
})
|