@smartcat/sanity-plugin 1.3.0 → 1.4.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/dist/index.cjs +368 -132
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +371 -135
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/tool/AddItemsSearch.tsx +462 -0
- package/src/tool/DocTitle.tsx +23 -0
- package/src/tool/ProjectView.tsx +15 -237
- package/src/tool/itemActions.tsx +216 -0
- package/src/tool/processing.test.ts +11 -1
- package/src/tool/processing.ts +9 -3
package/src/tool/ProjectView.tsx
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import {useCallback, useEffect, useRef, useState} from 'react'
|
|
2
|
-
import {useClient, useRelativeTime, useSchema
|
|
2
|
+
import {useClient, useRelativeTime, useSchema} from 'sanity'
|
|
3
3
|
import {applyImportedTranslations, prepareExport, type PendingDeletion} from './processing'
|
|
4
|
-
import {
|
|
5
|
-
import {serializeToLocjson} from '../lib/locjson/serialize'
|
|
6
|
-
import {buildLocjsonFilename} from '../lib/locjson/filename'
|
|
7
|
-
import {resolveDocumentTitle} from '../lib/documentTitle'
|
|
4
|
+
import {localizationMode} from '../lib/locjson/fields'
|
|
8
5
|
import type {LocalizationMode} from '../lib/locjson/types'
|
|
9
|
-
import {useIntentLink} from 'sanity/router'
|
|
10
6
|
import {
|
|
11
7
|
Badge,
|
|
12
8
|
Box,
|
|
@@ -17,9 +13,6 @@ import {
|
|
|
17
13
|
Flex,
|
|
18
14
|
Heading,
|
|
19
15
|
Inline,
|
|
20
|
-
Menu,
|
|
21
|
-
MenuButton,
|
|
22
|
-
MenuItem,
|
|
23
16
|
Spinner,
|
|
24
17
|
Stack,
|
|
25
18
|
Text,
|
|
@@ -28,19 +21,18 @@ import {
|
|
|
28
21
|
} from '@sanity/ui'
|
|
29
22
|
import {
|
|
30
23
|
ArrowLeftIcon,
|
|
31
|
-
LaunchIcon,
|
|
32
24
|
TrashIcon,
|
|
33
25
|
PublishIcon,
|
|
34
26
|
CloseIcon,
|
|
35
27
|
DownloadIcon,
|
|
36
28
|
SyncIcon,
|
|
37
29
|
RemoveCircleIcon,
|
|
38
|
-
JsonIcon,
|
|
39
|
-
ChevronDownIcon,
|
|
40
30
|
} from '@sanity/icons'
|
|
41
31
|
import styled, {css, keyframes} from 'styled-components'
|
|
42
32
|
import {API_VERSION} from '../lib/constants'
|
|
43
|
-
import {
|
|
33
|
+
import {DocTitle} from './DocTitle'
|
|
34
|
+
import {AddItemsSearch} from './AddItemsSearch'
|
|
35
|
+
import {OpenInStudioButton, ViewLocjsonButton} from './itemActions'
|
|
44
36
|
import {PROJECT_DETAIL_QUERY, type ProjectDetail, type ProjectItemRef} from './data'
|
|
45
37
|
import {StatusBadge, statusLabel} from './StatusBadge'
|
|
46
38
|
import {ItemProgress, languageTitle} from './ItemProgress'
|
|
@@ -71,26 +63,6 @@ function languageMappingLabel(language: SmartcatLanguage): string {
|
|
|
71
63
|
return code === language.id ? language.id : `${language.id} → ${code}`
|
|
72
64
|
}
|
|
73
65
|
|
|
74
|
-
/**
|
|
75
|
-
* Resolve an item's title the way the Studio does — by running the schema type's
|
|
76
|
-
* `preview.prepare` — so types without a literal `title` field (e.g. field-level
|
|
77
|
-
* localized docs) show the same label as in the structure tree, not 'Untitled'.
|
|
78
|
-
*/
|
|
79
|
-
function DocTitle({doc}: {doc: ProjectItemRef['doc']}) {
|
|
80
|
-
const schema = useSchema()
|
|
81
|
-
const schemaType = doc ? schema.get(doc._type) : undefined
|
|
82
|
-
const preview = useValuePreview({
|
|
83
|
-
enabled: Boolean(doc && schemaType),
|
|
84
|
-
schemaType,
|
|
85
|
-
value: doc ? {_id: doc._id, _type: doc._type} : undefined,
|
|
86
|
-
})
|
|
87
|
-
// String-only: never fall back to the raw projected value, which is an
|
|
88
|
-
// internationalized-array object for field-level i18n types (renders as React #31).
|
|
89
|
-
const title = typeof preview.value?.title === 'string' ? preview.value.title : undefined
|
|
90
|
-
// Placeholder while the async preview resolves, so a real title isn't briefly mislabeled.
|
|
91
|
-
return <>{title ?? (preview.isLoading ? '…' : UNTITLED)}</>
|
|
92
|
-
}
|
|
93
|
-
|
|
94
66
|
/**
|
|
95
67
|
* Statuses during which a Function is actively running, so the project shouldn't
|
|
96
68
|
* be re-sent until it settles. `translating`/`sent`/`completed` are resting
|
|
@@ -128,205 +100,6 @@ function LastUpdate({date}: {date: string}) {
|
|
|
128
100
|
)
|
|
129
101
|
}
|
|
130
102
|
|
|
131
|
-
/** Opens the document in the Studio. */
|
|
132
|
-
function OpenInStudioButton({id, type}: {id: string; type: string}) {
|
|
133
|
-
const {onClick, href} = useIntentLink({intent: 'edit', params: {id, type}})
|
|
134
|
-
return (
|
|
135
|
-
<Button
|
|
136
|
-
as="a"
|
|
137
|
-
href={href}
|
|
138
|
-
onClick={onClick}
|
|
139
|
-
mode="bleed"
|
|
140
|
-
icon={LaunchIcon}
|
|
141
|
-
text="Open in Studio"
|
|
142
|
-
fontSize={0}
|
|
143
|
-
padding={2}
|
|
144
|
-
/>
|
|
145
|
-
)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Read-only, monospace, full-height code view for the LocJSON modal. */
|
|
149
|
-
const CodeArea = styled.textarea`
|
|
150
|
-
width: 100%;
|
|
151
|
-
height: 70vh;
|
|
152
|
-
resize: none;
|
|
153
|
-
border: none;
|
|
154
|
-
outline: none;
|
|
155
|
-
padding: 0;
|
|
156
|
-
background: transparent;
|
|
157
|
-
color: inherit;
|
|
158
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
159
|
-
font-size: 12px;
|
|
160
|
-
line-height: 1.5;
|
|
161
|
-
white-space: pre;
|
|
162
|
-
tab-size: 2;
|
|
163
|
-
`
|
|
164
|
-
|
|
165
|
-
/** Target-locale siblings of a document-level item, via translation.metadata. */
|
|
166
|
-
const VIEW_SIBLINGS_QUERY = `*[_type == "translation.metadata" && references($id)][0].translations[]{"language": _key, "doc": value->}`
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Dropdown that builds the LocJSON the export would produce for an item — same
|
|
170
|
-
* field selection and serialize options as {@link prepareExport} — and shows it
|
|
171
|
-
* (pretty-printed) in a read-only modal. Offers "Source only" (what a plain
|
|
172
|
-
* export sends) plus "Source + <language>" for each target language: the
|
|
173
|
-
* bilingual file the send-existing flow would upload for that language, so you
|
|
174
|
-
* can inspect exactly what goes to Smartcat per language.
|
|
175
|
-
*/
|
|
176
|
-
function ViewLocjsonButton(props: {
|
|
177
|
-
docId: string
|
|
178
|
-
type: string
|
|
179
|
-
/** Whether the export will translate the published doc (else prefer the draft). */
|
|
180
|
-
sourceIsPublished: boolean | null
|
|
181
|
-
client: {fetch: (query: string, params?: Record<string, unknown>) => Promise<unknown>}
|
|
182
|
-
schema: {get: (typeName: string) => unknown}
|
|
183
|
-
sourceLanguage: string
|
|
184
|
-
documentI18nLanguageField: string
|
|
185
|
-
fieldI18nLanguageField: string
|
|
186
|
-
/** Project target languages (Sanity locale ids). */
|
|
187
|
-
targetLanguages: string[]
|
|
188
|
-
/** Configured languages (for titles + Smartcat codes). */
|
|
189
|
-
languages: SmartcatLanguage[]
|
|
190
|
-
}) {
|
|
191
|
-
const {docId, type, sourceIsPublished, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField, targetLanguages, languages} = props
|
|
192
|
-
const [open, setOpen] = useState(false)
|
|
193
|
-
const [content, setContent] = useState<string | null>(null)
|
|
194
|
-
const [filename, setFilename] = useState<string | null>(null)
|
|
195
|
-
const [title, setTitle] = useState<string>('Source only')
|
|
196
|
-
const [error, setError] = useState<string | null>(null)
|
|
197
|
-
|
|
198
|
-
// Build the LocJSON for one variant: `targetLanguage` undefined ⇒ source-only
|
|
199
|
-
// (a plain export), otherwise the bilingual file for that Sanity locale.
|
|
200
|
-
const build = useCallback(
|
|
201
|
-
async (targetLanguage?: string) => {
|
|
202
|
-
setOpen(true)
|
|
203
|
-
setContent(null)
|
|
204
|
-
setFilename(null)
|
|
205
|
-
setError(null)
|
|
206
|
-
setTitle(targetLanguage ? `Source + ${languageTitle(languages, targetLanguage)}` : 'Source only')
|
|
207
|
-
try {
|
|
208
|
-
// Resolve the same version the export will send: the draft (fallback to
|
|
209
|
-
// published) unless the item was added from the published perspective.
|
|
210
|
-
const raw = (await client.fetch(
|
|
211
|
-
sourceIsPublished
|
|
212
|
-
? '*[_id == $id][0]'
|
|
213
|
-
: 'coalesce(*[_id == "drafts." + $id][0], *[_id == $id][0])',
|
|
214
|
-
{id: docId},
|
|
215
|
-
)) as (Record<string, unknown> & {_id: string}) | null
|
|
216
|
-
if (!raw) throw new Error(`Document not found: ${docId}`)
|
|
217
|
-
const isDraft = typeof raw._id === 'string' && raw._id.startsWith('drafts.')
|
|
218
|
-
// Serialize under the canonical (bare) id, matching the export.
|
|
219
|
-
const doc = isDraft ? {...raw, _id: docId} : raw
|
|
220
|
-
const fields = getTranslatableFields(schema.get(type), {exclude: [documentI18nLanguageField]})
|
|
221
|
-
|
|
222
|
-
// For a bilingual variant, resolve the existing translation the same way
|
|
223
|
-
// the export does: field-level from the inline member, document-level from
|
|
224
|
-
// the target-locale sibling document.
|
|
225
|
-
let target: {language: string; doc?: Record<string, unknown> & {_id: string; _type: string}} | undefined
|
|
226
|
-
if (targetLanguage) {
|
|
227
|
-
if (localizationMode(schema.get(type)) === 'document') {
|
|
228
|
-
const variants = (await client.fetch(VIEW_SIBLINGS_QUERY, {id: docId})) as
|
|
229
|
-
| {language: string; doc: (Record<string, unknown> & {_id: string; _type: string}) | null}[]
|
|
230
|
-
| null
|
|
231
|
-
const sibling = (variants ?? []).find((v) => v.language === targetLanguage)?.doc ?? undefined
|
|
232
|
-
target = {language: targetLanguage, doc: sibling}
|
|
233
|
-
} else {
|
|
234
|
-
target = {language: targetLanguage}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const locjson = serializeToLocjson(doc as never, fields, {
|
|
239
|
-
sourceLanguage,
|
|
240
|
-
fieldLanguageKey: fieldI18nLanguageField,
|
|
241
|
-
target,
|
|
242
|
-
})
|
|
243
|
-
// Same name the export sends to Smartcat; the last path segment is the
|
|
244
|
-
// file's basename (the Smartcat "folder/" prefix can't be a local filename).
|
|
245
|
-
const smartcatCode = targetLanguage
|
|
246
|
-
? languages.find((l) => l.id === targetLanguage)?.smartcatLanguage || targetLanguage
|
|
247
|
-
: undefined
|
|
248
|
-
const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never), {
|
|
249
|
-
draft: isDraft,
|
|
250
|
-
language: smartcatCode,
|
|
251
|
-
})
|
|
252
|
-
setFilename(smartcatName.split('/').pop() || smartcatName)
|
|
253
|
-
setContent(JSON.stringify(locjson, null, 2))
|
|
254
|
-
} catch (err) {
|
|
255
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
256
|
-
}
|
|
257
|
-
},
|
|
258
|
-
[client, schema, docId, type, sourceIsPublished, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField, languages],
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
const download = useCallback(() => {
|
|
262
|
-
if (content == null || !filename) return
|
|
263
|
-
const url = URL.createObjectURL(new Blob([content], {type: 'application/json'}))
|
|
264
|
-
const anchor = document.createElement('a')
|
|
265
|
-
anchor.href = url
|
|
266
|
-
anchor.download = filename
|
|
267
|
-
anchor.click()
|
|
268
|
-
URL.revokeObjectURL(url)
|
|
269
|
-
}, [content, filename])
|
|
270
|
-
|
|
271
|
-
return (
|
|
272
|
-
<>
|
|
273
|
-
<MenuButton
|
|
274
|
-
id={`locjson-menu-${docId}`}
|
|
275
|
-
button={<Button mode="bleed" icon={JsonIcon} iconRight={ChevronDownIcon} title="View LocJSON" fontSize={0} padding={2} />}
|
|
276
|
-
menu={
|
|
277
|
-
<Menu>
|
|
278
|
-
<MenuItem text="Source only" onClick={() => build()} />
|
|
279
|
-
{targetLanguages.map((lang) => (
|
|
280
|
-
<MenuItem key={lang} text={`Source + ${languageTitle(languages, lang)}`} onClick={() => build(lang)} />
|
|
281
|
-
))}
|
|
282
|
-
</Menu>
|
|
283
|
-
}
|
|
284
|
-
popover={{portal: true}}
|
|
285
|
-
/>
|
|
286
|
-
{open && (
|
|
287
|
-
<Dialog
|
|
288
|
-
id={`locjson-${docId}`}
|
|
289
|
-
header={
|
|
290
|
-
<Flex align="center" gap={3}>
|
|
291
|
-
<Text weight="semibold">LocJSON · {type} · {title}</Text>
|
|
292
|
-
<Button
|
|
293
|
-
icon={DownloadIcon}
|
|
294
|
-
text="Download"
|
|
295
|
-
mode="ghost"
|
|
296
|
-
fontSize={1}
|
|
297
|
-
padding={2}
|
|
298
|
-
disabled={content == null}
|
|
299
|
-
onClick={download}
|
|
300
|
-
/>
|
|
301
|
-
</Flex>
|
|
302
|
-
}
|
|
303
|
-
onClose={() => setOpen(false)}
|
|
304
|
-
width={3}
|
|
305
|
-
>
|
|
306
|
-
<Box padding={4}>
|
|
307
|
-
{error ? (
|
|
308
|
-
<Text size={1} muted>
|
|
309
|
-
{error}
|
|
310
|
-
</Text>
|
|
311
|
-
) : content === null ? (
|
|
312
|
-
<Flex align="center" justify="center" padding={5}>
|
|
313
|
-
<Spinner muted />
|
|
314
|
-
</Flex>
|
|
315
|
-
) : (
|
|
316
|
-
<CodeArea
|
|
317
|
-
readOnly
|
|
318
|
-
spellCheck={false}
|
|
319
|
-
value={content}
|
|
320
|
-
onFocus={(e) => e.currentTarget.select()}
|
|
321
|
-
/>
|
|
322
|
-
)}
|
|
323
|
-
</Box>
|
|
324
|
-
</Dialog>
|
|
325
|
-
)}
|
|
326
|
-
</>
|
|
327
|
-
)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
103
|
export function ProjectView({
|
|
331
104
|
projectId,
|
|
332
105
|
onBack,
|
|
@@ -1034,11 +807,16 @@ export function ProjectView({
|
|
|
1034
807
|
</Stack>
|
|
1035
808
|
)}
|
|
1036
809
|
|
|
1037
|
-
<
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
810
|
+
<AddItemsSearch
|
|
811
|
+
projectId={projectId}
|
|
812
|
+
translatableTypes={translatableTypes}
|
|
813
|
+
existingDocIds={new Set(items.map((i) => i.docId))}
|
|
814
|
+
sourceLanguage={sourceLanguage}
|
|
815
|
+
languages={languages}
|
|
816
|
+
targetLanguages={targets}
|
|
817
|
+
documentI18nLanguageField={documentI18nLanguageField}
|
|
818
|
+
fieldI18nLanguageField={fieldI18nLanguageField}
|
|
819
|
+
/>
|
|
1042
820
|
</Stack>
|
|
1043
821
|
|
|
1044
822
|
{confirmDelete && (
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {useCallback, useState} from 'react'
|
|
2
|
+
import {useIntentLink} from 'sanity/router'
|
|
3
|
+
import {Box, Button, Dialog, Flex, Menu, MenuButton, MenuItem, Spinner, Text} from '@sanity/ui'
|
|
4
|
+
import {LaunchIcon, JsonIcon, ChevronDownIcon, DownloadIcon} from '@sanity/icons'
|
|
5
|
+
import styled from 'styled-components'
|
|
6
|
+
import {getTranslatableFields, localizationMode} from '../lib/locjson/fields'
|
|
7
|
+
import {serializeToLocjson} from '../lib/locjson/serialize'
|
|
8
|
+
import {buildLocjsonFilename} from '../lib/locjson/filename'
|
|
9
|
+
import {resolveDocumentTitle} from '../lib/documentTitle'
|
|
10
|
+
import {languageTitle} from './ItemProgress'
|
|
11
|
+
import type {SmartcatLanguage} from '../types'
|
|
12
|
+
|
|
13
|
+
/** Opens the document in the Studio. */
|
|
14
|
+
export function OpenInStudioButton({id, type}: {id: string; type: string}) {
|
|
15
|
+
const {onClick, href} = useIntentLink({intent: 'edit', params: {id, type}})
|
|
16
|
+
return (
|
|
17
|
+
<Button
|
|
18
|
+
as="a"
|
|
19
|
+
href={href}
|
|
20
|
+
onClick={onClick}
|
|
21
|
+
mode="bleed"
|
|
22
|
+
icon={LaunchIcon}
|
|
23
|
+
text="Open in Studio"
|
|
24
|
+
fontSize={0}
|
|
25
|
+
padding={2}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read-only, monospace, full-height code view for the LocJSON modal. */
|
|
31
|
+
const CodeArea = styled.textarea`
|
|
32
|
+
width: 100%;
|
|
33
|
+
height: 70vh;
|
|
34
|
+
resize: none;
|
|
35
|
+
border: none;
|
|
36
|
+
outline: none;
|
|
37
|
+
padding: 0;
|
|
38
|
+
background: transparent;
|
|
39
|
+
color: inherit;
|
|
40
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
41
|
+
font-size: 12px;
|
|
42
|
+
line-height: 1.5;
|
|
43
|
+
white-space: pre;
|
|
44
|
+
tab-size: 2;
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Target-locale siblings of a document-level item, via translation.metadata. The
|
|
49
|
+
* member's locale lives in its language field (v5+, uuid `_key`) or in `_key`
|
|
50
|
+
* itself (v4.x) — `coalesce` reads whichever applies, per {@link fieldLanguageKey}.
|
|
51
|
+
*/
|
|
52
|
+
function siblingsQuery(fieldLanguageKey: string): string {
|
|
53
|
+
return `*[_type == "translation.metadata" && references($id)][0].translations[]{"language": coalesce(${fieldLanguageKey}, _key), "doc": value->}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Dropdown that builds the LocJSON the export would produce for an item — same
|
|
58
|
+
* field selection and serialize options as {@link prepareExport} — and shows it
|
|
59
|
+
* (pretty-printed) in a read-only modal. Offers "Source only" (what a plain
|
|
60
|
+
* export sends) plus "Source + <language>" for each target language: the
|
|
61
|
+
* bilingual file the send-existing flow would upload for that language, so you
|
|
62
|
+
* can inspect exactly what goes to Smartcat per language.
|
|
63
|
+
*/
|
|
64
|
+
export function ViewLocjsonButton(props: {
|
|
65
|
+
docId: string
|
|
66
|
+
type: string
|
|
67
|
+
/** Whether the export will translate the published doc (else prefer the draft). */
|
|
68
|
+
sourceIsPublished: boolean | null
|
|
69
|
+
client: {fetch: (query: string, params?: Record<string, unknown>) => Promise<unknown>}
|
|
70
|
+
schema: {get: (typeName: string) => unknown}
|
|
71
|
+
sourceLanguage: string
|
|
72
|
+
documentI18nLanguageField: string
|
|
73
|
+
fieldI18nLanguageField: string
|
|
74
|
+
/** Project target languages (Sanity locale ids). */
|
|
75
|
+
targetLanguages: string[]
|
|
76
|
+
/** Configured languages (for titles + Smartcat codes). */
|
|
77
|
+
languages: SmartcatLanguage[]
|
|
78
|
+
}) {
|
|
79
|
+
const {docId, type, sourceIsPublished, client, schema, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField, targetLanguages, languages} = props
|
|
80
|
+
const [open, setOpen] = useState(false)
|
|
81
|
+
const [content, setContent] = useState<string | null>(null)
|
|
82
|
+
const [filename, setFilename] = useState<string | null>(null)
|
|
83
|
+
const [title, setTitle] = useState<string>('Source only')
|
|
84
|
+
const [error, setError] = useState<string | null>(null)
|
|
85
|
+
|
|
86
|
+
// Build the LocJSON for one variant: `targetLanguage` undefined ⇒ source-only
|
|
87
|
+
// (a plain export), otherwise the bilingual file for that Sanity locale.
|
|
88
|
+
const build = useCallback(
|
|
89
|
+
async (targetLanguage?: string) => {
|
|
90
|
+
setOpen(true)
|
|
91
|
+
setContent(null)
|
|
92
|
+
setFilename(null)
|
|
93
|
+
setError(null)
|
|
94
|
+
setTitle(targetLanguage ? `Source + ${languageTitle(languages, targetLanguage)}` : 'Source only')
|
|
95
|
+
try {
|
|
96
|
+
// Resolve the same version the export will send: the draft (fallback to
|
|
97
|
+
// published) unless the item was added from the published perspective.
|
|
98
|
+
const raw = (await client.fetch(
|
|
99
|
+
sourceIsPublished
|
|
100
|
+
? '*[_id == $id][0]'
|
|
101
|
+
: 'coalesce(*[_id == "drafts." + $id][0], *[_id == $id][0])',
|
|
102
|
+
{id: docId},
|
|
103
|
+
)) as (Record<string, unknown> & {_id: string}) | null
|
|
104
|
+
if (!raw) throw new Error(`Document not found: ${docId}`)
|
|
105
|
+
const isDraft = typeof raw._id === 'string' && raw._id.startsWith('drafts.')
|
|
106
|
+
// Serialize under the canonical (bare) id, matching the export.
|
|
107
|
+
const doc = isDraft ? {...raw, _id: docId} : raw
|
|
108
|
+
const fields = getTranslatableFields(schema.get(type), {exclude: [documentI18nLanguageField]})
|
|
109
|
+
|
|
110
|
+
// For a bilingual variant, resolve the existing translation the same way
|
|
111
|
+
// the export does: field-level from the inline member, document-level from
|
|
112
|
+
// the target-locale sibling document.
|
|
113
|
+
let target: {language: string; doc?: Record<string, unknown> & {_id: string; _type: string}} | undefined
|
|
114
|
+
if (targetLanguage) {
|
|
115
|
+
if (localizationMode(schema.get(type)) === 'document') {
|
|
116
|
+
const variants = (await client.fetch(siblingsQuery(fieldI18nLanguageField), {id: docId})) as
|
|
117
|
+
| {language: string; doc: (Record<string, unknown> & {_id: string; _type: string}) | null}[]
|
|
118
|
+
| null
|
|
119
|
+
const sibling = (variants ?? []).find((v) => v.language === targetLanguage)?.doc ?? undefined
|
|
120
|
+
target = {language: targetLanguage, doc: sibling}
|
|
121
|
+
} else {
|
|
122
|
+
target = {language: targetLanguage}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const locjson = serializeToLocjson(doc as never, fields, {
|
|
127
|
+
sourceLanguage,
|
|
128
|
+
fieldLanguageKey: fieldI18nLanguageField,
|
|
129
|
+
target,
|
|
130
|
+
})
|
|
131
|
+
// Same name the export sends to Smartcat; the last path segment is the
|
|
132
|
+
// file's basename (the Smartcat "folder/" prefix can't be a local filename).
|
|
133
|
+
const smartcatCode = targetLanguage
|
|
134
|
+
? languages.find((l) => l.id === targetLanguage)?.smartcatLanguage || targetLanguage
|
|
135
|
+
: undefined
|
|
136
|
+
const smartcatName = buildLocjsonFilename(type, docId, resolveDocumentTitle(schema, doc as never), {
|
|
137
|
+
draft: isDraft,
|
|
138
|
+
language: smartcatCode,
|
|
139
|
+
})
|
|
140
|
+
setFilename(smartcatName.split('/').pop() || smartcatName)
|
|
141
|
+
setContent(JSON.stringify(locjson, null, 2))
|
|
142
|
+
} catch (err) {
|
|
143
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[client, schema, docId, type, sourceIsPublished, sourceLanguage, documentI18nLanguageField, fieldI18nLanguageField, languages],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const download = useCallback(() => {
|
|
150
|
+
if (content == null || !filename) return
|
|
151
|
+
const url = URL.createObjectURL(new Blob([content], {type: 'application/json'}))
|
|
152
|
+
const anchor = document.createElement('a')
|
|
153
|
+
anchor.href = url
|
|
154
|
+
anchor.download = filename
|
|
155
|
+
anchor.click()
|
|
156
|
+
URL.revokeObjectURL(url)
|
|
157
|
+
}, [content, filename])
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<>
|
|
161
|
+
<MenuButton
|
|
162
|
+
id={`locjson-menu-${docId}`}
|
|
163
|
+
button={<Button mode="bleed" icon={JsonIcon} iconRight={ChevronDownIcon} title="View LocJSON" fontSize={0} padding={2} />}
|
|
164
|
+
menu={
|
|
165
|
+
<Menu>
|
|
166
|
+
<MenuItem text="Source only" onClick={() => build()} />
|
|
167
|
+
{targetLanguages.map((lang) => (
|
|
168
|
+
<MenuItem key={lang} text={`Source + ${languageTitle(languages, lang)}`} onClick={() => build(lang)} />
|
|
169
|
+
))}
|
|
170
|
+
</Menu>
|
|
171
|
+
}
|
|
172
|
+
popover={{portal: true}}
|
|
173
|
+
/>
|
|
174
|
+
{open && (
|
|
175
|
+
<Dialog
|
|
176
|
+
id={`locjson-${docId}`}
|
|
177
|
+
header={
|
|
178
|
+
<Flex align="center" gap={3}>
|
|
179
|
+
<Text weight="semibold">LocJSON · {type} · {title}</Text>
|
|
180
|
+
<Button
|
|
181
|
+
icon={DownloadIcon}
|
|
182
|
+
text="Download"
|
|
183
|
+
mode="ghost"
|
|
184
|
+
fontSize={1}
|
|
185
|
+
padding={2}
|
|
186
|
+
disabled={content == null}
|
|
187
|
+
onClick={download}
|
|
188
|
+
/>
|
|
189
|
+
</Flex>
|
|
190
|
+
}
|
|
191
|
+
onClose={() => setOpen(false)}
|
|
192
|
+
width={3}
|
|
193
|
+
>
|
|
194
|
+
<Box padding={4}>
|
|
195
|
+
{error ? (
|
|
196
|
+
<Text size={1} muted>
|
|
197
|
+
{error}
|
|
198
|
+
</Text>
|
|
199
|
+
) : content === null ? (
|
|
200
|
+
<Flex align="center" justify="center" padding={5}>
|
|
201
|
+
<Spinner muted />
|
|
202
|
+
</Flex>
|
|
203
|
+
) : (
|
|
204
|
+
<CodeArea
|
|
205
|
+
readOnly
|
|
206
|
+
spellCheck={false}
|
|
207
|
+
value={content}
|
|
208
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
</Box>
|
|
212
|
+
</Dialog>
|
|
213
|
+
)}
|
|
214
|
+
</>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {beforeAll, describe, expect, it} from 'vitest'
|
|
2
2
|
import {Schema} from '@sanity/schema'
|
|
3
3
|
import {JSDOM} from 'jsdom'
|
|
4
|
-
import {applyImportedTranslations, prepareExport, type ProcessingClient} from './processing'
|
|
4
|
+
import {applyImportedTranslations, prepareExport, siblingsQuery, type ProcessingClient} from './processing'
|
|
5
5
|
|
|
6
6
|
// processing.ts uses the browser's global DOMParser; provide one via jsdom.
|
|
7
7
|
beforeAll(() => {
|
|
@@ -696,3 +696,13 @@ describe('nested + array-member field-level translation', () => {
|
|
|
696
696
|
])
|
|
697
697
|
})
|
|
698
698
|
})
|
|
699
|
+
|
|
700
|
+
describe('siblingsQuery', () => {
|
|
701
|
+
// The v5+ translation.metadata member stores its locale in a language field
|
|
702
|
+
// with a uuid `_key`; reading only `_key` (the v4.x layout) missed every v5+
|
|
703
|
+
// sibling, so send-existing attached no target for document-level items.
|
|
704
|
+
it('reads the locale from the configured field, falling back to `_key` (v4.x)', () => {
|
|
705
|
+
expect(siblingsQuery('language')).toContain('"language": coalesce(language, _key)')
|
|
706
|
+
expect(siblingsQuery('_key')).toContain('"language": coalesce(_key, _key)')
|
|
707
|
+
})
|
|
708
|
+
})
|
package/src/tool/processing.ts
CHANGED
|
@@ -153,10 +153,16 @@ const ITEMS_QUERY = `*[_id == $id][0]{
|
|
|
153
153
|
* group, every locale variant's `{language, doc}` — the send-existing flow reads
|
|
154
154
|
* each target language's sibling as the translation to upload. Document-level
|
|
155
155
|
* only; field-level types keep all languages inline on the same document.
|
|
156
|
+
*
|
|
157
|
+
* The variant's locale lives in its language field (v5+, with a uuid `_key`) or
|
|
158
|
+
* in `_key` itself (v4.x); `coalesce` reads whichever applies per the configured
|
|
159
|
+
* {@link fieldLanguageKey}. Reading only `_key` misses v5+ siblings entirely.
|
|
156
160
|
*/
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
export function siblingsQuery(fieldLanguageKey: string): string {
|
|
162
|
+
return `*[_type == "translation.metadata" && references($ids)]{
|
|
163
|
+
"variants": translations[]{ "language": coalesce(${fieldLanguageKey}, _key), "doc": value-> }
|
|
159
164
|
}`
|
|
165
|
+
}
|
|
160
166
|
|
|
161
167
|
export async function prepareExport({
|
|
162
168
|
client,
|
|
@@ -222,7 +228,7 @@ export async function prepareExport({
|
|
|
222
228
|
.map((item) => publishedId((item.doc as {_id: string})._id))
|
|
223
229
|
if (flaggedDocLevelIds.length > 0) {
|
|
224
230
|
const groups = await client.fetch<{variants: {language: string; doc: TargetDoc | null}[]}[]>(
|
|
225
|
-
|
|
231
|
+
siblingsQuery(fieldLanguageKey),
|
|
226
232
|
{ids: flaggedDocLevelIds},
|
|
227
233
|
)
|
|
228
234
|
for (const group of groups ?? []) {
|