@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartcat/sanity-plugin",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -0,0 +1,462 @@
1
+ import {Fragment, useCallback, useEffect, useState, type MouseEvent} from 'react'
2
+ import {useClient, useSchema} from 'sanity'
3
+ import {useRouter} from 'sanity/router'
4
+ import {Badge, Box, Button, Card, Checkbox, Flex, Menu, MenuButton, MenuItem, Spinner, Stack, Text, TextInput, useToast} from '@sanity/ui'
5
+ import {AddIcon, CheckmarkIcon, ChevronDownIcon, SearchIcon} from '@sanity/icons'
6
+ import {uuid} from '@sanity/uuid'
7
+ import {API_VERSION, TRANSLATION_PROJECT_TYPE} from '../lib/constants'
8
+ import {ITEM_ID} from '../lib/projectItems'
9
+ import {DocTitle} from './DocTitle'
10
+ import {OpenInStudioButton, ViewLocjsonButton} from './itemActions'
11
+ import type {SmartcatLanguage} from '../types'
12
+
13
+ /** A raw matched row — a draft or published document variant. */
14
+ interface SearchRow {
15
+ _id: string
16
+ _type: string
17
+ /** True when this row is a draft, or a published doc that has a draft. */
18
+ hasDraft: boolean
19
+ }
20
+
21
+ /** A deduped search hit — one logical document, addable to the project. */
22
+ interface SearchHit {
23
+ /** Published (bare) id, the value stored as an item's `docId`. */
24
+ docId: string
25
+ _type: string
26
+ /** Whether a draft exists — drives the source badge and the added item's `sourceIsPublished`. */
27
+ hasDraft: boolean
28
+ }
29
+
30
+ /**
31
+ * Documents fetched per search before de-duplication. Higher than what we show,
32
+ * since a document can appear twice (draft + published). Inlined into the query
33
+ * (GROQ slices must be constant).
34
+ */
35
+ const QUERY_LIMIT = 50
36
+
37
+ /** Distinct documents shown after de-duplication. */
38
+ const DISPLAY_LIMIT = 25
39
+
40
+ /** Max query tokens honored, to keep the generated query bounded. */
41
+ const MAX_TOKENS = 6
42
+
43
+ /** Don't search until the box has some substance, or single letters flood results. */
44
+ const MIN_QUERY_LENGTH = 2
45
+
46
+ /**
47
+ * Fields a term matches against in the default ("title") mode, plus `_type`/`_id`
48
+ * so a term can match the schema type name or id. Kept in one place: this is the
49
+ * curated notion of "a document's title" across arbitrary schema types.
50
+ */
51
+ const TITLE_FIELDS = '[title, name, heading, label, question, slug.current, _type, _id]'
52
+
53
+ /** Title-ish fields used for scoring, so title matches rank above `_type`/content hits. */
54
+ const SCORE_FIELDS = '[title, name, heading, label, question, slug.current]'
55
+
56
+ /** Types the plugin never treats as translatable, excluded when no allowlist is configured. */
57
+ const EXCLUDED_TYPES = ['smartcat.translationProject', 'smartcat.settings', 'translation.metadata']
58
+
59
+ /**
60
+ * Turn the raw search box text into a GROQ query + params, or `null` when there's
61
+ * nothing worth searching for yet.
62
+ *
63
+ * Each whitespace-separated term must match (AND across terms); within a term the
64
+ * match spans the title fields plus `_type`/`_id`. So `"faq cancel"` finds an FAQ
65
+ * titled "Can I cancel my subscription" (`faq`→`_type`, `cancel`→title) but not a
66
+ * `page` whose body mentions "cancel" — unless "Also search in content" is on,
67
+ * which widens each term to the whole document (`@`).
68
+ */
69
+ function buildSearch(
70
+ rawQuery: string,
71
+ searchContent: boolean,
72
+ translatableTypes: string[] | undefined,
73
+ ): {query: string; params: Record<string, unknown>} | null {
74
+ if (rawQuery.trim().length < MIN_QUERY_LENGTH) return null
75
+ const tokens = rawQuery
76
+ .trim()
77
+ .split(/\s+/)
78
+ // Neutralize text::query operators so plain text can't be read as phrase
79
+ // quoting (`"`), exclusion (leading `-`), or an explicit wildcard (`*`).
80
+ .map((t) => t.replace(/["*]/g, '').replace(/^-+/, '').trim())
81
+ .filter(Boolean)
82
+ .slice(0, MAX_TOKENS)
83
+ if (tokens.length === 0) return null
84
+
85
+ // Whole-document search when the editor opts in; otherwise the curated fields.
86
+ const target = searchContent ? '@' : TITLE_FIELDS
87
+ const params: Record<string, unknown> = {}
88
+ const tokenClauses: string[] = []
89
+ const scoreClauses: string[] = []
90
+ tokens.forEach((tok, i) => {
91
+ // Trailing wildcard → prefix matching, so results narrow as the editor types.
92
+ params[`t${i}`] = `${tok}*`
93
+ tokenClauses.push(`${target} match text::query($t${i})`)
94
+ scoreClauses.push(`boost(${SCORE_FIELDS} match text::query($t${i}), 2)`)
95
+ })
96
+
97
+ // Mirror resolveConfig: an explicit allowlist is a positive filter; otherwise
98
+ // exclude the plugin's own + document-i18n metadata + system types — never
99
+ // enumerating "all types", which could be thousands.
100
+ let typeFilter: string
101
+ if (translatableTypes) {
102
+ params.types = translatableTypes
103
+ typeFilter = '_type in $types'
104
+ } else {
105
+ params.excluded = EXCLUDED_TYPES
106
+ typeFilter = '!(_type in $excluded) && !(_type match "sanity.*")'
107
+ }
108
+
109
+ // Drafts are included (the plugin can translate the draft version). Both a
110
+ // draft and its published counterpart may match; de-duplication happens after
111
+ // the fetch. `hasDraft` lets a published-only match still detect its draft, so
112
+ // a document whose draft didn't match the term is still flagged correctly.
113
+ const filter = [typeFilter, ...tokenClauses].join(' && ')
114
+ const projection = '{_id, _type, "hasDraft": _id in path("drafts.**") || count(*[_id == "drafts." + ^._id]) > 0}'
115
+ const query = `*[${filter}] | score(${scoreClauses.join(', ')}) | order(_score desc)[0...${QUERY_LIMIT}]${projection}`
116
+ return {query, params}
117
+ }
118
+
119
+ /**
120
+ * Collapse draft/published variants into one hit per logical document, keyed by
121
+ * the bare (published) id. Input is ordered by relevance; the Map preserves that
122
+ * order (first occurrence wins the position), and `hasDraft` is OR-ed across
123
+ * variants so a draft is detected whichever variant matched.
124
+ */
125
+ function dedupeHits(rows: SearchRow[]): SearchHit[] {
126
+ const byDoc = new Map<string, SearchHit>()
127
+ for (const row of rows) {
128
+ const docId = row._id.replace(/^drafts\./, '')
129
+ const existing = byDoc.get(docId)
130
+ if (existing) existing.hasDraft = existing.hasDraft || row.hasDraft
131
+ else byDoc.set(docId, {docId, _type: row._type, hasDraft: row.hasDraft})
132
+ }
133
+ return [...byDoc.values()].slice(0, DISPLAY_LIMIT)
134
+ }
135
+
136
+ /** A translation project a searched document is an item of. */
137
+ interface ProjectRef {
138
+ _id: string
139
+ name?: string
140
+ }
141
+
142
+ /**
143
+ * For the given document ids, the projects that include each as a (source) item.
144
+ * One batched query builds a `docId → projects` map; `references()`/sibling
145
+ * membership is deliberately out of scope — what matters here is whether adding
146
+ * the document to a project would be redundant.
147
+ */
148
+ const MEMBERSHIP_QUERY = `*[_type == "${TRANSLATION_PROJECT_TYPE}" && count(items[${ITEM_ID} in $ids]) > 0] | order(name asc){
149
+ _id,
150
+ name,
151
+ "docIds": items[${ITEM_ID} in $ids]{"id": ${ITEM_ID}}
152
+ }`
153
+
154
+ /** Shape of one row from {@link MEMBERSHIP_QUERY}. */
155
+ interface MembershipRow {
156
+ _id: string
157
+ name?: string
158
+ docIds: {id: string}[]
159
+ }
160
+
161
+ /** A project name that navigates to its page in the Translations tool (SPA nav). */
162
+ function ProjectChip({project, current}: {project: ProjectRef; current: boolean}) {
163
+ const router = useRouter()
164
+ const onClick = useCallback(
165
+ (e: MouseEvent) => {
166
+ e.preventDefault()
167
+ router.navigate({projectId: project._id})
168
+ },
169
+ [router, project._id],
170
+ )
171
+ return (
172
+ <a href="#" onClick={onClick}>
173
+ {project.name || 'Untitled project'}
174
+ {current ? ' (this project)' : ''}
175
+ </a>
176
+ )
177
+ }
178
+
179
+ interface AddItemsSearchProps {
180
+ projectId: string
181
+ /** Configured translatable types (undefined = every non-plugin type). */
182
+ translatableTypes?: string[]
183
+ /** Published ids already in the project, so their results show as added, not addable. */
184
+ existingDocIds: Set<string>
185
+ /** Source language (Sanity locale id) — for the LocJSON preview. */
186
+ sourceLanguage: string
187
+ /** Configured languages (titles + Smartcat codes) — for the LocJSON preview. */
188
+ languages: SmartcatLanguage[]
189
+ /** Project target languages — the per-language bilingual LocJSON variants to offer. */
190
+ targetLanguages: string[]
191
+ /** document-internationalization locale field name. */
192
+ documentI18nLanguageField: string
193
+ /** internationalized-array locale key. */
194
+ fieldI18nLanguageField: string
195
+ }
196
+
197
+ /**
198
+ * Search box + results, shown under a project's item list, for finding documents
199
+ * anywhere in the dataset and adding them to the project without opening each one.
200
+ * Searches drafts and published documents; a document with a draft is added
201
+ * draft-as-source (the plugin translates the draft), matching a fresh "Add to
202
+ * translation project", with existing-translations upload left off. Linked-content
203
+ * gathering is not applied here — each result adds just that one document.
204
+ */
205
+ export function AddItemsSearch({
206
+ projectId,
207
+ translatableTypes,
208
+ existingDocIds,
209
+ sourceLanguage,
210
+ languages,
211
+ targetLanguages,
212
+ documentI18nLanguageField,
213
+ fieldI18nLanguageField,
214
+ }: AddItemsSearchProps) {
215
+ const client = useClient({apiVersion: API_VERSION})
216
+ const schema = useSchema()
217
+ const toast = useToast()
218
+ const [query, setQuery] = useState('')
219
+ const [searchContent, setSearchContent] = useState(false)
220
+ const [results, setResults] = useState<SearchHit[] | null>(null)
221
+ const [loading, setLoading] = useState(false)
222
+ const [addingId, setAddingId] = useState<string | null>(null)
223
+ // Optimistic: added rows show as "Added" immediately, before the project's
224
+ // live subscription refreshes `existingDocIds`.
225
+ const [addedIds, setAddedIds] = useState<Set<string>>(new Set())
226
+ // docId → the projects that already include it (live), so each result lists
227
+ // its memberships the way the document editor's status banner does.
228
+ const [projectsByDoc, setProjectsByDoc] = useState<Map<string, ProjectRef[]>>(new Map())
229
+
230
+ useEffect(() => {
231
+ const built = buildSearch(query, searchContent, translatableTypes)
232
+ if (!built) {
233
+ setResults(null)
234
+ setLoading(false)
235
+ return undefined
236
+ }
237
+ setLoading(true)
238
+ let cancelled = false
239
+ const handle = setTimeout(() => {
240
+ client
241
+ .fetch<SearchRow[]>(built.query, built.params)
242
+ .then((res) => {
243
+ if (!cancelled) setResults(dedupeHits(res))
244
+ })
245
+ .catch((err) => {
246
+ if (cancelled) return
247
+ setResults([])
248
+ toast.push({status: 'error', title: 'Search failed', description: String(err)})
249
+ })
250
+ .finally(() => {
251
+ if (!cancelled) setLoading(false)
252
+ })
253
+ }, 250)
254
+ return () => {
255
+ cancelled = true
256
+ clearTimeout(handle)
257
+ }
258
+ }, [query, searchContent, translatableTypes, client, toast])
259
+
260
+ // Resolve (and keep live) the projects each visible result belongs to. Keyed
261
+ // on the result ids so it re-runs when the result set changes; the listener
262
+ // then reflects adds/removes anywhere without a manual refetch.
263
+ const docIdsKey = (results ?? []).map((r) => r.docId).join(',')
264
+ useEffect(() => {
265
+ const ids = docIdsKey ? docIdsKey.split(',') : []
266
+ if (ids.length === 0) {
267
+ setProjectsByDoc(new Map())
268
+ return undefined
269
+ }
270
+ let cancelled = false
271
+ const load = () =>
272
+ client
273
+ .fetch<MembershipRow[]>(MEMBERSHIP_QUERY, {ids})
274
+ .then((rows) => {
275
+ if (cancelled) return
276
+ const map = new Map<string, ProjectRef[]>()
277
+ for (const row of rows) {
278
+ for (const {id} of row.docIds ?? []) {
279
+ const list = map.get(id) ?? []
280
+ list.push({_id: row._id, name: row.name})
281
+ map.set(id, list)
282
+ }
283
+ }
284
+ setProjectsByDoc(map)
285
+ })
286
+ .catch(() => !cancelled && setProjectsByDoc(new Map()))
287
+ load()
288
+ const sub = client.listen(MEMBERSHIP_QUERY, {ids}, {visibility: 'query'}).subscribe({next: load, error: () => {}})
289
+ return () => {
290
+ cancelled = true
291
+ sub.unsubscribe()
292
+ }
293
+ }, [docIdsKey, client])
294
+
295
+ // Once the project's live items reflect an optimistic add, drop it from the
296
+ // local set. Otherwise `addedIds` (which only ever grows) would keep a row
297
+ // marked "Added" even after it's removed from the project — `existingDocIds`
298
+ // then becomes the single source of truth, so removal flips it back to addable.
299
+ const existingKey = [...existingDocIds].sort().join(',')
300
+ useEffect(() => {
301
+ setAddedIds((prev) => {
302
+ if (prev.size === 0) return prev
303
+ const next = new Set([...prev].filter((id) => !existingDocIds.has(id)))
304
+ return next.size === prev.size ? prev : next
305
+ })
306
+ // existingDocIds is a fresh Set each render; `existingKey` tracks its contents.
307
+ // eslint-disable-next-line react-hooks/exhaustive-deps
308
+ }, [existingKey])
309
+
310
+ const addItem = async (hit: SearchHit, sendExistingTranslations: boolean) => {
311
+ setAddingId(hit.docId)
312
+ try {
313
+ const item = {
314
+ _type: 'smartcat.projectItem',
315
+ _key: uuid(),
316
+ docId: hit.docId,
317
+ docType: hit._type,
318
+ // Prefer the draft as source when one exists (the plugin translates the
319
+ // draft version); the caller chose whether to also upload existing
320
+ // translations — matching a fresh "Add to translation project".
321
+ sourceIsPublished: !hit.hasDraft,
322
+ sendExistingTranslations,
323
+ }
324
+ await client.patch(projectId).setIfMissing({items: []}).insert('after', 'items[-1]', [item]).commit()
325
+ setAddedIds((prev) => new Set(prev).add(hit.docId))
326
+ toast.push({status: 'success', title: 'Added to project'})
327
+ } catch (err) {
328
+ toast.push({status: 'error', title: 'Failed to add item', description: String(err)})
329
+ } finally {
330
+ setAddingId(null)
331
+ }
332
+ }
333
+
334
+ return (
335
+ <Stack space={3}>
336
+ <Card padding={4} radius={2} border>
337
+ <Stack space={4}>
338
+ <Stack space={3}>
339
+ <TextInput
340
+ icon={SearchIcon}
341
+ placeholder="Search documents to add — by title, or schema type…"
342
+ value={query}
343
+ onChange={(e) => setQuery(e.currentTarget.value)}
344
+ clearButton={query.length > 0}
345
+ onClear={() => setQuery('')}
346
+ />
347
+ <Flex as="label" align="center" gap={2} style={{cursor: 'pointer'}}>
348
+ <Checkbox checked={searchContent} onChange={(e) => setSearchContent(e.currentTarget.checked)} />
349
+ <Text size={1} muted>
350
+ Also search in content (slower; matches body text, not just titles)
351
+ </Text>
352
+ </Flex>
353
+ </Stack>
354
+
355
+ {loading ? (
356
+ <Flex align="center" justify="center" padding={4}>
357
+ <Spinner muted />
358
+ </Flex>
359
+ ) : results === null ? null : results.length === 0 ? (
360
+ <Text muted align="center" size={1}>
361
+ No documents match “{query.trim()}”.
362
+ </Text>
363
+ ) : (
364
+ <Stack space={2}>
365
+ {results.map((hit) => {
366
+ // "Added" means added to *this* project — the only case where
367
+ // adding again is redundant. Membership in other projects is fine
368
+ // and just listed below.
369
+ const inThisProject = addedIds.has(hit.docId) || existingDocIds.has(hit.docId)
370
+ const memberships = projectsByDoc.get(hit.docId) ?? []
371
+ // Preview (and translate) the draft when there is one; else the published doc.
372
+ const previewId = hit.hasDraft ? `drafts.${hit.docId}` : hit.docId
373
+ return (
374
+ <Card key={hit.docId} padding={2} radius={2} shadow={1}>
375
+ <Flex align="center" gap={3}>
376
+ <Box flex={1}>
377
+ <Stack space={2}>
378
+ <Flex align="center" gap={2}>
379
+ <Badge
380
+ tone={hit.hasDraft ? 'caution' : 'positive'}
381
+ paddingX={2}
382
+ title={
383
+ hit.hasDraft
384
+ ? 'The draft version will be the translation source'
385
+ : 'The published version will be the translation source'
386
+ }
387
+ >
388
+ {hit.hasDraft ? 'Draft' : 'Published'}
389
+ </Badge>
390
+ <Text weight="medium" textOverflow="ellipsis">
391
+ <DocTitle doc={{_id: previewId, _type: hit._type}} />
392
+ </Text>
393
+ <Badge tone="primary" paddingX={2}>
394
+ {hit._type}
395
+ </Badge>
396
+ </Flex>
397
+ {memberships.length > 0 && (
398
+ <Text size={0} muted>
399
+ In:{' '}
400
+ {memberships.map((p, i) => (
401
+ <Fragment key={p._id}>
402
+ {i > 0 && ', '}
403
+ <ProjectChip project={p} current={p._id === projectId} />
404
+ </Fragment>
405
+ ))}
406
+ </Text>
407
+ )}
408
+ </Stack>
409
+ </Box>
410
+ <OpenInStudioButton id={hit.docId} type={hit._type} />
411
+ <ViewLocjsonButton
412
+ docId={hit.docId}
413
+ type={hit._type}
414
+ sourceIsPublished={!hit.hasDraft}
415
+ client={client}
416
+ schema={schema}
417
+ sourceLanguage={sourceLanguage}
418
+ documentI18nLanguageField={documentI18nLanguageField}
419
+ fieldI18nLanguageField={fieldI18nLanguageField}
420
+ targetLanguages={targetLanguages}
421
+ languages={languages}
422
+ />
423
+ {inThisProject ? (
424
+ <Button mode="bleed" icon={CheckmarkIcon} text="Added" fontSize={1} padding={2} disabled />
425
+ ) : (
426
+ <MenuButton
427
+ id={`add-${hit.docId}`}
428
+ button={
429
+ <Button
430
+ mode="ghost"
431
+ icon={AddIcon}
432
+ iconRight={ChevronDownIcon}
433
+ text="Add"
434
+ fontSize={1}
435
+ padding={2}
436
+ loading={addingId === hit.docId}
437
+ disabled={addingId !== null}
438
+ />
439
+ }
440
+ menu={
441
+ <Menu>
442
+ <MenuItem text="Add source" onClick={() => addItem(hit, false)} />
443
+ <MenuItem text="Add source + translations" onClick={() => addItem(hit, true)} />
444
+ </Menu>
445
+ }
446
+ popover={{portal: true}}
447
+ />
448
+ )}
449
+ </Flex>
450
+ </Card>
451
+ )
452
+ })}
453
+ </Stack>
454
+ )}
455
+ </Stack>
456
+ </Card>
457
+ <Text size={1} muted>
458
+ You can also add content from a document’s “⋯ → Add to translation project” menu.
459
+ </Text>
460
+ </Stack>
461
+ )
462
+ }
@@ -0,0 +1,23 @@
1
+ import {useSchema, useValuePreview} from 'sanity'
2
+ import {UNTITLED} from '../lib/documentTitle'
3
+
4
+ /**
5
+ * Resolve a document's title the way the Studio does — by running the schema
6
+ * type's `preview.prepare` — so types without a literal `title` field (e.g.
7
+ * field-level localized docs) show the same label as in the structure tree,
8
+ * not 'Untitled'.
9
+ */
10
+ export function DocTitle({doc}: {doc: {_id: string; _type: string} | null}) {
11
+ const schema = useSchema()
12
+ const schemaType = doc ? schema.get(doc._type) : undefined
13
+ const preview = useValuePreview({
14
+ enabled: Boolean(doc && schemaType),
15
+ schemaType,
16
+ value: doc ? {_id: doc._id, _type: doc._type} : undefined,
17
+ })
18
+ // String-only: never fall back to the raw projected value, which is an
19
+ // internationalized-array object for field-level i18n types (renders as React #31).
20
+ const title = typeof preview.value?.title === 'string' ? preview.value.title : undefined
21
+ // Placeholder while the async preview resolves, so a real title isn't briefly mislabeled.
22
+ return <>{title ?? (preview.isLoading ? '…' : UNTITLED)}</>
23
+ }