@happyvertical/smrt-content 0.43.9 → 0.43.10

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.
Files changed (46) hide show
  1. package/agents/content-list.md +69 -0
  2. package/dist/chunks/content-corrections-BxfA3tW1.js +107 -0
  3. package/dist/chunks/content-corrections-BxfA3tW1.js.map +1 -0
  4. package/dist/chunks/content-query-4w62u5_0.js +4092 -0
  5. package/dist/chunks/content-query-4w62u5_0.js.map +1 -0
  6. package/dist/chunks/content-reviews-BWrYEQc6.js +124 -0
  7. package/dist/chunks/content-reviews-BWrYEQc6.js.map +1 -0
  8. package/dist/chunks/content-versions-Di3vcTgb.js +284 -0
  9. package/dist/chunks/content-versions-Di3vcTgb.js.map +1 -0
  10. package/dist/chunks/contents-BV8RoP_R.js +612 -0
  11. package/dist/chunks/contents-BV8RoP_R.js.map +1 -0
  12. package/dist/chunks/rolldown-runtime-D7D4PA-g.js +13 -0
  13. package/dist/content-chat-handlers.d.ts +1 -1
  14. package/dist/content-chat-handlers.d.ts.map +1 -1
  15. package/dist/content-governance.d.ts +2 -0
  16. package/dist/content-governance.d.ts.map +1 -1
  17. package/dist/content.d.ts +2 -2
  18. package/dist/content.d.ts.map +1 -1
  19. package/dist/index.js +142 -4717
  20. package/dist/index.js.map +1 -1
  21. package/dist/manifest.json +142 -5
  22. package/dist/server/content-list-actions.d.ts +123 -0
  23. package/dist/server/content-list-actions.d.ts.map +1 -0
  24. package/dist/server.d.ts +6 -0
  25. package/dist/server.d.ts.map +1 -0
  26. package/dist/server.js +874 -0
  27. package/dist/server.js.map +1 -0
  28. package/dist/smrt-knowledge.json +96 -9
  29. package/dist/svelte/components/ContentList.svelte +1028 -24
  30. package/dist/svelte/components/ContentList.svelte.d.ts +3 -0
  31. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  32. package/dist/svelte/content-list-controller.d.ts +3 -1
  33. package/dist/svelte/content-list-controller.d.ts.map +1 -1
  34. package/dist/svelte/content-list-controller.js +15 -1
  35. package/dist/svelte/content-list-workflows.d.ts +87 -0
  36. package/dist/svelte/content-list-workflows.d.ts.map +1 -0
  37. package/dist/svelte/content-list-workflows.js +199 -0
  38. package/dist/svelte/i18n.contribution.d.ts +109 -0
  39. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  40. package/dist/svelte/i18n.contribution.js +109 -0
  41. package/dist/svelte/index.d.ts +1 -0
  42. package/dist/svelte/index.d.ts.map +1 -1
  43. package/dist/svelte/index.js +2 -0
  44. package/package.json +19 -14
  45. package/dist/chunks/content-transparency-Bh6axaZj.js +0 -606
  46. package/dist/chunks/content-transparency-Bh6axaZj.js.map +0 -1
@@ -33,6 +33,8 @@ export interface ContentListUrlStateBinding {
33
33
  <script lang="ts">
34
34
  import {
35
35
  DataTable,
36
+ type DataSurfaceActionResult,
37
+ type DataSurfaceIdentity,
36
38
  type DataTableColumn,
37
39
  type DataTableViewState,
38
40
  } from '@happyvertical/smrt-ui/data';
@@ -74,6 +76,7 @@ import {
74
76
  } from '../content-list-controller.js';
75
77
  import {
76
78
  CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE,
79
+ type ContentListDataQueryRequest,
77
80
  type ContentListQueryDrop,
78
81
  type ContentListQueryDropReason,
79
82
  type ContentListQueryNotices,
@@ -94,6 +97,7 @@ import {
94
97
  type ContentListJob,
95
98
  type ContentListJobBinding,
96
99
  type ContentListJobSnapshot,
100
+ type ContentListJobTarget,
97
101
  } from '../content-list-runtime.js';
98
102
  import {
99
103
  type ContentListSavedView,
@@ -101,6 +105,15 @@ import {
101
105
  restoreContentListSavedView,
102
106
  toContentListSavedViewInput,
103
107
  } from '../content-list-saved-views.js';
108
+ import {
109
+ type ContentListWorkflowBinding,
110
+ ContentListWorkflowError,
111
+ type ContentListWorkflowErrorReason,
112
+ type ContentListWorkflowId,
113
+ type ContentListWorkflowRequest,
114
+ CONTENT_LIST_WORKFLOW_OPTIONS,
115
+ contentListWorkflowOutcomes,
116
+ } from '../content-list-workflows.js';
104
117
  import {
105
118
  applyContentListViewState,
106
119
  type ContentListStateDrop,
@@ -114,6 +127,165 @@ import ImageThumbnail from './ImageThumbnail.svelte';
114
127
 
115
128
  const { t } = useI18n();
116
129
 
130
+ const WORKFLOW_LABEL_MESSAGES: Record<
131
+ ContentListWorkflowId,
132
+ keyof typeof M
133
+ > = {
134
+ 'move-to-trash': M['content.content_list.workflow_move_to_trash'],
135
+ 'mark-draft': M['content.content_list.workflow_mark_draft'],
136
+ 'submit-review': M['content.content_list.workflow_submit_review'],
137
+ publish: M['content.content_list.workflow_publish'],
138
+ archive: M['content.content_list.workflow_archive'],
139
+ restore: M['content.content_list.workflow_restore'],
140
+ 'automated-review': M['content.content_list.workflow_automated_review'],
141
+ 'format-body': M['content.content_list.workflow_format_body'],
142
+ categorize: M['content.content_list.workflow_categorize'],
143
+ optimize: M['content.content_list.workflow_optimize'],
144
+ };
145
+
146
+ const WORKFLOW_CONSEQUENCE_MESSAGES: Record<string, keyof typeof M> = {
147
+ 'Content leaves active views and becomes restorable from trash.':
148
+ M['content.content_list.workflow_consequence_trash'],
149
+ 'Published content is no longer public.':
150
+ M['content.content_list.workflow_consequence_unpublished'],
151
+ 'Content enters the review queue.':
152
+ M['content.content_list.workflow_consequence_review_queue'],
153
+ 'Content becomes public and may create a publication snapshot.':
154
+ M['content.content_list.workflow_consequence_published'],
155
+ 'Content leaves trash.':
156
+ M['content.content_list.workflow_consequence_leave_trash'],
157
+ 'Publishing makes content public.':
158
+ M['content.content_list.workflow_consequence_restore_published'],
159
+ 'Creates a review result and may create a review version.':
160
+ M['content.content_list.workflow_consequence_review_result'],
161
+ 'The persisted content body may change.':
162
+ M['content.content_list.workflow_consequence_body_change'],
163
+ 'The content category changes.':
164
+ M['content.content_list.workflow_consequence_category_change'],
165
+ 'Content fields may be revised or completed.':
166
+ M['content.content_list.workflow_consequence_fields_change'],
167
+ };
168
+
169
+ const WORKFLOW_REASON_MESSAGES: Record<string, keyof typeof M> = {
170
+ not_deleted: M['content.content_list.workflow_reason_not_deleted'],
171
+ publish_readiness_failed:
172
+ M['content.content_list.workflow_reason_publish_readiness_failed'],
173
+ deleted: M['content.content_list.workflow_reason_deleted'],
174
+ already_draft: M['content.content_list.workflow_reason_already_draft'],
175
+ requires_draft: M['content.content_list.workflow_reason_requires_draft'],
176
+ invalid_status: M['content.content_list.workflow_reason_invalid_status'],
177
+ already_archived: M['content.content_list.workflow_reason_already_archived'],
178
+ governance_unavailable:
179
+ M['content.content_list.workflow_reason_governance_unavailable'],
180
+ review_policy_unavailable:
181
+ M['content.content_list.workflow_reason_review_policy_unavailable'],
182
+ review_kind_mismatch:
183
+ M['content.content_list.workflow_reason_review_kind_mismatch'],
184
+ handler_unavailable:
185
+ M['content.content_list.workflow_reason_handler_unavailable'],
186
+ requires_draft_or_review:
187
+ M['content.content_list.workflow_reason_requires_draft_or_review'],
188
+ not_found_or_denied:
189
+ M['content.content_list.workflow_reason_not_found_or_denied'],
190
+ row_revision_drifted:
191
+ M['content.content_list.workflow_reason_row_revision_drifted'],
192
+ permission_denied:
193
+ M['content.content_list.workflow_reason_permission_denied'],
194
+ not_found: M['content.content_list.workflow_reason_not_found'],
195
+ stale_query_fingerprint:
196
+ M['content.content_list.workflow_reason_stale_query_fingerprint'],
197
+ matching_count_drifted:
198
+ M['content.content_list.workflow_reason_matching_count_drifted'],
199
+ limit_exceeded: M['content.content_list.workflow_reason_limit_exceeded'],
200
+ invalid_request: M['content.content_list.workflow_reason_invalid_request'],
201
+ invalid_payload: M['content.content_list.workflow_reason_invalid_payload'],
202
+ confirmation_required:
203
+ M['content.content_list.workflow_reason_confirmation_required'],
204
+ stale_preview: M['content.content_list.workflow_reason_stale_preview'],
205
+ denied: M['content.content_list.workflow_reason_denied'],
206
+ unknown_permission:
207
+ M['content.content_list.workflow_reason_unknown_permission'],
208
+ execution_failed:
209
+ M['content.content_list.workflow_reason_execution_failed'],
210
+ idempotency_conflict:
211
+ M['content.content_list.workflow_reason_idempotency_conflict'],
212
+ };
213
+
214
+ const WORKFLOW_TRANSPORT_MESSAGES: Record<
215
+ ContentListWorkflowErrorReason,
216
+ keyof typeof M
217
+ > = {
218
+ network_failure: M['content.content_list.workflow_transport_network_failure'],
219
+ invalid_json: M['content.content_list.workflow_transport_invalid_json'],
220
+ http_error: M['content.content_list.workflow_transport_http_error'],
221
+ invalid_result: M['content.content_list.workflow_transport_invalid_result'],
222
+ invalid_job_status:
223
+ M['content.content_list.workflow_transport_invalid_job_status'],
224
+ invalid_job_result:
225
+ M['content.content_list.workflow_transport_invalid_job_result'],
226
+ };
227
+
228
+ function workflowLabel(id: ContentListWorkflowId): string {
229
+ return t(WORKFLOW_LABEL_MESSAGES[id]);
230
+ }
231
+
232
+ function workflowConsequenceLabel(consequence: string): string {
233
+ return t(
234
+ WORKFLOW_CONSEQUENCE_MESSAGES[consequence] ??
235
+ M['content.content_list.workflow_consequence_unknown'],
236
+ );
237
+ }
238
+
239
+ function workflowReasonLabel(
240
+ reason: string | undefined,
241
+ fallback: keyof typeof M,
242
+ params?: Record<string, string | number>,
243
+ ): string {
244
+ return t((reason && WORKFLOW_REASON_MESSAGES[reason]) ?? fallback, params);
245
+ }
246
+
247
+ function workflowExceptionMessage(
248
+ error: unknown,
249
+ fallback: keyof typeof M,
250
+ ): string {
251
+ if (error instanceof ContentListWorkflowError) {
252
+ return t(WORKFLOW_TRANSPORT_MESSAGES[error.reason], {
253
+ status: error.status ?? '',
254
+ });
255
+ }
256
+ return t(fallback);
257
+ }
258
+
259
+ function workflowScopeLabel(scope: string): string {
260
+ switch (scope) {
261
+ case 'explicit-ids':
262
+ return t(M['content.content_list.workflow_scope_explicit_ids']);
263
+ case 'current-page':
264
+ return t(M['content.content_list.workflow_scope_current_page']);
265
+ case 'all-matching':
266
+ return t(M['content.content_list.workflow_scope_all_matching']);
267
+ default:
268
+ return scope;
269
+ }
270
+ }
271
+
272
+ function workflowJobStatusLabel(status: string): string {
273
+ switch (status) {
274
+ case 'queued':
275
+ return t(M['content.content_list.job_queued']);
276
+ case 'running':
277
+ return t(M['content.content_list.job_running']);
278
+ case 'succeeded':
279
+ return t(M['content.content_list.job_succeeded']);
280
+ case 'failed':
281
+ return t(M['content.content_list.job_failed']);
282
+ case 'cancelled':
283
+ return t(M['content.content_list.workflow_status_cancelled']);
284
+ default:
285
+ return status;
286
+ }
287
+ }
288
+
117
289
  /** One reported refusal, from a restore or from the query translation. */
118
290
  type ContentListDropNotice = ContentListStateDrop | ContentListQueryDrop;
119
291
 
@@ -153,6 +325,8 @@ interface Props {
153
325
  savedViews?: ContentListSavedViewStore;
154
326
  /** Shared background-workflow state. The same binding guards submissions. */
155
327
  jobs?: ContentListJobBinding;
328
+ /** Opt-in, authenticated preview/apply client for bulk workflows (#2453). */
329
+ workflows?: ContentListWorkflowBinding;
156
330
  }
157
331
 
158
332
  let {
@@ -173,6 +347,7 @@ let {
173
347
  urlState = undefined,
174
348
  savedViews = undefined,
175
349
  jobs = undefined,
350
+ workflows = undefined,
176
351
  }: Props = $props();
177
352
 
178
353
  const initialQuery = untrack(() => query);
@@ -391,6 +566,34 @@ $effect(() => {
391
566
  }
392
567
  });
393
568
  });
569
+ let settledWorkflowQuery = $state<ContentListDataQueryRequest | null>(null);
570
+ let settledWorkflowFingerprint = $state<string | null>(null);
571
+ let settledWorkflowRevision = $state<string | null>(null);
572
+ let selectedWorkflow = $state<ContentListWorkflowId>('mark-draft');
573
+ let workflowCategory = $state('');
574
+ let workflowRestoreStatus = $state<'draft' | 'review' | 'published'>('draft');
575
+ let workflowFormat = $state<'markdown' | 'html'>('markdown');
576
+ let workflowReviewKind = $state('');
577
+ let workflowPolicyKey = $state('');
578
+ let workflowInstructions = $state('');
579
+ let workflowPending = $state(false);
580
+ let pageHeaderSelectionOnly = $state(false);
581
+ let workflowPreview = $state<import('@happyvertical/smrt-ui/data').DataSurfaceActionResult | null>(null);
582
+ let workflowResult = $state<import('@happyvertical/smrt-ui/data').DataSurfaceActionResult | null>(null);
583
+ let workflowError = $state<string | null>(null);
584
+ let workflowConfirmOpen = $state(false);
585
+ let workflowIntentAtPreview = '';
586
+ let workflowIdempotencyKey = '';
587
+ let workflowQueuedJobs = $state<
588
+ Array<{
589
+ intent: string;
590
+ jobId: string;
591
+ actionId: ContentListWorkflowId;
592
+ requestId: string;
593
+ identity: DataSurfaceIdentity;
594
+ target: ContentListJobTarget;
595
+ }>
596
+ >([]);
394
597
 
395
598
  $effect(() =>
396
599
  controller.subscribe((transition) => {
@@ -637,6 +840,36 @@ function refreshQuery(): void {
637
840
  })
638
841
  .catch(() => undefined);
639
842
  }
843
+
844
+ function settleWorkflowQueryResult(
845
+ result: unknown,
846
+ request: ContentListDataQueryRequest,
847
+ signature: string,
848
+ ): void {
849
+ settledSignature = signature;
850
+ resultNotices = readContentListQueryNotices(result);
851
+ if (result !== null && typeof result === 'object' && !Array.isArray(result)) {
852
+ const envelope = result as Record<string, unknown>;
853
+ settledWorkflowQuery = request;
854
+ settledWorkflowFingerprint =
855
+ typeof envelope.queryFingerprint === 'string'
856
+ ? envelope.queryFingerprint
857
+ : null;
858
+ const freshness = envelope.freshness;
859
+ settledWorkflowRevision =
860
+ freshness !== null &&
861
+ typeof freshness === 'object' &&
862
+ !Array.isArray(freshness) &&
863
+ typeof (freshness as Record<string, unknown>).asOf === 'string'
864
+ ? String((freshness as Record<string, unknown>).asOf)
865
+ : null;
866
+ return;
867
+ }
868
+ settledWorkflowQuery = null;
869
+ settledWorkflowFingerprint = null;
870
+ settledWorkflowRevision = null;
871
+ }
872
+
640
873
  /**
641
874
  * A retry re-reads the same query, so its answer replaces the rendered rows —
642
875
  * and therefore has to replace the completeness flags that describe them too.
@@ -648,13 +881,20 @@ function refreshQuery(): void {
648
881
  function retryQuery(): void {
649
882
  if (!queryBinding) return;
650
883
  const signature = executedSignature;
884
+ const request = executedWorkflowQuery;
651
885
  void queryBinding
652
886
  .retry()
653
887
  .then((result) => {
654
888
  // `retry()` resolves undefined when there is no request to repeat, and a
655
889
  // newer query may have superseded this one while it was in flight.
656
- if (result === undefined || executedSignature !== signature) return;
657
- resultNotices = readContentListQueryNotices(result);
890
+ if (
891
+ result === undefined ||
892
+ request === null ||
893
+ executedSignature !== signature ||
894
+ signature === undefined
895
+ )
896
+ return;
897
+ settleWorkflowQueryResult(result, request, signature);
658
898
  })
659
899
  .catch(() => undefined);
660
900
  }
@@ -680,9 +920,9 @@ const querySignature = $derived(
680
920
  );
681
921
 
682
922
  let executedSignature: string | undefined;
923
+ let executedWorkflowQuery: ContentListDataQueryRequest | null = null;
683
924
  let publishedUrlSignature: string | undefined;
684
925
 
685
-
686
926
  $effect(() => {
687
927
  const signature = querySignature;
688
928
  untrack(() => {
@@ -727,15 +967,16 @@ $effect(() => {
727
967
  // unhandled one. The resolved envelope carries the server's completeness
728
968
  // flags, which the binding itself does not expose.
729
969
  activeQueryKey = contentListQueryRequestKey(translated.request);
970
+ executedWorkflowQuery = translated.request;
730
971
  void queryBinding
731
972
  .execute(translated.request)
732
973
  .then((result) => {
733
974
  // A newer query may have superseded this one while it was in flight.
734
975
  if (executedSignature !== signature) return;
735
976
  // The binding's rows and total now describe THIS query, so the page
736
- // may be clamped against them.
737
- settledSignature = signature;
738
- resultNotices = readContentListQueryNotices(result);
977
+ // may be clamped against them and workflows may bind to the exact
978
+ // settled request.
979
+ settleWorkflowQueryResult(result, translated.request, signature);
739
980
  })
740
981
  .catch(() => undefined);
741
982
  }
@@ -833,15 +1074,48 @@ const identifiedRowKeys = $derived(
833
1074
  const selectablePageRowIds = $derived(
834
1075
  selectableContentListRowIds(pageRows.filter((row) => !rowPending(row))),
835
1076
  );
1077
+ const allMatchingSelected = $derived(tableState.selection.scope === 'allMatching');
836
1078
  const allPageSelected = $derived(
837
- selectablePageRowIds.length > 0 &&
838
- selectablePageRowIds.every((rowId) => selectedRowKeys.has(String(rowId))),
1079
+ allMatchingSelected ||
1080
+ (selectablePageRowIds.length > 0 &&
1081
+ selectablePageRowIds.every((rowId) => selectedRowKeys.has(String(rowId)))),
839
1082
  );
840
1083
  const somePageSelected = $derived(
841
1084
  !allPageSelected &&
842
1085
  selectablePageRowIds.some((rowId) => selectedRowKeys.has(String(rowId))),
843
1086
  );
844
- const selectedCount = $derived(tableState.selectedRowIds.length);
1087
+ const selectedCount = $derived(
1088
+ tableState.selection.scope === 'allMatching'
1089
+ ? tableState.selection.expectedCount
1090
+ : tableState.selectedRowIds.length,
1091
+ );
1092
+ const exactMatchingCount = $derived(
1093
+ settledSignature === querySignature
1094
+ ? contentListQueryExactTotal(queryBinding?.total)
1095
+ : undefined,
1096
+ );
1097
+ const canSelectAllMatching = $derived(
1098
+ Boolean(
1099
+ workflows &&
1100
+ serverBacked &&
1101
+ settledWorkflowQuery &&
1102
+ settledWorkflowFingerprint &&
1103
+ settledWorkflowRevision &&
1104
+ exactMatchingCount !== undefined &&
1105
+ exactMatchingCount > 0 &&
1106
+ exactMatchingCount <= (workflows?.maxSelectionSize ?? 200),
1107
+ ),
1108
+ );
1109
+ const workflowPayloadValid = $derived(
1110
+ selectedWorkflow !== 'categorize' || workflowCategory.trim().length > 0,
1111
+ );
1112
+ const workflowDuplicateQueued = $derived(
1113
+ workflowQueuedJobs.some((job) => job.intent === workflowIntentSignature()),
1114
+ );
1115
+ const workflowQueuedJob = $derived(
1116
+ workflowQueuedJobs.find((job) => job.intent === workflowIntentSignature()) ??
1117
+ workflowQueuedJobs[0],
1118
+ );
845
1119
 
846
1120
  /** Synthetic ids the adapter minted for rows that carry no durable identity. */
847
1121
  const unidentifiedRowKeys = $derived(
@@ -976,7 +1250,22 @@ const surfaceOptions = $derived(
976
1250
  registry: dataSurface.registry,
977
1251
  descriptor:
978
1252
  dataSurface.descriptor ??
979
- buildContentListSurfaceDescriptor({ columnLabels }),
1253
+ (() => {
1254
+ const descriptor = buildContentListSurfaceDescriptor({
1255
+ columnLabels,
1256
+ includeWorkflows: workflows !== undefined,
1257
+ });
1258
+ return {
1259
+ ...descriptor,
1260
+ identity: workflows?.identity ?? descriptor.identity,
1261
+ limits: {
1262
+ ...descriptor.limits,
1263
+ ...(workflows?.maxSelectionSize === undefined
1264
+ ? {}
1265
+ : { maxSelectionSize: workflows.maxSelectionSize }),
1266
+ },
1267
+ };
1268
+ })(),
980
1269
  }
981
1270
  : undefined,
982
1271
  );
@@ -1154,14 +1443,19 @@ async function deleteSelectedView() {
1154
1443
  }
1155
1444
 
1156
1445
  function isSelected(row: ContentListRow): boolean {
1157
- return selectedRowKeys.has(String(row.id));
1446
+ return allMatchingSelected || selectedRowKeys.has(String(row.id));
1158
1447
  }
1159
1448
 
1160
1449
  function rowPending(row: ContentListRow): boolean {
1161
1450
  return (
1162
1451
  jobSnapshot.pendingRowIds.has(String(row.id)) ||
1163
1452
  (activeQueryKey !== undefined &&
1164
- jobSnapshot.pendingQueryKeys.has(activeQueryKey))
1453
+ jobSnapshot.pendingQueryKeys.has(activeQueryKey)) ||
1454
+ workflowQueuedJobs.some((job) =>
1455
+ job.target.kind === 'query'
1456
+ ? activeQueryKey !== undefined && job.target.queryKey === activeQueryKey
1457
+ : job.target.rowIds.includes(String(row.id)),
1458
+ )
1165
1459
  );
1166
1460
  }
1167
1461
 
@@ -1185,29 +1479,558 @@ function formattedLastUpdated(value: number): string {
1185
1479
  }
1186
1480
 
1187
1481
  function toggleRow(row: ContentListRow) {
1188
- if (!row.identified || rowPending(row)) return;
1482
+ if (
1483
+ workflowPending ||
1484
+ !row.identified ||
1485
+ rowPending(row) ||
1486
+ allMatchingSelected
1487
+ )
1488
+ return;
1489
+ pageHeaderSelectionOnly = false;
1189
1490
  controller.dispatch({ type: 'toggleRowSelection', rowId: row.id });
1190
1491
  }
1191
1492
 
1192
1493
  function togglePageSelection() {
1193
- const remaining = tableState.selectedRowIds.filter(
1194
- (rowId) =>
1195
- !selectablePageRowIds.some(
1196
- (pageRowId) => String(pageRowId) === String(rowId),
1494
+ if (workflowPending) return;
1495
+ if (allMatchingSelected) {
1496
+ clearSelection();
1497
+ return;
1498
+ }
1499
+ const rowKey = (rowId: string | number) =>
1500
+ `${typeof rowId}:${String(rowId)}`;
1501
+ const pageKeys = new Set(selectablePageRowIds.map(rowKey));
1502
+ if (allPageSelected) {
1503
+ pageHeaderSelectionOnly = false;
1504
+ controller.dispatch({
1505
+ type: 'setSelectedRows',
1506
+ rowIds: tableState.selectedRowIds.filter(
1507
+ (rowId) => !pageKeys.has(rowKey(rowId)),
1197
1508
  ),
1509
+ });
1510
+ return;
1511
+ }
1512
+ const selected = new Map(
1513
+ tableState.selectedRowIds.map((rowId) => [rowKey(rowId), rowId]),
1198
1514
  );
1515
+ pageHeaderSelectionOnly =
1516
+ serverBacked && tableState.selectedRowIds.length === 0;
1517
+ for (const rowId of selectablePageRowIds) selected.set(rowKey(rowId), rowId);
1199
1518
  controller.dispatch({
1200
1519
  type: 'setSelectedRows',
1201
- rowIds: allPageSelected
1202
- ? remaining
1203
- : [...remaining, ...selectablePageRowIds],
1520
+ rowIds: [...selected.values()],
1521
+ });
1522
+ }
1523
+
1524
+ function selectAllMatching() {
1525
+ if (
1526
+ workflowPending ||
1527
+ !canSelectAllMatching ||
1528
+ !settledWorkflowFingerprint ||
1529
+ !settledWorkflowRevision ||
1530
+ exactMatchingCount === undefined
1531
+ ) return;
1532
+ pageHeaderSelectionOnly = false;
1533
+ controller.dispatch({
1534
+ type: 'selectAllMatching',
1535
+ queryFingerprint: settledWorkflowFingerprint,
1536
+ queryRevision: settledWorkflowRevision,
1537
+ expectedCount: exactMatchingCount,
1204
1538
  });
1205
1539
  }
1206
1540
 
1207
1541
  function clearSelection() {
1542
+ if (workflowPending) return;
1543
+ pageHeaderSelectionOnly = false;
1208
1544
  controller.dispatch({ type: 'setSelectedRows', rowIds: [] });
1209
1545
  }
1210
1546
 
1547
+ function workflowPayload(): Record<string, string> | undefined {
1548
+ switch (selectedWorkflow) {
1549
+ case 'categorize':
1550
+ return { category: workflowCategory.trim() };
1551
+ case 'restore':
1552
+ return { status: workflowRestoreStatus };
1553
+ case 'format-body':
1554
+ return { format: workflowFormat };
1555
+ case 'automated-review': {
1556
+ const payload: Record<string, string> = {};
1557
+ if (workflowReviewKind.trim()) payload.kind = workflowReviewKind.trim();
1558
+ if (workflowPolicyKey.trim()) payload.policyKey = workflowPolicyKey.trim();
1559
+ return payload;
1560
+ }
1561
+ case 'optimize':
1562
+ return workflowInstructions.trim()
1563
+ ? { instructions: workflowInstructions.trim() }
1564
+ : {};
1565
+ default:
1566
+ return {};
1567
+ }
1568
+ }
1569
+
1570
+ function workflowSelection(): ContentListWorkflowRequest['selection'] | null {
1571
+ const selection = tableState.selection;
1572
+ if (selection.scope === 'allMatching') {
1573
+ return { scope: 'all-matching', queryFingerprint: selection.queryFingerprint };
1574
+ }
1575
+ const currentPageKeys = new Set(pageRows.map((row) => String(row.id)));
1576
+ const selectablePageKeys = new Set(selectablePageRowIds.map(String));
1577
+ const effectiveRowIds =
1578
+ selection.scope === 'page' || pageHeaderSelectionOnly
1579
+ ? selection.rowIds.filter((rowId) => {
1580
+ const key = String(rowId);
1581
+ return !currentPageKeys.has(key) || selectablePageKeys.has(key);
1582
+ })
1583
+ : selection.rowIds;
1584
+ if (
1585
+ serverBacked &&
1586
+ pageHeaderSelectionOnly &&
1587
+ selectablePageRowIds.length === pageRows.length &&
1588
+ effectiveRowIds.length === selectablePageRowIds.length &&
1589
+ selectablePageRowIds.every((rowId) =>
1590
+ effectiveRowIds.some(
1591
+ (selectedId) => String(selectedId) === String(rowId),
1592
+ ),
1593
+ )
1594
+ ) {
1595
+ return { scope: 'current-page' };
1596
+ }
1597
+ if (effectiveRowIds.length === 0) return null;
1598
+ return { scope: 'explicit-ids', rowIds: effectiveRowIds };
1599
+ }
1600
+
1601
+ function workflowJobTarget(
1602
+ request: ContentListWorkflowRequest,
1603
+ ): ContentListJobTarget {
1604
+ if (request.selection.scope === 'all-matching' && activeQueryKey !== undefined)
1605
+ return { kind: 'query', queryKey: activeQueryKey };
1606
+ if (request.selection.scope === 'explicit-ids')
1607
+ return { kind: 'rows', rowIds: request.selection.rowIds.map(String) };
1608
+ return { kind: 'rows', rowIds: selectablePageRowIds.map(String) };
1609
+ }
1610
+
1611
+ function workflowIntentSignature(): string {
1612
+ const selection = workflowSelection();
1613
+ if (selection?.scope === 'explicit-ids') {
1614
+ return JSON.stringify([
1615
+ selectedWorkflow,
1616
+ workflowPayload(),
1617
+ {
1618
+ scope: selection.scope,
1619
+ rowIds: [...selection.rowIds].sort((left, right) => {
1620
+ const leftKey = `${typeof left}:${String(left)}`;
1621
+ const rightKey = `${typeof right}:${String(right)}`;
1622
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
1623
+ }),
1624
+ },
1625
+ ]);
1626
+ }
1627
+ return JSON.stringify([
1628
+ selectedWorkflow,
1629
+ workflowPayload(),
1630
+ tableState.selection,
1631
+ settledWorkflowFingerprint,
1632
+ querySignature,
1633
+ ]);
1634
+ }
1635
+
1636
+ function createWorkflowRequest(
1637
+ phase: 'preview' | 'apply',
1638
+ ): ContentListWorkflowRequest | null {
1639
+ const selection = workflowSelection();
1640
+ if (!selection) return null;
1641
+ const queryRequired = selection.scope !== 'explicit-ids';
1642
+ if (
1643
+ queryRequired &&
1644
+ (!settledWorkflowQuery || settledSignature !== querySignature)
1645
+ ) return null;
1646
+ if (
1647
+ selection.scope === 'all-matching' &&
1648
+ selection.queryFingerprint !== settledWorkflowFingerprint
1649
+ ) return null;
1650
+ return {
1651
+ version: 1,
1652
+ requestId: globalThis.crypto?.randomUUID?.() ?? `content-workflow-${Date.now()}`,
1653
+ identity: workflows?.identity ?? { surfaceId: 'content-list', kind: 'table' },
1654
+ actionId: selectedWorkflow,
1655
+ phase,
1656
+ selection,
1657
+ payload: workflowPayload(),
1658
+ expectedRevision: workflows?.revision ?? 0,
1659
+ target: {
1660
+ ...(queryRequired && settledWorkflowQuery
1661
+ ? { query: settledWorkflowQuery }
1662
+ : {}),
1663
+ expectedCount:
1664
+ selection.scope === 'explicit-ids'
1665
+ ? selection.rowIds.length
1666
+ : selectedCount,
1667
+ },
1668
+ };
1669
+ }
1670
+
1671
+ function workflowIdentityMatches(
1672
+ actual: DataSurfaceIdentity | null | undefined,
1673
+ expected: DataSurfaceIdentity,
1674
+ ): boolean {
1675
+ return (
1676
+ actual !== null &&
1677
+ actual !== undefined &&
1678
+ actual.surfaceId === expected.surfaceId &&
1679
+ actual.kind === expected.kind &&
1680
+ (expected.subject
1681
+ ? actual.subject?.type === expected.subject.type &&
1682
+ actual.subject.id === expected.subject.id
1683
+ : actual.subject === undefined)
1684
+ );
1685
+ }
1686
+
1687
+ function workflowResultMatchesRequest(
1688
+ result: DataSurfaceActionResult,
1689
+ request: ContentListWorkflowRequest,
1690
+ ): boolean {
1691
+ return (
1692
+ result.version === 1 &&
1693
+ result.requestId === request.requestId &&
1694
+ result.phase === request.phase &&
1695
+ result.actionId === request.actionId &&
1696
+ workflowIdentityMatches(result.identity, request.identity)
1697
+ );
1698
+ }
1699
+
1700
+ function workflowResultMessage(result: import('@happyvertical/smrt-ui/data').DataSurfaceActionResult): string {
1701
+ const details = result.details ?? {};
1702
+ const accepted = typeof details.accepted === 'number' ? details.accepted : 0;
1703
+ if (details.background === true) {
1704
+ return t(M['content.content_list.workflow_result_background'], {
1705
+ accepted,
1706
+ });
1707
+ }
1708
+ const skipped = typeof details.skipped === 'number' ? details.skipped : 0;
1709
+ const failed = typeof details.failed === 'number' ? details.failed : 0;
1710
+ return t(M['content.content_list.workflow_result_summary'], {
1711
+ accepted,
1712
+ skipped,
1713
+ failed,
1714
+ });
1715
+ }
1716
+
1717
+ function applyWorkflowSelectionOutcomes(
1718
+ result: import('@happyvertical/smrt-ui/data').DataSurfaceActionResult,
1719
+ ): boolean {
1720
+ const outcomes = contentListWorkflowOutcomes(result);
1721
+ const rowKey = (rowId: string | number) =>
1722
+ `${typeof rowId}:${String(rowId)}`;
1723
+ if (tableState.selection.scope === 'allMatching') {
1724
+ const uniqueOutcomes = new Set(outcomes.map((outcome) => rowKey(outcome.rowId)));
1725
+ if (
1726
+ outcomes.length !== tableState.selection.expectedCount ||
1727
+ uniqueOutcomes.size !== outcomes.length
1728
+ ) {
1729
+ workflowError = t(
1730
+ M['content.content_list.workflow_incomplete_outcomes'],
1731
+ );
1732
+ return false;
1733
+ }
1734
+ controller.dispatch({
1735
+ type: 'setSelectedRows',
1736
+ rowIds: outcomes
1737
+ .filter((outcome) => outcome.status !== 'accepted')
1738
+ .map((outcome) => outcome.rowId),
1739
+ });
1740
+ return true;
1741
+ }
1742
+ const accepted = new Set(
1743
+ outcomes
1744
+ .filter((outcome) => outcome.status === 'accepted')
1745
+ .map((outcome) => rowKey(outcome.rowId)),
1746
+ );
1747
+ controller.dispatch({
1748
+ type: 'setSelectedRows',
1749
+ rowIds: tableState.selectedRowIds.filter(
1750
+ (rowId) => !accepted.has(rowKey(rowId)),
1751
+ ),
1752
+ });
1753
+ return true;
1754
+ }
1755
+
1756
+ function workflowPreviewMessage(): string {
1757
+ const details = workflowPreview?.details ?? {};
1758
+ const count = typeof details.count === 'number' ? details.count : selectedCount;
1759
+ const labels = Array.isArray(details.representativeLabels)
1760
+ ? details.representativeLabels.filter((entry): entry is string => typeof entry === 'string')
1761
+ : [];
1762
+ const consequences = Array.isArray(details.consequences)
1763
+ ? details.consequences.filter((entry): entry is string => typeof entry === 'string')
1764
+ : [];
1765
+ const skipped = typeof details.skipped === 'number' ? details.skipped : 0;
1766
+ const resolvedScope =
1767
+ typeof details.resolvedScope === 'string'
1768
+ ? details.resolvedScope
1769
+ : workflowSelection()?.scope;
1770
+ const ineligible = Array.isArray(details.ineligible)
1771
+ ? details.ineligible.flatMap((entry) => {
1772
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
1773
+ return [];
1774
+ const outcome = entry as Record<string, unknown>;
1775
+ if (
1776
+ typeof outcome.rowId !== 'string' &&
1777
+ typeof outcome.rowId !== 'number'
1778
+ )
1779
+ return [];
1780
+ const reason =
1781
+ typeof outcome.reason === 'string'
1782
+ ? ` (${workflowReasonLabel(
1783
+ outcome.reason,
1784
+ M['content.content_list.workflow_reason_unknown'],
1785
+ )})`
1786
+ : '';
1787
+ return [`${String(outcome.rowId)}${reason}`];
1788
+ })
1789
+ : [];
1790
+ return [
1791
+ resolvedScope
1792
+ ? t(M['content.content_list.workflow_preview_scope'], {
1793
+ scope: workflowScopeLabel(resolvedScope),
1794
+ })
1795
+ : '',
1796
+ t(
1797
+ count === 1
1798
+ ? M['content.content_list.workflow_preview_count_one']
1799
+ : M['content.content_list.workflow_preview_count_many'],
1800
+ { count },
1801
+ ),
1802
+ labels.length
1803
+ ? t(M['content.content_list.workflow_preview_examples'], {
1804
+ labels: labels.join(', '),
1805
+ })
1806
+ : '',
1807
+ skipped
1808
+ ? t(M['content.content_list.workflow_preview_ineligible_count'], {
1809
+ count: skipped,
1810
+ })
1811
+ : '',
1812
+ ineligible.length
1813
+ ? t(M['content.content_list.workflow_preview_ineligible'], {
1814
+ items: ineligible.join(', '),
1815
+ })
1816
+ : '',
1817
+ ...consequences.map(workflowConsequenceLabel),
1818
+ ].filter(Boolean).join(' ');
1819
+ }
1820
+
1821
+ async function previewWorkflow() {
1822
+ if (!workflows || workflowPending || workflowDuplicateQueued) return;
1823
+ const request = createWorkflowRequest('preview');
1824
+ if (!request) return;
1825
+ workflowPending = true;
1826
+ workflowError = null;
1827
+ workflowResult = null;
1828
+ workflowIdempotencyKey = '';
1829
+ try {
1830
+ const result = await workflows.client.preview(request);
1831
+ if (!workflowResultMatchesRequest(result, request)) {
1832
+ workflowError = t(M['content.content_list.workflow_preview_mismatch']);
1833
+ return;
1834
+ }
1835
+ if (!result.ok) {
1836
+ workflowError = workflowReasonLabel(
1837
+ result.reason,
1838
+ M['content.content_list.workflow_preview_failed'],
1839
+ );
1840
+ return;
1841
+ }
1842
+ workflowPreview = result;
1843
+ workflowIntentAtPreview = workflowIntentSignature();
1844
+ workflowIdempotencyKey =
1845
+ globalThis.crypto?.randomUUID?.() ?? `content-workflow-apply-${Date.now()}`;
1846
+ workflowConfirmOpen = true;
1847
+ } catch (error) {
1848
+ workflowError = workflowExceptionMessage(
1849
+ error,
1850
+ M['content.content_list.workflow_preview_failed'],
1851
+ );
1852
+ } finally {
1853
+ workflowPending = false;
1854
+ }
1855
+ }
1856
+
1857
+ async function applyWorkflow() {
1858
+ if (!workflows || workflowPending || !workflowPreview?.confirmationToken) return;
1859
+ if (workflowIntentAtPreview !== workflowIntentSignature()) {
1860
+ workflowConfirmOpen = false;
1861
+ workflowPreview = null;
1862
+ workflowIdempotencyKey = '';
1863
+ workflowError = t(M['content.content_list.workflow_selection_changed']);
1864
+ return;
1865
+ }
1866
+ const request = createWorkflowRequest('apply');
1867
+ if (!request) return;
1868
+ request.confirmationToken = workflowPreview.confirmationToken;
1869
+ request.idempotencyKey = workflowIdempotencyKey;
1870
+ const appliedIntent = workflowIntentAtPreview;
1871
+ const appliedTarget = workflowJobTarget(request);
1872
+ workflowPending = true;
1873
+ workflowError = null;
1874
+ try {
1875
+ const result = await workflows.client.apply(request);
1876
+ if (!workflowResultMatchesRequest(result, request)) {
1877
+ workflowError = t(M['content.content_list.workflow_apply_mismatch']);
1878
+ return;
1879
+ }
1880
+ if (appliedIntent !== workflowIntentSignature()) {
1881
+ if (
1882
+ result.ok &&
1883
+ result.details?.background === true &&
1884
+ typeof result.details.jobId === 'string' &&
1885
+ !workflowQueuedJobs.some((job) => job.intent === appliedIntent)
1886
+ ) {
1887
+ workflowQueuedJobs = [
1888
+ ...workflowQueuedJobs,
1889
+ {
1890
+ intent: appliedIntent,
1891
+ jobId: result.details.jobId,
1892
+ actionId: request.actionId,
1893
+ requestId:
1894
+ typeof result.details.jobRequestId === 'string'
1895
+ ? result.details.jobRequestId
1896
+ : request.requestId,
1897
+ identity: request.identity,
1898
+ target: appliedTarget,
1899
+ },
1900
+ ];
1901
+ }
1902
+ workflowError = t(M['content.content_list.workflow_changed_applying']);
1903
+ workflowPreview = null;
1904
+ workflowIdempotencyKey = '';
1905
+ workflowConfirmOpen = false;
1906
+ return;
1907
+ }
1908
+ workflowResult = result;
1909
+ if (!result.ok) {
1910
+ workflowError = workflowReasonLabel(
1911
+ result.reason,
1912
+ M['content.content_list.workflow_failed'],
1913
+ );
1914
+ workflowPreview = null;
1915
+ workflowIdempotencyKey = '';
1916
+ workflowConfirmOpen = false;
1917
+ return;
1918
+ }
1919
+ if (result.details?.background !== true) {
1920
+ applyWorkflowSelectionOutcomes(result);
1921
+ refreshQuery();
1922
+ } else if (
1923
+ typeof result.details.jobId === 'string' &&
1924
+ !workflowQueuedJobs.some((job) => job.intent === workflowIntentAtPreview)
1925
+ ) {
1926
+ workflowQueuedJobs = [
1927
+ ...workflowQueuedJobs,
1928
+ {
1929
+ intent: workflowIntentAtPreview,
1930
+ jobId: result.details.jobId,
1931
+ actionId: request.actionId,
1932
+ requestId:
1933
+ typeof result.details.jobRequestId === 'string'
1934
+ ? result.details.jobRequestId
1935
+ : request.requestId,
1936
+ identity: request.identity,
1937
+ target: appliedTarget,
1938
+ },
1939
+ ];
1940
+ }
1941
+ workflowPreview = null;
1942
+ workflowIdempotencyKey = '';
1943
+ workflowConfirmOpen = false;
1944
+ } catch (error) {
1945
+ workflowError = workflowExceptionMessage(
1946
+ error,
1947
+ M['content.content_list.workflow_failed'],
1948
+ );
1949
+ } finally {
1950
+ workflowPending = false;
1951
+ }
1952
+ }
1953
+
1954
+ async function checkWorkflowJob() {
1955
+ if (!workflows?.client.status || !workflowQueuedJob || workflowPending) return;
1956
+ const queuedJob = workflowQueuedJob;
1957
+ workflowPending = true;
1958
+ workflowError = null;
1959
+ try {
1960
+ const job = await workflows.client.status(queuedJob.jobId);
1961
+ const matchesCurrentIntent =
1962
+ queuedJob.intent === workflowIntentSignature();
1963
+ if (job.status === 'queued' || job.status === 'running') {
1964
+ workflowError = t(M['content.content_list.workflow_job_still'], {
1965
+ id: job.jobId,
1966
+ status: workflowJobStatusLabel(job.status),
1967
+ });
1968
+ return;
1969
+ }
1970
+ if (job.status === 'succeeded' && !job.result) {
1971
+ workflowError = t(M['content.content_list.workflow_job_missing_result'], {
1972
+ id: job.jobId,
1973
+ });
1974
+ return;
1975
+ }
1976
+ if (
1977
+ job.result &&
1978
+ (job.result.version !== 1 ||
1979
+ job.result.phase !== 'apply' ||
1980
+ job.result.actionId !== queuedJob.actionId ||
1981
+ job.result.requestId !== queuedJob.requestId ||
1982
+ !workflowIdentityMatches(job.result.identity, queuedJob.identity))
1983
+ ) {
1984
+ workflowError = t(M['content.content_list.workflow_job_wrong_workflow'], {
1985
+ id: job.jobId,
1986
+ });
1987
+ return;
1988
+ }
1989
+ workflowQueuedJobs = workflowQueuedJobs.filter(
1990
+ (queued) => queued.jobId !== job.jobId,
1991
+ );
1992
+ if (!matchesCurrentIntent) {
1993
+ if (job.status === 'succeeded' && job.result?.ok) refreshQuery();
1994
+ workflowError = t(
1995
+ M['content.content_list.workflow_changed_checking_job'],
1996
+ );
1997
+ return;
1998
+ }
1999
+ if (job.status === 'succeeded' && job.result) {
2000
+ if (matchesCurrentIntent) workflowResult = job.result;
2001
+ if (!job.result.ok) {
2002
+ workflowError = workflowReasonLabel(
2003
+ job.result.reason,
2004
+ M['content.content_list.workflow_job_failed'],
2005
+ { id: job.jobId },
2006
+ );
2007
+ } else if (matchesCurrentIntent) {
2008
+ applyWorkflowSelectionOutcomes(job.result);
2009
+ }
2010
+ if (job.result.ok) refreshQuery();
2011
+ }
2012
+ if (job.status !== 'succeeded') {
2013
+ workflowError = workflowReasonLabel(
2014
+ job.reason,
2015
+ M['content.content_list.workflow_job_status'],
2016
+ { id: job.jobId, status: workflowJobStatusLabel(job.status) },
2017
+ );
2018
+ }
2019
+ } catch (error) {
2020
+ workflowError = workflowExceptionMessage(
2021
+ error,
2022
+ M['content.content_list.workflow_job_failed'],
2023
+ );
2024
+ } finally {
2025
+ workflowPending = false;
2026
+ }
2027
+ }
2028
+
2029
+ function cancelWorkflowConfirmation() {
2030
+ if (workflowPending) return;
2031
+ workflowConfirmOpen = false;
2032
+ }
2033
+
1211
2034
  function handlePageChange(page: number) {
1212
2035
  controller.dispatch({ type: 'setPage', page });
1213
2036
  }
@@ -1303,6 +2126,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1303
2126
  <Checkbox
1304
2127
  checked={allPageSelected}
1305
2128
  indeterminate={somePageSelected}
2129
+ disabled={workflowPending}
1306
2130
  aria-label={t(M['content.content_list.select_all'])}
1307
2131
  onchange={togglePageSelection}
1308
2132
  />
@@ -1311,7 +2135,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1311
2135
  {#snippet selectCell({ row }: { row: ContentListRow })}
1312
2136
  <Checkbox
1313
2137
  checked={isSelected(row)}
1314
- disabled={!row.identified || rowPending(row)}
2138
+ disabled={workflowPending || !row.identified || rowPending(row) || allMatchingSelected}
1315
2139
  aria-label={selectRowLabel(row)}
1316
2140
  title={row.identified
1317
2141
  ? rowPending(row)
@@ -1526,6 +2350,18 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1526
2350
  <rect x="3" y="14" width="7" height="7"></rect>
1527
2351
  </svg>
1528
2352
  </Button>
2353
+
2354
+ {#if workflowQueuedJobs.length > 0 && workflows?.client.status}
2355
+ <Button
2356
+ variant="ghost"
2357
+ size="sm"
2358
+ type="button"
2359
+ disabled={workflowPending}
2360
+ onclick={() => void checkWorkflowJob()}
2361
+ >
2362
+ {t(M['content.content_list.check_job'])}
2363
+ </Button>
2364
+ {/if}
1529
2365
  <Button
1530
2366
  variant="ghost"
1531
2367
  size="sm"
@@ -1694,6 +2530,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1694
2530
  <Checkbox
1695
2531
  checked={allPageSelected}
1696
2532
  indeterminate={somePageSelected}
2533
+ disabled={workflowPending}
1697
2534
  aria-label={t(M['content.content_list.select_all'])}
1698
2535
  onchange={togglePageSelection}
1699
2536
  />
@@ -1702,13 +2539,136 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1702
2539
  {t(M['content.content_list.selection_count'], { count: selectedCount })}
1703
2540
  </span>
1704
2541
  {#if selectedCount > 0}
1705
- <Button variant="ghost" size="sm" type="button" class="clear-selection" onclick={clearSelection}>
2542
+ <Button variant="ghost" size="sm" type="button" class="clear-selection" disabled={workflowPending} onclick={clearSelection}>
1706
2543
  {t(M['content.content_list.clear_selection'])}
1707
2544
  </Button>
1708
2545
  {/if}
2546
+ {#if canSelectAllMatching && !allMatchingSelected && exactMatchingCount !== undefined && exactMatchingCount > selectedCount}
2547
+ <Button
2548
+ variant="ghost"
2549
+ size="sm"
2550
+ type="button"
2551
+ class="select-all-matching"
2552
+ disabled={workflowPending}
2553
+ onclick={selectAllMatching}
2554
+ >
2555
+ {t(M['content.content_list.select_all_matching'], {
2556
+ count: exactMatchingCount,
2557
+ })}
2558
+ </Button>
2559
+ {/if}
1709
2560
  </div>
1710
2561
  {/if}
1711
2562
 
2563
+ {#if workflows && (selectedCount > 0 || workflowError || workflowResult)}
2564
+ <section class="content-workflows" aria-label={t(M['content.content_list.bulk_workflows'])}>
2565
+ <Select
2566
+ aria-label={t(M['content.content_list.bulk_workflow'])}
2567
+ value={selectedWorkflow}
2568
+ disabled={workflowPending}
2569
+ onchange={(event: Event) => {
2570
+ selectedWorkflow = (event.currentTarget as HTMLSelectElement).value as ContentListWorkflowId;
2571
+ workflowPreview = null;
2572
+ workflowIdempotencyKey = '';
2573
+ workflowResult = null;
2574
+ workflowError = null;
2575
+ }}
2576
+ >
2577
+ {#each CONTENT_LIST_WORKFLOW_OPTIONS as option (option.id)}
2578
+ <option value={option.id}>{workflowLabel(option.id)}</option>
2579
+ {/each}
2580
+ </Select>
2581
+
2582
+ {#if selectedWorkflow === 'categorize'}
2583
+ <Input
2584
+ aria-label={t(M['content.content_list.category_path'])}
2585
+ placeholder={t(M['content.content_list.category_path'])}
2586
+ value={workflowCategory}
2587
+ disabled={workflowPending}
2588
+ oninput={(event: Event) => workflowCategory = (event.currentTarget as HTMLInputElement).value}
2589
+ />
2590
+ {:else if selectedWorkflow === 'restore'}
2591
+ <Select
2592
+ aria-label={t(M['content.content_list.restore_status'])}
2593
+ value={workflowRestoreStatus}
2594
+ disabled={workflowPending}
2595
+ onchange={(event: Event) => workflowRestoreStatus = (event.currentTarget as HTMLSelectElement).value as typeof workflowRestoreStatus}
2596
+ >
2597
+ <option value="draft">{t(M['content.content_list.workflow_draft'])}</option>
2598
+ <option value="review">{t(M['content.content_list.workflow_review'])}</option>
2599
+ <option value="published">{t(M['content.content_list.workflow_published'])}</option>
2600
+ </Select>
2601
+ {:else if selectedWorkflow === 'format-body'}
2602
+ <Select
2603
+ aria-label={t(M['content.content_list.body_format'])}
2604
+ value={workflowFormat}
2605
+ disabled={workflowPending}
2606
+ onchange={(event: Event) => workflowFormat = (event.currentTarget as HTMLSelectElement).value as typeof workflowFormat}
2607
+ >
2608
+ <option value="markdown">{t(M['content.content_list.workflow_markdown'])}</option>
2609
+ <option value="html">{t(M['content.content_list.workflow_html'])}</option>
2610
+ </Select>
2611
+ {:else if selectedWorkflow === 'automated-review'}
2612
+ <Select
2613
+ aria-label={t(M['content.content_list.review_kind'])}
2614
+ value={workflowReviewKind}
2615
+ disabled={workflowPending}
2616
+ onchange={(event: Event) => workflowReviewKind = (event.currentTarget as HTMLSelectElement).value}
2617
+ >
2618
+ <option value="">{t(M['content.content_list.default_review_kind'])}</option>
2619
+ <option value="facts">{t(M['content.content_list.workflow_facts'])}</option>
2620
+ <option value="safety">{t(M['content.content_list.workflow_safety'])}</option>
2621
+ <option value="custom">{t(M['content.content_list.workflow_custom'])}</option>
2622
+ </Select>
2623
+ <Input
2624
+ aria-label={t(M['content.content_list.review_policy_key'])}
2625
+ placeholder={t(M['content.content_list.policy_key_optional'])}
2626
+ value={workflowPolicyKey}
2627
+ disabled={workflowPending}
2628
+ oninput={(event: Event) => workflowPolicyKey = (event.currentTarget as HTMLInputElement).value}
2629
+ />
2630
+ {:else if selectedWorkflow === 'optimize'}
2631
+ <Input
2632
+ aria-label={t(M['content.content_list.optimization_instructions'])}
2633
+ placeholder={t(M['content.content_list.optimization_instructions_optional'])}
2634
+ value={workflowInstructions}
2635
+ disabled={workflowPending}
2636
+ oninput={(event: Event) => workflowInstructions = (event.currentTarget as HTMLInputElement).value}
2637
+ />
2638
+ {/if}
2639
+
2640
+ <Button
2641
+ variant="ghost"
2642
+ size="sm"
2643
+ type="button"
2644
+ disabled={
2645
+ workflowPending || !workflowPayloadValid || workflowDuplicateQueued
2646
+ }
2647
+ onclick={() => void previewWorkflow()}
2648
+ >
2649
+ {workflowPending
2650
+ ? t(M['content.content_list.workflow_working'])
2651
+ : workflowDuplicateQueued
2652
+ ? t(M['content.content_list.workflow_job_queued'])
2653
+ : t(M['content.content_list.workflow_preview'])}
2654
+ </Button>
2655
+
2656
+ {#if workflowError}
2657
+ <p class="content-workflows__error" role="alert">{workflowError}</p>
2658
+ {/if}
2659
+ {#if workflowResult?.ok}
2660
+ <p class="content-workflows__result" role="status">
2661
+ {workflowResultMessage(workflowResult)}
2662
+ {#if typeof workflowResult.details?.jobId === 'string'}
2663
+ {t(M['content.content_list.job_queued_progress'], {
2664
+ id: workflowResult.details.jobId,
2665
+ })}
2666
+ {/if}
2667
+ </p>
2668
+ {/if}
2669
+ </section>
2670
+ {/if}
2671
+
1712
2672
  {#if refreshing && viewMode !== 'compact'}
1713
2673
  <!-- DataTable announces its own refresh; the card views need their own. -->
1714
2674
  <p class="content-refreshing" role="status" aria-live="polite">
@@ -1754,7 +2714,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1754
2714
  <div class="content-row__select">
1755
2715
  <Checkbox
1756
2716
  checked={isSelected(row)}
1757
- disabled={!row.identified || rowPending(row)}
2717
+ disabled={workflowPending || !row.identified || rowPending(row) || allMatchingSelected}
1758
2718
  aria-label={selectRowLabel(row)}
1759
2719
  title={row.identified
1760
2720
  ? rowPending(row)
@@ -1848,7 +2808,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1848
2808
  <div class="content-header__eyebrow">
1849
2809
  <Checkbox
1850
2810
  checked={isSelected(row)}
1851
- disabled={!row.identified || rowPending(row)}
2811
+ disabled={workflowPending || !row.identified || rowPending(row) || allMatchingSelected}
1852
2812
  aria-label={selectRowLabel(row)}
1853
2813
  title={row.identified
1854
2814
  ? rowPending(row)
@@ -1945,6 +2905,23 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1945
2905
  oncancel={cancelDelete}
1946
2906
  />
1947
2907
 
2908
+ <ConfirmDialog
2909
+ open={workflowConfirmOpen}
2910
+ title={t(M['content.content_list.workflow_confirm'], {
2911
+ workflow: selectedWorkflow
2912
+ ? workflowLabel(selectedWorkflow)
2913
+ : t(M['content.content_list.workflow_noun']),
2914
+ })}
2915
+ message={workflowPreviewMessage()}
2916
+ confirmLabel={workflowPending
2917
+ ? t(M['content.content_list.workflow_applying'])
2918
+ : t(M['content.content_list.workflow_apply'])}
2919
+ cancelLabel={t(M['content.content_list.cancel'])}
2920
+ destructive={CONTENT_LIST_WORKFLOW_OPTIONS.find((option) => option.id === selectedWorkflow)?.sensitivity === 'sensitive'}
2921
+ onconfirm={() => void applyWorkflow()}
2922
+ oncancel={cancelWorkflowConfirmation}
2923
+ />
2924
+
1948
2925
  <style>
1949
2926
  .content-list-wrapper {
1950
2927
  display: flex;
@@ -2059,6 +3036,33 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2059
3036
  cursor: pointer;
2060
3037
  }
2061
3038
 
3039
+ .content-workflows {
3040
+ display: flex;
3041
+ align-items: center;
3042
+ flex-wrap: wrap;
3043
+ gap: 0.75rem;
3044
+ margin-bottom: 1rem;
3045
+ padding: 0.75rem;
3046
+ border: 1px solid var(--smrt-color-outline-variant);
3047
+ border-radius: 0.65rem;
3048
+ background: var(--smrt-color-surface-container-low);
3049
+ }
3050
+
3051
+ .content-workflows__error,
3052
+ .content-workflows__result {
3053
+ flex-basis: 100%;
3054
+ margin: 0;
3055
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
3056
+ }
3057
+
3058
+ .content-workflows__error {
3059
+ color: var(--smrt-color-error);
3060
+ }
3061
+
3062
+ .content-workflows__result {
3063
+ color: var(--smrt-color-on-surface-variant);
3064
+ }
3065
+
2062
3066
  .content-header__eyebrow,
2063
3067
  .content-row__eyebrow {
2064
3068
  display: flex;