@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
package/README.md
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# @smartcat/sanity-plugin
|
|
2
|
+
|
|
3
|
+
Group Sanity content into **translation projects**, send them to
|
|
4
|
+
[Smartcat](https://www.smartcat.com) for translation, and import the translations
|
|
5
|
+
back into your Studio — as locale documents (document-level i18n) or inline field
|
|
6
|
+
variants (field-level i18n).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @smartcat/sanity-plugin
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
Add the plugin to your `sanity.config.ts` and list the document types that can be translated:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import {defineConfig} from 'sanity'
|
|
20
|
+
import {smartcatTranslation} from '@smartcat/sanity-plugin'
|
|
21
|
+
|
|
22
|
+
export default defineConfig({
|
|
23
|
+
// ...
|
|
24
|
+
plugins: [
|
|
25
|
+
smartcatTranslation({
|
|
26
|
+
translatableTypes: ['page', 'blogPost'],
|
|
27
|
+
sourceLanguage: 'en',
|
|
28
|
+
}),
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This:
|
|
34
|
+
|
|
35
|
+
- registers a `smartcat.translationProject` document type, and
|
|
36
|
+
- adds an **Add to translation project** action to the `⋯` menu of `page` and
|
|
37
|
+
`blogPost` documents.
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
| Option | Type | Description |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `translatableTypes` | `string[]` | Document types that show the **Add to translation project** action and status line. Omit to allow every document type (except the plugin's own `smartcat.*` types). |
|
|
44
|
+
| `rootTypes` | `string[]` | Root/standalone types where the "include linked documents" crawl stops (they're translated on their own). Defaults to `translatableTypes` if set, otherwise `[]` (no boundary — every transitively-linked translatable document is pulled in). |
|
|
45
|
+
| `sourceLanguage` | `string` | Source language tag for new projects. Defaults to `"en"`. |
|
|
46
|
+
| `languages` | `SmartcatLanguage[]` | Target languages offered in the dashboard's "Send to Smartcat" picker. |
|
|
47
|
+
| `documentI18nPluginLanguageField` | `string` | Field name `@sanity/document-internationalization` stores each document's locale in. Match that plugin's `languageField` setting. Defaults to `"language"`. |
|
|
48
|
+
| `fieldI18nPluginLanguageField` | `string` | How `sanity-plugin-internationalized-array` identifies the locale within a field's members. `"language"` (default) for v5+; set to `"_key"` for v4.x and lower. |
|
|
49
|
+
|
|
50
|
+
> The dashboard's link to the Smartcat project uses the URL the export Function
|
|
51
|
+
> stores on the document (`smartcatProjectUrl`), derived from the Function's
|
|
52
|
+
> `SMARTCAT_API_SERVER` — so the hostname lives only in the Function's env, not in
|
|
53
|
+
> Studio config.
|
|
54
|
+
|
|
55
|
+
## Localization modes (document- vs field-level)
|
|
56
|
+
|
|
57
|
+
The plugin supports both Sanity localization styles, chosen **per type** and
|
|
58
|
+
detected automatically from the schema — no extra config, and no dependency on
|
|
59
|
+
either localization plugin:
|
|
60
|
+
|
|
61
|
+
- **Document-level** (`@sanity/document-internationalization`): one document per
|
|
62
|
+
language. Imports create/update a locale **variant document** linked via
|
|
63
|
+
`translation.metadata`.
|
|
64
|
+
- **Field-level** (`sanity-plugin-internationalized-array`): all languages live
|
|
65
|
+
inline on the **same document** as arrays of `{_key, language, value}`. Imports
|
|
66
|
+
write the translated `value` back into that same document as the target-language
|
|
67
|
+
member — no variant, no metadata.
|
|
68
|
+
|
|
69
|
+
A type is treated as **field-level** if it declares any `internationalizedArray*`
|
|
70
|
+
field (detected by naming convention / member `_type` matching
|
|
71
|
+
`/^internationalizedArray.+Value$/`); in that case **only** those array fields are
|
|
72
|
+
translated. Otherwise it's document-level (top-level `string`/`text`/Portable Text
|
|
73
|
+
fields). The mode is a pure function of the schema — computed in the Studio (once
|
|
74
|
+
per type), shown as a `mode:` badge next to each project item, and used to route
|
|
75
|
+
imports. It is **not** written into LocJSON (which carries only document identity).
|
|
76
|
+
|
|
77
|
+
The locale **field/key** each plugin uses is set explicitly via config, not guessed:
|
|
78
|
+
`documentI18nPluginLanguageField` (the document's locale field, default `"language"`)
|
|
79
|
+
and `fieldI18nPluginLanguageField` (the array member's locale key — `"language"` for
|
|
80
|
+
internationalized-array v5+, or `"_key"` for v4.x and lower). These drive both reading
|
|
81
|
+
the source and writing translations back, with no fallback between conventions.
|
|
82
|
+
|
|
83
|
+
### Which fields are translated
|
|
84
|
+
|
|
85
|
+
Within a type, fields are selected by their resolved schema type — a conservative
|
|
86
|
+
allowlist, so nothing unexpected is ever sent for translation:
|
|
87
|
+
|
|
88
|
+
| Field type | Translated? | Sent as |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `string`, `text` | ✅ | plain text |
|
|
91
|
+
| Portable Text (`array` containing a `block` member) | ✅ | HTML |
|
|
92
|
+
| `internationalizedArray*` (field-level i18n) | ✅ | classified by the member's inner `value` type — plain text or HTML |
|
|
93
|
+
| `slug`, `url`, `datetime`, `number`, `boolean`, `reference`, `image`, `file` | ❌ | — |
|
|
94
|
+
| `object` types, and any field nested inside them | ❌ | — (only top-level fields are walked) |
|
|
95
|
+
| `array` without a `block` member (e.g. arrays of references) | ❌ | — |
|
|
96
|
+
| names starting with `_`, the locale field, and `documentInternationalization`-excluded fields | ❌ | — |
|
|
97
|
+
| anything marked `smartcatTranslation.include` | ✅ | derived from the resolved `jsonType` (string → plain text, block array → HTML) |
|
|
98
|
+
|
|
99
|
+
Notes:
|
|
100
|
+
|
|
101
|
+
- Plain text is matched on the **base** types `string`/`text` only — a custom
|
|
102
|
+
type aliased to string (`defineType({name: 'headingString', type: 'string'})`)
|
|
103
|
+
is **not** translated unless you opt it in (see
|
|
104
|
+
[Including custom field types](#including-custom-field-types)).
|
|
105
|
+
- Selection is **not** recursive: strings nested inside an `object` (e.g. an `seo`
|
|
106
|
+
object's `metaTitle`, or an inline image's `alt`) are not picked up.
|
|
107
|
+
- In **field-level** types, only the `internationalizedArray*` fields are
|
|
108
|
+
translated; plain top-level `string`/Portable Text fields on the same document
|
|
109
|
+
are skipped.
|
|
110
|
+
|
|
111
|
+
### Excluding fields from translation
|
|
112
|
+
|
|
113
|
+
For **document-level** types, mark a field non-translatable with
|
|
114
|
+
`@sanity/document-internationalization`'s own field option — the plugin honors it,
|
|
115
|
+
so there's no separate exclude list to maintain:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
defineField({
|
|
119
|
+
name: 'sku',
|
|
120
|
+
type: 'string',
|
|
121
|
+
options: {documentInternationalization: {exclude: true}},
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The document's locale field is excluded automatically (it's the configured
|
|
126
|
+
`documentI18nPluginLanguageField`). For **field-level** types nothing extra is needed:
|
|
127
|
+
only the fields you explicitly declare as `internationalizedArray*` are translated.
|
|
128
|
+
|
|
129
|
+
### Including custom field types
|
|
130
|
+
|
|
131
|
+
The default allowlist matches the **base** `string`/`text` types by name, so a
|
|
132
|
+
custom type aliased to string (or to a Portable Text array) is skipped. Opt it
|
|
133
|
+
back in with `options.smartcatTranslation.include` — either once on the custom
|
|
134
|
+
type, or per field:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// Once on the alias — every field using it becomes translatable:
|
|
138
|
+
defineType({
|
|
139
|
+
name: 'headingString',
|
|
140
|
+
type: 'string',
|
|
141
|
+
options: {smartcatTranslation: {include: true}},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// …or per field:
|
|
145
|
+
defineField({
|
|
146
|
+
name: 'tagline',
|
|
147
|
+
type: 'string',
|
|
148
|
+
options: {smartcatTranslation: {include: true}},
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The translation kind is derived from the resolved `jsonType` (`string` → plain
|
|
153
|
+
text, an `array` containing a `block` → HTML). If the type has no translatable
|
|
154
|
+
representation (e.g. a `number`), the field is skipped and a warning is logged.
|
|
155
|
+
`include` also overrides a `documentInternationalization.exclude` on the same
|
|
156
|
+
field, for the case where a field shouldn't be *duplicated* into a new locale
|
|
157
|
+
document but should still be *translated*.
|
|
158
|
+
|
|
159
|
+
## Architecture: thin Functions, browser processing
|
|
160
|
+
|
|
161
|
+
All content transformation (Portable Text ⇄ HTML, field selection, and writing
|
|
162
|
+
translations back — as locale variants or inline field-level members) runs **in the
|
|
163
|
+
Studio (browser)**, where the real schema and a native DOM
|
|
164
|
+
are available. Three **Sanity Functions** do only the Smartcat API calls that need
|
|
165
|
+
the secret API key — so they stay dependency-free and bundle cleanly (no publishing
|
|
166
|
+
required). The browser and Functions hand off through transient `outbox`/`inbox`
|
|
167
|
+
fields on the project, coordinated by `status`:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
draft → (browser serializes → outbox) → queued → [export fn uploads] → sent
|
|
171
|
+
sent → [progress fn mirrors Smartcat stage % onto `progress`] → translating
|
|
172
|
+
translating → (Import) → importing → [import fn downloads COMPLETE targets → inbox]
|
|
173
|
+
→ downloaded → (browser writes variants / inline members) → completed (only when every
|
|
174
|
+
target is 100% on all stages AND imported; else back to translating)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Project lifecycle & re-sync.** A new translation project isn't linked to
|
|
178
|
+
Smartcat — target languages are editable. The **first** "Send to Smartcat" creates
|
|
179
|
+
one Smartcat project, stores its GUID/URL, mirrors Smartcat's (possibly
|
|
180
|
+
de-duplicated) project name back, and locks the target languages. Every **later**
|
|
181
|
+
send reuses that same project: each source file is *updated in place* by its
|
|
182
|
+
tracked document id (Smartcat merges changes, keeping existing translations) and
|
|
183
|
+
only newly-added items are created — no duplicate Smartcat projects.
|
|
184
|
+
|
|
185
|
+
**Removing items.** An item not yet in Smartcat has a **Delete** button that
|
|
186
|
+
removes it from the project immediately. An item already in Smartcat instead has a
|
|
187
|
+
**Mark for deletion** toggle (local, not persisted on reload); on the next
|
|
188
|
+
re-sync those documents are deleted from Smartcat (one delete removes all target
|
|
189
|
+
languages) and the items are removed from the project.
|
|
190
|
+
|
|
191
|
+
**Progress & completion.** The dashboard shows per-document, per-language,
|
|
192
|
+
per-stage progress under each item. On page load (and via "Refresh progress") the
|
|
193
|
+
browser bumps a `progressRequestedAt` trigger; the **progress Function** fetches the
|
|
194
|
+
Smartcat project and mirrors each target's workflow-stage percentages onto the
|
|
195
|
+
project's `progress` array. Import pulls every target that has **confirmed content
|
|
196
|
+
at any stage** (`mode=confirmed`, so unconfirmed segments fall back to source);
|
|
197
|
+
targets with nothing confirmed are skipped, and re-importing refreshes the variant
|
|
198
|
+
(or inline members) as more is confirmed. The project is marked `completed` only
|
|
199
|
+
once every target is at 100% on all stages **and** imported (computed at import
|
|
200
|
+
time).
|
|
201
|
+
|
|
202
|
+
### 1. Add the three Functions
|
|
203
|
+
|
|
204
|
+
`functions/smartcat-export/index.ts`:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import {documentEventHandler} from '@sanity/functions'
|
|
208
|
+
import {createClient} from '@sanity/client'
|
|
209
|
+
import {SmartcatClient} from '@smartcat/sanity-plugin/smartcat'
|
|
210
|
+
import {runExportUpload} from '@smartcat/sanity-plugin/export'
|
|
211
|
+
|
|
212
|
+
export const handler = documentEventHandler<{_id: string}>(async ({context, event}) => {
|
|
213
|
+
const sanity = createClient({...context.clientOptions, apiVersion: '2025-02-01'})
|
|
214
|
+
const smartcat = new SmartcatClient({
|
|
215
|
+
server: process.env.SMARTCAT_API_SERVER as string,
|
|
216
|
+
workspaceId: process.env.SMARTCAT_WORKSPACE_ID as string,
|
|
217
|
+
apiKey: process.env.SMARTCAT_API_KEY as string,
|
|
218
|
+
})
|
|
219
|
+
await runExportUpload({sanity, smartcat, projectId: event.data._id})
|
|
220
|
+
})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`functions/smartcat-import/index.ts` is identical but imports `runImportDownload`
|
|
224
|
+
from `@smartcat/sanity-plugin/import`; `functions/smartcat-progress/index.ts`
|
|
225
|
+
imports `runProgressSync` from `@smartcat/sanity-plugin/progress`.
|
|
226
|
+
|
|
227
|
+
### 2. Register them in `sanity.blueprint.ts`
|
|
228
|
+
|
|
229
|
+
Declare the credentials once and share them across all three Functions (**Option B** —
|
|
230
|
+
see below):
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
|
|
234
|
+
|
|
235
|
+
const T = 'smartcat.translationProject'
|
|
236
|
+
const smartcatEnv = {
|
|
237
|
+
SMARTCAT_API_SERVER: process.env.SMARTCAT_API_SERVER as string,
|
|
238
|
+
SMARTCAT_WORKSPACE_ID: process.env.SMARTCAT_WORKSPACE_ID as string,
|
|
239
|
+
SMARTCAT_API_KEY: process.env.SMARTCAT_API_KEY as string,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export default defineBlueprint({
|
|
243
|
+
resources: [
|
|
244
|
+
defineDocumentFunction({
|
|
245
|
+
name: 'smartcat-export',
|
|
246
|
+
timeout: 120,
|
|
247
|
+
env: smartcatEnv,
|
|
248
|
+
event: {on: ['create', 'update'], filter: `_type == "${T}" && status == "queued"`, projection: '{_id}'},
|
|
249
|
+
}),
|
|
250
|
+
defineDocumentFunction({
|
|
251
|
+
name: 'smartcat-import',
|
|
252
|
+
timeout: 300, // import polls Smartcat per document
|
|
253
|
+
env: smartcatEnv,
|
|
254
|
+
event: {on: ['create', 'update'], filter: `_type == "${T}" && status == "importing"`, projection: '{_id}'},
|
|
255
|
+
}),
|
|
256
|
+
defineDocumentFunction({
|
|
257
|
+
name: 'smartcat-progress',
|
|
258
|
+
timeout: 60,
|
|
259
|
+
env: smartcatEnv,
|
|
260
|
+
// Fires on the dashboard's progressRequestedAt bump; never re-triggers on its own write.
|
|
261
|
+
event: {on: ['create', 'update'], filter: `_type == "${T}" && defined(smartcatProjectId) && delta::changedAny(progressRequestedAt)`, projection: '{_id}'},
|
|
262
|
+
}),
|
|
263
|
+
],
|
|
264
|
+
})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### 3. Provide credentials & deploy
|
|
268
|
+
|
|
269
|
+
Function env vars are scoped per-Function. Rather than setting all three vars on
|
|
270
|
+
each Function with `sanity functions env add` (Option A), **Option B** declares them
|
|
271
|
+
once in the blueprint's `env` (above), resolved from your deploy-time environment.
|
|
272
|
+
|
|
273
|
+
In practice you don't need to write the Functions or the blueprint yourself —
|
|
274
|
+
they ship pre-built in `@smartcat/sanity-functions`, with a deploy CLI that
|
|
275
|
+
resolves the Smartcat credentials (env → prompt, saved to a gitignored
|
|
276
|
+
`.smartcat/credentials.json`) and Sanity auth (`SANITY_AUTH_TOKEN` → `sanity login`)
|
|
277
|
+
for you:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
npx @smartcat/sanity-functions deploy
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
See [INSTALL.md](../../INSTALL.md) for the full flow, including the CI recipe.
|
|
284
|
+
|
|
285
|
+
> Because the Functions only call the Smartcat API (no `@sanity/block-tools`,
|
|
286
|
+
> `jsdom`, etc.), they bundle without a function-level `package.json` and without
|
|
287
|
+
> publishing this plugin. The heavy lifting happens in the Studio.
|
|
288
|
+
|
|
289
|
+
> **Note:** Since processing runs in the browser, export/import advance while the
|
|
290
|
+
> editor has the Studio open. Import pulls each target's **confirmed** translation
|
|
291
|
+
> (`mode=confirmed`) for any target with confirmed content, falling back to source
|
|
292
|
+
> for unconfirmed segments. Import is idempotent in both modes (variant `set`, or
|
|
293
|
+
> updating the inline member in place), so re-importing simply refreshes the
|
|
294
|
+
> translation as more segments get confirmed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const TRANSLATION_PROJECT_TYPE = "smartcat.translationProject", SETTINGS_TYPE = "smartcat.settings", SETTINGS_DOC_ID = "smartcat.settings", API_VERSION = "2025-02-01";
|
|
3
|
+
function requireSourceLanguage(sourceLanguage) {
|
|
4
|
+
if (!sourceLanguage)
|
|
5
|
+
throw new Error("Smartcat plugin: `sourceLanguage` is not configured");
|
|
6
|
+
return sourceLanguage;
|
|
7
|
+
}
|
|
8
|
+
exports.API_VERSION = API_VERSION;
|
|
9
|
+
exports.SETTINGS_DOC_ID = SETTINGS_DOC_ID;
|
|
10
|
+
exports.SETTINGS_TYPE = SETTINGS_TYPE;
|
|
11
|
+
exports.TRANSLATION_PROJECT_TYPE = TRANSLATION_PROJECT_TYPE;
|
|
12
|
+
exports.requireSourceLanguage = requireSourceLanguage;
|
|
13
|
+
//# sourceMappingURL=constants.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.cjs","sources":["../../src/lib/constants.ts"],"sourcesContent":["/**\n * Document type for a translation project (a named set of items to translate).\n *\n * @public\n */\nexport const TRANSLATION_PROJECT_TYPE = 'smartcat.translationProject'\n\n/** Singleton document holding workspace-wide Smartcat settings (template cache). */\nexport const SETTINGS_TYPE = 'smartcat.settings'\n\n/** Fixed `_id` of the settings singleton. */\nexport const SETTINGS_DOC_ID = 'smartcat.settings'\n\n/** Sanity API version used by the plugin's client calls. */\nexport const API_VERSION = '2025-02-01'\n\n/** Resolves the configured source language, failing if it's not set. */\nexport function requireSourceLanguage(sourceLanguage: string | undefined): string {\n if (!sourceLanguage) {\n throw new Error('Smartcat plugin: `sourceLanguage` is not configured')\n }\n return sourceLanguage\n}\n"],"names":[],"mappings":";AAKO,MAAM,2BAA2B,+BAG3B,gBAAgB,qBAGhB,kBAAkB,qBAGlB,cAAc;AAGpB,SAAS,sBAAsB,gBAA4C;AAChF,MAAI,CAAC;AACH,UAAM,IAAI,MAAM,qDAAqD;AAEvE,SAAO;AACT;;;;;;"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
function buildLanguageMappings(languages, ids) {
|
|
3
|
+
const byId = new Map(languages.map((l) => [l.id, l.smartcatLanguage || l.id]));
|
|
4
|
+
return ids.map((id) => ({ sanityId: id, smartcatLanguage: byId.get(id) || id }));
|
|
5
|
+
}
|
|
6
|
+
function toSmartcatLanguage(mappings, sanityId) {
|
|
7
|
+
return mappings?.find((m) => m.sanityId === sanityId)?.smartcatLanguage ?? sanityId;
|
|
8
|
+
}
|
|
9
|
+
function toSanityLanguage(mappings, smartcatLanguage) {
|
|
10
|
+
return mappings?.find((m) => m.smartcatLanguage === smartcatLanguage)?.sanityId ?? smartcatLanguage;
|
|
11
|
+
}
|
|
12
|
+
function findDuplicateSmartcatLanguages(mappings) {
|
|
13
|
+
const byCode = /* @__PURE__ */ new Map();
|
|
14
|
+
for (const m of mappings) {
|
|
15
|
+
const ids = byCode.get(m.smartcatLanguage) ?? [];
|
|
16
|
+
ids.push(m.sanityId), byCode.set(m.smartcatLanguage, ids);
|
|
17
|
+
}
|
|
18
|
+
return [...byCode.entries()].filter(([, ids]) => ids.length > 1).map(([smartcatLanguage, sanityIds]) => ({ smartcatLanguage, sanityIds }));
|
|
19
|
+
}
|
|
20
|
+
const ITEM_ID = "coalesce(docId, _ref)", ITEM_ID_FROM_SUBQUERY = "coalesce(^.docId, ^._ref)", itemDocDeref = (projection = "") => `*[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}`, itemDocDerefRespectingDraft = (projection = "") => `select(sourceIsPublished == true => *[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}, coalesce(*[_id == "drafts." + ${ITEM_ID_FROM_SUBQUERY}][0]${projection}, *[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}))`;
|
|
21
|
+
function keyify(value) {
|
|
22
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "-") || "x";
|
|
23
|
+
}
|
|
24
|
+
function targetKey(sourceDocId, language) {
|
|
25
|
+
return keyify(`${sourceDocId}__${language}`);
|
|
26
|
+
}
|
|
27
|
+
function clampPercent(value) {
|
|
28
|
+
return typeof value != "number" || Number.isNaN(value) ? 0 : Math.max(0, Math.min(100, Math.round(value)));
|
|
29
|
+
}
|
|
30
|
+
function stripDraft(id) {
|
|
31
|
+
return id.replace(/^drafts\./, "");
|
|
32
|
+
}
|
|
33
|
+
function titleCase(value) {
|
|
34
|
+
return value && value.charAt(0).toUpperCase() + value.slice(1);
|
|
35
|
+
}
|
|
36
|
+
function buildStageNameMap(scProject) {
|
|
37
|
+
const map = /* @__PURE__ */ new Map();
|
|
38
|
+
return (scProject.workflowStages ?? []).forEach((stage, i) => {
|
|
39
|
+
stage.id && map.set(stage.id, titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`));
|
|
40
|
+
}), map;
|
|
41
|
+
}
|
|
42
|
+
function stagesFromDocument(doc, stageNames) {
|
|
43
|
+
return (doc.workflowStages ?? []).map((stage, i) => ({
|
|
44
|
+
_key: stage.id || `s${i}`,
|
|
45
|
+
name: stage.id && stageNames.get(stage.id) || titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`),
|
|
46
|
+
percent: clampPercent(stage.progress)
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
function isTargetComplete(stages) {
|
|
50
|
+
return stages.length > 0 && stages.every((s) => s.percent >= 100);
|
|
51
|
+
}
|
|
52
|
+
function hasConfirmedContent(stages) {
|
|
53
|
+
return stages.some((s) => s.percent > 0);
|
|
54
|
+
}
|
|
55
|
+
function targetPercent(stages) {
|
|
56
|
+
return stages.length === 0 ? 0 : Math.round(stages.reduce((sum, s) => sum + s.percent, 0) / stages.length);
|
|
57
|
+
}
|
|
58
|
+
function sourceIdPrefix(doc) {
|
|
59
|
+
return ((doc.name || doc.filename || doc.fullPath || "").split("/").pop() ?? "").replace(/\.locjson$/i, "").replace(/-draft$/, "").slice(-8);
|
|
60
|
+
}
|
|
61
|
+
function buildProgress(items, scProject, previous, now, mappings) {
|
|
62
|
+
const stageNames = buildStageNameMap(scProject), importedSet = /* @__PURE__ */ new Set(), titleByDoc = /* @__PURE__ */ new Map();
|
|
63
|
+
for (const doc of previous ?? []) {
|
|
64
|
+
doc.title && titleByDoc.set(doc.sourceDocId, doc.title);
|
|
65
|
+
for (const target of doc.targets)
|
|
66
|
+
target.imported && importedSet.add(`${doc.sourceDocId}__${target.language}`);
|
|
67
|
+
}
|
|
68
|
+
const itemByPrefix = /* @__PURE__ */ new Map();
|
|
69
|
+
for (const item of items) itemByPrefix.set(stripDraft(item._id).slice(0, 8), item);
|
|
70
|
+
const byDoc = /* @__PURE__ */ new Map();
|
|
71
|
+
for (const scDoc of scProject.documents ?? []) {
|
|
72
|
+
if (!scDoc.targetLanguage) continue;
|
|
73
|
+
const item = itemByPrefix.get(sourceIdPrefix(scDoc));
|
|
74
|
+
if (!item) continue;
|
|
75
|
+
const language = toSanityLanguage(mappings, scDoc.targetLanguage);
|
|
76
|
+
let docProgress = byDoc.get(item._id);
|
|
77
|
+
if (!docProgress) {
|
|
78
|
+
const title = item.title || titleByDoc.get(item._id);
|
|
79
|
+
docProgress = { _key: targetKey(item._id, "doc"), sourceDocId: item._id, title, targets: [] }, byDoc.set(item._id, docProgress);
|
|
80
|
+
}
|
|
81
|
+
const stages = stagesFromDocument(scDoc, stageNames);
|
|
82
|
+
docProgress.targets.push({
|
|
83
|
+
_key: targetKey(item._id, language),
|
|
84
|
+
language,
|
|
85
|
+
smartcatDocumentId: scDoc.id,
|
|
86
|
+
stages,
|
|
87
|
+
complete: isTargetComplete(stages),
|
|
88
|
+
imported: importedSet.has(`${item._id}__${language}`),
|
|
89
|
+
syncedAt: now
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
const docProgress = byDoc.get(item._id);
|
|
95
|
+
docProgress && (docProgress.targets.sort((a, b) => a.language.localeCompare(b.language)), result.push(docProgress));
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function summarizeCompletion(progress) {
|
|
100
|
+
let total = 0, complete = 0, imported = 0, anyProgress = !1;
|
|
101
|
+
for (const doc of progress)
|
|
102
|
+
for (const target of doc.targets)
|
|
103
|
+
total++, target.complete && complete++, target.imported && imported++, target.stages.some((s) => s.percent > 0) && (anyProgress = !0);
|
|
104
|
+
return {
|
|
105
|
+
total,
|
|
106
|
+
complete,
|
|
107
|
+
imported,
|
|
108
|
+
allDone: total > 0 && complete === total && imported === total,
|
|
109
|
+
anyProgress
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const PROGRESS_QUERY = `*[_id == $id][0]{
|
|
113
|
+
_id,
|
|
114
|
+
status,
|
|
115
|
+
smartcatProjectId,
|
|
116
|
+
smartcatLanguages[]{sanityId, smartcatLanguage},
|
|
117
|
+
"items": items[]{ "_id": ${ITEM_ID}, "title": ${itemDocDerefRespectingDraft(".title")} },
|
|
118
|
+
progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}
|
|
119
|
+
}`;
|
|
120
|
+
function itemsFromProject(project) {
|
|
121
|
+
const result = [];
|
|
122
|
+
for (const item of project.items ?? []) {
|
|
123
|
+
const _id = item?._id ?? item?.doc?._id;
|
|
124
|
+
_id && result.push({ _id, title: item?.title ?? item?.doc?.title ?? void 0 });
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
async function runProgressSync(options) {
|
|
129
|
+
const { sanity, smartcat, projectId, now = () => (/* @__PURE__ */ new Date()).toISOString() } = options, project = await sanity.fetch(PROGRESS_QUERY, { id: projectId });
|
|
130
|
+
if (!project) throw new Error(`Translation project ${projectId} not found`);
|
|
131
|
+
if (!project.smartcatProjectId)
|
|
132
|
+
throw new Error("Project has no smartcatProjectId \u2014 export it first");
|
|
133
|
+
const timestamp = now(), log = [];
|
|
134
|
+
smartcat.setRequestLogger?.(({ method, path, status, body }) => {
|
|
135
|
+
status >= 200 && status < 300 || (log.push({
|
|
136
|
+
level: "error",
|
|
137
|
+
message: status ? `Error ${status} while performing ${method} ${path}` : `${method} ${path} did not complete`
|
|
138
|
+
}), body && log.push({ level: "error", indent: !0, message: body }));
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const scProject = await smartcat.getProject(project.smartcatProjectId), progress = buildProgress(
|
|
142
|
+
itemsFromProject(project),
|
|
143
|
+
scProject,
|
|
144
|
+
project.progress,
|
|
145
|
+
timestamp,
|
|
146
|
+
project.smartcatLanguages
|
|
147
|
+
), summary = summarizeCompletion(progress), patch = { progress, progressSyncedAt: timestamp, lastError: null };
|
|
148
|
+
return project.status === "sent" && summary.anyProgress && (patch.status = "translating"), await sanity.patch(projectId).set(patch).commit(), { summary };
|
|
149
|
+
} catch (err) {
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
return log.some((l) => l.level === "error") || log.push({ level: "error", message }), await sanity.patch(projectId).set({ lastError: message, functionLog: JSON.stringify(log), progressSyncedAt: timestamp }).commit().catch(() => {
|
|
152
|
+
}), { summary: summarizeCompletion(project.progress ?? []), error: message };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
exports.ITEM_ID = ITEM_ID;
|
|
156
|
+
exports.ITEM_ID_FROM_SUBQUERY = ITEM_ID_FROM_SUBQUERY;
|
|
157
|
+
exports.buildLanguageMappings = buildLanguageMappings;
|
|
158
|
+
exports.buildProgress = buildProgress;
|
|
159
|
+
exports.buildStageNameMap = buildStageNameMap;
|
|
160
|
+
exports.findDuplicateSmartcatLanguages = findDuplicateSmartcatLanguages;
|
|
161
|
+
exports.hasConfirmedContent = hasConfirmedContent;
|
|
162
|
+
exports.isTargetComplete = isTargetComplete;
|
|
163
|
+
exports.itemDocDeref = itemDocDeref;
|
|
164
|
+
exports.itemDocDerefRespectingDraft = itemDocDerefRespectingDraft;
|
|
165
|
+
exports.itemsFromProject = itemsFromProject;
|
|
166
|
+
exports.keyify = keyify;
|
|
167
|
+
exports.runProgressSync = runProgressSync;
|
|
168
|
+
exports.sourceIdPrefix = sourceIdPrefix;
|
|
169
|
+
exports.stagesFromDocument = stagesFromDocument;
|
|
170
|
+
exports.summarizeCompletion = summarizeCompletion;
|
|
171
|
+
exports.targetKey = targetKey;
|
|
172
|
+
exports.targetPercent = targetPercent;
|
|
173
|
+
exports.toSmartcatLanguage = toSmartcatLanguage;
|
|
174
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":["../../src/lib/languageMap.ts","../../src/lib/projectItems.ts","../../src/progress/core.ts"],"sourcesContent":["/**\n * Sanity ↔ Smartcat language-code mapping.\n *\n * Sanity locale tags (`id`) don't always match the codes Smartcat understands\n * (e.g. Sanity \"PL-PL\" vs Smartcat \"pl\"). The browser resolves the mapping from\n * plugin config and stamps the relevant pairs onto the project; the thin\n * Functions read them back. This module is dependency-free so it bundles into\n * both the Studio and the Functions.\n *\n * The invariant: everything stored in Sanity uses the Sanity `id`; only the\n * Smartcat HTTP calls use `smartcatLanguage`. So callers map forward (id → code)\n * when talking to Smartcat and invert (code → id) when storing what comes back.\n */\n\nexport interface SmartcatLanguageMapping {\n sanityId: string\n smartcatLanguage: string\n}\n\n/** Minimal language shape needed to build mappings (a subset of SmartcatLanguage). */\ninterface MappableLanguage {\n id: string\n smartcatLanguage?: string\n}\n\n/**\n * Builds the mapping pairs for the given Sanity ids, resolving each to its\n * configured `smartcatLanguage` (falling back to the id when not configured).\n */\nexport function buildLanguageMappings(\n languages: MappableLanguage[],\n ids: string[],\n): SmartcatLanguageMapping[] {\n const byId = new Map(languages.map((l) => [l.id, l.smartcatLanguage || l.id]))\n return ids.map((id) => ({sanityId: id, smartcatLanguage: byId.get(id) || id}))\n}\n\n/** Sanity id → Smartcat code. Identity when no mapping is found. */\nexport function toSmartcatLanguage(\n mappings: SmartcatLanguageMapping[] | undefined,\n sanityId: string,\n): string {\n return mappings?.find((m) => m.sanityId === sanityId)?.smartcatLanguage ?? sanityId\n}\n\n/** Smartcat code → Sanity id. Identity when no mapping is found. */\nexport function toSanityLanguage(\n mappings: SmartcatLanguageMapping[] | undefined,\n smartcatLanguage: string,\n): string {\n return mappings?.find((m) => m.smartcatLanguage === smartcatLanguage)?.sanityId ?? smartcatLanguage\n}\n\n/**\n * Finds Smartcat codes that more than one Sanity id maps to. A non-empty result\n * means the inverse (code → id) is ambiguous, so translations can't be routed\n * back — callers should reject such a configuration.\n */\nexport function findDuplicateSmartcatLanguages(\n mappings: SmartcatLanguageMapping[],\n): {smartcatLanguage: string; sanityIds: string[]}[] {\n const byCode = new Map<string, string[]>()\n for (const m of mappings) {\n const ids = byCode.get(m.smartcatLanguage) ?? []\n ids.push(m.sanityId)\n byCode.set(m.smartcatLanguage, ids)\n }\n return [...byCode.entries()]\n .filter(([, ids]) => ids.length > 1)\n .map(([smartcatLanguage, sanityIds]) => ({smartcatLanguage, sanityIds}))\n}\n","/**\n * GROQ fragments for reading a translation project's `items`.\n *\n * An item is either the current `smartcat.projectItem` shape (`{docId, docType}`)\n * or a legacy weak reference (`{_ref}`) from before the migration. These\n * fragments normalize across both so the old/new tolerance lives in one place —\n * interpolate them into item queries rather than re-writing `coalesce(...)`.\n */\n\n/** Normalized item id, evaluated in the item's own scope (inside `items[...]`). */\nexport const ITEM_ID = 'coalesce(docId, _ref)'\n\n/** Normalized item id, evaluated from a subquery nested under an item (via `^`). */\nexport const ITEM_ID_FROM_SUBQUERY = 'coalesce(^.docId, ^._ref)'\n\n/** Dereference an item's document, with an optional projection e.g. `{_id, _type}`. */\nexport const itemDocDeref = (projection = ''): string => `*[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}`\n\n/**\n * Dereference an item's document, honoring where it was added from: items added\n * in the **published** perspective (`sourceIsPublished == true`) resolve the\n * published document only; every other item (including legacy items with no\n * flag) prefers the **draft**, falling back to published. This makes the export\n * translate the content the editor was actually viewing, and gives pre-existing\n * projects the draft-preferring default for free.\n */\nexport const itemDocDerefRespectingDraft = (projection = ''): string =>\n `select(` +\n `sourceIsPublished == true => *[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection}, ` +\n `coalesce(*[_id == \"drafts.\" + ${ITEM_ID_FROM_SUBQUERY}][0]${projection}, *[_id == ${ITEM_ID_FROM_SUBQUERY}][0]${projection})` +\n `)`\n","import type {SmartcatProject, SmartcatDocument, RequestLogger} from '../smartcat/types'\nimport {toSanityLanguage, type SmartcatLanguageMapping} from '../lib/languageMap'\nimport {ITEM_ID, itemDocDerefRespectingDraft} from '../lib/projectItems'\nimport type {LogLine} from '../lib/log'\n\n/**\n * Per-document, per-language, per-stage translation progress mirrored from\n * Smartcat onto a `smartcat.translationProject`.\n *\n * This module is intentionally **dependency-free** (no DOM, no @sanity/* runtime)\n * so it bundles cleanly into the thin `smartcat-progress` / `smartcat-import`\n * Functions. All keys are deterministic, so repeated refreshes patch the same\n * array members in place instead of churning `_key`s.\n */\n\nexport interface StageProgress {\n /** Stable key for the Sanity array item (always set by `stagesFromDocument`). */\n _key?: string\n name: string\n /** 0–100, rounded. */\n percent: number\n}\n\nexport interface TargetProgress {\n _key: string\n language: string\n /** Smartcat target document id (`<fileGuid>_<languageNumber>`). */\n smartcatDocumentId?: string\n stages: StageProgress[]\n /** All stages at 100%. */\n complete: boolean\n /** A locale variant has been built from this target (set by the browser). */\n imported: boolean\n syncedAt?: string\n}\n\nexport interface DocProgress {\n _key: string\n sourceDocId: string\n title?: string\n targets: TargetProgress[]\n}\n\n/** Sanitizes an arbitrary string into a stable, Sanity-safe array `_key`. */\nexport function keyify(value: string): string {\n return value.replace(/[^a-zA-Z0-9_-]/g, '-') || 'x'\n}\n\n/** Builds a progress target's `_key` from its document + language. */\nexport function targetKey(sourceDocId: string, language: string): string {\n return keyify(`${sourceDocId}__${language}`)\n}\n\nfunction clampPercent(value: number | undefined): number {\n if (typeof value !== 'number' || Number.isNaN(value)) return 0\n return Math.max(0, Math.min(100, Math.round(value)))\n}\n\n/** Strips a `drafts.` prefix from a Sanity document id. */\nfunction stripDraft(id: string): string {\n return id.replace(/^drafts\\./, '')\n}\n\nfunction titleCase(value: string): string {\n return value ? value.charAt(0).toUpperCase() + value.slice(1) : value\n}\n\n/**\n * Builds a stage-id → display-name map from a project's `workflowStages`. On\n * Smartcat, document-level stages carry only `{id, progress}` — the human name\n * (`stageType`, e.g. \"translation\") lives on the project-level stages.\n */\nexport function buildStageNameMap(scProject: SmartcatProject): Map<string, string> {\n const map = new Map<string, string>()\n ;(scProject.workflowStages ?? []).forEach((stage, i) => {\n if (stage.id) map.set(stage.id, titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`))\n })\n return map\n}\n\n/** Maps a Smartcat document's workflow stages to display-ready stage progress. */\nexport function stagesFromDocument(\n doc: SmartcatDocument,\n stageNames: Map<string, string>,\n): StageProgress[] {\n const stages = doc.workflowStages ?? []\n return stages.map((stage, i) => ({\n _key: stage.id || `s${i}`,\n name:\n (stage.id && stageNames.get(stage.id)) ||\n titleCase(stage.stageType || stage.stageName || `Stage ${i + 1}`),\n percent: clampPercent(stage.progress),\n }))\n}\n\n/** A target is complete when it has stages and every one is at 100%. */\nexport function isTargetComplete(stages: StageProgress[]): boolean {\n return stages.length > 0 && stages.every((s) => s.percent >= 100)\n}\n\n/**\n * Whether a target has any content confirmed at any stage — i.e. there is\n * something worth importing. Used to gate `mode=confirmed` downloads so we skip\n * targets with nothing confirmed yet (which would otherwise come back as source).\n */\nexport function hasConfirmedContent(stages: StageProgress[]): boolean {\n return stages.some((s) => s.percent > 0)\n}\n\n/** Average completion across a target's stages (0–100). */\nexport function targetPercent(stages: StageProgress[]): number {\n if (stages.length === 0) return 0\n return Math.round(stages.reduce((sum, s) => sum + s.percent, 0) / stages.length)\n}\n\n/** A project item we correlate Smartcat documents back to. */\nexport interface ProjectItem {\n _id: string\n title?: string\n}\n\n/**\n * Extracts the 8-char source-id prefix Smartcat preserves in a document's\n * filename (built as `<title>-<idPrefix>.locjson`, or `<title>-<idPrefix>-draft.locjson`\n * when the content came from the document's draft). Used to correlate a Smartcat\n * document back to a Sanity source document.\n *\n * A trailing `-draft` marker is stripped first, so a draft-sourced file resolves\n * to the same id as its published counterpart. The prefix is then always the\n * final 8 characters before the (marker and) extension, so take the tail rather\n * than splitting on a dash — the title or the id prefix itself can\n * contain dashes (e.g. a custom id like `demo-pdpA` → prefix `demo-pdp`), which\n * would make a last-dash split return the wrong fragment.\n *\n * Only a literal `.locjson` extension is stripped (Smartcat's API often returns\n * the name without it). A generic strip-after-last-dot would eat the tail of any\n * DOTTED title — e.g. micro-copy keys like `aiAssistantModal.resetChat-11AKrV8G`\n * — leaving the wrong 8 chars, so those documents silently never correlate,\n * never import, and re-syncs duplicate them instead of updating.\n */\nexport function sourceIdPrefix(doc: SmartcatDocument): string {\n const base = doc.name || doc.filename || doc.fullPath || ''\n const leaf = base.split('/').pop() ?? ''\n const noExt = leaf.replace(/\\.locjson$/i, '')\n // Draft-sourced files carry a trailing `-draft` marker (informational only, so\n // a project never holds both a document's draft and its published version).\n // Strip it before taking the tail so a draft file correlates to the same source\n // id as its published counterpart — the id prefix is what identifies the item.\n const noDraft = noExt.replace(/-draft$/, '')\n return noDraft.slice(-8)\n}\n\n/**\n * Builds the full progress array from a freshly fetched Smartcat project and the\n * project's items — independent of any export-time skeleton, so it works for\n * projects exported before progress tracking existed.\n *\n * Documents are correlated to items by the 8-char id prefix in their filename;\n * `imported` flags are carried over from the previous progress (by source doc +\n * language) so importing isn't forgotten on refresh.\n *\n * `mappings` invert Smartcat's returned target codes back to Sanity ids, so all\n * stored progress uses Sanity locale tags. When omitted, codes pass through\n * unchanged (identity) — keeping legacy projects working.\n */\nexport function buildProgress(\n items: ProjectItem[],\n scProject: SmartcatProject,\n previous: DocProgress[] | undefined,\n now: string,\n mappings?: SmartcatLanguageMapping[],\n): DocProgress[] {\n const stageNames = buildStageNameMap(scProject)\n\n const importedSet = new Set<string>()\n // Titles are computed Sanity-native in the Studio and shipped on the outbox, so\n // they may not be re-derivable server-side. Carry the previously-stored title\n // forward so a progress sync (which only has the raw `doc.title`) can't clobber\n // it with an \"Untitled\" for types that have no literal title field.\n const titleByDoc = new Map<string, string>()\n for (const doc of previous ?? []) {\n if (doc.title) titleByDoc.set(doc.sourceDocId, doc.title)\n for (const target of doc.targets) {\n if (target.imported) importedSet.add(`${doc.sourceDocId}__${target.language}`)\n }\n }\n\n const itemByPrefix = new Map<string, ProjectItem>()\n for (const item of items) itemByPrefix.set(stripDraft(item._id).slice(0, 8), item)\n\n const byDoc = new Map<string, DocProgress>()\n for (const scDoc of scProject.documents ?? []) {\n if (!scDoc.targetLanguage) continue\n const item = itemByPrefix.get(sourceIdPrefix(scDoc))\n if (!item) continue\n\n // Store the Sanity locale tag, not Smartcat's code.\n const language = toSanityLanguage(mappings, scDoc.targetLanguage)\n\n let docProgress = byDoc.get(item._id)\n if (!docProgress) {\n const title = item.title || titleByDoc.get(item._id)\n docProgress = {_key: targetKey(item._id, 'doc'), sourceDocId: item._id, title, targets: []}\n byDoc.set(item._id, docProgress)\n }\n const stages = stagesFromDocument(scDoc, stageNames)\n docProgress.targets.push({\n _key: targetKey(item._id, language),\n language,\n smartcatDocumentId: scDoc.id,\n stages,\n complete: isTargetComplete(stages),\n imported: importedSet.has(`${item._id}__${language}`),\n syncedAt: now,\n })\n }\n\n // Preserve item order; sort each doc's targets by language for stable display.\n const result: DocProgress[] = []\n for (const item of items) {\n const docProgress = byDoc.get(item._id)\n if (!docProgress) continue\n docProgress.targets.sort((a, b) => a.language.localeCompare(b.language))\n result.push(docProgress)\n }\n return result\n}\n\nexport interface CompletionSummary {\n /** Total (document × language) targets. */\n total: number\n /** Targets whose stages are all at 100%. */\n complete: number\n /** Targets a variant has been built for. */\n imported: number\n /** True when every target is complete and imported (and there is ≥1 target). */\n allDone: boolean\n /** True when at least one stage shows any progress. */\n anyProgress: boolean\n}\n\n/** Summarizes a progress array for status decisions and the dashboard. */\nexport function summarizeCompletion(progress: DocProgress[]): CompletionSummary {\n let total = 0\n let complete = 0\n let imported = 0\n let anyProgress = false\n for (const doc of progress) {\n for (const target of doc.targets) {\n total++\n if (target.complete) complete++\n if (target.imported) imported++\n if (target.stages.some((s) => s.percent > 0)) anyProgress = true\n }\n }\n return {\n total,\n complete,\n imported,\n allDone: total > 0 && complete === total && imported === total,\n anyProgress,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Function-side orchestration: refresh progress from Smartcat\n// ---------------------------------------------------------------------------\n\n/** Minimal structural subset of @sanity/client used by the progress refresh. */\nexport interface SanityLikeClient {\n fetch<T = unknown>(query: string, params?: Record<string, unknown>): Promise<T>\n patch(id: string): {\n set(attrs: Record<string, unknown>): {commit(): Promise<unknown>}\n }\n}\n\n/** Structural subset of SmartcatClient the progress refresh needs. */\nexport interface SmartcatProgressClient {\n getProject(projectId: string): Promise<SmartcatProject>\n /** Optional: trace every HTTP call so a timeout/error reaches the functionLog. */\n setRequestLogger?(logger: RequestLogger): void\n}\n\nexport interface RunProgressSyncOptions {\n sanity: SanityLikeClient\n smartcat: SmartcatProgressClient\n /** `_id` of the `smartcat.translationProject` to refresh. */\n projectId: string\n now?: () => string\n}\n\nexport interface RunProgressSyncResult {\n summary: CompletionSummary\n /** Set when the Smartcat fetch failed (e.g. timed out); the run was recorded, not thrown. */\n error?: string\n}\n\nconst PROGRESS_QUERY = `*[_id == $id][0]{\n _id,\n status,\n smartcatProjectId,\n smartcatLanguages[]{sanityId, smartcatLanguage},\n \"items\": items[]{ \"_id\": ${ITEM_ID}, \"title\": ${itemDocDerefRespectingDraft('.title')} },\n progress[]{_key, sourceDocId, title, targets[]{_key, language, smartcatDocumentId, stages, complete, imported, syncedAt}}\n}`\n\ninterface ProgressProjectData {\n _id: string\n status?: string\n smartcatProjectId?: string\n smartcatLanguages?: SmartcatLanguageMapping[]\n items?: ProjectItemRef[]\n progress?: DocProgress[]\n}\n\n/** A project item as read for correlation: its stored id (always present) plus a\n * best-effort title. `doc` is the legacy shape — some callers still project it. */\ninterface ProjectItemRef {\n _id?: string\n title?: string\n doc?: {_id?: string; title?: string} | null\n}\n\n/**\n * Resolves a project's items into the lightweight shape we correlate on. The id\n * is the item's stored `docId` — NOT a dereferenced document — so an item whose\n * document exists only as a draft (never published) still correlates instead of\n * being silently dropped. Tolerates the legacy `{doc}` projection.\n */\nexport function itemsFromProject(project: {items?: ProjectItemRef[]}): ProjectItem[] {\n const result: ProjectItem[] = []\n for (const item of project.items ?? []) {\n const _id = item?._id ?? item?.doc?._id\n if (!_id) continue\n result.push({_id, title: item?.title ?? item?.doc?.title ?? undefined})\n }\n return result\n}\n\n/**\n * Thin progress step run by the `smartcat-progress` Function: fetches the\n * Smartcat project and mirrors its per-stage progress onto the project's\n * `progress` array. Read-only with respect to Smartcat — it never downloads or\n * creates variants, so it is cheap to run on every dashboard page load.\n */\nexport async function runProgressSync(\n options: RunProgressSyncOptions,\n): Promise<RunProgressSyncResult> {\n const {sanity, smartcat, projectId, now = () => new Date().toISOString()} = options\n\n const project = await sanity.fetch<ProgressProjectData | null>(PROGRESS_QUERY, {id: projectId})\n if (!project) throw new Error(`Translation project ${projectId} not found`)\n if (!project.smartcatProjectId) {\n throw new Error('Project has no smartcatProjectId — export it first')\n }\n\n const timestamp = now()\n\n // Trace HTTP failures into a functionLog so a timeout/error surfaces in the UI.\n // Successful calls are left unlogged: this Function runs on every page load, and\n // functionLog is shared with export/import — writing it on every success would\n // wipe a useful export/import log.\n const log: LogLine[] = []\n smartcat.setRequestLogger?.(({method, path, status, body}) => {\n if (status >= 200 && status < 300) return\n log.push({\n level: 'error',\n message: status ? `Error ${status} while performing ${method} ${path}` : `${method} ${path} did not complete`,\n })\n if (body) log.push({level: 'error', indent: true, message: body})\n })\n\n try {\n const scProject = await smartcat.getProject(project.smartcatProjectId)\n const progress = buildProgress(\n itemsFromProject(project),\n scProject,\n project.progress,\n timestamp,\n project.smartcatLanguages,\n )\n const summary = summarizeCompletion(progress)\n\n const patch: Record<string, unknown> = {progress, progressSyncedAt: timestamp, lastError: null}\n // Gentle one-way nudge: once Smartcat reports any work, reflect it in the badge.\n // Completion remains owned by the import flow.\n if (project.status === 'sent' && summary.anyProgress) {\n patch.status = 'translating'\n }\n\n await sanity.patch(projectId).set(patch).commit()\n\n return {summary}\n } catch (err) {\n // Record the failure and return instead of throwing: a throw would let the\n // platform retry, and a timed-out fetch has nothing transient to gain. Leave\n // the existing `progress` untouched (no zeroing-out) and only stamp the error\n // plus a fresh `progressSyncedAt`, which releases the UI refresh spinner and\n // makes the log land where the dashboard reads it.\n const message = err instanceof Error ? err.message : String(err)\n if (!log.some((l) => l.level === 'error')) log.push({level: 'error', message})\n await sanity\n .patch(projectId)\n .set({lastError: message, functionLog: JSON.stringify(log), progressSyncedAt: timestamp})\n .commit()\n .catch(() => {})\n return {summary: summarizeCompletion(project.progress ?? []), error: message}\n }\n}\n"],"names":[],"mappings":"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"}
|