@elytracms/studio 0.0.102 → 0.0.103
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/convex/ingest.test.ts +54 -21
- package/convex/ingest.ts +52 -15
- package/dist/components/ai/translate-document-dialog.d.ts +43 -0
- package/dist/components/ai/translate-document-dialog.js +104 -36
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/ai/translate-skipped.d.ts +19 -3
- package/dist/components/ai/translate-skipped.js +33 -7
- package/dist/components/ai/translate-skipped.js.map +1 -1
- package/dist/lib/ai/translate-apply.d.ts +69 -0
- package/dist/lib/ai/translate-apply.js +36 -0
- package/dist/lib/ai/translate-apply.js.map +1 -0
- package/dist/lib/ai/translate-document.d.ts +94 -7
- package/dist/lib/ai/translate-document.js +175 -55
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +33 -2
- package/dist/lib/ai/writer-form.js +188 -27
- package/dist/lib/ai/writer-form.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +48 -6
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +37 -1
- package/dist/lib/i18n/messages.en.js +50 -6
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/package.json +3 -3
package/convex/ingest.test.ts
CHANGED
|
@@ -77,11 +77,12 @@ const testIngestModule = {
|
|
|
77
77
|
}
|
|
78
78
|
if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
|
|
79
79
|
if (payload.target !== 'contact') return { ok: false, status: 404, message: `No form “${payload.target}”.` }
|
|
80
|
+
// EC-750: the row's id first — the claim binds every file to it (the owner release is asked for).
|
|
81
|
+
const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
|
|
80
82
|
// EC-695: one call for every upload, after every check that can refuse — all bound or none.
|
|
81
|
-
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt)
|
|
83
|
+
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt, id)
|
|
82
84
|
if (!claim.ok) return claim
|
|
83
85
|
const files = claim.files.map((file) => file.storageId)
|
|
84
|
-
const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
|
|
85
86
|
await saveRecord(
|
|
86
87
|
ctx,
|
|
87
88
|
testSubmissionKind,
|
|
@@ -340,9 +341,10 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
340
341
|
/**
|
|
341
342
|
* EC-671 — a claim binds only bytes the deployment's own facts say are this intent's, each
|
|
342
343
|
* check isolated (the others pass by construction) beside a control the same harness claims;
|
|
343
|
-
* and `releaseIngestUploads` deletes only what a claim bound
|
|
344
|
-
*
|
|
345
|
-
* `packages/forms/src/convex/claim-ownership.convex.test.ts
|
|
344
|
+
* and `releaseIngestUploads` deletes only what a claim bound to the OWNER it is asked for
|
|
345
|
+
* (EC-750). The forms addon's end-to-end probes (its rollback and removal, the attacker's
|
|
346
|
+
* harvest; a sibling's removal) are `packages/forms/src/convex/claim-ownership.convex.test.ts`
|
|
347
|
+
* and `sibling-release.convex.test.ts`.
|
|
346
348
|
*/
|
|
347
349
|
describe('the claim binds only this intent’s bytes; release deletes only bound bytes (EC-671)', () => {
|
|
348
350
|
const PNG = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
@@ -352,6 +354,11 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
352
354
|
}
|
|
353
355
|
const claimWith = (t: ReturnType<typeof backend>, uploadId: string, storageId: string) =>
|
|
354
356
|
signedPost(t, { ...SUBMISSION, uploads: [{ uploadId, storageId }] })
|
|
357
|
+
/** The id of the row an accepted submission wrote — the owner its claim bound the files to (EC-750). */
|
|
358
|
+
const ownerOf = async (answer: Response) => {
|
|
359
|
+
expect(answer.status).toBe(200)
|
|
360
|
+
return ((await answer.json()) as { id: string }).id
|
|
361
|
+
}
|
|
355
362
|
async function insertAsset(t: ReturnType<typeof backend>, files: { storageKey: string; thumb?: string }) {
|
|
356
363
|
await t.run(async (ctx) => {
|
|
357
364
|
await ctx.db.insert('assets', {
|
|
@@ -372,6 +379,15 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
372
379
|
.filter((row) => row.kind === 'ingestBinding')
|
|
373
380
|
.map((row) => ({ storageId: row.recordId, uploadId: (row.data as { uploadId: string }).uploadId })),
|
|
374
381
|
)
|
|
382
|
+
/** EC-750: every binding's owner, by its storage id. */
|
|
383
|
+
const owners = (t: ReturnType<typeof backend>) =>
|
|
384
|
+
t.run(async (ctx) =>
|
|
385
|
+
Object.fromEntries(
|
|
386
|
+
(await ctx.db.query('records').collect())
|
|
387
|
+
.filter((row) => row.kind === 'ingestBinding')
|
|
388
|
+
.map((row) => [row.recordId, (row.data as { owner: string }).owner]),
|
|
389
|
+
),
|
|
390
|
+
)
|
|
375
391
|
|
|
376
392
|
it('(1) a file stored BEFORE the intent — in the same millisecond — is refused, rejected and kept; one stored after is claimed', async () => {
|
|
377
393
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
@@ -429,18 +445,24 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
429
445
|
expect([await stored(t, original), await stored(t, preview)]).toEqual([true, true])
|
|
430
446
|
})
|
|
431
447
|
|
|
432
|
-
it('releaseIngestUploads deletes
|
|
448
|
+
it('EC-750: releaseIngestUploads deletes the files its OWNER claimed, with their bindings — never another owner’s, never an asset’s', async () => {
|
|
433
449
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
434
450
|
const t = backend()
|
|
435
|
-
const
|
|
451
|
+
const [mine, theirs] = [await declare(t), await declare(t)]
|
|
436
452
|
const own = await storeFile(t, 400, 'image/png')
|
|
437
|
-
|
|
453
|
+
const other = await storeFile(t, 400, 'image/png')
|
|
454
|
+
const owner = await ownerOf(await claimWith(t, mine, own))
|
|
455
|
+
const otherOwner = await ownerOf(await claimWith(t, theirs, other))
|
|
438
456
|
const assetFile = await storeFile(t, 400, 'image/png')
|
|
439
457
|
await insertAsset(t, { storageKey: assetFile })
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
expect(
|
|
443
|
-
expect(await
|
|
458
|
+
expect(await owners(t)).toEqual({ [own]: owner, [other]: otherOwner })
|
|
459
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, owner))
|
|
460
|
+
expect(released).toEqual({ deleted: [own], kept: [] })
|
|
461
|
+
expect([await stored(t, own), await stored(t, other), await stored(t, assetFile)]).toEqual([false, true, true])
|
|
462
|
+
expect(await bindings(t)).toEqual([{ storageId: other, uploadId: theirs }])
|
|
463
|
+
// An owner with nothing bound any more (released already, or a row that never claimed) deletes nothing.
|
|
464
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, owner))).toEqual({ deleted: [], kept: [] })
|
|
465
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, 'sub_never'))).toEqual({ deleted: [], kept: [] })
|
|
444
466
|
})
|
|
445
467
|
|
|
446
468
|
// EC-685 (Codex r4 F20, ordering 2): a fact checked at claim time is not permanent — an
|
|
@@ -460,12 +482,20 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
460
482
|
const promoted = await storeFile(t, 400, 'image/png')
|
|
461
483
|
const preview = await storeFile(t, 40, 'image/png')
|
|
462
484
|
const unreferenced = await storeFile(t, 400, 'image/png')
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
485
|
+
// One submission claims all three (EC-750: one owner).
|
|
486
|
+
const owner = await ownerOf(
|
|
487
|
+
await signedPost(t, {
|
|
488
|
+
...SUBMISSION,
|
|
489
|
+
uploads: [
|
|
490
|
+
{ uploadId: asFile, storageId: promoted },
|
|
491
|
+
{ uploadId: asThumb, storageId: preview },
|
|
492
|
+
{ uploadId: plain, storageId: unreferenced },
|
|
493
|
+
],
|
|
494
|
+
}),
|
|
495
|
+
)
|
|
466
496
|
// An editor promotes two of the uploads to an asset (its file and its preview) — after the claims.
|
|
467
497
|
await insertAsset(t, { storageKey: promoted, thumb: preview })
|
|
468
|
-
const released = await t.run((ctx) => releaseIngestUploads(ctx,
|
|
498
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, owner))
|
|
469
499
|
expect(released).toEqual({ deleted: [unreferenced], kept: [promoted, preview] })
|
|
470
500
|
expect([await stored(t, promoted), await stored(t, preview), await stored(t, unreferenced)]).toEqual([true, true, false])
|
|
471
501
|
expect(await bindings(t)).toEqual([]) // ownership passed: no binding is left to hold the asset's bytes
|
|
@@ -480,7 +510,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
480
510
|
const t = backend()
|
|
481
511
|
const uploadId = await declare(t)
|
|
482
512
|
const own = await storeFile(t, 400, 'image/png')
|
|
483
|
-
|
|
513
|
+
const owner = await ownerOf(await claimWith(t, uploadId, own))
|
|
484
514
|
await insertAsset(t, { storageKey: own }) // promoted to an asset while the submission still holds it
|
|
485
515
|
const editor = await asEditor(t)
|
|
486
516
|
expect(await editor.mutation(deleteBlob, { storageKey: own })).toEqual({ deleted: false, heldBy: 'ingest' })
|
|
@@ -490,7 +520,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
490
520
|
await t.run(async (ctx) => {
|
|
491
521
|
for (const row of await ctx.db.query('assets').collect()) await ctx.db.delete(row._id)
|
|
492
522
|
})
|
|
493
|
-
expect(await t.run((ctx) => releaseIngestUploads(ctx,
|
|
523
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, owner))).toEqual({ deleted: [own], kept: [] })
|
|
494
524
|
expect(await stored(t, own)).toBe(false)
|
|
495
525
|
})
|
|
496
526
|
|
|
@@ -593,9 +623,12 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
593
623
|
const t = backend()
|
|
594
624
|
const uploadId = await declare(t)
|
|
595
625
|
const own = await storeFile(t, 400, 'image/png')
|
|
596
|
-
|
|
626
|
+
const owner = await ownerOf(await claimWith(t, uploadId, own))
|
|
597
627
|
const row = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((r) => r.kind === 'ingestBinding')!)
|
|
598
|
-
expect(row.data).toEqual({ uploadId, target: 'contact', boundAt: '2026-10-26T12:00:00.000Z' })
|
|
628
|
+
expect(row.data).toEqual({ uploadId, target: 'contact', owner, boundAt: '2026-10-26T12:00:00.000Z' })
|
|
629
|
+
// EC-750: the owner fills the static `key1` slot (`by_kind_key1`) — no schema or index change.
|
|
630
|
+
expect(row.key1).toBe(owner)
|
|
631
|
+
expect(row.key2).toBeUndefined()
|
|
599
632
|
expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
|
|
600
633
|
expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
|
|
601
634
|
})
|
|
@@ -610,7 +643,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
610
643
|
),
|
|
611
644
|
)
|
|
612
645
|
const claimDirect = (t: ReturnType<typeof backend>, claims: { uploadId: string; storageId: string }[]) =>
|
|
613
|
-
t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z'))
|
|
646
|
+
t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z', 'sub_direct'))
|
|
614
647
|
|
|
615
648
|
it('EC-695: a submission with one refused claim binds NOTHING — the claims before it stay issued; nothing is deleted', async () => {
|
|
616
649
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
package/convex/ingest.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { IngestHandlerResult, IngestPayload, IngestUploadClaim } from '@ely
|
|
|
18
18
|
import { defineRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
|
|
19
19
|
import { createUuidIdFactory } from '@elytracms/core/determinism'
|
|
20
20
|
import type { MutationCtx, QueryCtx } from './functions'
|
|
21
|
-
import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
21
|
+
import { findRecordDoc, listRecordWire, queryRecordDocs, saveRecord } from './record-functions'
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* EC-617 — the ONE inbound receiver of the public ingest seam (ruling EC-565 § 6c), and the
|
|
@@ -44,7 +44,8 @@ import { findRecordDoc, listRecordWire, saveRecord } from './record-functions'
|
|
|
44
44
|
* claims its stored files with {@link claimIngestUploads}, which checks that the deployment
|
|
45
45
|
* stored each for ITS intent (EC-671) — its bytes the ones the upload request declared by
|
|
46
46
|
* SHA-256 (EC-685) — and that it matches the declared size and type, and binds them all or
|
|
47
|
-
* none (EC-695)
|
|
47
|
+
* none (EC-695) to the record that claims them (EC-750); {@link releaseIngestUploads}, asked
|
|
48
|
+
* for that record, is the only way such bytes are deleted.
|
|
48
49
|
*
|
|
49
50
|
* The engine never imports an addon: the handler is an internal-mutation REFERENCE the
|
|
50
51
|
* consumer passes in its one `http.ts` line (`defineStudioHttp({ auth, ingest: {
|
|
@@ -195,6 +196,11 @@ export function ingestReceiver(handlers: IngestHandlers = {}): PublicHttpAction
|
|
|
195
196
|
// it never deletes, and ingest bytes are deleted only through {@link releaseIngestUploads},
|
|
196
197
|
// which deletes only what a claim bound.
|
|
197
198
|
//
|
|
199
|
+
// EC-750 (Codex r9 F46) — bound to WHOM. A binding records the record that claimed the file
|
|
200
|
+
// (`owner`), and release is asked for an owner, never for storage ids: removing a row deletes
|
|
201
|
+
// only the files that row claimed. Before, release took the removed row's `files` ids — a column
|
|
202
|
+
// a whole-record write can fill with another row's file — and deleted any bound one.
|
|
203
|
+
//
|
|
198
204
|
// EC-685 — causation. Eligibility (stored after the intent, unbound, no asset's) is not
|
|
199
205
|
// ownership: with intents A and B open, bytes stored through B's URL were eligible for A. So
|
|
200
206
|
// the intent DECLARES the bytes' SHA-256 before they exist, and the claim requires the digest
|
|
@@ -236,14 +242,25 @@ export const ingestUploadRecordKind = defineRecordKind({
|
|
|
236
242
|
* and "is it bound?" is one indexed read. Written only by {@link claimIngestUploads}, in the
|
|
237
243
|
* claim's transaction; read by the claim (a file another claim bound is refused) and by
|
|
238
244
|
* {@link releaseIngestUploads}. Engine-internal (no public functions), like the intent.
|
|
245
|
+
*
|
|
246
|
+
* EC-750 (Codex r9 F46) — and WHOSE: `owner` is the id of the record that claimed the file (a
|
|
247
|
+
* forms submission), its `key1` slot, so "this record's files" is one indexed read
|
|
248
|
+
* (`by_kind_key1`) and release asks that question, never "is this id bound?". `target` is the
|
|
249
|
+
* intent's (the form every submission to it shares), so it cannot say whose a file is. A
|
|
250
|
+
* binding written before EC-750 has no owner and no slot: no release reads it, so it is never
|
|
251
|
+
* deleted and keeps protecting its bytes from `assets:deleteBlob` (residue, never loss — EC-671's
|
|
252
|
+
* no-migration precedent).
|
|
239
253
|
*/
|
|
240
254
|
export const ingestBindingRecordKind = defineRecordKind({
|
|
241
255
|
kind: 'ingestBinding',
|
|
242
256
|
module: 'ingest',
|
|
257
|
+
keys: ['owner'],
|
|
243
258
|
time: 'boundAt',
|
|
244
259
|
fields: {
|
|
245
260
|
uploadId: z.string().min(1),
|
|
246
261
|
target: z.string().min(1),
|
|
262
|
+
/** EC-750: the id of the record that claimed the file — what {@link releaseIngestUploads} is asked for. */
|
|
263
|
+
owner: z.string().min(1),
|
|
247
264
|
boundAt: z.string().min(1),
|
|
248
265
|
},
|
|
249
266
|
})
|
|
@@ -403,12 +420,18 @@ async function assetReferencesStorage(ctx: MutationCtx, storageId: string): Prom
|
|
|
403
420
|
* A handler calls this ONCE per submission, after every check of its own that can refuse: a
|
|
404
421
|
* mutation that RETURNS commits its writes (a claim made before a later refusal stays bound with
|
|
405
422
|
* no row to hold it); one that THROWS commits nothing.
|
|
423
|
+
*
|
|
424
|
+
* EC-750: `owner` is the id of the record the handler is about to write — mint it BEFORE the
|
|
425
|
+
* claim. Every binding records it, and {@link releaseIngestUploads} deletes a file only for the
|
|
426
|
+
* owner that claimed it: a row naming another row's file (an editor's whole-record write can
|
|
427
|
+
* put any id in its `files`) never deletes it.
|
|
406
428
|
*/
|
|
407
429
|
export async function claimIngestUploads(
|
|
408
430
|
ctx: MutationCtx,
|
|
409
431
|
claims: readonly IngestUploadClaim[],
|
|
410
432
|
target: string,
|
|
411
433
|
receivedAt: string,
|
|
434
|
+
owner: string,
|
|
412
435
|
): Promise<IngestUploadsClaimResult> {
|
|
413
436
|
const accepted: AcceptedClaim[] = []
|
|
414
437
|
for (const [index, claim] of claims.entries()) {
|
|
@@ -418,7 +441,13 @@ export async function claimIngestUploads(
|
|
|
418
441
|
}
|
|
419
442
|
const stamps = { createdAt: receivedAt, updatedAt: receivedAt }
|
|
420
443
|
for (const claim of accepted) {
|
|
421
|
-
await saveRecord(
|
|
444
|
+
await saveRecord(
|
|
445
|
+
ctx,
|
|
446
|
+
ingestBindingRecordKind,
|
|
447
|
+
claim.storageId,
|
|
448
|
+
{ uploadId: claim.uploadId, target, owner, boundAt: receivedAt },
|
|
449
|
+
stamps,
|
|
450
|
+
)
|
|
422
451
|
await saveRecord(ctx, ingestUploadRecordKind, claim.uploadId, { state: 'claimed', storageId: claim.storageId }, stamps)
|
|
423
452
|
}
|
|
424
453
|
return { ok: true, files: accepted.map(({ storageId, field, contentType, size }) => ({ storageId, field, contentType, size })) }
|
|
@@ -508,18 +537,25 @@ async function judgeIngestUploadClaim(
|
|
|
508
537
|
}
|
|
509
538
|
|
|
510
539
|
/**
|
|
511
|
-
* Delete ingest bytes
|
|
512
|
-
*
|
|
513
|
-
*
|
|
540
|
+
* Delete the ingest bytes ONE record claimed (EC-671, EC-750) — the ONE way ingest bytes are
|
|
541
|
+
* deleted. It is asked for an OWNER (the id {@link claimIngestUploads} bound the files to), never
|
|
542
|
+
* for storage ids: it reads that owner's bindings by their key (`by_kind_key1`) and deletes each
|
|
543
|
+
* file with its binding — so it never sees, and never writes, another owner's binding. A row's
|
|
544
|
+
* `files` column is the visitor's and the editor's word (a whole-record write can name any id,
|
|
545
|
+
* or clear the list), so release never reads it: a row naming another row's file deletes
|
|
546
|
+
* nothing, and a row whose list was cleared still deletes what it claimed.
|
|
547
|
+
*
|
|
548
|
+
* Of the owner's files it KEEPS, returned in `kept`:
|
|
514
549
|
*
|
|
515
|
-
* - an id with no binding — an asset's, an id a write put into a row by hand, a file claimed
|
|
516
|
-
* before bindings existed — because ingest cannot show it owns them;
|
|
517
550
|
* - EC-685: a bound file an asset row references now (`storageKey` or `thumb.storageKey` — an
|
|
518
551
|
* editor promoted the upload to an asset with `assets:upsert`, a legitimate edit). Ownership
|
|
519
552
|
* has passed to the asset: the binding is spent (deleted) and the bytes stay, so the
|
|
520
553
|
* asset's own delete removes them later. Read in this transaction, so no asset can acquire
|
|
521
554
|
* the file between the check and the delete.
|
|
522
555
|
*
|
|
556
|
+
* Never touched: a binding of another owner, and a binding written before EC-750 (no owner, no
|
|
557
|
+
* key slot — residue, never loss; it keeps protecting its bytes from `assets:deleteBlob`).
|
|
558
|
+
*
|
|
523
559
|
* Call it when removing a row that holds claimed files — never for a refused submission, which
|
|
524
560
|
* bound nothing ({@link claimIngestUploads}, EC-695). The mirror is `assets:deleteBlob`, which keeps bytes a binding holds
|
|
525
561
|
* ({@link ingestBindingHolds}): each delete path re-checks the OTHER owner in its own
|
|
@@ -527,16 +563,17 @@ async function judgeIngestUploadClaim(
|
|
|
527
563
|
*/
|
|
528
564
|
export async function releaseIngestUploads(
|
|
529
565
|
ctx: MutationCtx,
|
|
530
|
-
|
|
566
|
+
owner: string,
|
|
531
567
|
): Promise<{ deleted: string[]; kept: string[] }> {
|
|
532
568
|
const deleted: string[] = []
|
|
533
569
|
const kept: string[] = []
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
570
|
+
const owned = await queryRecordDocs(
|
|
571
|
+
ctx,
|
|
572
|
+
ingestBindingRecordKind,
|
|
573
|
+
resolveRecordQuery(ingestBindingRecordKind, { where: { owner } }),
|
|
574
|
+
)
|
|
575
|
+
for (const binding of owned) {
|
|
576
|
+
const storageId = binding.recordId
|
|
540
577
|
await ctx.db.delete(binding._id)
|
|
541
578
|
if (await assetReferencesStorage(ctx, storageId)) {
|
|
542
579
|
kept.push(storageId) // the asset owns the bytes now (EC-685)
|
|
@@ -1,5 +1,48 @@
|
|
|
1
|
+
import type { CollectionDef, Locale } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { TranslateMode, TranslatePlan, TranslateSkip, TranslateWrite } from '../../lib/ai/index.js';
|
|
1
3
|
import type { TranslateDocumentActionProps } from './translate-document-action.js';
|
|
4
|
+
/** What the done phase reports — what the run actually wrote, against what it planned. */
|
|
5
|
+
export interface TranslateRunSummary {
|
|
6
|
+
mode: TranslateMode;
|
|
7
|
+
plan: TranslatePlan;
|
|
8
|
+
/** Units translated (the fill message's count). */
|
|
9
|
+
translated: number;
|
|
10
|
+
locales: number;
|
|
11
|
+
cancelled: boolean;
|
|
12
|
+
/** EC-752: the writes that landed (a write refused at its revision is in {@link refused}). */
|
|
13
|
+
writes: readonly TranslateWrite[];
|
|
14
|
+
refused: readonly TranslateWrite[];
|
|
15
|
+
/** EC-752: the version saved before a re-translation wrote. */
|
|
16
|
+
restorePoint?: number;
|
|
17
|
+
/** EC-593: every text position the run could not translate — listed, never silent. */
|
|
18
|
+
skipped: readonly TranslateSkip[];
|
|
19
|
+
}
|
|
2
20
|
export declare function TranslateDocumentDialog({ projectId, collection, docRef, document, revision, localeConfig, flushDraft, open, onOpenChange, }: TranslateDocumentActionProps & {
|
|
3
21
|
open: boolean;
|
|
4
22
|
onOpenChange: (open: boolean) => void;
|
|
5
23
|
}): import("react").JSX.Element;
|
|
24
|
+
/**
|
|
25
|
+
* EC-752 — the confirm phase: the target languages, the scope choice and, for a
|
|
26
|
+
* re-translation, its plan (what it replaces, fills and leaves as it is — "up to").
|
|
27
|
+
*/
|
|
28
|
+
export declare function TranslateConfirm({ mode, onModeChange, base, targets, plan, collection, }: {
|
|
29
|
+
mode: TranslateMode;
|
|
30
|
+
onModeChange: (mode: TranslateMode) => void;
|
|
31
|
+
base: Locale;
|
|
32
|
+
targets: readonly Locale[];
|
|
33
|
+
/** The run's plan — present in `retranslate-all`. */
|
|
34
|
+
plan: TranslatePlan | null;
|
|
35
|
+
collection: CollectionDef;
|
|
36
|
+
}): import("react").JSX.Element;
|
|
37
|
+
/** The headline of the done phase (EC-752: mode-aware; slugs and replacements are listed below it). */
|
|
38
|
+
export declare function translateDoneMessage(summary: TranslateRunSummary, base: Locale): string;
|
|
39
|
+
/**
|
|
40
|
+
* EC-752 — the done phase: what was actually written (a re-translation lists what it
|
|
41
|
+
* replaced and filled), the slugs it set, what was refused at its revision, what a
|
|
42
|
+
* re-translation planned but left as it was, its restore point, and what was not translated.
|
|
43
|
+
*/
|
|
44
|
+
export declare function TranslateDoneSummary({ summary, base, collection, }: {
|
|
45
|
+
summary: TranslateRunSummary;
|
|
46
|
+
base: Locale;
|
|
47
|
+
collection: CollectionDef;
|
|
48
|
+
}): import("react").JSX.Element;
|
|
@@ -2,17 +2,19 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { createCollectionRegistry } from '@elytracms/core/cms-core';
|
|
4
4
|
import { Check, Languages, Loader2, X } from 'lucide-react';
|
|
5
|
-
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Row, Stack, Text, } from '@elytracms/studio/ui';
|
|
5
|
+
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Row, SegmentedControl, Stack, Text, } from '@elytracms/studio/ui';
|
|
6
6
|
import { translate } from '../../lib/i18n/index.js';
|
|
7
7
|
import { localeChipLabel } from '../../lib/locale/index.js';
|
|
8
8
|
import { getStudioOperations } from '../../lib/operations/index.js';
|
|
9
9
|
import { resolveDeploymentAi } from '../../lib/workspace/index.js';
|
|
10
|
-
import { resolveAiConfig, resolveKnowledge, translateDocument } from '../../lib/ai/index.js';
|
|
10
|
+
import { planTranslation, resolveAiConfig, resolveKnowledge, translateDocument } from '../../lib/ai/index.js';
|
|
11
|
+
import { applyTranslation } from '../../lib/ai/translate-apply.js';
|
|
11
12
|
import { cmsValidationIssueMessage } from '../../lib/persona/labels.js';
|
|
12
13
|
import { useCms } from '../cms/cms-store.js';
|
|
13
|
-
import { TranslateSkipped } from './translate-skipped.js';
|
|
14
|
+
import { TranslateBucketList, TranslateSkipped } from './translate-skipped.js';
|
|
14
15
|
export function TranslateDocumentDialog({ projectId, collection, docRef, document, revision, localeConfig, flushDraft, open, onOpenChange, }) {
|
|
15
16
|
const [phase, setPhase] = useState('confirm');
|
|
17
|
+
const [mode, setMode] = useState('fill-empty');
|
|
16
18
|
const [progress, setProgress] = useState(null);
|
|
17
19
|
const [summary, setSummary] = useState(null);
|
|
18
20
|
/**
|
|
@@ -20,7 +22,7 @@ export function TranslateDocumentDialog({ projectId, collection, docRef, documen
|
|
|
20
22
|
* EC-473 `ai.error.applyInvalid` copy ("the proposal no longer fits the current
|
|
21
23
|
* document: …"), which is exactly this situation: no new catalog string, and the
|
|
22
24
|
* editor is told WHICH rules the translation would have broken rather than a bare
|
|
23
|
-
* "failed".
|
|
25
|
+
* "failed". EC-752: also why a re-translation wrote nothing (no restore point).
|
|
24
26
|
*/
|
|
25
27
|
const [refusal, setRefusal] = useState(null);
|
|
26
28
|
const abortRef = useRef(null);
|
|
@@ -32,6 +34,10 @@ export function TranslateDocumentDialog({ projectId, collection, docRef, documen
|
|
|
32
34
|
useEffect(() => () => abortRef.current?.abort(), []);
|
|
33
35
|
const base = localeConfig.default;
|
|
34
36
|
const targets = localeConfig.locales.filter((l) => l !== base);
|
|
37
|
+
// EC-752 rule 4: the confirm lists the run's OWN plan — the same judge, on the same input.
|
|
38
|
+
const plan = useMemo(() => mode === 'retranslate-all'
|
|
39
|
+
? planTranslation({ collection, document, localeConfig, mode, validateOptions: cms.validateOptions })
|
|
40
|
+
: null, [mode, collection, document, localeConfig, cms.validateOptions]);
|
|
35
41
|
const run = useCallback(async () => {
|
|
36
42
|
if (projectId === undefined) {
|
|
37
43
|
setPhase('error');
|
|
@@ -56,6 +62,7 @@ export function TranslateDocumentDialog({ projectId, collection, docRef, documen
|
|
|
56
62
|
collection,
|
|
57
63
|
document,
|
|
58
64
|
localeConfig,
|
|
65
|
+
mode,
|
|
59
66
|
knowledge,
|
|
60
67
|
signal: controller.signal,
|
|
61
68
|
onProgress: setProgress,
|
|
@@ -78,36 +85,44 @@ export function TranslateDocumentDialog({ projectId, collection, docRef, documen
|
|
|
78
85
|
setPhase('error');
|
|
79
86
|
return;
|
|
80
87
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const { operations } = await getStudioOperations();
|
|
92
|
-
// Provenance: an AI-tagged version snapshot (best-effort — the draft already landed).
|
|
93
|
-
try {
|
|
94
|
-
await operations.documents.snapshot({
|
|
88
|
+
const { operations } = await getStudioOperations();
|
|
89
|
+
// EC-752 rule 3 (in `applyTranslation`): a re-translation records a labelled restore
|
|
90
|
+
// point of the stored draft BEFORE it writes; then the patches go through the session's
|
|
91
|
+
// queue at the revision the draft was authored on (EC-595 A2 / EC-647 — a bucket a
|
|
92
|
+
// colleague changed meanwhile is refused on that field and shown there); then the
|
|
93
|
+
// AI-provenance snapshot.
|
|
94
|
+
const applied = await applyTranslation(result, {
|
|
95
|
+
commit: (patches) => cms.commitPatches(docRef, patches, patches.map(() => revision)),
|
|
96
|
+
snapshot: async ({ label, origin }) => {
|
|
97
|
+
const recorded = await operations.documents.snapshot({
|
|
95
98
|
projectId,
|
|
96
99
|
ref: docRef,
|
|
97
|
-
label
|
|
98
|
-
origin:
|
|
100
|
+
label,
|
|
101
|
+
...(origin ? { origin } : {}),
|
|
99
102
|
});
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
103
|
+
if (!recorded.ok)
|
|
104
|
+
throw new Error(recorded.error.message);
|
|
105
|
+
return recorded.value;
|
|
106
|
+
},
|
|
107
|
+
}, { restorePoint: translate('ai.translate.snapshotLabel'), provenance: translate('cms.version.aiAuthoredTitle') });
|
|
108
|
+
if (applied.status === 'snapshot-failed') {
|
|
109
|
+
setRefusal(translate('ai.translate.snapshotFailed'));
|
|
110
|
+
setPhase('error');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (applied.status === 'failed') {
|
|
114
|
+
setPhase('error');
|
|
115
|
+
return;
|
|
104
116
|
}
|
|
105
117
|
setSummary({
|
|
118
|
+
mode,
|
|
119
|
+
plan: result.plan,
|
|
106
120
|
translated: result.translated,
|
|
107
|
-
slugs: result.slugsDerived,
|
|
108
121
|
locales: Object.keys(result.perLocale).length,
|
|
109
122
|
cancelled: result.cancelled,
|
|
110
|
-
|
|
123
|
+
writes: applied.writes,
|
|
124
|
+
refused: applied.refused,
|
|
125
|
+
...(applied.restorePoint !== undefined ? { restorePoint: applied.restorePoint } : {}),
|
|
111
126
|
skipped: result.skipped,
|
|
112
127
|
});
|
|
113
128
|
setPhase('done');
|
|
@@ -118,21 +133,74 @@ export function TranslateDocumentDialog({ projectId, collection, docRef, documen
|
|
|
118
133
|
finally {
|
|
119
134
|
abortRef.current = null;
|
|
120
135
|
}
|
|
121
|
-
}, [projectId, collection, document, revision, localeConfig, docRef, flushDraft, registry, cms]);
|
|
136
|
+
}, [projectId, collection, document, revision, localeConfig, mode, docRef, flushDraft, registry, cms]);
|
|
122
137
|
const close = () => {
|
|
123
138
|
if (phase === 'running')
|
|
124
139
|
return; // never close mid-run — Cancel aborts instead
|
|
125
140
|
onOpenChange(false);
|
|
126
141
|
};
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
return translate('ai.translate.nothing');
|
|
130
|
-
if (s.cancelled)
|
|
131
|
-
return translate('ai.translate.cancelled');
|
|
132
|
-
return translate('ai.translate.applied', { count: s.translated, locales: s.locales });
|
|
133
|
-
};
|
|
134
|
-
return (_jsx(Dialog, { open: open, onOpenChange: (next) => (!next ? close() : undefined), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: _jsxs(Row, { gap: "2", align: "center", children: [_jsx(Languages, { className: "size-4" }), " ", translate('ai.translate.dialogTitle')] }) }), _jsx(DialogDescription, { children: translate('ai.translate.dialogDescription', { base: localeChipLabel(base) }) })] }), phase === 'confirm' ? (_jsxs(Stack, { gap: "4", className: "py-2", children: [_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "xs", weight: "medium", tone: "muted", children: translate('ai.translate.targetsLabel') }), _jsx(Row, { gap: "1", wrap: true, children: targets.map((l) => (_jsx(Badge, { variant: "outline", children: localeChipLabel(l) }, l))) })] }), _jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "xs", weight: "medium", tone: "muted", children: translate('ai.translate.scopeLabel') }), _jsxs(Row, { gap: "2", align: "center", children: [_jsx(Badge, { variant: "default", children: translate('ai.translate.fillMissing') }), _jsx(Button, { size: "sm", variant: "ghost", disabled: true, title: translate('ai.translate.retranslateSoon'), children: translate('ai.translate.retranslateAll') }), _jsx(Text, { size: "xs", tone: "muted", children: translate('ai.translate.retranslateSoon') })] })] })] })) : null, phase === 'running' ? (_jsxs(Stack, { gap: "3", align: "center", className: "py-6", children: [_jsx(Loader2, { className: "size-6 animate-spin text-primary" }), _jsx(Text, { size: "sm", tone: "muted", "data-testid": "translate-progress", children: progress && progress.total > 0
|
|
142
|
+
const replaces = (plan?.buckets ?? []).some((bucket) => bucket.kind === 'replace');
|
|
143
|
+
return (_jsx(Dialog, { open: open, onOpenChange: (next) => (!next ? close() : undefined), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: _jsxs(Row, { gap: "2", align: "center", children: [_jsx(Languages, { className: "size-4" }), " ", translate('ai.translate.dialogTitle')] }) }), _jsx(DialogDescription, { children: translate('ai.translate.dialogDescription', { base: localeChipLabel(base) }) })] }), phase === 'confirm' ? (_jsx(TranslateConfirm, { mode: mode, onModeChange: setMode, base: base, targets: targets, plan: plan, collection: collection })) : null, phase === 'running' ? (_jsxs(Stack, { gap: "3", align: "center", className: "py-6", children: [_jsx(Loader2, { className: "size-6 animate-spin text-primary" }), _jsx(Text, { size: "sm", tone: "muted", "data-testid": "translate-progress", children: progress && progress.total > 0
|
|
135
144
|
? translate('ai.translate.progress', { completed: progress.completed, total: progress.total })
|
|
136
|
-
: translate('ai.translate.preparing') }), progress && progress.total > 0 ? (_jsx("div", { className: "h-1.5 w-56 overflow-hidden rounded-full bg-muted", children: _jsx("div", { className: "h-full rounded-full bg-primary transition-all", style: { width: `${Math.round((progress.completed / progress.total) * 100)}%` } }) })) : null] })) : null, phase === 'done' && summary ? (_jsxs(Stack, { gap: "2", align: "center", className: "py-6", children: [_jsx(Check, { className: "size-6 text-success" }), _jsx(
|
|
145
|
+
: translate('ai.translate.preparing') }), progress && progress.total > 0 ? (_jsx("div", { className: "h-1.5 w-56 overflow-hidden rounded-full bg-muted", children: _jsx("div", { className: "h-full rounded-full bg-primary transition-all", style: { width: `${Math.round((progress.completed / progress.total) * 100)}%` } }) })) : null] })) : null, phase === 'done' && summary ? (_jsxs(Stack, { gap: "2", align: "center", className: "py-6", children: [_jsx(Check, { className: "size-6 text-success" }), _jsx(TranslateDoneSummary, { summary: summary, base: base, collection: collection })] })) : null, phase === 'error' ? (_jsxs(Stack, { gap: "2", align: "center", className: "py-6", children: [_jsx(X, { className: "size-6 text-destructive" }), _jsx(Text, { size: "sm", tone: "muted", className: "text-center", "data-testid": "translate-error", children: refusal ?? translate('ai.translate.failed') })] })) : null, _jsx(DialogFooter, { className: "mt-2", children: phase === 'confirm' ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: close, children: translate('common.cancel') }), mode === 'retranslate-all' && replaces ? (_jsxs(Button, { size: "sm", variant: "destructive", onClick: () => void run(), "data-testid": "translate-confirm", children: [_jsx(Languages, {}), " ", translate('ai.translate.confirmReplace')] })) : (_jsxs(Button, { size: "sm", onClick: () => void run(), "data-testid": "translate-confirm", children: [_jsx(Languages, {}), " ", translate('ai.translate.confirm')] }))] })) : phase === 'running' ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => abortRef.current?.abort(), "data-testid": "translate-cancel", children: translate('common.cancel') })) : (_jsx(Button, { size: "sm", onClick: close, "data-testid": "translate-close", children: translate('ai.translate.close') })) })] }) }));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* EC-752 — the confirm phase: the target languages, the scope choice and, for a
|
|
149
|
+
* re-translation, its plan (what it replaces, fills and leaves as it is — "up to").
|
|
150
|
+
*/
|
|
151
|
+
export function TranslateConfirm({ mode, onModeChange, base, targets, plan, collection, }) {
|
|
152
|
+
const baseLabel = localeChipLabel(base);
|
|
153
|
+
return (_jsxs(Stack, { gap: "4", className: "py-2", children: [_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "xs", weight: "medium", tone: "muted", children: translate('ai.translate.targetsLabel') }), _jsx(Row, { gap: "1", wrap: true, children: targets.map((l) => (_jsx(Badge, { variant: "outline", children: localeChipLabel(l) }, l))) })] }), _jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "xs", weight: "medium", tone: "muted", children: translate('ai.translate.scopeLabel') }), _jsx(SegmentedControl, { ariaLabel: translate('ai.translate.scopeLabel'), testId: "translate-scope", items: [
|
|
154
|
+
{
|
|
155
|
+
key: 'fill-empty',
|
|
156
|
+
label: translate('ai.translate.fillMissing'),
|
|
157
|
+
active: mode === 'fill-empty',
|
|
158
|
+
onClick: () => onModeChange('fill-empty'),
|
|
159
|
+
testId: 'translate-scope-fill',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: 'retranslate-all',
|
|
163
|
+
label: translate('ai.translate.retranslateAll'),
|
|
164
|
+
active: mode === 'retranslate-all',
|
|
165
|
+
onClick: () => onModeChange('retranslate-all'),
|
|
166
|
+
testId: 'translate-scope-retranslate',
|
|
167
|
+
},
|
|
168
|
+
] }), _jsx(Text, { size: "xs", tone: "muted", children: translate(mode === 'retranslate-all' ? 'ai.translate.retranslateHint' : 'ai.translate.fillMissingHint', {
|
|
169
|
+
base: baseLabel,
|
|
170
|
+
}) })] }), mode === 'retranslate-all' && plan ? _jsx(RetranslatePlan, { plan: plan, base: base, collection: collection }) : null] }));
|
|
171
|
+
}
|
|
172
|
+
/** EC-752 rule 4 — what a re-translation will do, from its own pre-provider plan. */
|
|
173
|
+
function RetranslatePlan({ plan, base, collection }) {
|
|
174
|
+
const baseLabel = localeChipLabel(base);
|
|
175
|
+
const replace = plan.buckets.filter((bucket) => bucket.kind === 'replace');
|
|
176
|
+
const fill = plan.buckets.filter((bucket) => bucket.kind === 'fill');
|
|
177
|
+
return (_jsxs(Stack, { gap: "2", "data-testid": "translate-plan", children: [replace.length > 0 ? (_jsx(TranslateBucketList, { title: translate('ai.translate.planReplaceTitle', { base: baseLabel, count: replace.length }), buckets: replace, collection: collection, testId: "translate-plan-replace" })) : (_jsx(Text, { size: "xs", tone: "muted", children: translate('ai.translate.planReplaceNone') })), _jsx(TranslateBucketList, { title: translate('ai.translate.planFillTitle', { base: baseLabel, count: fill.length }), buckets: fill, collection: collection, testId: "translate-plan-fill" }), _jsx(TranslateSkipped, { skipped: plan.skipped, collection: collection, mode: plan.mode, title: translate('ai.translate.planKeptTitle', { count: plan.skipped.length }) }), _jsxs(Stack, { gap: "px", children: [_jsx(Text, { size: "xs", tone: "muted", children: translate('ai.translate.planWhole') }), _jsx(Text, { size: "xs", tone: "muted", children: translate('ai.translate.planSlugsKept') }), replace.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", children: translate('ai.translate.planRestorePoint') })) : null] })] }));
|
|
178
|
+
}
|
|
179
|
+
/** The headline of the done phase (EC-752: mode-aware; slugs and replacements are listed below it). */
|
|
180
|
+
export function translateDoneMessage(summary, base) {
|
|
181
|
+
const retranslate = summary.mode === 'retranslate-all';
|
|
182
|
+
const nothing = summary.plan.total === 0 && summary.skipped.length === 0 && summary.writes.length === 0 && summary.refused.length === 0;
|
|
183
|
+
if (nothing) {
|
|
184
|
+
return retranslate
|
|
185
|
+
? translate('ai.translate.retranslateNothing', { base: localeChipLabel(base) })
|
|
186
|
+
: translate('ai.translate.nothing');
|
|
187
|
+
}
|
|
188
|
+
if (summary.cancelled)
|
|
189
|
+
return translate(retranslate ? 'ai.translate.retranslateCancelled' : 'ai.translate.cancelled');
|
|
190
|
+
if (retranslate)
|
|
191
|
+
return translate('ai.translate.retranslateDone');
|
|
192
|
+
return translate('ai.translate.applied', { count: summary.translated, locales: summary.locales });
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* EC-752 — the done phase: what was actually written (a re-translation lists what it
|
|
196
|
+
* replaced and filled), the slugs it set, what was refused at its revision, what a
|
|
197
|
+
* re-translation planned but left as it was, its restore point, and what was not translated.
|
|
198
|
+
*/
|
|
199
|
+
export function TranslateDoneSummary({ summary, base, collection, }) {
|
|
200
|
+
const retranslate = summary.mode === 'retranslate-all';
|
|
201
|
+
const of = (kind) => summary.writes.filter((write) => write.kind === kind);
|
|
202
|
+
const done = (bucket) => [...summary.writes, ...summary.refused].some((write) => write.field === bucket.field && write.locale === bucket.locale);
|
|
203
|
+
const kept = retranslate ? summary.plan.buckets.filter((bucket) => !done(bucket)) : [];
|
|
204
|
+
return (_jsxs(Stack, { gap: "2", className: "w-full", children: [_jsx(Text, { size: "sm", tone: "muted", className: "text-center", "data-testid": "translate-summary", children: translateDoneMessage(summary, base) }), retranslate ? (_jsxs(_Fragment, { children: [_jsx(TranslateBucketList, { title: translate('ai.translate.replacedTitle', { count: of('replace').length }), buckets: of('replace'), collection: collection, testId: "translate-replaced" }), _jsx(TranslateBucketList, { title: translate('ai.translate.filledTitle', { count: of('fill').length }), buckets: of('fill'), collection: collection, testId: "translate-filled" })] })) : null, _jsx(TranslateBucketList, { title: translate('ai.translate.slugsTitle', { count: of('slug').length }), buckets: of('slug'), collection: collection, testId: "translate-slugs" }), _jsx(TranslateBucketList, { title: translate('ai.translate.refusedTitle', { count: summary.refused.length }), buckets: summary.refused, collection: collection, testId: "translate-refused" }), _jsx(TranslateBucketList, { title: translate('ai.translate.keptTitle', { count: kept.length }), buckets: kept, collection: collection, testId: "translate-kept" }), summary.restorePoint !== undefined ? (_jsx(Text, { size: "xs", tone: "muted", "data-testid": "translate-restore-point", children: translate('ai.translate.restorePoint', { version: summary.restorePoint }) })) : null, _jsx(TranslateSkipped, { skipped: summary.skipped, collection: collection, mode: summary.mode })] }));
|
|
137
205
|
}
|
|
138
206
|
//# sourceMappingURL=translate-document-dialog.js.map
|