@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,290 @@
1
+ import {describe, expect, it} from 'vitest'
2
+ import {
3
+ buildProgress,
4
+ buildStageNameMap,
5
+ isTargetComplete,
6
+ itemsFromProject,
7
+ runProgressSync,
8
+ sourceIdPrefix,
9
+ stagesFromDocument,
10
+ summarizeCompletion,
11
+ targetPercent,
12
+ type DocProgress,
13
+ } from './index'
14
+ import type {SmartcatProject} from '../smartcat/types'
15
+ import {SmartcatTimeoutError} from '../smartcat/client'
16
+
17
+ // Stage-id → name map mirroring Smartcat's project-level workflowStages.
18
+ const STAGE_NAMES = [
19
+ {id: 'st-tr', stageType: 'translation'},
20
+ {id: 'st-ed', stageType: 'editing'},
21
+ ]
22
+
23
+ describe('stage helpers', () => {
24
+ it('names document stages via the project-level stage map and clamps percentages', () => {
25
+ const names = buildStageNameMap({id: 'p', workflowStages: STAGE_NAMES})
26
+ const stages = stagesFromDocument(
27
+ {id: 'g_1', workflowStages: [{id: 'st-tr', progress: 100}, {id: 'st-ed', progress: 42.6}]},
28
+ names,
29
+ )
30
+ expect(stages).toEqual([
31
+ {_key: 'st-tr', name: 'Translation', percent: 100},
32
+ {_key: 'st-ed', name: 'Editing', percent: 43},
33
+ ])
34
+ })
35
+
36
+ it('treats missing/empty stages as not complete', () => {
37
+ expect(isTargetComplete([])).toBe(false)
38
+ expect(isTargetComplete([{name: 'T', percent: 100}])).toBe(true)
39
+ expect(isTargetComplete([{name: 'T', percent: 100}, {name: 'E', percent: 99}])).toBe(false)
40
+ })
41
+
42
+ it('averages stage percentages', () => {
43
+ expect(targetPercent([{name: 'T', percent: 100}, {name: 'E', percent: 50}])).toBe(75)
44
+ expect(targetPercent([])).toBe(0)
45
+ })
46
+
47
+ it('extracts the source-id prefix from a document filename', () => {
48
+ expect(sourceIdPrefix({id: 'g_1', name: 'About Us-f18f0d41', fullPath: 'page/About Us-f18f0d41'})).toBe('f18f0d41')
49
+ expect(sourceIdPrefix({id: 'g_1', filename: 'Contact-3f14f092.locjson'})).toBe('3f14f092')
50
+ // The prefix is the final 8 chars — robust to dashes in the title or the id
51
+ // prefix itself (e.g. a custom id `demo-pdpA` → prefix `demo-pdp`).
52
+ expect(sourceIdPrefix({id: 'g_1', name: 'Product A-demo-pdp', fullPath: 'pdpPage/Product A-demo-pdp'})).toBe('demo-pdp')
53
+ expect(sourceIdPrefix({id: 'g_1', filename: 'Some-Dashed-Title-3f14f092.locjson'})).toBe('3f14f092')
54
+ // DOTTED titles (e.g. micro-copy keys): only a literal `.locjson` may be
55
+ // stripped — a generic strip-after-last-dot would eat `.resetChat-11AKrV8G`
56
+ // and return the wrong prefix, so the document never correlates.
57
+ expect(sourceIdPrefix({id: 'g_1', name: 'aiAssistantModal.resetChat-11AKrV8G'})).toBe('11AKrV8G')
58
+ expect(sourceIdPrefix({id: 'g_1', filename: 'aiAssistantModal.resetChat-11AKrV8G.locjson'})).toBe('11AKrV8G')
59
+ expect(sourceIdPrefix({id: 'g_1', name: 'v2.5 Landing Page-ab12cd34'})).toBe('ab12cd34')
60
+ })
61
+
62
+ it('correlates a draft-sourced file to the same id as its published counterpart', () => {
63
+ // Draft files carry a trailing `-draft` marker; it must be stripped so both
64
+ // resolve to the same source id (a project holds one version, never both).
65
+ const published = {id: 'g_1', filename: 'page/About Us-f18f0d41.locjson'}
66
+ const draft = {id: 'g_2', filename: 'page/About Us-f18f0d41-draft.locjson'}
67
+ expect(sourceIdPrefix(published)).toBe('f18f0d41')
68
+ expect(sourceIdPrefix(draft)).toBe('f18f0d41')
69
+ expect(sourceIdPrefix(draft)).toBe(sourceIdPrefix(published))
70
+ // Marker stripped from the name form (no extension) too.
71
+ expect(sourceIdPrefix({id: 'g_3', name: 'About Us-f18f0d41-draft'})).toBe('f18f0d41')
72
+ })
73
+ })
74
+
75
+ const scProject: SmartcatProject = {
76
+ id: 'p1',
77
+ workflowStages: STAGE_NAMES,
78
+ documents: [
79
+ {id: '817_12', name: 'About Us-f18f0d41', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 100}, {id: 'st-ed', progress: 100}]},
80
+ {id: '817_10', name: 'About Us-f18f0d41', targetLanguage: 'de', workflowStages: [{id: 'st-tr', progress: 30}, {id: 'st-ed', progress: 0}]},
81
+ ],
82
+ }
83
+
84
+ const items = [{_id: 'f18f0d41-aaaa-bbbb', title: 'About Us'}]
85
+
86
+ describe('buildProgress', () => {
87
+ it('correlates documents to items by id prefix and groups by source doc', () => {
88
+ const progress = buildProgress(items, scProject, undefined, '2026-06-11T00:00:00Z')
89
+ expect(progress).toHaveLength(1)
90
+ expect(progress[0]).toMatchObject({sourceDocId: 'f18f0d41-aaaa-bbbb', title: 'About Us'})
91
+ const [de, fr] = progress[0].targets // sorted by language
92
+ expect(de).toMatchObject({language: 'de', smartcatDocumentId: '817_10', complete: false})
93
+ expect(fr).toMatchObject({language: 'fr', smartcatDocumentId: '817_12', complete: true})
94
+ expect(fr.stages).toEqual([
95
+ {_key: 'st-tr', name: 'Translation', percent: 100},
96
+ {_key: 'st-ed', name: 'Editing', percent: 100},
97
+ ])
98
+ })
99
+
100
+ it('carries imported flags over from previous progress', () => {
101
+ const previous: DocProgress[] = [
102
+ {_key: 'd', sourceDocId: 'f18f0d41-aaaa-bbbb', targets: [{_key: 'k', language: 'fr', stages: [], complete: true, imported: true}]},
103
+ ]
104
+ const progress = buildProgress(items, scProject, previous, 'now')
105
+ expect(progress[0].targets.find((t) => t.language === 'fr')?.imported).toBe(true)
106
+ expect(progress[0].targets.find((t) => t.language === 'de')?.imported).toBe(false)
107
+ })
108
+
109
+ it('carries a previously-stored title forward when the item has none', () => {
110
+ // A progress sync only has the raw `doc.title` (absent for types whose title
111
+ // is computed via preview), so the title stored at export time must survive.
112
+ const titleless = [{_id: 'f18f0d41-aaaa-bbbb'}]
113
+ const previous: DocProgress[] = [
114
+ {_key: 'd', sourceDocId: 'f18f0d41-aaaa-bbbb', title: 'Computed Name', targets: []},
115
+ ]
116
+ const progress = buildProgress(titleless, scProject, previous, 'now')
117
+ expect(progress[0].title).toBe('Computed Name')
118
+ })
119
+
120
+ it('drops documents that match no item', () => {
121
+ const progress = buildProgress([{_id: 'zzzzzzzz-x', title: 'Other'}], scProject, undefined, 'now')
122
+ expect(progress).toEqual([])
123
+ })
124
+
125
+ it('inverts Smartcat codes back to Sanity ids using the mapping', () => {
126
+ const scWithPolish: SmartcatProject = {
127
+ id: 'p1',
128
+ workflowStages: STAGE_NAMES,
129
+ documents: [
130
+ {id: '817_12', name: 'About Us-f18f0d41', targetLanguage: 'pl', workflowStages: [{id: 'st-tr', progress: 100}, {id: 'st-ed', progress: 100}]},
131
+ ],
132
+ }
133
+ const previous: DocProgress[] = [
134
+ {_key: 'd', sourceDocId: 'f18f0d41-aaaa-bbbb', targets: [{_key: 'k', language: 'PL-PL', stages: [], complete: true, imported: true}]},
135
+ ]
136
+ const progress = buildProgress(items, scWithPolish, previous, 'now', [{sanityId: 'PL-PL', smartcatLanguage: 'pl'}])
137
+ const target = progress[0].targets[0]
138
+ expect(target.language).toBe('PL-PL') // not 'pl'
139
+ expect(target._key).toBe('f18f0d41-aaaa-bbbb__PL-PL') // keyed on the Sanity id
140
+ expect(target.imported).toBe(true) // imported flag matched on the Sanity id
141
+ })
142
+
143
+ it('resolves items from a project shape', () => {
144
+ expect(itemsFromProject({items: [{doc: {_id: 'a'}}, {doc: null}]})).toEqual([{_id: 'a'}])
145
+ })
146
+
147
+ it('resolves items by stored id, so a draft-only item still correlates', () => {
148
+ // Current query projects the item's stored `docId` as `_id` (no deref), so an
149
+ // item whose document exists only as a draft is not dropped.
150
+ expect(itemsFromProject({items: [{_id: 'draftOnlyId', title: 'Draft Page'}]})).toEqual([
151
+ {_id: 'draftOnlyId', title: 'Draft Page'},
152
+ ])
153
+ // Items with no id at all (deleted/unresolvable) are still dropped.
154
+ expect(itemsFromProject({items: [{doc: null}, {}]})).toEqual([])
155
+ })
156
+ })
157
+
158
+ describe('summarizeCompletion', () => {
159
+ it('flags allDone only when every target is complete AND imported', () => {
160
+ const progress: DocProgress[] = [
161
+ {
162
+ _key: 'd1',
163
+ sourceDocId: 'doc1',
164
+ targets: [
165
+ {_key: 'a', language: 'fr', stages: [{name: 'T', percent: 100}], complete: true, imported: true},
166
+ {_key: 'b', language: 'de', stages: [{name: 'T', percent: 100}], complete: true, imported: false},
167
+ ],
168
+ },
169
+ ]
170
+ expect(summarizeCompletion(progress)).toMatchObject({total: 2, complete: 2, imported: 1, allDone: false, anyProgress: true})
171
+ })
172
+ })
173
+
174
+ describe('runProgressSync', () => {
175
+ function makeSanity(project: unknown) {
176
+ const sets: Record<string, unknown>[] = []
177
+ const client = {
178
+ fetch: (async () => project) as never,
179
+ patch: () => ({set: (attrs: Record<string, unknown>) => { sets.push(attrs); return {commit: async () => null} }}),
180
+ }
181
+ return {client, sets}
182
+ }
183
+
184
+ it('refreshes progress and nudges sent → translating once work starts', async () => {
185
+ const {client, sets} = makeSanity({
186
+ _id: 'p',
187
+ status: 'sent',
188
+ smartcatProjectId: 'sc1',
189
+ items: [{doc: {_id: 'f18f0d41-aaaa-bbbb'}}],
190
+ progress: [],
191
+ })
192
+ const smartcat = {getProject: async (): Promise<SmartcatProject> => scProject}
193
+
194
+ const {summary} = await runProgressSync({sanity: client, smartcat, projectId: 'p', now: () => 'T'})
195
+ expect(summary.total).toBe(2)
196
+ expect(summary.anyProgress).toBe(true)
197
+ expect(sets[0].status).toBe('translating')
198
+ expect(sets[0].progressSyncedAt).toBe('T')
199
+ })
200
+
201
+ it('does not change status when there is no progress yet', async () => {
202
+ const zero: SmartcatProject = {
203
+ id: 'sc1',
204
+ workflowStages: STAGE_NAMES,
205
+ documents: [{id: '817_12', name: 'About Us-f18f0d41', targetLanguage: 'fr', workflowStages: [{id: 'st-tr', progress: 0}]}],
206
+ }
207
+ const {client, sets} = makeSanity({
208
+ _id: 'p',
209
+ status: 'sent',
210
+ smartcatProjectId: 'sc1',
211
+ items: [{doc: {_id: 'f18f0d41-aaaa-bbbb'}}],
212
+ progress: [],
213
+ })
214
+ const smartcat = {getProject: async (): Promise<SmartcatProject> => zero}
215
+ await runProgressSync({sanity: client, smartcat, projectId: 'p', now: () => 'T'})
216
+ expect(sets[0].status).toBeUndefined()
217
+ })
218
+
219
+ it('clears lastError and does not write functionLog on success', async () => {
220
+ const {client, sets} = makeSanity({
221
+ _id: 'p',
222
+ status: 'sent',
223
+ smartcatProjectId: 'sc1',
224
+ items: [{doc: {_id: 'f18f0d41-aaaa-bbbb'}}],
225
+ progress: [],
226
+ })
227
+ const smartcat = {getProject: async (): Promise<SmartcatProject> => scProject, setRequestLogger: () => {}}
228
+ await runProgressSync({sanity: client, smartcat, projectId: 'p', now: () => 'T'})
229
+ expect(sets[0].lastError).toBeNull()
230
+ // functionLog is shared with export/import — a successful refresh must not touch it.
231
+ expect(sets[0].functionLog).toBeUndefined()
232
+ })
233
+
234
+ it('records a Smartcat failure instead of throwing, preserving existing progress', async () => {
235
+ const existing: DocProgress[] = [
236
+ {_key: 'k', sourceDocId: 'd', title: 'T', targets: []},
237
+ ]
238
+ const {client, sets} = makeSanity({
239
+ _id: 'p',
240
+ status: 'translating',
241
+ smartcatProjectId: 'sc1',
242
+ items: [{doc: {_id: 'd'}}],
243
+ progress: existing,
244
+ })
245
+ const smartcat = {
246
+ getProject: async (): Promise<SmartcatProject> => {
247
+ throw new SmartcatTimeoutError('GET', '/api/integration/v1/project/sc1', 570_000)
248
+ },
249
+ setRequestLogger: () => {},
250
+ }
251
+
252
+ const {error} = await runProgressSync({sanity: client, smartcat, projectId: 'p', now: () => 'T'})
253
+
254
+ expect(error).toContain('9m30s')
255
+ expect(sets[0].lastError).toContain('9m30s')
256
+ expect(sets[0].progressSyncedAt).toBe('T')
257
+ // The failure is captured in the log the dashboard renders...
258
+ const log = JSON.parse(sets[0].functionLog as string) as {level: string; message: string}[]
259
+ expect(log.some((l) => l.level === 'error' && l.message.includes('9m30s'))).toBe(true)
260
+ // ...and the existing progress is left untouched (not zeroed out).
261
+ expect(sets[0].progress).toBeUndefined()
262
+ })
263
+
264
+ it('captures request-logger error lines into functionLog', async () => {
265
+ const {client, sets} = makeSanity({
266
+ _id: 'p',
267
+ status: 'sent',
268
+ smartcatProjectId: 'sc1',
269
+ items: [],
270
+ progress: [],
271
+ })
272
+ let logger: (info: {method: string; path: string; status: number; body: string}) => void = () => {}
273
+ const smartcat = {
274
+ getProject: async (): Promise<SmartcatProject> => {
275
+ // Simulate the client tracing a failed call before rejecting.
276
+ logger({method: 'GET', path: '/project/sc1', status: 0, body: 'timed out'})
277
+ throw new Error('boom')
278
+ },
279
+ setRequestLogger: (l: typeof logger) => {
280
+ logger = l
281
+ },
282
+ }
283
+
284
+ await runProgressSync({sanity: client, smartcat, projectId: 'p', now: () => 'T'})
285
+
286
+ const log = JSON.parse(sets[0].functionLog as string) as {level: string; message: string; indent?: boolean}[]
287
+ expect(log).toContainEqual({level: 'error', message: 'GET /project/sc1 did not complete'})
288
+ expect(log).toContainEqual({level: 'error', indent: true, message: 'timed out'})
289
+ })
290
+ })
@@ -0,0 +1,55 @@
1
+ import {defineType, defineField, defineArrayMember, type SchemaTypeDefinition} from 'sanity'
2
+ import {CogIcon} from '@sanity/icons'
3
+ import {SETTINGS_TYPE} from '../lib/constants'
4
+
5
+ /**
6
+ * Workspace-wide Smartcat settings, stored as a single fixed-id document.
7
+ *
8
+ * Holds the cached project-template list. The browser bumps `templatesRequestedAt`
9
+ * (Studio init / create-dialog open); the `smartcat-templates` Function fetches
10
+ * the list from Smartcat and writes `templates` + `templatesSyncedAt` back.
11
+ * All fields are managed automatically — never hand-edited.
12
+ */
13
+ export function createSettingsType(): SchemaTypeDefinition {
14
+ return defineType({
15
+ name: SETTINGS_TYPE,
16
+ title: 'Smartcat settings',
17
+ type: 'document',
18
+ icon: CogIcon,
19
+ fields: [
20
+ defineField({
21
+ name: 'templates',
22
+ title: 'Project templates',
23
+ type: 'array',
24
+ readOnly: true,
25
+ of: [
26
+ defineArrayMember({
27
+ type: 'object',
28
+ name: 'smartcatTemplate',
29
+ fields: [
30
+ defineField({name: 'id', type: 'string'}),
31
+ defineField({name: 'name', type: 'string'}),
32
+ ],
33
+ preview: {select: {title: 'name', subtitle: 'id'}},
34
+ }),
35
+ ],
36
+ }),
37
+ // Handshake fields for the on-demand template refresh: the browser bumps
38
+ // `templatesRequestedAt`; the smartcat-templates Function writes
39
+ // `templatesSyncedAt`. Distinct fields stop the Function re-triggering on
40
+ // its own write (filter is delta::changedAny(templatesRequestedAt)).
41
+ defineField({name: 'templatesRequestedAt', type: 'datetime', readOnly: true, hidden: true}),
42
+ defineField({name: 'templatesSyncedAt', type: 'datetime', readOnly: true, hidden: true}),
43
+ ],
44
+ preview: {
45
+ select: {templates: 'templates', syncedAt: 'templatesSyncedAt'},
46
+ prepare({templates, syncedAt}) {
47
+ const count = Array.isArray(templates) ? templates.length : 0
48
+ return {
49
+ title: 'Smartcat settings',
50
+ subtitle: syncedAt ? `${count} template${count === 1 ? '' : 's'} cached` : 'Not synced yet',
51
+ }
52
+ },
53
+ },
54
+ })
55
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Schema option this plugin adds, mirroring how `@sanity/document-internationalization`
3
+ * contributes `options.documentInternationalization.exclude`.
4
+ *
5
+ * Set it on a field — or once on a custom type — to force translation of a type
6
+ * the default allowlist (`string`/`text`/Portable Text) would otherwise skip,
7
+ * e.g. a custom string alias. The kind is derived from the resolved `jsonType`.
8
+ */
9
+ export interface SmartcatTranslationSchemaOptions {
10
+ smartcatTranslation?: {
11
+ /**
12
+ * Force this field/type into translation even when its base type isn't in the
13
+ * default allowlist. Overrides a `documentInternationalization.exclude` on the
14
+ * same field. Ignored if the type has no translatable representation.
15
+ */
16
+ include?: boolean
17
+ }
18
+ }
19
+
20
+ declare module 'sanity' {
21
+ interface StringOptions extends SmartcatTranslationSchemaOptions {}
22
+ interface TextOptions extends SmartcatTranslationSchemaOptions {}
23
+ interface ArrayOptions extends SmartcatTranslationSchemaOptions {}
24
+ interface ObjectOptions extends SmartcatTranslationSchemaOptions {}
25
+ }
@@ -0,0 +1,268 @@
1
+ import {defineType, defineField, defineArrayMember, type SchemaTypeDefinition} from 'sanity'
2
+ import {TranslateIcon} from '@sanity/icons'
3
+ import {TRANSLATION_PROJECT_TYPE} from '../lib/constants'
4
+ import type {TranslationProjectStatus} from '../types'
5
+
6
+ /**
7
+ * Single source of truth for status display titles, used by both the schema's
8
+ * status dropdown (document editor) and the dashboard's StatusBadge. Titles are
9
+ * kept terse so they read well in both places.
10
+ */
11
+ export const STATUS_OPTIONS: {title: string; value: TranslationProjectStatus}[] = [
12
+ {title: 'Draft', value: 'draft'},
13
+ {title: 'Queued', value: 'queued'},
14
+ {title: 'Sent', value: 'sent'},
15
+ {title: 'In progress', value: 'translating'},
16
+ {title: 'Importing', value: 'importing'},
17
+ {title: 'Downloaded', value: 'downloaded'},
18
+ {title: 'Completed', value: 'completed'},
19
+ {title: 'Error', value: 'error'},
20
+ ]
21
+
22
+ /**
23
+ * Builds the translation-project document type.
24
+ *
25
+ * `items` holds `smartcat.projectItem` objects (a `docId`/`docType` pair), not
26
+ * Sanity references, so any document type can be added — including ones linked
27
+ * transitively — without the consumer having to enumerate every referenceable
28
+ * type in a reference `to` list. Reads dereference `docId` manually via GROQ.
29
+ */
30
+ export function createTranslationProjectType(): SchemaTypeDefinition {
31
+ return defineType({
32
+ name: TRANSLATION_PROJECT_TYPE,
33
+ title: 'Translation project',
34
+ type: 'document',
35
+ icon: TranslateIcon,
36
+ fields: [
37
+ defineField({
38
+ name: 'name',
39
+ title: 'Project name',
40
+ type: 'string',
41
+ validation: (rule) => rule.required(),
42
+ }),
43
+ defineField({
44
+ name: 'status',
45
+ type: 'string',
46
+ // Driven by the dashboard and Functions, not edited by hand.
47
+ readOnly: true,
48
+ initialValue: 'draft',
49
+ options: {list: STATUS_OPTIONS, layout: 'radio'},
50
+ }),
51
+ defineField({
52
+ name: 'sourceLanguage',
53
+ title: 'Source language',
54
+ type: 'string',
55
+ }),
56
+ // Workflow chosen at creation: `template:<id>` or a standard preset.
57
+ // Read by the export Function to build Smartcat's createProject params.
58
+ defineField({name: 'workflow', type: 'string', readOnly: true, hidden: true}),
59
+ defineField({
60
+ name: 'targetLanguages',
61
+ title: 'Target languages',
62
+ type: 'array',
63
+ of: [defineArrayMember({type: 'string'})],
64
+ options: {layout: 'tags'},
65
+ }),
66
+ // Sanity-id → Smartcat-code mapping for this project's source + target
67
+ // languages, stamped by the browser at export time so the thin Functions
68
+ // (which can't see plugin config) can map to/from Smartcat codes.
69
+ defineField({
70
+ name: 'smartcatLanguages',
71
+ type: 'array',
72
+ readOnly: true,
73
+ hidden: true,
74
+ of: [
75
+ defineArrayMember({
76
+ type: 'object',
77
+ name: 'smartcatLanguageMapping',
78
+ fields: [
79
+ defineField({name: 'sanityId', type: 'string'}),
80
+ defineField({name: 'smartcatLanguage', type: 'string'}),
81
+ ],
82
+ }),
83
+ ],
84
+ }),
85
+ defineField({
86
+ name: 'items',
87
+ title: 'Items',
88
+ description: 'Content added to this project for translation.',
89
+ // Populated by the "Add to translation project" action, not by hand.
90
+ readOnly: true,
91
+ type: 'array',
92
+ of: [
93
+ defineArrayMember({
94
+ type: 'object',
95
+ name: 'smartcat.projectItem',
96
+ title: 'Project item',
97
+ fields: [
98
+ defineField({name: 'docId', title: 'Document ID', type: 'string'}),
99
+ defineField({name: 'docType', title: 'Document type', type: 'string'}),
100
+ // Set when the item was added from the published perspective, so the
101
+ // export translates the published document. Absent/false (incl. all
102
+ // legacy items) prefers the draft — see itemDocDerefRespectingDraft.
103
+ defineField({name: 'sourceIsPublished', title: 'Source is published', type: 'boolean'}),
104
+ ],
105
+ preview: {select: {title: 'docId', subtitle: 'docType'}},
106
+ }),
107
+ ],
108
+ }),
109
+ defineField({
110
+ name: 'smartcatProjectId',
111
+ title: 'Smartcat project ID',
112
+ type: 'string',
113
+ readOnly: true,
114
+ description: 'GUID of the corresponding project in Smartcat.',
115
+ }),
116
+ defineField({
117
+ name: 'smartcatProjectUrl',
118
+ title: 'Smartcat project URL',
119
+ type: 'url',
120
+ readOnly: true,
121
+ description: 'Link to the project in Smartcat (set by the export Function).',
122
+ }),
123
+ defineField({name: 'lastExportAt', type: 'datetime', readOnly: true}),
124
+ defineField({name: 'lastImportAt', type: 'datetime', readOnly: true}),
125
+ // Per-document, per-language, per-stage translation progress, mirrored from
126
+ // Smartcat. The export captures the target document ids; the progress/import
127
+ // Functions refresh the stage percentages; the browser sets `imported`.
128
+ defineField({
129
+ name: 'progress',
130
+ title: 'Translation progress',
131
+ type: 'array',
132
+ readOnly: true,
133
+ of: [
134
+ defineArrayMember({
135
+ type: 'object',
136
+ name: 'smartcatDocProgress',
137
+ fields: [
138
+ defineField({name: 'sourceDocId', type: 'string'}),
139
+ defineField({name: 'title', type: 'string'}),
140
+ defineField({
141
+ name: 'targets',
142
+ type: 'array',
143
+ of: [
144
+ defineArrayMember({
145
+ type: 'object',
146
+ name: 'smartcatTargetProgress',
147
+ fields: [
148
+ defineField({name: 'language', type: 'string'}),
149
+ defineField({name: 'smartcatDocumentId', type: 'string'}),
150
+ defineField({
151
+ name: 'stages',
152
+ type: 'array',
153
+ of: [
154
+ defineArrayMember({
155
+ type: 'object',
156
+ name: 'smartcatStageProgress',
157
+ fields: [
158
+ defineField({name: 'name', type: 'string'}),
159
+ defineField({name: 'percent', type: 'number'}),
160
+ ],
161
+ }),
162
+ ],
163
+ }),
164
+ // All stages at 100% (set by the progress/import Functions).
165
+ defineField({name: 'complete', type: 'boolean'}),
166
+ // A locale variant has been built from this target (set by the browser).
167
+ defineField({name: 'imported', type: 'boolean'}),
168
+ defineField({name: 'syncedAt', type: 'datetime'}),
169
+ ],
170
+ }),
171
+ ],
172
+ }),
173
+ ],
174
+ }),
175
+ ],
176
+ }),
177
+ // Handshake fields for the on-demand progress refresh: the browser bumps
178
+ // `progressRequestedAt`; the smartcat-progress Function reacts and writes
179
+ // `progressSyncedAt`. Keeping them distinct stops the Function re-triggering
180
+ // on its own write (filter is delta::changedAny(progressRequestedAt)).
181
+ defineField({name: 'progressRequestedAt', type: 'datetime', readOnly: true, hidden: true}),
182
+ defineField({name: 'progressSyncedAt', type: 'datetime', readOnly: true, hidden: true}),
183
+ defineField({
184
+ name: 'lastError',
185
+ title: 'Last error',
186
+ type: 'string',
187
+ readOnly: true,
188
+ description: 'Most recent export/import error, if any.',
189
+ }),
190
+ // JSON-encoded LogLine[] from the most recent Function run (export upload or
191
+ // import download). Transient diagnostic data the browser merges into its
192
+ // live Log panel; overwritten each run.
193
+ defineField({
194
+ name: 'functionLog',
195
+ type: 'text',
196
+ readOnly: true,
197
+ hidden: true,
198
+ }),
199
+ // Transient transport buffers. The browser (plugin) serializes content into
200
+ // `outbox`; the thin export Function uploads it. The thin import Function
201
+ // downloads translated files into `inbox`; the browser turns them into
202
+ // locale variants. Both are cleared after processing.
203
+ defineField({
204
+ name: 'outbox',
205
+ type: 'array',
206
+ readOnly: true,
207
+ hidden: true,
208
+ of: [
209
+ defineArrayMember({
210
+ type: 'object',
211
+ name: 'smartcatOutboxItem',
212
+ fields: [
213
+ defineField({name: 'filename', type: 'string'}),
214
+ defineField({name: 'locjson', type: 'text'}),
215
+ ],
216
+ }),
217
+ ],
218
+ }),
219
+ defineField({
220
+ name: 'inbox',
221
+ type: 'array',
222
+ readOnly: true,
223
+ hidden: true,
224
+ of: [
225
+ defineArrayMember({
226
+ type: 'object',
227
+ name: 'smartcatInboxItem',
228
+ fields: [
229
+ defineField({name: 'sourceDocId', type: 'string'}),
230
+ defineField({name: 'targetLanguage', type: 'string'}),
231
+ defineField({name: 'smartcatDocumentId', type: 'string'}),
232
+ defineField({name: 'locjson', type: 'text'}),
233
+ ],
234
+ }),
235
+ ],
236
+ }),
237
+ // Transient: items the browser marked for deletion. On the next export the
238
+ // Function deletes the Smartcat document and removes the item + its progress.
239
+ defineField({
240
+ name: 'pendingDeletions',
241
+ type: 'array',
242
+ readOnly: true,
243
+ hidden: true,
244
+ of: [
245
+ defineArrayMember({
246
+ type: 'object',
247
+ name: 'smartcatPendingDeletion',
248
+ fields: [
249
+ defineField({name: 'itemKey', type: 'string'}),
250
+ defineField({name: 'sourceDocId', type: 'string'}),
251
+ defineField({name: 'smartcatDocumentId', type: 'string'}),
252
+ ],
253
+ }),
254
+ ],
255
+ }),
256
+ ],
257
+ preview: {
258
+ select: {name: 'name', status: 'status', items: 'items'},
259
+ prepare({name, status, items}) {
260
+ const count = Array.isArray(items) ? items.length : 0
261
+ return {
262
+ title: name || 'Untitled project',
263
+ subtitle: `${status || 'draft'} · ${count} item${count === 1 ? '' : 's'}`,
264
+ }
265
+ },
266
+ },
267
+ })
268
+ }