@smartcat/sanity-plugin 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +294 -0
  2. package/dist/_chunks-cjs/constants.cjs +13 -0
  3. package/dist/_chunks-cjs/constants.cjs.map +1 -0
  4. package/dist/_chunks-cjs/index.cjs +168 -0
  5. package/dist/_chunks-cjs/index.cjs.map +1 -0
  6. package/dist/_chunks-cjs/index2.cjs +311 -0
  7. package/dist/_chunks-cjs/index2.cjs.map +1 -0
  8. package/dist/_chunks-cjs/workflow.cjs +16 -0
  9. package/dist/_chunks-cjs/workflow.cjs.map +1 -0
  10. package/dist/_chunks-es/constants.js +14 -0
  11. package/dist/_chunks-es/constants.js.map +1 -0
  12. package/dist/_chunks-es/index.js +169 -0
  13. package/dist/_chunks-es/index.js.map +1 -0
  14. package/dist/_chunks-es/index2.js +313 -0
  15. package/dist/_chunks-es/index2.js.map +1 -0
  16. package/dist/_chunks-es/workflow.js +17 -0
  17. package/dist/_chunks-es/workflow.js.map +1 -0
  18. package/dist/export.cjs +160 -0
  19. package/dist/export.cjs.map +1 -0
  20. package/dist/export.d.cts +162 -0
  21. package/dist/export.d.ts +162 -0
  22. package/dist/export.js +162 -0
  23. package/dist/export.js.map +1 -0
  24. package/dist/import.cjs +169 -0
  25. package/dist/import.cjs.map +1 -0
  26. package/dist/import.d.cts +155 -0
  27. package/dist/import.d.ts +155 -0
  28. package/dist/import.js +169 -0
  29. package/dist/import.js.map +1 -0
  30. package/dist/index.cjs +2103 -0
  31. package/dist/index.cjs.map +1 -0
  32. package/dist/index.d.cts +122 -0
  33. package/dist/index.d.ts +122 -0
  34. package/dist/index.js +2110 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/locjson.cjs +12 -0
  37. package/dist/locjson.cjs.map +1 -0
  38. package/dist/locjson.d.cts +287 -0
  39. package/dist/locjson.d.ts +287 -0
  40. package/dist/locjson.js +12 -0
  41. package/dist/locjson.js.map +1 -0
  42. package/dist/progress.cjs +16 -0
  43. package/dist/progress.cjs.map +1 -0
  44. package/dist/progress.d.cts +262 -0
  45. package/dist/progress.d.ts +262 -0
  46. package/dist/progress.js +16 -0
  47. package/dist/progress.js.map +1 -0
  48. package/dist/smartcat.cjs +287 -0
  49. package/dist/smartcat.cjs.map +1 -0
  50. package/dist/smartcat.d.cts +234 -0
  51. package/dist/smartcat.d.ts +234 -0
  52. package/dist/smartcat.js +287 -0
  53. package/dist/smartcat.js.map +1 -0
  54. package/dist/templates.cjs +12 -0
  55. package/dist/templates.cjs.map +1 -0
  56. package/dist/templates.d.cts +52 -0
  57. package/dist/templates.d.ts +52 -0
  58. package/dist/templates.js +12 -0
  59. package/dist/templates.js.map +1 -0
  60. package/package.json +101 -15
  61. package/src/actions/AddToProjectAction.tsx +258 -0
  62. package/src/export/export.test.ts +537 -0
  63. package/src/export/index.ts +393 -0
  64. package/src/form/TranslationStatusInput.tsx +212 -0
  65. package/src/import/import.test.ts +346 -0
  66. package/src/import/index.ts +418 -0
  67. package/src/index.ts +63 -0
  68. package/src/lib/constants.ts +23 -0
  69. package/src/lib/documentTitle.test.ts +43 -0
  70. package/src/lib/documentTitle.ts +30 -0
  71. package/src/lib/languageMap.test.ts +56 -0
  72. package/src/lib/languageMap.ts +71 -0
  73. package/src/lib/linkedDocuments.test.ts +56 -0
  74. package/src/lib/linkedDocuments.ts +100 -0
  75. package/src/lib/locjson/deserialize.ts +60 -0
  76. package/src/lib/locjson/fields.ts +355 -0
  77. package/src/lib/locjson/filename.ts +27 -0
  78. package/src/lib/locjson/index.ts +10 -0
  79. package/src/lib/locjson/locjson.test.ts +545 -0
  80. package/src/lib/locjson/paths.test.ts +41 -0
  81. package/src/lib/locjson/paths.ts +73 -0
  82. package/src/lib/locjson/portableText.ts +98 -0
  83. package/src/lib/locjson/serialize.ts +124 -0
  84. package/src/lib/locjson/types.ts +106 -0
  85. package/src/lib/log.ts +33 -0
  86. package/src/lib/projectItems.ts +17 -0
  87. package/src/lib/resolveConfig.test.ts +62 -0
  88. package/src/lib/resolveConfig.ts +47 -0
  89. package/src/lib/workflow.test.ts +37 -0
  90. package/src/lib/workflow.ts +48 -0
  91. package/src/lib/zip.fixtures.ts +75 -0
  92. package/src/lib/zip.test.ts +110 -0
  93. package/src/lib/zip.ts +164 -0
  94. package/src/progress/core.ts +387 -0
  95. package/src/progress/index.ts +7 -0
  96. package/src/progress/progress.test.ts +268 -0
  97. package/src/schema/settings.ts +55 -0
  98. package/src/schema/translatableOptions.ts +25 -0
  99. package/src/schema/translationProject.ts +264 -0
  100. package/src/smartcat/client.test.ts +222 -0
  101. package/src/smartcat/client.ts +351 -0
  102. package/src/smartcat/index.ts +10 -0
  103. package/src/smartcat/types.ts +99 -0
  104. package/src/templates/core.ts +55 -0
  105. package/src/templates/index.ts +5 -0
  106. package/src/templates/templates.test.ts +50 -0
  107. package/src/tool/Dashboard.tsx +44 -0
  108. package/src/tool/ItemProgress.tsx +176 -0
  109. package/src/tool/LogPanel.tsx +207 -0
  110. package/src/tool/ProjectView.tsx +975 -0
  111. package/src/tool/ProjectsView.tsx +208 -0
  112. package/src/tool/StatusBadge.tsx +33 -0
  113. package/src/tool/StudioInit.tsx +42 -0
  114. package/src/tool/WorkflowSelect.tsx +41 -0
  115. package/src/tool/data.ts +79 -0
  116. package/src/tool/index.ts +19 -0
  117. package/src/tool/processing.test.ts +623 -0
  118. package/src/tool/processing.ts +766 -0
  119. package/src/tool/useTemplates.ts +84 -0
  120. package/src/tool/useWorkflowSelection.ts +65 -0
  121. package/src/types.ts +77 -0
@@ -0,0 +1,110 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {isZip, unzip, unzipText} from './zip'
3
+ import {buildZip} from './zip.fixtures'
4
+
5
+ /**
6
+ * A REAL Smartcat-format export archive: generated with the exact library and
7
+ * settings production uses — Smartcat.Fork.SharpZipLib 1.5.1, `ZipOutputStream
8
+ * { UseZip64 = On }`, streamed entries with `IsUnicodeText = true` (see
9
+ * ExportDocumentsProcessor.cs:1525 in the smartcat repo). Forced ZIP64 means
10
+ * every central-directory entry carries 0xFFFFFFFF size placeholders with the
11
+ * real values in ZIP64 extra fields — the format that a non-ZIP64 reader fails
12
+ * on (which silently degraded every batch to per-document fallback in v1).
13
+ */
14
+ const SMARTCAT_ZIP64_ARCHIVE_B64 =
15
+ 'UEsDBC0AAAgIAAeK6FzGlJGm//////////8iABQAcGFnZS9BYm91dC1hYmMxMjM0NShmci1GUikubG9janNvbgEAEAB8AAAAAAAA' +
16
+ 'AGwAAAAAAAAAFYsxDsMgEAT/sjUukjiNS1fJGyIKgk8RNuEsOKREiL/7vNVqZ7Zhz7xTlkAFU8NvKC4F+Z99YV+/lOS5YIJ7+8v1' +
17
+ 'Nt4H0qB3g6qefl4NG6kPCRIJBoVr9qQAD4qRYQ3E5Q/JOc2cVuWw3fYDUEsDBC0AAAgIAAeK6Fxyl2Ox//////////8iABQAcGFn' +
18
+ 'ZS9Iw7ZtZS1kZWY2Nzg5MChmci1GUikubG9janNvbgEAEAB7AAAAAAAAAHEAAAAAAAAAFYsxDsIwEAT/srUjUZGQH1BQp0AuotwF' +
19
+ 'WQQ7Op8lkOVv8QE+xmWr1exsxS5pZ9HAGWPFu8tzDPo5OqWlvDjqlTCCeD33w+XUrRa05lDMs8+94snmQ4NuDIeciixsA6YkG8E7' +
20
+ '6CwP1gPdft9IDN98+wNQSwMELQAACAgAB4roXGMJtzf//////////ysAFAByZXNwb25zaXZlSW1hZ2UvQWx0LTk5ODg3NzY2KGZy' +
21
+ 'LUZSKS5sb2Nqc29uAQAQAEUAAAAAAAAAQQAAAAAAAACrViooyi9ILSrJTC1WsqpWqtAtTszLLKkEsVPyk0tzU/NKPFOUrJQsLS0s' +
22
+ 'zM3NzHQTgUCptlZHqRSoDqgnOrYWAFBLAQIzAC0AAAgIAAeK6FzGlJGm//////////8iABQAAAAAAAAAAAAAAAAAAABwYWdlL0Fi' +
23
+ 'b3V0LWFiYzEyMzQ1KGZyLUZSKS5sb2Nqc29uAQAQAHwAAAAAAAAAbAAAAAAAAABQSwECMwAtAAAICAAHiuhccpdjsf//////////' +
24
+ 'IgAUAAAAAAAAAAAAAADAAAAAcGFnZS9Iw7ZtZS1kZWY2Nzg5MChmci1GUikubG9janNvbgEAEAB7AAAAAAAAAHEAAAAAAAAAUEsB' +
25
+ 'AjMALQAACAgAB4roXGMJtzf//////////ysAFAAAAAAAAAAAAAAAhQEAAHJlc3BvbnNpdmVJbWFnZS9BbHQtOTk4ODc3NjYoZnIt' +
26
+ 'RlIpLmxvY2pzb24BABAARQAAAAAAAABBAAAAAAAAAFBLBQYAAAAAAwADADUBAAAjAgAAAAA='
27
+
28
+ function fromBase64(b64: string): Uint8Array {
29
+ const bin = atob(b64)
30
+ const out = new Uint8Array(bin.length)
31
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
32
+ return out
33
+ }
34
+
35
+ describe('isZip', () => {
36
+ it('recognizes the PK magic', async () => {
37
+ const zip = await buildZip([{name: 'a.txt', content: 'hello'}])
38
+ expect(isZip(zip)).toBe(true)
39
+ })
40
+
41
+ it('rejects plain text and short payloads', () => {
42
+ expect(isZip(new TextEncoder().encode('{"units":[]}'))).toBe(false)
43
+ expect(isZip(new Uint8Array([0x50, 0x4b]))).toBe(false)
44
+ })
45
+ })
46
+
47
+ describe('unzip', () => {
48
+ it('parses a real Smartcat production-format archive (SharpZipLib, forced ZIP64)', async () => {
49
+ const bytes = fromBase64(SMARTCAT_ZIP64_ARCHIVE_B64)
50
+ expect(isZip(bytes)).toBe(true)
51
+
52
+ const entries = await unzipText(bytes)
53
+ expect(entries.map((e) => e.name)).toEqual([
54
+ 'page/About-abc12345(fr-FR).locjson',
55
+ 'page/Höme-def67890(fr-FR).locjson',
56
+ 'responsiveImage/Alt-99887766(fr-FR).locjson',
57
+ ])
58
+ // Contents parse as LocJSON and carry the x-sanity correlation metadata.
59
+ const first = JSON.parse(entries[0].content)
60
+ expect(first.properties['x-sanity'].documentId).toBe('abc12345-eeee')
61
+ expect(first.units[0].target).toEqual(['Bonjour'])
62
+ const second = JSON.parse(entries[1].content)
63
+ expect(second.properties['x-sanity'].documentId).toBe('def67890-ffff')
64
+ expect(second.units[0].target).toEqual(['Mönde'])
65
+ const third = JSON.parse(entries[2].content)
66
+ expect(third.properties['x-sanity'].documentId).toBe('99887766-aaaa')
67
+ })
68
+
69
+ it('extracts stored entries', async () => {
70
+ const zip = await buildZip([
71
+ {name: 'page/About-abc12345(fr).locjson', content: '{"a":1}'},
72
+ {name: 'page/Home-def67890(fr).locjson', content: '{"b":2}'},
73
+ ])
74
+ const entries = await unzipText(zip)
75
+ expect(entries).toEqual([
76
+ {name: 'page/About-abc12345(fr).locjson', content: '{"a":1}'},
77
+ {name: 'page/Home-def67890(fr).locjson', content: '{"b":2}'},
78
+ ])
79
+ })
80
+
81
+ it('inflates deflate-compressed entries', async () => {
82
+ const long = 'translated content '.repeat(200)
83
+ const zip = await buildZip([{name: 'doc.locjson', content: long, method: 8}])
84
+ const entries = await unzipText(zip)
85
+ expect(entries).toHaveLength(1)
86
+ expect(entries[0].content).toBe(long)
87
+ })
88
+
89
+ it('skips directory entries', async () => {
90
+ const zip = await buildZip([
91
+ {name: 'page/', content: ''},
92
+ {name: 'page/About.locjson', content: '{}'},
93
+ ])
94
+ const entries = await unzip(zip)
95
+ expect(entries.map((e) => e.name)).toEqual(['page/About.locjson'])
96
+ })
97
+
98
+ it('preserves non-ASCII entry names and contents', async () => {
99
+ const zip = await buildZip([{name: 'sida/Åh nej-abc12345(sv).locjson', content: '{"t":"översättning"}', method: 8}])
100
+ const entries = await unzipText(zip)
101
+ expect(entries[0].name).toBe('sida/Åh nej-abc12345(sv).locjson')
102
+ expect(entries[0].content).toBe('{"t":"översättning"}')
103
+ })
104
+
105
+ it('throws on payloads without an end-of-central-directory record', async () => {
106
+ await expect(unzip(new TextEncoder().encode('PK\x03\x04 not really a zip'))).rejects.toThrow(
107
+ /end-of-central-directory/,
108
+ )
109
+ })
110
+ })
package/src/lib/zip.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Minimal ZIP reader for Smartcat batch-export archives.
3
+ *
4
+ * Dependency-free by design (like the rest of the Function-side code): built on
5
+ * `DataView` + the global `DecompressionStream` (Node 18+ / browsers). Supports
6
+ * the two compression methods Smartcat archives use — stored (0) and deflate (8).
7
+ *
8
+ * ZIP64 is REQUIRED, not an edge case: Smartcat writes export archives with
9
+ * SharpZipLib's `UseZip64 = On` (forced), so central-directory size/offset
10
+ * fields hold `0xFFFFFFFF` placeholders with the real 64-bit values in each
11
+ * entry's ZIP64 extra field — even for tiny archives. Large archives
12
+ * additionally use the ZIP64 end-of-central-directory record.
13
+ */
14
+
15
+ export interface ZipEntry {
16
+ /** Entry path inside the archive (folders separated by `/`). */
17
+ name: string
18
+ data: Uint8Array
19
+ }
20
+
21
+ const LOCAL_HEADER_SIG = 0x04034b50
22
+ const CENTRAL_HEADER_SIG = 0x02014b50
23
+ const EOCD_SIG = 0x06054b50
24
+ const ZIP64_EOCD_SIG = 0x06064b50
25
+ const ZIP64_EOCD_LOCATOR_SIG = 0x07064b50
26
+ /** EOCD record is 22 bytes + an optional comment of up to 65535 bytes. */
27
+ const EOCD_MIN_SIZE = 22
28
+ /** ZIP64 EOCD locator is a fixed 20 bytes, sitting right before the classic EOCD. */
29
+ const ZIP64_LOCATOR_SIZE = 20
30
+ /** Placeholder meaning "the real value is in the ZIP64 extra field / record". */
31
+ const U32_MAX = 0xffffffff
32
+ const U16_MAX = 0xffff
33
+ /** Extra-field header id of the ZIP64 extended information block. */
34
+ const ZIP64_EXTRA_ID = 0x0001
35
+
36
+ /** Whether the payload looks like a ZIP archive (`PK\x03\x04` magic). */
37
+ export function isZip(bytes: Uint8Array): boolean {
38
+ return (
39
+ bytes.length >= 4 &&
40
+ bytes[0] === 0x50 &&
41
+ bytes[1] === 0x4b &&
42
+ bytes[2] === 0x03 &&
43
+ bytes[3] === 0x04
44
+ )
45
+ }
46
+
47
+ /** Extracts all file entries (directories are skipped). */
48
+ export async function unzip(bytes: Uint8Array): Promise<ZipEntry[]> {
49
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
50
+ const eocd = findEndOfCentralDirectory(view)
51
+ const entries: ZipEntry[] = []
52
+
53
+ let offset = eocd.centralDirectoryOffset
54
+ for (let i = 0; i < eocd.entryCount; i++) {
55
+ if (view.getUint32(offset, true) !== CENTRAL_HEADER_SIG) {
56
+ throw new Error(`Invalid ZIP: bad central directory entry at ${offset}`)
57
+ }
58
+ const method = view.getUint16(offset + 10, true)
59
+ let compressedSize: number = view.getUint32(offset + 20, true)
60
+ let uncompressedSize: number = view.getUint32(offset + 24, true)
61
+ const nameLength = view.getUint16(offset + 28, true)
62
+ const extraLength = view.getUint16(offset + 30, true)
63
+ const commentLength = view.getUint16(offset + 32, true)
64
+ let localHeaderOffset: number = view.getUint32(offset + 42, true)
65
+ const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength))
66
+
67
+ // ZIP64: any field at its 32-bit max is a placeholder; the real value lives
68
+ // in the entry's ZIP64 extra block, in a fixed order, holding ONLY the
69
+ // fields that overflowed (Smartcat's forced-ZIP64 writer always defers the
70
+ // sizes this way, even for small files).
71
+ if (compressedSize === U32_MAX || uncompressedSize === U32_MAX || localHeaderOffset === U32_MAX) {
72
+ const extraStart = offset + 46 + nameLength
73
+ const extraEnd = extraStart + extraLength
74
+ let p = extraStart
75
+ while (p + 4 <= extraEnd) {
76
+ const id = view.getUint16(p, true)
77
+ const size = view.getUint16(p + 2, true)
78
+ if (id === ZIP64_EXTRA_ID) {
79
+ let q = p + 4
80
+ if (uncompressedSize === U32_MAX) {
81
+ uncompressedSize = Number(view.getBigUint64(q, true))
82
+ q += 8
83
+ }
84
+ if (compressedSize === U32_MAX) {
85
+ compressedSize = Number(view.getBigUint64(q, true))
86
+ q += 8
87
+ }
88
+ if (localHeaderOffset === U32_MAX) {
89
+ localHeaderOffset = Number(view.getBigUint64(q, true))
90
+ }
91
+ break
92
+ }
93
+ p += 4 + size
94
+ }
95
+ if (compressedSize === U32_MAX || localHeaderOffset === U32_MAX) {
96
+ throw new Error(`Invalid ZIP: "${name}" needs ZIP64 sizes but has no ZIP64 extra field`)
97
+ }
98
+ }
99
+ offset += 46 + nameLength + extraLength + commentLength
100
+
101
+ if (name.endsWith('/')) continue // directory entry
102
+
103
+ // The local header repeats name/extra with its own lengths; the data
104
+ // follows it. Sizes come from the central directory (authoritative even
105
+ // when the local header deferred them to a data descriptor).
106
+ if (view.getUint32(localHeaderOffset, true) !== LOCAL_HEADER_SIG) {
107
+ throw new Error(`Invalid ZIP: bad local header for "${name}"`)
108
+ }
109
+ const localNameLength = view.getUint16(localHeaderOffset + 26, true)
110
+ const localExtraLength = view.getUint16(localHeaderOffset + 28, true)
111
+ const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength
112
+ const compressed = bytes.subarray(dataStart, dataStart + compressedSize)
113
+
114
+ entries.push({name, data: await decompress(method, compressed, name)})
115
+ }
116
+ return entries
117
+ }
118
+
119
+ /** Convenience: unzip and decode every file entry as UTF-8 text. */
120
+ export async function unzipText(bytes: Uint8Array): Promise<{name: string; content: string}[]> {
121
+ const entries = await unzip(bytes)
122
+ const decoder = new TextDecoder()
123
+ return entries.map((e) => ({name: e.name, content: decoder.decode(e.data)}))
124
+ }
125
+
126
+ function findEndOfCentralDirectory(view: DataView): {
127
+ entryCount: number
128
+ centralDirectoryOffset: number
129
+ } {
130
+ const lowest = Math.max(0, view.byteLength - EOCD_MIN_SIZE - 0xffff)
131
+ for (let i = view.byteLength - EOCD_MIN_SIZE; i >= lowest; i--) {
132
+ if (view.getUint32(i, true) !== EOCD_SIG) continue
133
+
134
+ let entryCount: number = view.getUint16(i + 10, true)
135
+ let centralDirectoryOffset: number = view.getUint32(i + 16, true)
136
+
137
+ // ZIP64: placeholder values defer to the ZIP64 EOCD record, found via the
138
+ // fixed-size locator that sits immediately before the classic EOCD.
139
+ if (entryCount === U16_MAX || centralDirectoryOffset === U32_MAX) {
140
+ const locator = i - ZIP64_LOCATOR_SIZE
141
+ if (locator < 0 || view.getUint32(locator, true) !== ZIP64_EOCD_LOCATOR_SIG) {
142
+ throw new Error('Invalid ZIP: ZIP64 values without a ZIP64 end-of-central-directory locator')
143
+ }
144
+ const zip64Eocd = Number(view.getBigUint64(locator + 8, true))
145
+ if (view.getUint32(zip64Eocd, true) !== ZIP64_EOCD_SIG) {
146
+ throw new Error('Invalid ZIP: bad ZIP64 end-of-central-directory record')
147
+ }
148
+ entryCount = Number(view.getBigUint64(zip64Eocd + 32, true))
149
+ centralDirectoryOffset = Number(view.getBigUint64(zip64Eocd + 48, true))
150
+ }
151
+ return {entryCount, centralDirectoryOffset}
152
+ }
153
+ throw new Error('Invalid ZIP: end-of-central-directory record not found')
154
+ }
155
+
156
+ async function decompress(method: number, data: Uint8Array, name: string): Promise<Uint8Array> {
157
+ if (method === 0) return data // stored
158
+ if (method === 8) {
159
+ // Copy into a fresh ArrayBuffer so the Blob sees exactly this entry's bytes.
160
+ const stream = new Blob([data.slice()]).stream().pipeThrough(new DecompressionStream('deflate-raw'))
161
+ return new Uint8Array(await new Response(stream).arrayBuffer())
162
+ }
163
+ throw new Error(`Unsupported ZIP compression method ${method} for "${name}"`)
164
+ }
@@ -0,0 +1,387 @@
1
+ import type {SmartcatProject, SmartcatDocument, RequestLogger} from '../smartcat/types'
2
+ import {toSanityLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'
3
+ import {itemDocDeref} 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`). Used to correlate a Smartcat
125
+ * document back to a Sanity source document.
126
+ *
127
+ * The prefix is always the final 8 characters before the extension, so take the
128
+ * tail rather than splitting on a dash — the title or the id prefix itself can
129
+ * contain dashes (e.g. a custom id like `demo-pdpA` → prefix `demo-pdp`), which
130
+ * would make a last-dash split return the wrong fragment.
131
+ *
132
+ * Only a literal `.locjson` extension is stripped (Smartcat's API often returns
133
+ * the name without it). A generic strip-after-last-dot would eat the tail of any
134
+ * DOTTED title — e.g. micro-copy keys like `aiAssistantModal.resetChat-11AKrV8G`
135
+ * — leaving the wrong 8 chars, so those documents silently never correlate,
136
+ * never import, and re-syncs duplicate them instead of updating.
137
+ */
138
+ export function sourceIdPrefix(doc: SmartcatDocument): string {
139
+ const base = doc.name || doc.filename || doc.fullPath || ''
140
+ const leaf = base.split('/').pop() ?? ''
141
+ const noExt = leaf.replace(/\.locjson$/i, '')
142
+ return noExt.slice(-8)
143
+ }
144
+
145
+ /**
146
+ * Builds the full progress array from a freshly fetched Smartcat project and the
147
+ * project's items — independent of any export-time skeleton, so it works for
148
+ * projects exported before progress tracking existed.
149
+ *
150
+ * Documents are correlated to items by the 8-char id prefix in their filename;
151
+ * `imported` flags are carried over from the previous progress (by source doc +
152
+ * language) so importing isn't forgotten on refresh.
153
+ *
154
+ * `mappings` invert Smartcat's returned target codes back to Sanity ids, so all
155
+ * stored progress uses Sanity locale tags. When omitted, codes pass through
156
+ * unchanged (identity) — keeping legacy projects working.
157
+ */
158
+ export function buildProgress(
159
+ items: ProjectItem[],
160
+ scProject: SmartcatProject,
161
+ previous: DocProgress[] | undefined,
162
+ now: string,
163
+ mappings?: SmartcatLanguageMapping[],
164
+ ): DocProgress[] {
165
+ const stageNames = buildStageNameMap(scProject)
166
+
167
+ const importedSet = new Set<string>()
168
+ // Titles are computed Sanity-native in the Studio and shipped on the outbox, so
169
+ // they may not be re-derivable server-side. Carry the previously-stored title
170
+ // forward so a progress sync (which only has the raw `doc.title`) can't clobber
171
+ // it with an "Untitled" for types that have no literal title field.
172
+ const titleByDoc = new Map<string, string>()
173
+ for (const doc of previous ?? []) {
174
+ if (doc.title) titleByDoc.set(doc.sourceDocId, doc.title)
175
+ for (const target of doc.targets) {
176
+ if (target.imported) importedSet.add(`${doc.sourceDocId}__${target.language}`)
177
+ }
178
+ }
179
+
180
+ const itemByPrefix = new Map<string, ProjectItem>()
181
+ for (const item of items) itemByPrefix.set(stripDraft(item._id).slice(0, 8), item)
182
+
183
+ const byDoc = new Map<string, DocProgress>()
184
+ for (const scDoc of scProject.documents ?? []) {
185
+ if (!scDoc.targetLanguage) continue
186
+ const item = itemByPrefix.get(sourceIdPrefix(scDoc))
187
+ if (!item) continue
188
+
189
+ // Store the Sanity locale tag, not Smartcat's code.
190
+ const language = toSanityLanguage(mappings, scDoc.targetLanguage)
191
+
192
+ let docProgress = byDoc.get(item._id)
193
+ if (!docProgress) {
194
+ const title = item.title || titleByDoc.get(item._id)
195
+ docProgress = {_key: targetKey(item._id, 'doc'), sourceDocId: item._id, title, targets: []}
196
+ byDoc.set(item._id, docProgress)
197
+ }
198
+ const stages = stagesFromDocument(scDoc, stageNames)
199
+ docProgress.targets.push({
200
+ _key: targetKey(item._id, language),
201
+ language,
202
+ smartcatDocumentId: scDoc.id,
203
+ stages,
204
+ complete: isTargetComplete(stages),
205
+ imported: importedSet.has(`${item._id}__${language}`),
206
+ syncedAt: now,
207
+ })
208
+ }
209
+
210
+ // Preserve item order; sort each doc's targets by language for stable display.
211
+ const result: DocProgress[] = []
212
+ for (const item of items) {
213
+ const docProgress = byDoc.get(item._id)
214
+ if (!docProgress) continue
215
+ docProgress.targets.sort((a, b) => a.language.localeCompare(b.language))
216
+ result.push(docProgress)
217
+ }
218
+ return result
219
+ }
220
+
221
+ export interface CompletionSummary {
222
+ /** Total (document × language) targets. */
223
+ total: number
224
+ /** Targets whose stages are all at 100%. */
225
+ complete: number
226
+ /** Targets a variant has been built for. */
227
+ imported: number
228
+ /** True when every target is complete and imported (and there is ≥1 target). */
229
+ allDone: boolean
230
+ /** True when at least one stage shows any progress. */
231
+ anyProgress: boolean
232
+ }
233
+
234
+ /** Summarizes a progress array for status decisions and the dashboard. */
235
+ export function summarizeCompletion(progress: DocProgress[]): CompletionSummary {
236
+ let total = 0
237
+ let complete = 0
238
+ let imported = 0
239
+ let anyProgress = false
240
+ for (const doc of progress) {
241
+ for (const target of doc.targets) {
242
+ total++
243
+ if (target.complete) complete++
244
+ if (target.imported) imported++
245
+ if (target.stages.some((s) => s.percent > 0)) anyProgress = true
246
+ }
247
+ }
248
+ return {
249
+ total,
250
+ complete,
251
+ imported,
252
+ allDone: total > 0 && complete === total && imported === total,
253
+ anyProgress,
254
+ }
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Function-side orchestration: refresh progress from Smartcat
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /** Minimal structural subset of @sanity/client used by the progress refresh. */
262
+ export interface SanityLikeClient {
263
+ fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>
264
+ patch(id: string): {
265
+ set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}
266
+ }
267
+ }
268
+
269
+ /** Structural subset of SmartcatClient the progress refresh needs. */
270
+ export interface SmartcatProgressClient {
271
+ getProject(projectId: string): Promise<SmartcatProject>
272
+ /** Optional: trace every HTTP call so a timeout/error reaches the functionLog. */
273
+ setRequestLogger?(logger: RequestLogger): void
274
+ }
275
+
276
+ export interface RunProgressSyncOptions {
277
+ sanity: SanityLikeClient
278
+ smartcat: SmartcatProgressClient
279
+ /** `_id` of the `smartcat.translationProject` to refresh. */
280
+ projectId: string
281
+ now?: () => string
282
+ }
283
+
284
+ export interface RunProgressSyncResult {
285
+ summary: CompletionSummary
286
+ /** Set when the Smartcat fetch failed (e.g. timed out); the run was recorded, not thrown. */
287
+ error?: string
288
+ }
289
+
290
+ const PROGRESS_QUERY = `*[_id == $id][0]{
291
+ _id,
292
+ status,
293
+ smartcatProjectId,
294
+ smartcatLanguages[]{sanityId, smartcatLanguage},
295
+ "items": items[]{ "doc": ${itemDocDeref('{_id}')} },
296
+ progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
297
+ }`
298
+
299
+ interface ProgressProjectData {
300
+ _id: string
301
+ status?: string
302
+ smartcatProjectId?: string
303
+ smartcatLanguages?: SmartcatLanguageMapping[]
304
+ items?: {doc: {_id: string} | null}[]
305
+ progress?: DocProgress[]
306
+ }
307
+
308
+ /** Resolves a project's referenced items into the lightweight shape we correlate on. */
309
+ export function itemsFromProject(project: {
310
+ items?: {doc: {_id: string} | null}[]
311
+ }): ProjectItem[] {
312
+ return (project.items ?? [])
313
+ .map((i) => i?.doc)
314
+ .filter((d): d is {_id: string} => Boolean(d))
315
+ .map((d) => ({_id: d._id}))
316
+ }
317
+
318
+ /**
319
+ * Thin progress step run by the `smartcat-progress` Function: fetches the
320
+ * Smartcat project and mirrors its per-stage progress onto the project's
321
+ * `progress` array. Read-only with respect to Smartcat — it never downloads or
322
+ * creates variants, so it is cheap to run on every dashboard page load.
323
+ */
324
+ export async function runProgressSync(
325
+ options: RunProgressSyncOptions,
326
+ ): Promise<RunProgressSyncResult> {
327
+ const {sanity, smartcat, projectId, now = () => new Date().toISOString()} = options
328
+
329
+ const project = await sanity.fetch<ProgressProjectData | null>(PROGRESS_QUERY, {id: projectId})
330
+ if (!project) throw new Error(`Translation project ${projectId} not found`)
331
+ if (!project.smartcatProjectId) {
332
+ throw new Error('Project has no smartcatProjectId — export it first')
333
+ }
334
+
335
+ const timestamp = now()
336
+
337
+ // Trace HTTP failures into a functionLog so a timeout/error surfaces in the UI.
338
+ // Successful calls are left unlogged: this Function runs on every page load, and
339
+ // functionLog is shared with export/import — writing it on every success would
340
+ // wipe a useful export/import log.
341
+ const log: LogLine[] = []
342
+ smartcat.setRequestLogger?.(({method, path, status, body}) => {
343
+ if (status >= 200 && status < 300) return
344
+ log.push({
345
+ level: 'error',
346
+ message: status ? `Error ${status} while performing ${method} ${path}` : `${method} ${path} did not complete`,
347
+ })
348
+ if (body) log.push({level: 'error', indent: true, message: body})
349
+ })
350
+
351
+ try {
352
+ const scProject = await smartcat.getProject(project.smartcatProjectId)
353
+ const progress = buildProgress(
354
+ itemsFromProject(project),
355
+ scProject,
356
+ project.progress,
357
+ timestamp,
358
+ project.smartcatLanguages,
359
+ )
360
+ const summary = summarizeCompletion(progress)
361
+
362
+ const patch: Record<string, unknown> = {progress, progressSyncedAt: timestamp, lastError: null}
363
+ // Gentle one-way nudge: once Smartcat reports any work, reflect it in the badge.
364
+ // Completion remains owned by the import flow.
365
+ if (project.status === 'sent' && summary.anyProgress) {
366
+ patch.status = 'translating'
367
+ }
368
+
369
+ await sanity.patch(projectId).set(patch).commit()
370
+
371
+ return {summary}
372
+ } catch (err) {
373
+ // Record the failure and return instead of throwing: a throw would let the
374
+ // platform retry, and a timed-out fetch has nothing transient to gain. Leave
375
+ // the existing `progress` untouched (no zeroing-out) and only stamp the error
376
+ // plus a fresh `progressSyncedAt`, which releases the UI refresh spinner and
377
+ // makes the log land where the dashboard reads it.
378
+ const message = err instanceof Error ? err.message : String(err)
379
+ if (!log.some((l) => l.level === 'error')) log.push({level: 'error', message})
380
+ await sanity
381
+ .patch(projectId)
382
+ .set({lastError: message, functionLog: JSON.stringify(log), progressSyncedAt: timestamp})
383
+ .commit()
384
+ .catch(() => {})
385
+ return {summary: summarizeCompletion(project.progress ?? []), error: message}
386
+ }
387
+ }
@@ -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'