@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.
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 +174 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +338 -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 +175 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +340 -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 +2172 -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 +2179 -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 +291 -0
  39. package/dist/locjson.d.ts +291 -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 +277 -0
  45. package/dist/progress.d.ts +277 -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 +274 -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 +34 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +615 -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 +157 -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 +31 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +54 -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 +409 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +290 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +268 -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 +46 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +1004 -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 +86 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +698 -0
  118. package/src/tool/processing.ts +839 -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,409 @@
1
+ import type {SmartcatProject, SmartcatDocument, RequestLogger} from '../smartcat/types'
2
+ import {toSanityLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'
3
+ import {ITEM_ID, itemDocDerefRespectingDraft} from '../lib/projectItems'
4
+ import type {LogLine} from '../lib/log'
5
+
6
+ /**
7
+ * Per-document, per-language, per-stage translation progress mirrored from
8
+ * Smartcat onto a `smartcat.translationProject`.
9
+ *
10
+ * This module is intentionally **dependency-free** (no DOM, no @sanity/* runtime)
11
+ * so it bundles cleanly into the thin `smartcat-progress` / `smartcat-import`
12
+ * Functions. All keys are deterministic, so repeated refreshes patch the same
13
+ * array members in place instead of churning `_key`s.
14
+ */
15
+
16
+ export interface StageProgress {
17
+ /** Stable key for the Sanity array item (always set by `stagesFromDocument`). */
18
+ _key?: string
19
+ name: string
20
+ /** 0–100, rounded. */
21
+ percent: number
22
+ }
23
+
24
+ export interface TargetProgress {
25
+ _key: string
26
+ language: string
27
+ /** Smartcat target document id (`<fileGuid>_<languageNumber>`). */
28
+ smartcatDocumentId?: string
29
+ stages: StageProgress[]
30
+ /** All stages at 100%. */
31
+ complete: boolean
32
+ /** A locale variant has been built from this target (set by the browser). */
33
+ imported: boolean
34
+ syncedAt?: string
35
+ }
36
+
37
+ export interface DocProgress {
38
+ _key: string
39
+ sourceDocId: string
40
+ title?: string
41
+ targets: TargetProgress[]
42
+ }
43
+
44
+ /** Sanitizes an arbitrary string into a stable, Sanity-safe array `_key`. */
45
+ export function keyify(value: string): string {
46
+ return value.replace(/[^a-zA-Z0-9_-]/g, '-') || 'x'
47
+ }
48
+
49
+ /** Builds a progress target's `_key` from its document + language. */
50
+ export function targetKey(sourceDocId: string, language: string): string {
51
+ return keyify(`${sourceDocId}__${language}`)
52
+ }
53
+
54
+ function clampPercent(value: number | undefined): number {
55
+ if (typeof value !== 'number' || Number.isNaN(value)) return 0
56
+ return Math.max(0, Math.min(100, Math.round(value)))
57
+ }
58
+
59
+ /** Strips a `drafts.` prefix from a Sanity document id. */
60
+ function stripDraft(id: string): string {
61
+ return id.replace(/^drafts\./, '')
62
+ }
63
+
64
+ function titleCase(value: string): string {
65
+ return value ? value.charAt(0).toUpperCase() + value.slice(1) : value
66
+ }
67
+
68
+ /**
69
+ * Builds a stage-id → display-name map from a project's `workflowStages`. On
70
+ * Smartcat, document-level stages carry only `{id, progress}` — the human name
71
+ * (`stageType`, e.g. "translation") lives on the project-level stages.
72
+ */
73
+ export function buildStageNameMap(scProject: SmartcatProject): Map<string, string> {
74
+ const map = new Map<string, string>()
75
+ ;(scProject.workflowStages ?? []).forEach((stage, i) => {
76
+ if (stage.id) map.set(stage.id, titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`))
77
+ })
78
+ return map
79
+ }
80
+
81
+ /** Maps a Smartcat document's workflow stages to display-ready stage progress. */
82
+ export function stagesFromDocument(
83
+ doc: SmartcatDocument,
84
+ stageNames: Map<string, string>,
85
+ ): StageProgress[] {
86
+ const stages = doc.workflowStages ?? []
87
+ return stages.map((stage, i) => ({
88
+ _key: stage.id || `s${i}`,
89
+ name:
90
+ (stage.id && stageNames.get(stage.id)) ||
91
+ titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`),
92
+ percent: clampPercent(stage.progress),
93
+ }))
94
+ }
95
+
96
+ /** A target is complete when it has stages and every one is at 100%. */
97
+ export function isTargetComplete(stages: StageProgress[]): boolean {
98
+ return stages.length > 0 && stages.every((s) => s.percent >= 100)
99
+ }
100
+
101
+ /**
102
+ * Whether a target has any content confirmed at any stage — i.e. there is
103
+ * something worth importing. Used to gate `mode=confirmed` downloads so we skip
104
+ * targets with nothing confirmed yet (which would otherwise come back as source).
105
+ */
106
+ export function hasConfirmedContent(stages: StageProgress[]): boolean {
107
+ return stages.some((s) => s.percent > 0)
108
+ }
109
+
110
+ /** Average completion across a target's stages (0–100). */
111
+ export function targetPercent(stages: StageProgress[]): number {
112
+ if (stages.length === 0) return 0
113
+ return Math.round(stages.reduce((sum, s) => sum + s.percent, 0) / stages.length)
114
+ }
115
+
116
+ /** A project item we correlate Smartcat documents back to. */
117
+ export interface ProjectItem {
118
+ _id: string
119
+ title?: string
120
+ }
121
+
122
+ /**
123
+ * Extracts the 8-char source-id prefix Smartcat preserves in a document's
124
+ * filename (built as `<title>-<idPrefix>.locjson`, or `<title>-<idPrefix>-draft.locjson`
125
+ * when the content came from the document's draft). Used to correlate a Smartcat
126
+ * document back to a Sanity source document.
127
+ *
128
+ * A trailing `-draft` marker is stripped first, so a draft-sourced file resolves
129
+ * to the same id as its published counterpart. The prefix is then always the
130
+ * final 8 characters before the (marker and) extension, so take the tail rather
131
+ * than splitting on a dash — the title or the id prefix itself can
132
+ * contain dashes (e.g. a custom id like `demo-pdpA` → prefix `demo-pdp`), which
133
+ * would make a last-dash split return the wrong fragment.
134
+ *
135
+ * Only a literal `.locjson` extension is stripped (Smartcat's API often returns
136
+ * the name without it). A generic strip-after-last-dot would eat the tail of any
137
+ * DOTTED title — e.g. micro-copy keys like `aiAssistantModal.resetChat-11AKrV8G`
138
+ * — leaving the wrong 8 chars, so those documents silently never correlate,
139
+ * never import, and re-syncs duplicate them instead of updating.
140
+ */
141
+ export function sourceIdPrefix(doc: SmartcatDocument): string {
142
+ const base = doc.name || doc.filename || doc.fullPath || ''
143
+ const leaf = base.split('/').pop() ?? ''
144
+ const noExt = leaf.replace(/\.locjson$/i, '')
145
+ // Draft-sourced files carry a trailing `-draft` marker (informational only, so
146
+ // a project never holds both a document's draft and its published version).
147
+ // Strip it before taking the tail so a draft file correlates to the same source
148
+ // id as its published counterpart — the id prefix is what identifies the item.
149
+ const noDraft = noExt.replace(/-draft$/, '')
150
+ return noDraft.slice(-8)
151
+ }
152
+
153
+ /**
154
+ * Builds the full progress array from a freshly fetched Smartcat project and the
155
+ * project's items — independent of any export-time skeleton, so it works for
156
+ * projects exported before progress tracking existed.
157
+ *
158
+ * Documents are correlated to items by the 8-char id prefix in their filename;
159
+ * `imported` flags are carried over from the previous progress (by source doc +
160
+ * language) so importing isn't forgotten on refresh.
161
+ *
162
+ * `mappings` invert Smartcat's returned target codes back to Sanity ids, so all
163
+ * stored progress uses Sanity locale tags. When omitted, codes pass through
164
+ * unchanged (identity) — keeping legacy projects working.
165
+ */
166
+ export function buildProgress(
167
+ items: ProjectItem[],
168
+ scProject: SmartcatProject,
169
+ previous: DocProgress[] | undefined,
170
+ now: string,
171
+ mappings?: SmartcatLanguageMapping[],
172
+ ): DocProgress[] {
173
+ const stageNames = buildStageNameMap(scProject)
174
+
175
+ const importedSet = new Set<string>()
176
+ // Titles are computed Sanity-native in the Studio and shipped on the outbox, so
177
+ // they may not be re-derivable server-side. Carry the previously-stored title
178
+ // forward so a progress sync (which only has the raw `doc.title`) can't clobber
179
+ // it with an "Untitled" for types that have no literal title field.
180
+ const titleByDoc = new Map<string, string>()
181
+ for (const doc of previous ?? []) {
182
+ if (doc.title) titleByDoc.set(doc.sourceDocId, doc.title)
183
+ for (const target of doc.targets) {
184
+ if (target.imported) importedSet.add(`${doc.sourceDocId}__${target.language}`)
185
+ }
186
+ }
187
+
188
+ const itemByPrefix = new Map<string, ProjectItem>()
189
+ for (const item of items) itemByPrefix.set(stripDraft(item._id).slice(0, 8), item)
190
+
191
+ const byDoc = new Map<string, DocProgress>()
192
+ for (const scDoc of scProject.documents ?? []) {
193
+ if (!scDoc.targetLanguage) continue
194
+ const item = itemByPrefix.get(sourceIdPrefix(scDoc))
195
+ if (!item) continue
196
+
197
+ // Store the Sanity locale tag, not Smartcat's code.
198
+ const language = toSanityLanguage(mappings, scDoc.targetLanguage)
199
+
200
+ let docProgress = byDoc.get(item._id)
201
+ if (!docProgress) {
202
+ const title = item.title || titleByDoc.get(item._id)
203
+ docProgress = {_key: targetKey(item._id, 'doc'), sourceDocId: item._id, title, targets: []}
204
+ byDoc.set(item._id, docProgress)
205
+ }
206
+ const stages = stagesFromDocument(scDoc, stageNames)
207
+ docProgress.targets.push({
208
+ _key: targetKey(item._id, language),
209
+ language,
210
+ smartcatDocumentId: scDoc.id,
211
+ stages,
212
+ complete: isTargetComplete(stages),
213
+ imported: importedSet.has(`${item._id}__${language}`),
214
+ syncedAt: now,
215
+ })
216
+ }
217
+
218
+ // Preserve item order; sort each doc's targets by language for stable display.
219
+ const result: DocProgress[] = []
220
+ for (const item of items) {
221
+ const docProgress = byDoc.get(item._id)
222
+ if (!docProgress) continue
223
+ docProgress.targets.sort((a, b) => a.language.localeCompare(b.language))
224
+ result.push(docProgress)
225
+ }
226
+ return result
227
+ }
228
+
229
+ export interface CompletionSummary {
230
+ /** Total (document × language) targets. */
231
+ total: number
232
+ /** Targets whose stages are all at 100%. */
233
+ complete: number
234
+ /** Targets a variant has been built for. */
235
+ imported: number
236
+ /** True when every target is complete and imported (and there is ≥1 target). */
237
+ allDone: boolean
238
+ /** True when at least one stage shows any progress. */
239
+ anyProgress: boolean
240
+ }
241
+
242
+ /** Summarizes a progress array for status decisions and the dashboard. */
243
+ export function summarizeCompletion(progress: DocProgress[]): CompletionSummary {
244
+ let total = 0
245
+ let complete = 0
246
+ let imported = 0
247
+ let anyProgress = false
248
+ for (const doc of progress) {
249
+ for (const target of doc.targets) {
250
+ total++
251
+ if (target.complete) complete++
252
+ if (target.imported) imported++
253
+ if (target.stages.some((s) => s.percent > 0)) anyProgress = true
254
+ }
255
+ }
256
+ return {
257
+ total,
258
+ complete,
259
+ imported,
260
+ allDone: total > 0 && complete === total && imported === total,
261
+ anyProgress,
262
+ }
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Function-side orchestration: refresh progress from Smartcat
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /** Minimal structural subset of @sanity/client used by the progress refresh. */
270
+ export interface SanityLikeClient {
271
+ fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>
272
+ patch(id: string): {
273
+ set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}
274
+ }
275
+ }
276
+
277
+ /** Structural subset of SmartcatClient the progress refresh needs. */
278
+ export interface SmartcatProgressClient {
279
+ getProject(projectId: string): Promise<SmartcatProject>
280
+ /** Optional: trace every HTTP call so a timeout/error reaches the functionLog. */
281
+ setRequestLogger?(logger: RequestLogger): void
282
+ }
283
+
284
+ export interface RunProgressSyncOptions {
285
+ sanity: SanityLikeClient
286
+ smartcat: SmartcatProgressClient
287
+ /** `_id` of the `smartcat.translationProject` to refresh. */
288
+ projectId: string
289
+ now?: () => string
290
+ }
291
+
292
+ export interface RunProgressSyncResult {
293
+ summary: CompletionSummary
294
+ /** Set when the Smartcat fetch failed (e.g. timed out); the run was recorded, not thrown. */
295
+ error?: string
296
+ }
297
+
298
+ const PROGRESS_QUERY = `*[_id == $id][0]{
299
+ _id,
300
+ status,
301
+ smartcatProjectId,
302
+ smartcatLanguages[]{sanityId, smartcatLanguage},
303
+ "items": items[]{ "_id": ${ITEM_ID}, "title": ${itemDocDerefRespectingDraft('.title')} },
304
+ progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
305
+ }`
306
+
307
+ interface ProgressProjectData {
308
+ _id: string
309
+ status?: string
310
+ smartcatProjectId?: string
311
+ smartcatLanguages?: SmartcatLanguageMapping[]
312
+ items?: ProjectItemRef[]
313
+ progress?: DocProgress[]
314
+ }
315
+
316
+ /** A project item as read for correlation: its stored id (always present) plus a
317
+ * best-effort title. `doc` is the legacy shape — some callers still project it. */
318
+ interface ProjectItemRef {
319
+ _id?: string
320
+ title?: string
321
+ doc?: {_id?: string; title?: string} | null
322
+ }
323
+
324
+ /**
325
+ * Resolves a project's items into the lightweight shape we correlate on. The id
326
+ * is the item's stored `docId` — NOT a dereferenced document — so an item whose
327
+ * document exists only as a draft (never published) still correlates instead of
328
+ * being silently dropped. Tolerates the legacy `{doc}` projection.
329
+ */
330
+ export function itemsFromProject(project: {items?: ProjectItemRef[]}): ProjectItem[] {
331
+ const result: ProjectItem[] = []
332
+ for (const item of project.items ?? []) {
333
+ const _id = item?._id ?? item?.doc?._id
334
+ if (!_id) continue
335
+ result.push({_id, title: item?.title ?? item?.doc?.title ?? undefined})
336
+ }
337
+ return result
338
+ }
339
+
340
+ /**
341
+ * Thin progress step run by the `smartcat-progress` Function: fetches the
342
+ * Smartcat project and mirrors its per-stage progress onto the project's
343
+ * `progress` array. Read-only with respect to Smartcat — it never downloads or
344
+ * creates variants, so it is cheap to run on every dashboard page load.
345
+ */
346
+ export async function runProgressSync(
347
+ options: RunProgressSyncOptions,
348
+ ): Promise<RunProgressSyncResult> {
349
+ const {sanity, smartcat, projectId, now = () => new Date().toISOString()} = options
350
+
351
+ const project = await sanity.fetch<ProgressProjectData | null>(PROGRESS_QUERY, {id: projectId})
352
+ if (!project) throw new Error(`Translation project ${projectId} not found`)
353
+ if (!project.smartcatProjectId) {
354
+ throw new Error('Project has no smartcatProjectId — export it first')
355
+ }
356
+
357
+ const timestamp = now()
358
+
359
+ // Trace HTTP failures into a functionLog so a timeout/error surfaces in the UI.
360
+ // Successful calls are left unlogged: this Function runs on every page load, and
361
+ // functionLog is shared with export/import — writing it on every success would
362
+ // wipe a useful export/import log.
363
+ const log: LogLine[] = []
364
+ smartcat.setRequestLogger?.(({method, path, status, body}) => {
365
+ if (status >= 200 && status < 300) return
366
+ log.push({
367
+ level: 'error',
368
+ message: status ? `Error ${status} while performing ${method} ${path}` : `${method} ${path} did not complete`,
369
+ })
370
+ if (body) log.push({level: 'error', indent: true, message: body})
371
+ })
372
+
373
+ try {
374
+ const scProject = await smartcat.getProject(project.smartcatProjectId)
375
+ const progress = buildProgress(
376
+ itemsFromProject(project),
377
+ scProject,
378
+ project.progress,
379
+ timestamp,
380
+ project.smartcatLanguages,
381
+ )
382
+ const summary = summarizeCompletion(progress)
383
+
384
+ const patch: Record<string, unknown> = {progress, progressSyncedAt: timestamp, lastError: null}
385
+ // Gentle one-way nudge: once Smartcat reports any work, reflect it in the badge.
386
+ // Completion remains owned by the import flow.
387
+ if (project.status === 'sent' && summary.anyProgress) {
388
+ patch.status = 'translating'
389
+ }
390
+
391
+ await sanity.patch(projectId).set(patch).commit()
392
+
393
+ return {summary}
394
+ } catch (err) {
395
+ // Record the failure and return instead of throwing: a throw would let the
396
+ // platform retry, and a timed-out fetch has nothing transient to gain. Leave
397
+ // the existing `progress` untouched (no zeroing-out) and only stamp the error
398
+ // plus a fresh `progressSyncedAt`, which releases the UI refresh spinner and
399
+ // makes the log land where the dashboard reads it.
400
+ const message = err instanceof Error ? err.message : String(err)
401
+ if (!log.some((l) => l.level === 'error')) log.push({level: 'error', message})
402
+ await sanity
403
+ .patch(projectId)
404
+ .set({lastError: message, functionLog: JSON.stringify(log), progressSyncedAt: timestamp})
405
+ .commit()
406
+ .catch(() => {})
407
+ return {summary: summarizeCompletion(project.progress ?? []), error: message}
408
+ }
409
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Public entry for the `./progress` subpath. The implementation lives in
3
+ * `./core` so the other build entries (`./export`, `./import`) can import the
4
+ * shared helpers via a deep path without pkg-utils treating one entry as an
5
+ * external of another.
6
+ */
7
+ export * from './core'