@simmalugnt-se/payload-editor-assistant 0.10.0 → 0.11.1

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 (44) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -0
  3. package/dist/batch/targets.d.ts +25 -0
  4. package/dist/batch/targets.js +47 -0
  5. package/dist/capabilities.d.ts +4 -4
  6. package/dist/capabilities.js +3 -0
  7. package/dist/components/BatchPanel.d.ts +9 -0
  8. package/dist/components/BatchPanel.js +161 -0
  9. package/dist/components/EditorAssistantAside.js +4 -3
  10. package/dist/components/EditorAssistantProvider.js +2 -0
  11. package/dist/components/assistant-context.d.ts +11 -0
  12. package/dist/components/assistant-context.js +4 -0
  13. package/dist/components/batch-link.d.ts +5 -0
  14. package/dist/components/batch-link.js +35 -0
  15. package/dist/components/batch-run.d.ts +65 -0
  16. package/dist/components/batch-run.js +67 -0
  17. package/dist/components/copy.d.ts +82 -0
  18. package/dist/components/copy.js +81 -0
  19. package/dist/config/validate.js +3 -0
  20. package/dist/domain/audit.d.ts +2 -0
  21. package/dist/domain/audit.js +1 -0
  22. package/dist/domain/batch.d.ts +74 -0
  23. package/dist/domain/batch.js +342 -0
  24. package/dist/domain/content.d.ts +16 -0
  25. package/dist/domain/content.js +14 -5
  26. package/dist/domain/decide.js +4 -0
  27. package/dist/domain/guard.d.ts +3 -1
  28. package/dist/domain/guard.js +2 -2
  29. package/dist/domain/propose.d.ts +27 -1
  30. package/dist/domain/propose.js +88 -5
  31. package/dist/endpoints/batch.d.ts +8 -0
  32. package/dist/endpoints/batch.js +102 -0
  33. package/dist/package-name.d.ts +3 -2
  34. package/dist/package-name.js +3 -2
  35. package/dist/plugin.js +12 -2
  36. package/dist/runtime/agent.d.ts +4 -2
  37. package/dist/runtime/agent.js +5 -3
  38. package/dist/runtime/system-prompt.js +12 -1
  39. package/dist/runtime/tools.d.ts +15 -2
  40. package/dist/runtime/tools.js +20 -3
  41. package/dist/runtime/voice.d.ts +2 -2
  42. package/dist/runtime/voice.js +7 -3
  43. package/dist/styles/assistant.css +201 -0
  44. package/package.json +1 -1
@@ -0,0 +1,342 @@
1
+ import { changedPaths } from "../form/changed-paths.js";
2
+ import { compileOperations } from "../form/compile.js";
3
+ import { hashFormRevision } from "../form/hash.js";
4
+ import { isFormOperation } from "../form/operations.js";
5
+ import { cloneData, getAtPath, parseFieldPath, setAtPath } from "../form/path.js";
6
+ import { relationValueError } from "../form/relation-shape.js";
7
+ import { loadRichText } from "../form/rich-text.js";
8
+ import { finalizeCompiled, openRichText } from "../form/shape.js";
9
+ import { translationPaths } from "../form/translation.js";
10
+ import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
11
+ import { runAgentTurn } from "../runtime/agent.js";
12
+ import { sourceLocale } from "../runtime/translation-source.js";
13
+ import { configuredDefaultLocale, configuredLocales, entityFields, findCollectionConfig, hasDrafts, } from "../schema/discover.js";
14
+ import { fieldAtPath } from "../schema/fields.js";
15
+ import { projectRecord } from "../schema/project.js";
16
+ import { hasCapability, resolveAllowlist } from "./allowlist.js";
17
+ import { writeAudit } from "./audit.js";
18
+ import { readDocument } from "./content.js";
19
+ import { localApi } from "./local-api.js";
20
+ import { verifyDocumentRelations } from "./relations.js";
21
+ const BATCH_ID = /^[\w-]{1,64}$/;
22
+ /** An editor's own wording in a batch card; longer than any meta text or alt text needs. */
23
+ const MAX_EDIT_LENGTH = 5000;
24
+ /**
25
+ * One document of a batch: a turn on the saved document, as if it were open with the finding's field
26
+ * selected. The model may only propose a `content.update` within the target's fields.
27
+ */
28
+ export async function proposeBatchTarget(req, options, input, run = runAgentTurn) {
29
+ await loadRichText();
30
+ const { target } = input;
31
+ if (!BATCH_ID.test(input.batchId) || !input.prompt.trim()) {
32
+ return { ok: false, error: "tool_denied" };
33
+ }
34
+ const resolved = resolveAllowlist(options, { collection: target.collection });
35
+ if (!resolved || !hasCapability(resolved.allowlist, "content.update")) {
36
+ return { ok: false, error: "tool_denied", message: "Batch changes are not allowed here." };
37
+ }
38
+ const locale = target.locale ?? configuredDefaultLocale(req) ?? "en";
39
+ const saved = await readSaved(req, options, target.collection, target.id, locale);
40
+ if (!saved) {
41
+ return { ok: false, error: "tool_denied", message: "Document is not accessible." };
42
+ }
43
+ const fields = entityFields(req, options, { collection: target.collection });
44
+ const allowedPaths = await allowedPathsFor(req, options, input, locale, fields, saved.data);
45
+ if (!allowedPaths.ok) {
46
+ return { ok: false, error: allowedPaths.error };
47
+ }
48
+ const message = input.instruction?.trim()
49
+ ? `${input.prompt.trim()}\n\n${input.instruction.trim()}`
50
+ : input.prompt.trim();
51
+ const result = await run(req, options, {
52
+ message,
53
+ messages: [{ role: "user", text: message }],
54
+ bridge: {
55
+ collection: target.collection,
56
+ documentId: target.id,
57
+ title: saved.title,
58
+ locale,
59
+ language: input.language,
60
+ uiLanguage: input.language,
61
+ selection: input.scope === "field" ? target.path : undefined,
62
+ batch: { allowedPaths: allowedPaths.paths, requestId: `batch:${input.batchId}` },
63
+ },
64
+ formData: saved.data,
65
+ });
66
+ if (!result.ok) {
67
+ return { ok: false, error: result.error, message: result.message };
68
+ }
69
+ const pending = result.pendingApproval;
70
+ if (pending?.capability !== "content.update") {
71
+ return { ok: true, text: result.text };
72
+ }
73
+ return {
74
+ ok: true,
75
+ text: result.text,
76
+ proposal: {
77
+ approvalId: pending.approvalId,
78
+ nonce: pending.nonce,
79
+ lines: pending.lines ?? pending.summary.split("\n").map((text) => ({ path: "", text })),
80
+ },
81
+ };
82
+ }
83
+ /** The finding's field, or every field a translation still has to fill. */
84
+ async function allowedPathsFor(req, options, input, locale, fields, saved) {
85
+ if (input.scope === "field") {
86
+ return fieldAtPath(fields, input.target.path, saved)
87
+ ? { ok: true, paths: [input.target.path] }
88
+ : { ok: false, error: "tool_denied" };
89
+ }
90
+ const source = sourceLocale({
91
+ open: locale,
92
+ locales: configuredLocales(req),
93
+ defaultLocale: configuredDefaultLocale(req),
94
+ });
95
+ const from = source
96
+ ? await readSaved(req, options, input.target.collection, input.target.id, source)
97
+ : null;
98
+ if (!from) {
99
+ return { ok: false, error: "tool_denied" };
100
+ }
101
+ const empty = translationPaths(fields, saved, from.data).empty;
102
+ return empty.length ? { ok: true, paths: empty } : { ok: false, error: "nothing_to_do" };
103
+ }
104
+ async function readSaved(req, options, collection, id, locale) {
105
+ const read = await readDocument(req, options, { collection, documentId: id, locale });
106
+ if (!read.ok) {
107
+ return null;
108
+ }
109
+ const useAsTitle = findCollectionConfig(req, collection)?.admin?.useAsTitle;
110
+ const title = useAsTitle ? read.doc[useAsTitle] : undefined;
111
+ return {
112
+ data: projectRecord(read.doc, read.fields, options.redact, read.slug).data,
113
+ raw: read.doc,
114
+ title: typeof title === "string" && title ? title : undefined,
115
+ };
116
+ }
117
+ /**
118
+ * Saves one batch proposal to the document, or rejects it. Checked again as it is saved: the editor,
119
+ * the one-time approval, `content.update`, and that the saved document is still the one the proposal
120
+ * was made for. A collection with drafts gets a draft; one without is saved.
121
+ */
122
+ export async function applyBatchTarget(req, options, input) {
123
+ await loadRichText();
124
+ const requestId = BATCH_ID.test(input.batchId) ? `batch:${input.batchId}` : undefined;
125
+ let record;
126
+ try {
127
+ record = (await localApi(req).findByID({
128
+ collection: DEFAULT_APPROVAL_SLUG,
129
+ id: input.approvalId,
130
+ req,
131
+ overrideAccess: false,
132
+ user: req.user,
133
+ depth: 0,
134
+ }));
135
+ }
136
+ catch {
137
+ return { ok: false, error: "tool_denied", message: "Approval is not valid." };
138
+ }
139
+ if (!record ||
140
+ record.actor !== String(req.user?.id) ||
141
+ record.nonce !== input.nonce ||
142
+ record.capability !== "content.update" ||
143
+ !record.targetCollection ||
144
+ !record.documentId) {
145
+ return { ok: false, error: "tool_denied", message: "Approval is not valid." };
146
+ }
147
+ if (record.status !== "proposed") {
148
+ return { ok: false, error: "tool_denied", message: "Approval is no longer usable." };
149
+ }
150
+ const event = {
151
+ capability: "content.update",
152
+ risk: "approval",
153
+ targetCollection: record.targetCollection,
154
+ documentId: record.documentId,
155
+ locale: record.locale,
156
+ approvalId: String(record.id),
157
+ nonceId: record.nonce.slice(0, 8),
158
+ requestId,
159
+ };
160
+ if (new Date(record.expiresAt).getTime() <= Date.now()) {
161
+ await consume(req, record.id, "expired");
162
+ await writeAudit(req, options, { ...event, outcome: "expired" });
163
+ return { ok: false, error: "stale_context", message: "The proposal expired." };
164
+ }
165
+ if (input.action === "reject") {
166
+ await consume(req, record.id, "rejected");
167
+ await writeAudit(req, options, { ...event, outcome: "rejected" });
168
+ return { ok: true, status: "rejected" };
169
+ }
170
+ const fail = async (error, message) => {
171
+ await consume(req, record.id, "failed");
172
+ await writeAudit(req, options, { ...event, outcome: "failed", errorClass: error });
173
+ return { ok: false, error, message };
174
+ };
175
+ const resolved = resolveAllowlist(options, { collection: record.targetCollection });
176
+ if (!resolved || !hasCapability(resolved.allowlist, "content.update")) {
177
+ return fail("tool_denied", "Batch changes are not allowed here.");
178
+ }
179
+ if (!Array.isArray(record.operations) || !record.operations.every(isFormOperation)) {
180
+ return fail("tool_denied", "Stored operations are invalid.");
181
+ }
182
+ const stored = record.operations;
183
+ if ((await hashFormRevision(stored)) !== record.canonicalHash) {
184
+ return fail("tool_denied", "Proposal hash mismatch.");
185
+ }
186
+ const saved = await readSaved(req, options, record.targetCollection, record.documentId, record.locale);
187
+ if (!saved) {
188
+ return fail("tool_denied", "Document is not accessible.");
189
+ }
190
+ if ((await hashFormRevision(saved.data)) !== record.formRevision) {
191
+ return fail("stale_context", "The document changed after the proposal.");
192
+ }
193
+ const fields = entityFields(req, options, { collection: record.targetCollection });
194
+ const edited = withEdits(stored, input.edits, fields, saved.data);
195
+ if (!edited.ok) {
196
+ // Not the editor's approval to spend: the card can still be saved without the edit.
197
+ return { ok: false, error: "tool_denied", message: edited.message };
198
+ }
199
+ const operations = edited.operations;
200
+ const compiled = compileOperations(openRichText(saved.data, fields), operations, {
201
+ allowlist: resolved.allowlist.fields,
202
+ denyFields: options.denyFields,
203
+ });
204
+ if (!compiled.ok) {
205
+ return fail("tool_denied", compiled.error);
206
+ }
207
+ const finalized = finalizeCompiled(compiled.data, fields, saved.data);
208
+ if (!finalized.ok) {
209
+ return fail("tool_denied", finalized.error);
210
+ }
211
+ const paths = changedPaths(operations);
212
+ const relationError = relationValueError(fields, finalized.data, paths);
213
+ if (relationError) {
214
+ return fail("tool_denied", relationError);
215
+ }
216
+ if (options.validateProposal) {
217
+ const extra = await options.validateProposal({ operations, data: finalized.data });
218
+ if (!extra.ok) {
219
+ return fail("tool_denied", extra.errors.join(" "));
220
+ }
221
+ }
222
+ const relations = await verifyDocumentRelations(req, fields, finalized.data, finalized.links);
223
+ if (!relations.ok) {
224
+ return fail("tool_denied", relations.message);
225
+ }
226
+ const drafts = hasDrafts(findCollectionConfig(req, record.targetCollection)?.versions);
227
+ try {
228
+ await localApi(req).update({
229
+ collection: record.targetCollection,
230
+ id: record.documentId,
231
+ locale: record.locale,
232
+ data: changedTopLevel(saved.raw, finalized.data, paths),
233
+ draft: drafts,
234
+ depth: 0,
235
+ req,
236
+ user: req.user,
237
+ overrideAccess: false,
238
+ overrideLock: false,
239
+ });
240
+ }
241
+ catch (error) {
242
+ return isLocked(error)
243
+ ? fail("locked", "Someone else is editing the document.")
244
+ : fail("tool_denied", "The document could not be saved.");
245
+ }
246
+ const canonicalHash = await hashFormRevision(operations);
247
+ await consume(req, record.id, "executed");
248
+ // A hash that differs from the proposed event's says the editor changed the wording.
249
+ await writeAudit(req, options, {
250
+ ...event,
251
+ outcome: "executed",
252
+ fieldPaths: paths,
253
+ canonicalHash,
254
+ baseRevision: record.formRevision ?? undefined,
255
+ });
256
+ return { ok: true, status: drafts ? "draft" : "saved" };
257
+ }
258
+ /** The stored operations with the editor's wording, only where they set plain text. */
259
+ function withEdits(operations, edits, fields, saved) {
260
+ if (!edits || Object.keys(edits).length === 0) {
261
+ return { ok: true, operations };
262
+ }
263
+ const next = cloneData(operations);
264
+ for (const [path, value] of Object.entries(edits)) {
265
+ const operation = next.find((entry) => entry.op === "set" && entry.path === path);
266
+ const type = fieldAtPath(fields, path, saved)?.type;
267
+ if (operation?.op !== "set" ||
268
+ typeof operation.value !== "string" ||
269
+ (type !== "text" && type !== "textarea") ||
270
+ typeof value !== "string" ||
271
+ value.length > MAX_EDIT_LENGTH) {
272
+ return { ok: false, message: `${path} cannot be edited here.` };
273
+ }
274
+ operation.value = value;
275
+ }
276
+ return { ok: true, operations: next };
277
+ }
278
+ /**
279
+ * The top-level fields the change reaches, as saved with only the changed values replaced, so
280
+ * fields the assistant does not see (denied, redacted) are written back as they were.
281
+ */
282
+ function changedTopLevel(raw, changed, paths) {
283
+ const patched = cloneData(raw);
284
+ const data = {};
285
+ for (const path of paths) {
286
+ const segments = parseFieldPath(path);
287
+ setAtPath(patched, segments, getAtPath(changed, segments) ?? null);
288
+ const key = String(segments[0]);
289
+ data[key] = patched[key];
290
+ }
291
+ return data;
292
+ }
293
+ /** Payload's error for a document another editor has open (`Locked`, status 423). */
294
+ function isLocked(error) {
295
+ const record = error;
296
+ return record?.name === "Locked" || record?.status === 423;
297
+ }
298
+ async function consume(req, id, status) {
299
+ await localApi(req).update({
300
+ collection: DEFAULT_APPROVAL_SLUG,
301
+ id,
302
+ data: { status },
303
+ req,
304
+ user: req.user,
305
+ overrideAccess: false,
306
+ });
307
+ }
308
+ /** What the panel shows before the batch runs: titles and whether each document can be changed. */
309
+ export async function describeBatchTargets(req, options, targets) {
310
+ const infos = [];
311
+ // One after the other: the reads share the request.
312
+ for (const target of targets) {
313
+ const resolved = resolveAllowlist(options, { collection: target.collection });
314
+ const config = findCollectionConfig(req, target.collection);
315
+ const allowed = Boolean(resolved && hasCapability(resolved.allowlist, "content.update"));
316
+ const read = allowed
317
+ ? await readDocument(req, options, {
318
+ collection: target.collection,
319
+ documentId: target.id,
320
+ locale: target.locale ?? configuredDefaultLocale(req) ?? "en",
321
+ })
322
+ : undefined;
323
+ const doc = read?.ok ? read.doc : undefined;
324
+ const useAsTitle = config?.admin?.useAsTitle;
325
+ const title = [useAsTitle ? doc?.[useAsTitle] : undefined, doc?.filename].find((value) => typeof value === "string" && value.trim() !== "");
326
+ infos.push({
327
+ key: target.key,
328
+ title: title ?? target.id,
329
+ thumbnail: doc && config?.upload ? thumbnailOf(doc) : undefined,
330
+ drafts: hasDrafts(config?.versions),
331
+ allowed: Boolean(doc),
332
+ });
333
+ }
334
+ return infos;
335
+ }
336
+ function thumbnailOf(doc) {
337
+ const sizes = doc.sizes;
338
+ const candidates = [doc.thumbnailURL, sizes?.thumbnail?.url, doc.url];
339
+ const mimeType = typeof doc.mimeType === "string" ? doc.mimeType : "";
340
+ const url = candidates.find((value) => typeof value === "string" && !!value);
341
+ return url && (!mimeType || mimeType.startsWith("image/")) ? url : undefined;
342
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PayloadRequest } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
+ import type { FieldNode } from "../schema/fields.ts";
3
4
  export type ContentFindInput = {
4
5
  collection?: string;
5
6
  query: string;
@@ -28,3 +29,18 @@ export declare function findContentById(req: PayloadRequest, options: ValidatedE
28
29
  error: string;
29
30
  message: string;
30
31
  }>;
32
+ /**
33
+ * A document or global as Admin reads it, with the editor's access: the latest draft, and a field
34
+ * nobody filled in this language empty rather than another language's text. Unprojected; callers
35
+ * project it or check their own capability first.
36
+ */
37
+ export declare function readDocument(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: ContentFindByIdInput): Promise<{
38
+ ok: true;
39
+ doc: Record<string, unknown>;
40
+ fields: FieldNode[];
41
+ slug: string;
42
+ } | {
43
+ ok: false;
44
+ error: string;
45
+ message: string;
46
+ }>;
@@ -74,6 +74,18 @@ export async function findContentById(req, options, input) {
74
74
  if (!resolved || !hasCapability(resolved.allowlist, "content.findById")) {
75
75
  return { ok: false, error: "tool_denied", message: "Target is not allowlisted for read." };
76
76
  }
77
+ const read = await readDocument(req, options, input);
78
+ if (!read.ok) {
79
+ return read;
80
+ }
81
+ return { ok: true, ...projectRecord(read.doc, read.fields, options.redact, read.slug) };
82
+ }
83
+ /**
84
+ * A document or global as Admin reads it, with the editor's access: the latest draft, and a field
85
+ * nobody filled in this language empty rather than another language's text. Unprojected; callers
86
+ * project it or check their own capability first.
87
+ */
88
+ export async function readDocument(req, options, input) {
77
89
  const fields = entityFields(req, options, input);
78
90
  try {
79
91
  if (input.global) {
@@ -85,15 +97,13 @@ export async function findContentById(req, options, input) {
85
97
  slug: input.global,
86
98
  depth: 0,
87
99
  locale: input.locale,
88
- // As Admin reads it: a field nobody filled in this language is empty, not another's text.
89
100
  fallbackLocale: false,
90
101
  draft: true,
91
102
  req,
92
103
  user: req.user,
93
104
  overrideAccess: false,
94
105
  });
95
- const projected = projectRecord(doc, fields, options.redact, input.global);
96
- return { ok: true, ...projected };
106
+ return { ok: true, doc, fields, slug: input.global };
97
107
  }
98
108
  if (!input.collection || input.documentId === undefined) {
99
109
  return {
@@ -113,8 +123,7 @@ export async function findContentById(req, options, input) {
113
123
  user: req.user,
114
124
  overrideAccess: false,
115
125
  });
116
- const projected = projectRecord(doc, fields, options.redact, input.collection);
117
- return { ok: true, ...projected };
126
+ return { ok: true, doc, fields, slug: input.collection };
118
127
  }
119
128
  catch {
120
129
  return { ok: false, error: "tool_denied", message: "Document is not accessible." };
@@ -27,6 +27,10 @@ export async function decideChange(req, options, input) {
27
27
  if (record.status !== "proposed") {
28
28
  return { ok: false, error: "tool_denied", message: "Approval is no longer usable." };
29
29
  }
30
+ // A batch change is saved or rejected from the batch, against the saved document.
31
+ if (record.capability === "content.update") {
32
+ return { ok: false, error: "tool_denied", message: "Approval belongs to a batch." };
33
+ }
30
34
  if (new Date(record.expiresAt).getTime() <= Date.now()) {
31
35
  await consume(req, record.id, "expired");
32
36
  return { ok: false, error: "stale_context", message: "Approval expired." };
@@ -9,5 +9,7 @@ export type GuardSuccess = {
9
9
  ok: true;
10
10
  actor: string;
11
11
  };
12
- export declare function guardMutationRequest(req: PayloadRequest, options: ValidatedEditorAssistantOptions): Promise<GuardFailure | GuardSuccess>;
12
+ export declare function guardMutationRequest(req: PayloadRequest, options: ValidatedEditorAssistantOptions, guard?: {
13
+ rateLimit?: boolean;
14
+ }): Promise<GuardFailure | GuardSuccess>;
13
15
  export declare function readJsonBody<T>(req: PayloadRequest): Promise<T | null>;
@@ -1,6 +1,6 @@
1
1
  import { verifyCsrf, verifyOrigin } from "../endpoints/csrf.js";
2
2
  import { consumeRateLimit } from "../runtime/rate-limit.js";
3
- export async function guardMutationRequest(req, options) {
3
+ export async function guardMutationRequest(req, options, guard = {}) {
4
4
  if (!req.user) {
5
5
  return { ok: false, status: 401, error: "unauthorized" };
6
6
  }
@@ -11,7 +11,7 @@ export async function guardMutationRequest(req, options) {
11
11
  return { ok: false, status: 403, error: "unavailable" };
12
12
  }
13
13
  const actor = String(req.user.id);
14
- if (!consumeRateLimit(actor)) {
14
+ if (guard.rateLimit !== false && !consumeRateLimit(actor)) {
15
15
  return { ok: false, status: 429, error: "rate_limited" };
16
16
  }
17
17
  return { ok: true, actor };
@@ -1,7 +1,7 @@
1
1
  import type { PayloadRequest } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
3
  export type ProposeInput = {
4
- capability: "form.propose" | "draft.create";
4
+ capability: "form.propose" | "draft.create" | "content.update";
5
5
  collection?: string;
6
6
  global?: string;
7
7
  documentId?: string | number;
@@ -10,6 +10,23 @@ export type ProposeInput = {
10
10
  language?: "sv" | "en";
11
11
  operations: unknown;
12
12
  formData?: Record<string, unknown>;
13
+ /**
14
+ * `content.update` only: the fields the change may reach, each with what is under it. A batch
15
+ * proposal is against the saved document, so `formData` is ignored.
16
+ */
17
+ allowedPaths?: string[];
18
+ /** Logged with the audit events, e.g. `batch:<id>`. */
19
+ requestId?: string;
20
+ };
21
+ /** One line of the change list; a plain text value the editor may rewrite before saving has `edit`. */
22
+ export type ProposalLine = {
23
+ path: string;
24
+ text: string;
25
+ edit?: {
26
+ label: string;
27
+ before: string;
28
+ value: string;
29
+ };
13
30
  };
14
31
  /** The form as it would be with a proposal applied, so Admin can show it before it is kept. */
15
32
  export type ProposalPreview = {
@@ -23,6 +40,9 @@ export type ProposeResult = {
23
40
  expiresAt: string;
24
41
  summary: string;
25
42
  title: string;
43
+ lines: ProposalLine[];
44
+ /** Characters in each plain text value set, as content health counts them. */
45
+ characters: Record<string, number>;
26
46
  preview?: ProposalPreview;
27
47
  } | {
28
48
  ok: false;
@@ -30,3 +50,9 @@ export type ProposeResult = {
30
50
  message: string;
31
51
  };
32
52
  export declare function proposeChange(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: ProposeInput): Promise<ProposeResult>;
53
+ /** A saved document as the batch sees it: allowlisted fields in the target's language. */
54
+ export declare function readSavedForm(req: PayloadRequest, options: ValidatedEditorAssistantOptions, target: {
55
+ collection?: string;
56
+ documentId?: string | number;
57
+ locale: string;
58
+ }): Promise<Record<string, unknown> | null>;
@@ -10,9 +10,10 @@ import { finalizeCompiled, openRichText } from "../form/shape.js";
10
10
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
11
11
  import { entityFields, findCollectionConfig, hasDrafts } from "../schema/discover.js";
12
12
  import { fieldAtPath } from "../schema/fields.js";
13
+ import { projectRecord } from "../schema/project.js";
13
14
  import { hasCapability, resolveAllowlist } from "./allowlist.js";
14
15
  import { writeAudit } from "./audit.js";
15
- import { findContentById } from "./content.js";
16
+ import { findContentById, readDocument } from "./content.js";
16
17
  import { localApi } from "./local-api.js";
17
18
  import { labelRelations, verifyDocumentRelations } from "./relations.js";
18
19
  /** Long enough to look at a change in the preview before keeping it. */
@@ -28,7 +29,17 @@ export async function proposeChange(req, options, input) {
28
29
  return { ok: false, error: "tool_denied", message: "Operations are invalid." };
29
30
  }
30
31
  const typedOperations = operations;
31
- const current = await resolveCurrentForm(req, options, input);
32
+ if (input.capability === "content.update") {
33
+ const outside = batchScopeError(typedOperations, input);
34
+ if (outside) {
35
+ return { ok: false, error: "tool_denied", message: outside };
36
+ }
37
+ }
38
+ const resolvedForm = await resolveCurrentForm(req, options, input);
39
+ if (!resolvedForm) {
40
+ return { ok: false, error: "tool_denied", message: "Document is not accessible." };
41
+ }
42
+ const current = resolvedForm;
32
43
  const fields = entityFields(req, options, {
33
44
  collection: input.collection,
34
45
  global: input.global,
@@ -73,7 +84,7 @@ export async function proposeChange(req, options, input) {
73
84
  if (!relations.ok) {
74
85
  return { ok: false, error: "tool_denied", message: relations.message };
75
86
  }
76
- const formRevision = input.capability === "form.propose" ? await hashFormRevision(current) : undefined;
87
+ const formRevision = input.capability === "draft.create" ? undefined : await hashFormRevision(current);
77
88
  const canonicalHash = await hashFormRevision(typedOperations);
78
89
  const nonce = crypto.randomUUID();
79
90
  const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
@@ -90,6 +101,10 @@ export async function proposeChange(req, options, input) {
90
101
  ...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels),
91
102
  ...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language)),
92
103
  ];
104
+ const lines = diff.map((line) => ({
105
+ ...line,
106
+ ...editableLine(line.path, typedOperations, fields, current),
107
+ }));
93
108
  let created;
94
109
  try {
95
110
  created = await localApi(req).create({
@@ -129,6 +144,7 @@ export async function proposeChange(req, options, input) {
129
144
  nonceId: nonce.slice(0, 8),
130
145
  canonicalHash,
131
146
  baseRevision: formRevision,
147
+ requestId: input.requestId,
132
148
  });
133
149
  return {
134
150
  ok: true,
@@ -137,6 +153,8 @@ export async function proposeChange(req, options, input) {
137
153
  expiresAt,
138
154
  title: approvalTitle(input.capability, input.language),
139
155
  summary: diff.map((line) => line.text).join("\n"),
156
+ lines,
157
+ characters: Object.fromEntries(lines.flatMap((line) => line.edit ? [[line.path, [...line.edit.value.trim()].length]] : [])),
140
158
  preview: input.capability === "form.propose"
141
159
  ? { data: finalized.data, changedPaths: changedPaths(typedOperations) }
142
160
  : undefined,
@@ -144,12 +162,20 @@ export async function proposeChange(req, options, input) {
144
162
  }
145
163
  function approvalTitle(capability, language) {
146
164
  if (language === "sv") {
147
- return capability === "form.propose" ? "Förslag i formuläret" : "Nytt utkast att godkänna";
165
+ return capability === "draft.create" ? "Nytt utkast att godkänna" : "Förslag i formuläret";
148
166
  }
149
- return capability === "form.propose" ? "Suggested change" : "Pending draft create";
167
+ return capability === "draft.create" ? "Pending draft create" : "Suggested change";
150
168
  }
169
+ /**
170
+ * The data the operations apply to: the open form over the saved document for `form.propose`, the
171
+ * saved document alone for `content.update` (null when the editor cannot read it), nothing for a
172
+ * new draft.
173
+ */
151
174
  async function resolveCurrentForm(req, options, input) {
152
175
  const snapshot = input.formData && typeof input.formData === "object" ? input.formData : {};
176
+ if (input.capability === "content.update") {
177
+ return readSavedForm(req, options, input);
178
+ }
153
179
  if (input.capability !== "form.propose") {
154
180
  return {};
155
181
  }
@@ -164,6 +190,63 @@ async function resolveCurrentForm(req, options, input) {
164
190
  }
165
191
  return { ...saved.data, ...snapshot };
166
192
  }
193
+ /** A saved document as the batch sees it: allowlisted fields in the target's language. */
194
+ export async function readSavedForm(req, options, target) {
195
+ if (!target.collection || target.documentId === undefined) {
196
+ return null;
197
+ }
198
+ const read = await readDocument(req, options, {
199
+ collection: target.collection,
200
+ documentId: target.documentId,
201
+ locale: target.locale,
202
+ });
203
+ if (!read.ok) {
204
+ return null;
205
+ }
206
+ return projectRecord(read.doc, read.fields, options.redact, read.slug).data;
207
+ }
208
+ /**
209
+ * A batch proposal sets or clears values within its target's fields: no rows or blocks added,
210
+ * moved or removed, and nothing outside `allowedPaths`.
211
+ */
212
+ function batchScopeError(operations, input) {
213
+ if (!input.collection || input.global || input.documentId === undefined) {
214
+ return "A batch change needs a document in a collection.";
215
+ }
216
+ const allowed = input.allowedPaths ?? [];
217
+ for (const operation of operations) {
218
+ if (operation.op !== "set" && operation.op !== "unset") {
219
+ return "A batch change only sets field values; rows and blocks stay as they are.";
220
+ }
221
+ const inside = allowed.some((path) => operation.path === path || operation.path.startsWith(`${path}.`));
222
+ if (!inside) {
223
+ return `${operation.path} is outside this task. Change only ${allowed.join(", ")}.`;
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+ /** A plain text or textarea value this proposal sets, which the editor may rewrite in a batch. */
229
+ function editableLine(path, operations, fields, current) {
230
+ const operation = operations.find((entry) => entry.op === "set" && entry.path === path);
231
+ if (operation?.op !== "set" || typeof operation.value !== "string") {
232
+ return {};
233
+ }
234
+ const field = fieldAtPath(fields, path, current);
235
+ if (field?.type !== "text" && field?.type !== "textarea") {
236
+ return {};
237
+ }
238
+ const before = getAtPath(current, parseFieldPath(path));
239
+ return {
240
+ edit: {
241
+ label: capitalize(field.label ?? field.name),
242
+ before: typeof before === "string" ? before : "",
243
+ value: operation.value,
244
+ },
245
+ };
246
+ }
247
+ function capitalize(value) {
248
+ return value.charAt(0).toUpperCase() + value.slice(1);
249
+ }
167
250
  function draftCreateDenied(req, collection) {
168
251
  if (!collection) {
169
252
  return "Draft create requires a collection.";
@@ -0,0 +1,8 @@
1
+ import type { Endpoint } from "payload";
2
+ import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
+ /** Titles and access for the documents of a batch, before it runs. Read only. */
4
+ export declare function createBatchInfoEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
5
+ /** One document of a batch: a model turn that may leave a proposal to review. */
6
+ export declare function createBatchProposeEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
7
+ /** Saves or rejects one batch proposal. No model call, so it does not count against the rate. */
8
+ export declare function createBatchApplyEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;