@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 +28 -0
- package/README.md +25 -1
- package/dist/domain/content.js +43 -1
- package/dist/domain/decide.js +10 -8
- package/dist/domain/media.d.ts +43 -3
- package/dist/domain/media.js +82 -5
- package/dist/domain/propose.js +67 -20
- package/dist/domain/relations.d.ts +11 -1
- package/dist/domain/relations.js +39 -2
- package/dist/form/diff.d.ts +10 -1
- package/dist/form/diff.js +44 -2
- package/dist/form/rich-text.d.ts +72 -0
- package/dist/form/rich-text.js +254 -0
- package/dist/form/shape.d.ts +12 -3
- package/dist/form/shape.js +92 -13
- package/dist/runtime/agent.js +4 -1
- package/dist/runtime/context.d.ts +14 -9
- package/dist/runtime/context.js +12 -9
- package/dist/runtime/system-prompt.js +13 -0
- package/dist/runtime/tools.d.ts +15 -9
- package/dist/runtime/tools.js +50 -14
- package/dist/schema/fields.d.ts +7 -0
- package/dist/schema/fields.js +26 -0
- package/dist/schema/project.js +4 -0
- package/package.json +8 -1
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
|
|
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
|
package/dist/domain/content.js
CHANGED
|
@@ -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
|
|
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";
|
package/dist/domain/decide.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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 };
|
package/dist/domain/media.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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>;
|
package/dist/domain/media.js
CHANGED
|
@@ -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
|
-
*
|
|
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) >=
|
|
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 = {
|
package/dist/domain/propose.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>>;
|
package/dist/domain/relations.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { findCollectionConfig } from "../schema/discover.js";
|
|
1
2
|
import { localApi } from "./local-api.js";
|
|
2
|
-
|
|
3
|
-
|
|
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 [];
|
package/dist/form/diff.d.ts
CHANGED
|
@@ -4,6 +4,15 @@ export type HumanDiffLine = {
|
|
|
4
4
|
text: string;
|
|
5
5
|
};
|
|
6
6
|
export type DiffLanguage = "sv" | "en";
|
|
7
|
-
|
|
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 = " ";
|