@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.
@@ -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. The forms addon's end-to-end
344
- * probe (its rollback and removal, the attacker's harvest) is
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 a bound file and its binding, keeps an unbound one (an asset’s), and dedupes', async () => {
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 uploadId = await declare(t)
451
+ const [mine, theirs] = [await declare(t), await declare(t)]
436
452
  const own = await storeFile(t, 400, 'image/png')
437
- expect((await claimWith(t, uploadId, own)).status).toBe(200)
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
- const released = await t.run((ctx) => releaseIngestUploads(ctx, [own, assetFile, own]))
441
- expect(released).toEqual({ deleted: [own], kept: [assetFile] })
442
- expect([await stored(t, own), await stored(t, assetFile)]).toEqual([false, true])
443
- expect(await bindings(t)).toEqual([])
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
- for (const [uploadId, file] of [[asFile, promoted], [asThumb, preview], [plain, unreferenced]] as const) {
464
- expect((await claimWith(t, uploadId, file)).status).toBe(200)
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, [promoted, preview, unreferenced]))
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
- expect((await claimWith(t, uploadId, own)).status).toBe(200)
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, [own]))).toEqual({ deleted: [own], kept: [] })
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
- expect((await claimWith(t, uploadId, own)).status).toBe(200)
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); {@link releaseIngestUploads} is the only way such bytes are deleted.
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(ctx, ingestBindingRecordKind, claim.storageId, { uploadId: claim.uploadId, target, boundAt: receivedAt }, stamps)
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 an addon holds (EC-671) — the ONE way they are deleted, and it deletes
512
- * only files a claim BOUND that no asset references NOW: the bytes and their binding go
513
- * together. Any other id is KEPT and returned in `kept`:
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
- storageIds: readonly string[],
566
+ owner: string,
531
567
  ): Promise<{ deleted: string[]; kept: string[] }> {
532
568
  const deleted: string[] = []
533
569
  const kept: string[] = []
534
- for (const storageId of new Set(storageIds)) {
535
- const binding = await findRecordDoc(ctx, ingestBindingRecordKind, storageId)
536
- if (!binding) {
537
- kept.push(storageId)
538
- continue
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
- if (result.translated > 0 || result.slugsDerived > 0) {
82
- // EC-595 A2: the translation persists as the patches of the buckets it filled
83
- // (never the whole document) — through the session's queue, at the revision
84
- // the draft it was authored on is at (EC-647); a bucket a colleague changed
85
- // meanwhile is refused on that field and shown there.
86
- const outcome = await cms.commitPatches(docRef, result.patches, result.patches.map(() => revision));
87
- if (outcome?.status === 'failed') {
88
- setPhase('error');
89
- return;
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: translate('cms.version.aiAuthoredTitle'),
98
- origin: 'assistant',
100
+ label,
101
+ ...(origin ? { origin } : {}),
99
102
  });
100
- }
101
- catch {
102
- // ignore — provenance is additive
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
- nothing: result.total === 0 && result.skipped.length === 0,
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 doneMessage = (s) => {
128
- if (s.nothing)
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(Text, { size: "sm", tone: "muted", className: "text-center", "data-testid": "translate-summary", children: doneMessage(summary) }), _jsx(TranslateSkipped, { skipped: summary.skipped, 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') }), _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') })) })] }) }));
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