@simmalugnt-se/payload-editor-assistant 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ - Rich text keeps its formatting. The model reads Lexical as Markdown blocks, one per paragraph,
6
+ heading or list, and changes single blocks (`set` on `content.2`, `array.insert`,
7
+ `array.remove`); every other block stays exactly as stored, alignment and styles included.
8
+ Headings, lists, bold, italic and links come back through Payload's own converters with the
9
+ field's editor config. Internal links read and write as `[text](doc:pages/12)` and are checked
10
+ against the field's `LinkFeature` collections and the editor's access. Parts Markdown cannot
11
+ express (embedded uploads, relationships, custom blocks) are kept, and may be moved or removed but
12
+ not changed. Before, any change to rich text turned it into plain paragraphs.
13
+ - New optional peer dependency `@payloadcms/richtext-lexical`; without it, rich text works as before.
14
+ - The change list shows which parts of a rich text field changed.
15
+ - A list that is not rich text blocks is refused for a rich text field instead of reaching the form.
16
+ - `validateProposal` receives the data as it will be stored, rich text as Lexical, instead of the
17
+ compiled operations' raw values.
18
+
19
+ ## 0.7.0
20
+
21
+ - Picks images for upload fields, such as a share image. `content.find` on an upload collection
22
+ searches the filename and the collection's text fields (alt, caption, credit) and returns them
23
+ with the file's type and size; `media.view` takes `ids` to look at up to 6 candidates at a time
24
+ and 12 per turn, at a smaller size. The assistant proposes only an image it has looked at, and
25
+ says so when none fits. Fields with several images (`hasMany`) are not picked yet.
26
+ - The suggested change names a chosen image or related document (its title field, filename or
27
+ title) instead of showing its id.
28
+ - The model sees the fields inside groups, and which collection an upload or relationship points
29
+ at, so it finds fields such as `meta.image`.
30
+
3
31
  ## 0.6.0
4
32
 
5
33
  - `media.view`: the assistant can look at the image of the open upload document, so it writes alt
package/README.md CHANGED
@@ -80,7 +80,7 @@ Nothing is reachable unless you list it. Each collection or global gets the capa
80
80
  | `form.read` | read the open edit form, including unsaved changes | no |
81
81
  | `form.propose` | put a change in the open form, where the editor keeps or undoes it | yes |
82
82
  | `draft.create` | create a new draft document (collections only) | yes |
83
- | `media.view` | look at the image of the open upload document (upload collections only) | no |
83
+ | `media.view` | look at the open image, or at images by id when picking one (upload collections only) | no |
84
84
 
85
85
  The assistant never publishes. Form changes land in the open form and the editor saves as usual; an
86
86
  approved `draft.create` saves a new, unpublished draft. The `users` collection, Payload's own collections and fields that look like
@@ -103,6 +103,30 @@ address. It uses the smallest of the collection's `imageSizes` at least 768px wi
103
103
  original; JPEG, PNG, WebP and GIF up to 5 MB. Choose a model that reads images. Without
104
104
  `media.view` the assistant says it cannot see the image instead of guessing from the filename.
105
105
 
106
+ With `content.find` as well, the assistant picks images for upload fields, such as a page's share
107
+ image: it searches the collection by filename and text fields (alt, caption), looks at up to 6
108
+ candidates at a time (12 per turn) and proposes one it has seen, or says none fits:
109
+
110
+ ```ts
111
+ collections: {
112
+ media: { capabilities: ["content.find", "form.read", "form.propose", "media.view"] },
113
+ },
114
+ ```
115
+
116
+ The suggested change names the image ("Image: empty → office-2024.jpg"), and the form and Live
117
+ Preview show it. Images with alt texts are easier to find. Fields with several images (`hasMany`)
118
+ are not picked yet.
119
+
120
+ ## Rich text
121
+
122
+ With `@payloadcms/richtext-lexical` installed, the assistant reads Lexical rich text as Markdown,
123
+ one block per paragraph, heading or list, and changes only the blocks it is asked to: headings,
124
+ lists, bold, italic and links survive, and every other block stays exactly as stored. It writes only
125
+ what the field's editor allows. Internal links keep pointing at their documents, and new ones must
126
+ point at a collection the field's `LinkFeature` allows and that the editor can read. Parts Markdown
127
+ cannot express, such as an embedded image, are kept as they are. Without the package, rich text is
128
+ written as plain paragraphs as before.
129
+
106
130
  ## Keep or undo
107
131
 
108
132
  A proposed form change goes straight into the open form, so the fields and Live Preview show it
@@ -3,6 +3,8 @@ import { projectIdentity, projectRecord } from "../schema/project.js";
3
3
  import { hasCapability, resolveAllowlist } from "./allowlist.js";
4
4
  import { localApi } from "./local-api.js";
5
5
  const FIND_LIMIT = 8;
6
+ /** An upload's short text fields (alt, caption, credit) are returned up to this length. */
7
+ const UPLOAD_TEXT_LIMIT = 200;
6
8
  export async function findContent(req, options, input) {
7
9
  const query = input.query.trim();
8
10
  const slugs = input.collection
@@ -27,10 +29,18 @@ export async function findContent(req, options, input) {
27
29
  if (!config) {
28
30
  continue;
29
31
  }
32
+ // Uploads are found by their file and short texts, and described by them: an image has no title.
33
+ const uploadTexts = config.upload ? uploadTextFields(req, options, slug) : undefined;
30
34
  try {
31
35
  const found = await localApi(req).find({
32
36
  collection: slug,
33
- ...(query ? { where: searchWhere(query, config.admin?.useAsTitle) } : {}),
37
+ ...(query
38
+ ? {
39
+ where: uploadTexts
40
+ ? uploadWhere(query, uploadTexts)
41
+ : searchWhere(query, config.admin?.useAsTitle),
42
+ }
43
+ : {}),
34
44
  limit: FIND_LIMIT,
35
45
  depth: 0,
36
46
  locale: input.locale,
@@ -44,6 +54,7 @@ export async function findContent(req, options, input) {
44
54
  results.push({
45
55
  collection: slug,
46
56
  ...projectIdentity(doc, input.locale),
57
+ ...(uploadTexts ? uploadIdentity(doc, uploadTexts) : {}),
47
58
  });
48
59
  }
49
60
  }
@@ -106,6 +117,37 @@ export async function findContentById(req, options, input) {
106
117
  return { ok: false, error: "tool_denied", message: "Document is not accessible." };
107
118
  }
108
119
  }
120
+ /** Top-level text fields of an upload collection that the allowlist shows, e.g. alt and credit. */
121
+ function uploadTextFields(req, options, collection) {
122
+ return entityFields(req, options, { collection })
123
+ .filter((field) => field.type === "text" || field.type === "textarea")
124
+ .map((field) => field.name);
125
+ }
126
+ function uploadWhere(query, texts) {
127
+ return {
128
+ or: [
129
+ { id: { equals: query } },
130
+ { filename: { contains: query } },
131
+ ...texts.map((name) => ({ [name]: { contains: query } })),
132
+ ],
133
+ };
134
+ }
135
+ function uploadIdentity(doc, texts) {
136
+ const identity = {
137
+ filename: doc.filename,
138
+ mimeType: doc.mimeType,
139
+ width: doc.width,
140
+ height: doc.height,
141
+ };
142
+ for (const name of texts) {
143
+ const value = doc[name];
144
+ identity[name] =
145
+ typeof value === "string" && value.length > UPLOAD_TEXT_LIMIT
146
+ ? `${value.slice(0, UPLOAD_TEXT_LIMIT - 1)}…`
147
+ : value;
148
+ }
149
+ return identity;
150
+ }
109
151
  function searchWhere(query, useAsTitle) {
110
152
  const clauses = [{ id: { equals: query } }];
111
153
  const titleField = useAsTitle && useAsTitle !== "id" ? useAsTitle : "title";
@@ -1,7 +1,8 @@
1
1
  import { compileOperations } from "../form/compile.js";
2
2
  import { hashFormRevision } from "../form/hash.js";
3
3
  import { isFormOperation } from "../form/operations.js";
4
- import { finalizeCompiled } from "../form/shape.js";
4
+ import { loadRichText } from "../form/rich-text.js";
5
+ import { finalizeCompiled, openRichText } from "../form/shape.js";
5
6
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
6
7
  import { entityFields } from "../schema/discover.js";
7
8
  import { hasCapability, resolveAllowlist } from "./allowlist.js";
@@ -9,6 +10,7 @@ import { writeAudit } from "./audit.js";
9
10
  import { localApi } from "./local-api.js";
10
11
  import { verifyDocumentRelations } from "./relations.js";
11
12
  export async function decideChange(req, options, input) {
13
+ await loadRichText();
12
14
  const actor = String(req.user?.id);
13
15
  const found = await localApi(req).findByID({
14
16
  collection: DEFAULT_APPROVAL_SLUG,
@@ -67,7 +69,11 @@ export async function decideChange(req, options, input) {
67
69
  return { ok: false, error: "stale_context", message: "The form changed before approval." };
68
70
  }
69
71
  }
70
- const compiled = compileOperations(current, operations, {
72
+ const fields = entityFields(req, options, {
73
+ collection: record.targetCollection ?? undefined,
74
+ global: record.targetGlobal ?? undefined,
75
+ });
76
+ const compiled = compileOperations(openRichText(current, fields), operations, {
71
77
  allowlist: resolved.allowlist.fields,
72
78
  denyFields: options.denyFields,
73
79
  });
@@ -80,16 +86,12 @@ export async function decideChange(req, options, input) {
80
86
  await consume(req, record.id, "failed");
81
87
  return { ok: false, error: "tool_denied", message: "Proposal hash mismatch." };
82
88
  }
83
- const fields = entityFields(req, options, {
84
- collection: record.targetCollection ?? undefined,
85
- global: record.targetGlobal ?? undefined,
86
- });
87
- const finalized = finalizeCompiled(compiled.data, fields);
89
+ const finalized = finalizeCompiled(compiled.data, fields, current);
88
90
  if (!finalized.ok) {
89
91
  await consume(req, record.id, "failed");
90
92
  return { ok: false, error: "tool_denied", message: finalized.error };
91
93
  }
92
- const relations = await verifyDocumentRelations(req, fields, finalized.data);
94
+ const relations = await verifyDocumentRelations(req, fields, finalized.data, finalized.links);
93
95
  if (!relations.ok) {
94
96
  await consume(req, record.id, "failed");
95
97
  return { ok: false, error: "tool_denied", message: relations.message };
@@ -1,6 +1,8 @@
1
1
  import type { PayloadRequest } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
3
  export declare const MAX_IMAGE_BYTES: number;
4
+ export declare const MAX_IMAGES_PER_CALL = 6;
5
+ export declare const MAX_IMAGES_PER_TURN = 12;
4
6
  export type ImageSource = {
5
7
  filename: string;
6
8
  mimeType: string;
@@ -25,10 +27,10 @@ export type MediaViewResult = {
25
27
  message: string;
26
28
  };
27
29
  /**
28
- * The image file to show the model: the smallest of Payload's `imageSizes` at least
29
- * `PREFERRED_WIDTH` wide, otherwise the original.
30
+ * The image file to show the model: the smallest of Payload's `imageSizes` at least `minWidth`
31
+ * wide, otherwise the original.
30
32
  */
31
- export declare function pickImageSource(doc: Record<string, unknown>): ImageSource | null;
33
+ export declare function pickImageSource(doc: Record<string, unknown>, minWidth?: number): ImageSource | null;
32
34
  /**
33
35
  * Reads the image of a saved upload document with the editor's access, the way Payload serves it:
34
36
  * the storage adapter's handlers first, then the file in `staticDir`, then a public URL fetched
@@ -37,4 +39,42 @@ export declare function pickImageSource(doc: Record<string, unknown>): ImageSour
37
39
  export declare function viewMedia(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: {
38
40
  collection?: string;
39
41
  documentId?: string | number;
42
+ minWidth?: number;
40
43
  }): Promise<MediaViewResult>;
44
+ export type ViewedImage = {
45
+ id: string | number;
46
+ mediaType: string;
47
+ data: string;
48
+ };
49
+ export type MediaListResult = {
50
+ ok: true;
51
+ data: {
52
+ images: Array<{
53
+ id: string | number;
54
+ filename: string;
55
+ width?: number;
56
+ height?: number;
57
+ alt?: unknown;
58
+ } | {
59
+ id: string | number;
60
+ error: string;
61
+ }>;
62
+ };
63
+ images: ViewedImage[];
64
+ } | {
65
+ ok: false;
66
+ error: string;
67
+ message: string;
68
+ };
69
+ /**
70
+ * Several images by id, to compare candidates when picking one: at most `MAX_IMAGES_PER_CALL`, and
71
+ * never more than `budget.remaining` in the turn. Each is read as `viewMedia` reads the open one, at
72
+ * a smaller size; one that cannot be shown is listed with the reason instead.
73
+ */
74
+ export declare function viewMediaList(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: {
75
+ collection?: string;
76
+ ids: Array<string | number>;
77
+ budget: {
78
+ remaining: number;
79
+ };
80
+ }): Promise<MediaListResult>;
@@ -7,17 +7,21 @@ import { localApi } from "./local-api.js";
7
7
  const READABLE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
8
8
  /** Enough detail for an alt text without sending a full-size original. */
9
9
  const PREFERRED_WIDTH = 768;
10
+ /** Enough to tell candidates apart when picking one, at a fraction of the tokens. */
11
+ const BROWSE_WIDTH = 384;
10
12
  export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
13
+ export const MAX_IMAGES_PER_CALL = 6;
14
+ export const MAX_IMAGES_PER_TURN = 12;
11
15
  const FETCH_TIMEOUT_MS = 10_000;
12
16
  /**
13
- * The image file to show the model: the smallest of Payload's `imageSizes` at least
14
- * `PREFERRED_WIDTH` wide, otherwise the original.
17
+ * The image file to show the model: the smallest of Payload's `imageSizes` at least `minWidth`
18
+ * wide, otherwise the original.
15
19
  */
16
- export function pickImageSource(doc) {
20
+ export function pickImageSource(doc, minWidth = PREFERRED_WIDTH) {
17
21
  const original = asSource(doc);
18
22
  const sizes = Object.values(asRecord(doc.sizes) ?? {})
19
23
  .map((size) => asSource(asRecord(size) ?? {}))
20
- .filter((size) => size !== null && (size.width ?? 0) >= PREFERRED_WIDTH)
24
+ .filter((size) => size !== null && (size.width ?? 0) >= minWidth)
21
25
  .sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
22
26
  return sizes[0] ?? original;
23
27
  }
@@ -61,7 +65,7 @@ export async function viewMedia(req, options, input) {
61
65
  catch {
62
66
  return { ok: false, error: "tool_denied", message: "Document is not accessible." };
63
67
  }
64
- const source = pickImageSource(doc);
68
+ const source = pickImageSource(doc, input.minWidth);
65
69
  if (!source || !READABLE_TYPES.has(source.mimeType)) {
66
70
  return {
67
71
  ok: false,
@@ -84,6 +88,79 @@ export async function viewMedia(req, options, input) {
84
88
  image: { mediaType: source.mimeType, data: bytes.data.toString("base64") },
85
89
  };
86
90
  }
91
+ /**
92
+ * Several images by id, to compare candidates when picking one: at most `MAX_IMAGES_PER_CALL`, and
93
+ * never more than `budget.remaining` in the turn. Each is read as `viewMedia` reads the open one, at
94
+ * a smaller size; one that cannot be shown is listed with the reason instead.
95
+ */
96
+ export async function viewMediaList(req, options, input) {
97
+ const collection = input.collection ?? onlyViewableCollection(req, options);
98
+ if (!collection) {
99
+ return {
100
+ ok: false,
101
+ error: "tool_denied",
102
+ message: "Say which collection the images are in.",
103
+ };
104
+ }
105
+ const resolved = resolveAllowlist(options, { collection });
106
+ if (!resolved ||
107
+ !hasCapability(resolved.allowlist, "media.view") ||
108
+ !findCollectionConfig(req, collection)?.upload) {
109
+ return {
110
+ ok: false,
111
+ error: "tool_denied",
112
+ message: "Images in this collection are not allowlisted.",
113
+ };
114
+ }
115
+ if (input.ids.length > MAX_IMAGES_PER_CALL) {
116
+ return {
117
+ ok: false,
118
+ error: "tool_denied",
119
+ message: `Look at no more than ${MAX_IMAGES_PER_CALL} images at a time.`,
120
+ };
121
+ }
122
+ const listed = [];
123
+ const images = [];
124
+ for (const id of input.ids) {
125
+ if (input.budget.remaining <= 0) {
126
+ listed.push({
127
+ id,
128
+ error: `You have looked at ${MAX_IMAGES_PER_TURN} images this turn, the most allowed.`,
129
+ });
130
+ continue;
131
+ }
132
+ const viewed = await viewMedia(req, options, {
133
+ collection,
134
+ documentId: id,
135
+ minWidth: BROWSE_WIDTH,
136
+ });
137
+ if (!viewed.ok) {
138
+ listed.push({ id, error: viewed.message });
139
+ continue;
140
+ }
141
+ input.budget.remaining -= 1;
142
+ listed.push({ id, ...viewed.data });
143
+ images.push({ id, ...viewed.image });
144
+ }
145
+ if (images.length === 0) {
146
+ const reasons = listed.map((entry) => ("error" in entry ? entry.error : "")).filter(Boolean);
147
+ return {
148
+ ok: false,
149
+ error: "unsupported_media",
150
+ message: [...new Set(reasons)].join(" ") || "No image could be shown.",
151
+ };
152
+ }
153
+ return { ok: true, data: { images: listed }, images };
154
+ }
155
+ /** The one allowlisted upload collection with `media.view`, when there is exactly one. */
156
+ function onlyViewableCollection(req, options) {
157
+ const viewable = Object.entries(options.collections)
158
+ .filter(([slug, allowlist]) => {
159
+ return hasCapability(allowlist, "media.view") && findCollectionConfig(req, slug)?.upload;
160
+ })
161
+ .map(([slug]) => slug);
162
+ return viewable.length === 1 ? viewable[0] : undefined;
163
+ }
87
164
  async function readSource(req, collection, uploadConfig, source, prefix) {
88
165
  const upload = (asRecord(uploadConfig) ?? {});
89
166
  const tooLarge = {
@@ -1,19 +1,23 @@
1
1
  import { changedPaths } from "../form/changed-paths.js";
2
2
  import { compileOperations } from "../form/compile.js";
3
- import { humanDiff } from "../form/diff.js";
3
+ import { humanDiff, richTextLines } from "../form/diff.js";
4
4
  import { hashFormRevision } from "../form/hash.js";
5
5
  import { isFormOperation, normalizeOperations } from "../form/operations.js";
6
- import { finalizeCompiled } from "../form/shape.js";
6
+ import { getAtPath, parseFieldPath } from "../form/path.js";
7
+ import { loadRichText, toBlocks } from "../form/rich-text.js";
8
+ import { finalizeCompiled, openRichText } from "../form/shape.js";
7
9
  import { DEFAULT_APPROVAL_SLUG } from "../package-name.js";
8
10
  import { entityFields, findCollectionConfig, hasDrafts } from "../schema/discover.js";
11
+ import { fieldAtPath } from "../schema/fields.js";
9
12
  import { hasCapability, resolveAllowlist } from "./allowlist.js";
10
13
  import { writeAudit } from "./audit.js";
11
14
  import { findContentById } from "./content.js";
12
15
  import { localApi } from "./local-api.js";
13
- import { verifyDocumentRelations } from "./relations.js";
16
+ import { labelRelations, verifyDocumentRelations } from "./relations.js";
14
17
  /** Long enough to look at a change in the preview before keeping it. */
15
18
  const TTL_MS = 30 * 60_000;
16
19
  export async function proposeChange(req, options, input) {
20
+ await loadRichText();
17
21
  const resolved = resolveAllowlist(options, input);
18
22
  if (!resolved || !hasCapability(resolved.allowlist, input.capability)) {
19
23
  return { ok: false, error: "tool_denied", message: "Capability is not allowlisted." };
@@ -24,37 +28,43 @@ export async function proposeChange(req, options, input) {
24
28
  }
25
29
  const typedOperations = operations;
26
30
  const current = await resolveCurrentForm(req, options, input);
27
- const compiled = compileOperations(current, typedOperations, {
31
+ const fields = entityFields(req, options, {
32
+ collection: input.collection,
33
+ global: input.global,
34
+ });
35
+ // Rich text opens into blocks, so operations can reach one block and leave the rest as stored.
36
+ const compiled = compileOperations(openRichText(current, fields), typedOperations, {
28
37
  allowlist: resolved.allowlist.fields,
29
38
  denyFields: options.denyFields,
30
39
  });
31
40
  if (!compiled.ok) {
32
41
  return { ok: false, error: "tool_denied", message: compiled.error };
33
42
  }
34
- if (options.validateProposal) {
35
- const extra = await options.validateProposal({
36
- operations: typedOperations,
37
- data: compiled.data,
38
- });
39
- if (!extra.ok) {
40
- return { ok: false, error: "tool_denied", message: extra.errors.join(" ") };
41
- }
42
- }
43
43
  if (input.capability === "draft.create") {
44
44
  const blocked = draftCreateDenied(req, input.collection);
45
45
  if (blocked) {
46
46
  return { ok: false, error: "tool_denied", message: blocked };
47
47
  }
48
48
  }
49
- const fields = entityFields(req, options, {
50
- collection: input.collection,
51
- global: input.global,
52
- });
53
- const finalized = finalizeCompiled(compiled.data, fields);
49
+ const richTextBefore = richTextPaths(typedOperations, fields, current).map((path) => ({
50
+ path,
51
+ blocks: storedBlocks(fields, path, current),
52
+ after: getAtPath(compiled.data, parseFieldPath(path)),
53
+ }));
54
+ const finalized = finalizeCompiled(compiled.data, fields, current);
54
55
  if (!finalized.ok) {
55
56
  return { ok: false, error: "tool_denied", message: finalized.error };
56
57
  }
57
- const relations = await verifyDocumentRelations(req, fields, finalized.data);
58
+ if (options.validateProposal) {
59
+ const extra = await options.validateProposal({
60
+ operations: typedOperations,
61
+ data: finalized.data,
62
+ });
63
+ if (!extra.ok) {
64
+ return { ok: false, error: "tool_denied", message: extra.errors.join(" ") };
65
+ }
66
+ }
67
+ const relations = await verifyDocumentRelations(req, fields, finalized.data, finalized.links);
58
68
  if (!relations.ok) {
59
69
  return { ok: false, error: "tool_denied", message: relations.message };
60
70
  }
@@ -62,7 +72,21 @@ export async function proposeChange(req, options, input) {
62
72
  const canonicalHash = await hashFormRevision(typedOperations);
63
73
  const nonce = crypto.randomUUID();
64
74
  const expiresAt = new Date(Date.now() + TTL_MS).toISOString();
65
- const diff = humanDiff(current, typedOperations, input.language);
75
+ // Ids set by this change, before and after, get names in the change list.
76
+ const setIds = new Set(typedOperations.flatMap((operation) => operation.op === "set"
77
+ ? [operation.value, getAtPath(current, parseFieldPath(operation.path))]
78
+ .filter((value) => typeof value === "string" || typeof value === "number")
79
+ .map(String)
80
+ : []));
81
+ const labels = setIds.size
82
+ ? await labelRelations(req, fields, [finalized.data, current], setIds)
83
+ : undefined;
84
+ const inRichText = (operation) => "path" in operation &&
85
+ richTextBefore.some(({ path }) => operation.path === path || operation.path.startsWith(`${path}.`));
86
+ const diff = [
87
+ ...humanDiff(current, typedOperations.filter((operation) => !inRichText(operation)), input.language, labels),
88
+ ...richTextBefore.flatMap(({ path, blocks, after }) => richTextLines(path, blocks, Array.isArray(after) ? after : [after], input.language)),
89
+ ];
66
90
  let created;
67
91
  try {
68
92
  created = await localApi(req).create({
@@ -153,3 +177,26 @@ function draftCreateDenied(req, collection) {
153
177
  }
154
178
  return null;
155
179
  }
180
+ /** Rich text fields the operations reach, whole or by block, as form paths. */
181
+ function richTextPaths(operations, fields, current) {
182
+ const paths = new Set();
183
+ for (const operation of operations) {
184
+ if (!("path" in operation))
185
+ continue;
186
+ const segments = operation.path.split(".");
187
+ for (let end = 1; end <= segments.length; end++) {
188
+ const prefix = segments.slice(0, end).join(".");
189
+ if (fieldAtPath(fields, prefix, current)?.type === "richText") {
190
+ paths.add(prefix);
191
+ break;
192
+ }
193
+ }
194
+ }
195
+ return [...paths];
196
+ }
197
+ /** The stored rich text at `path` as the model read it: Markdown blocks and `{ keep, type }`. */
198
+ function storedBlocks(fields, path, current) {
199
+ const field = fieldAtPath(fields, path, current);
200
+ const blocks = toBlocks(getAtPath(current, parseFieldPath(path)), field?.editor);
201
+ return Array.isArray(blocks) ? blocks : [];
202
+ }
@@ -4,9 +4,19 @@ export type RelationRef = {
4
4
  collection: string;
5
5
  id: string;
6
6
  };
7
- export declare function verifyDocumentRelations(req: PayloadRequest, fields: FieldNode[], data: Record<string, unknown>): Promise<{
7
+ /**
8
+ * Every relation and upload in `data`, and `extra` refs such as internal links in rewritten rich
9
+ * text, must exist and be readable by the editor.
10
+ */
11
+ export declare function verifyDocumentRelations(req: PayloadRequest, fields: FieldNode[], data: Record<string, unknown>, extra?: RelationRef[]): Promise<{
8
12
  ok: true;
9
13
  } | {
10
14
  ok: false;
11
15
  message: string;
12
16
  }>;
17
+ /**
18
+ * Names for the documents that relation and upload fields in `data` point at, by id, for the change
19
+ * list: the collection's title field, then a filename or title. Read with the editor's access;
20
+ * anything unreadable keeps its id. `only` limits the lookups to those ids.
21
+ */
22
+ export declare function labelRelations(req: PayloadRequest, fields: FieldNode[], data: Array<Record<string, unknown>>, only?: Set<string>): Promise<Map<string, string>>;
@@ -1,6 +1,11 @@
1
+ import { findCollectionConfig } from "../schema/discover.js";
1
2
  import { localApi } from "./local-api.js";
2
- export async function verifyDocumentRelations(req, fields, data) {
3
- const refs = collectRefs(fields, data);
3
+ /**
4
+ * Every relation and upload in `data`, and `extra` refs such as internal links in rewritten rich
5
+ * text, must exist and be readable by the editor.
6
+ */
7
+ export async function verifyDocumentRelations(req, fields, data, extra = []) {
8
+ const refs = [...collectRefs(fields, data), ...extra];
4
9
  for (const ref of refs) {
5
10
  try {
6
11
  await localApi(req).findByID({
@@ -21,6 +26,38 @@ export async function verifyDocumentRelations(req, fields, data) {
21
26
  }
22
27
  return { ok: true };
23
28
  }
29
+ /**
30
+ * Names for the documents that relation and upload fields in `data` point at, by id, for the change
31
+ * list: the collection's title field, then a filename or title. Read with the editor's access;
32
+ * anything unreadable keeps its id. `only` limits the lookups to those ids.
33
+ */
34
+ export async function labelRelations(req, fields, data, only) {
35
+ const labels = new Map();
36
+ for (const ref of data.flatMap((entry) => collectRefs(fields, entry))) {
37
+ if (labels.has(ref.id) || (only && !only.has(ref.id))) {
38
+ continue;
39
+ }
40
+ try {
41
+ const doc = await localApi(req).findByID({
42
+ collection: ref.collection,
43
+ id: ref.id,
44
+ depth: 0,
45
+ req,
46
+ user: req.user,
47
+ overrideAccess: false,
48
+ });
49
+ const useAsTitle = findCollectionConfig(req, ref.collection)?.admin?.useAsTitle;
50
+ const label = [useAsTitle ? doc[useAsTitle] : undefined, doc.filename, doc.title].find((value) => typeof value === "string" && value.trim() !== "");
51
+ if (label) {
52
+ labels.set(ref.id, label);
53
+ }
54
+ }
55
+ catch {
56
+ // Unreadable: the change list shows the id, and verification refuses the proposal.
57
+ }
58
+ }
59
+ return labels;
60
+ }
24
61
  function collectRefs(fields, data) {
25
62
  if (!data || typeof data !== "object") {
26
63
  return [];
@@ -4,6 +4,15 @@ export type HumanDiffLine = {
4
4
  text: string;
5
5
  };
6
6
  export type DiffLanguage = "sv" | "en";
7
- export declare function humanDiff(current: Record<string, unknown>, operations: FormOperation[], language?: DiffLanguage): HumanDiffLine[];
7
+ /**
8
+ * `labels` names related documents by id (see `labelRelations`), so a chosen image reads as its
9
+ * filename or alt text instead of an id. Rich text is listed by `richTextLines` instead.
10
+ */
11
+ export declare function humanDiff(current: Record<string, unknown>, operations: FormOperation[], language?: DiffLanguage, labels?: Map<string, string>): HumanDiffLine[];
12
+ /**
13
+ * The parts of a rich text field that changed: `before` as the model read it (Markdown blocks),
14
+ * `after` as compiled, where `{ keep: i }` is block `i` unchanged.
15
+ */
16
+ export declare function richTextLines(path: string, before: unknown[], after: unknown[], language?: DiffLanguage): HumanDiffLine[];
8
17
  /** Sub-lines are indented two spaces; the approval panel nests them under the header. */
9
18
  export declare const DIFF_SUBLINE_INDENT = " ";