@simmalugnt-se/payload-editor-assistant 0.1.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/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/capabilities.d.ts +10 -0
- package/dist/capabilities.js +43 -0
- package/dist/collections/approvals.d.ts +2 -0
- package/dist/collections/approvals.js +36 -0
- package/dist/collections/audit.d.ts +2 -0
- package/dist/collections/audit.js +40 -0
- package/dist/components/EditorAssistantAction.d.ts +1 -0
- package/dist/components/EditorAssistantAction.js +17 -0
- package/dist/components/EditorAssistantAside.d.ts +1 -0
- package/dist/components/EditorAssistantAside.js +335 -0
- package/dist/components/EditorAssistantDocumentBridge.d.ts +1 -0
- package/dist/components/EditorAssistantDocumentBridge.js +48 -0
- package/dist/components/EditorAssistantProvider.d.ts +8 -0
- package/dist/components/EditorAssistantProvider.js +14 -0
- package/dist/components/assistant-api.d.ts +9 -0
- package/dist/components/assistant-api.js +34 -0
- package/dist/components/assistant-context.d.ts +66 -0
- package/dist/components/assistant-context.js +69 -0
- package/dist/config/denied-fields.d.ts +3 -0
- package/dist/config/denied-fields.js +34 -0
- package/dist/config/validate.d.ts +15 -0
- package/dist/config/validate.js +86 -0
- package/dist/domain/allowlist.d.ts +12 -0
- package/dist/domain/allowlist.js +14 -0
- package/dist/domain/audit.d.ts +17 -0
- package/dist/domain/audit.js +25 -0
- package/dist/domain/content.d.ts +30 -0
- package/dist/domain/content.js +117 -0
- package/dist/domain/decide.d.ts +30 -0
- package/dist/domain/decide.js +162 -0
- package/dist/domain/guard.d.ts +13 -0
- package/dist/domain/guard.js +29 -0
- package/dist/domain/local-api.d.ts +16 -0
- package/dist/domain/local-api.js +3 -0
- package/dist/domain/navigate.d.ts +25 -0
- package/dist/domain/navigate.js +85 -0
- package/dist/domain/propose.d.ts +26 -0
- package/dist/domain/propose.js +150 -0
- package/dist/domain/relations.d.ts +12 -0
- package/dist/domain/relations.js +103 -0
- package/dist/endpoints/chat.d.ts +3 -0
- package/dist/endpoints/chat.js +88 -0
- package/dist/endpoints/csrf.d.ts +6 -0
- package/dist/endpoints/csrf.js +57 -0
- package/dist/endpoints/decide.d.ts +3 -0
- package/dist/endpoints/decide.js +33 -0
- package/dist/endpoints/propose.d.ts +3 -0
- package/dist/endpoints/propose.js +32 -0
- package/dist/exports/client.d.ts +4 -0
- package/dist/exports/client.js +4 -0
- package/dist/form/compile.d.ts +17 -0
- package/dist/form/compile.js +168 -0
- package/dist/form/diff.d.ts +9 -0
- package/dist/form/diff.js +155 -0
- package/dist/form/empty.d.ts +2 -0
- package/dist/form/empty.js +79 -0
- package/dist/form/hash.d.ts +2 -0
- package/dist/form/hash.js +22 -0
- package/dist/form/operations.d.ts +52 -0
- package/dist/form/operations.js +52 -0
- package/dist/form/path.d.ts +5 -0
- package/dist/form/path.js +79 -0
- package/dist/form/shape.d.ts +19 -0
- package/dist/form/shape.js +137 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/package-name.d.ts +7 -0
- package/dist/package-name.js +7 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +110 -0
- package/dist/runtime/agent.d.ts +42 -0
- package/dist/runtime/agent.js +262 -0
- package/dist/runtime/context.d.ts +37 -0
- package/dist/runtime/context.js +92 -0
- package/dist/runtime/inflight.d.ts +2 -0
- package/dist/runtime/inflight.js +11 -0
- package/dist/runtime/language.d.ts +10 -0
- package/dist/runtime/language.js +27 -0
- package/dist/runtime/propose-loop.d.ts +7 -0
- package/dist/runtime/propose-loop.js +11 -0
- package/dist/runtime/rate-limit.d.ts +1 -0
- package/dist/runtime/rate-limit.js +14 -0
- package/dist/runtime/system-prompt.d.ts +3 -0
- package/dist/runtime/system-prompt.js +110 -0
- package/dist/runtime/tools.d.ts +56 -0
- package/dist/runtime/tools.js +189 -0
- package/dist/runtime/voice.d.ts +11 -0
- package/dist/runtime/voice.js +55 -0
- package/dist/schema/discover.d.ts +35 -0
- package/dist/schema/discover.js +93 -0
- package/dist/schema/fields.d.ts +21 -0
- package/dist/schema/fields.js +139 -0
- package/dist/schema/project.d.ts +15 -0
- package/dist/schema/project.js +111 -0
- package/dist/styles/assistant.css +362 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +3 -0
- package/dist/view.d.ts +22 -0
- package/dist/view.js +72 -0
- package/package.json +73 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ENDPOINT_PREFIX } from "../package-name.js";
|
|
2
|
+
import { runAgentTurn } from "../runtime/agent.js";
|
|
3
|
+
import { beginInflight, endInflight } from "../runtime/inflight.js";
|
|
4
|
+
import { resolveChatLanguage } from "../runtime/language.js";
|
|
5
|
+
import { consumeRateLimit } from "../runtime/rate-limit.js";
|
|
6
|
+
import { sanitizeAdminView } from "../view.js";
|
|
7
|
+
import { verifyCsrf, verifyOrigin } from "./csrf.js";
|
|
8
|
+
export function createChatEndpoint(options) {
|
|
9
|
+
return {
|
|
10
|
+
path: `${ENDPOINT_PREFIX}/chat`,
|
|
11
|
+
method: "post",
|
|
12
|
+
handler: async (req) => {
|
|
13
|
+
if (!req.user) {
|
|
14
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
15
|
+
}
|
|
16
|
+
if (!verifyOrigin(req, options.trustedOrigins) || !verifyCsrf(req)) {
|
|
17
|
+
return Response.json({ error: "unauthorized" }, { status: 403 });
|
|
18
|
+
}
|
|
19
|
+
if (options.enabled && !(await options.enabled({ userId: req.user.id }))) {
|
|
20
|
+
return Response.json({ error: "unavailable" }, { status: 403 });
|
|
21
|
+
}
|
|
22
|
+
const actor = String(req.user.id);
|
|
23
|
+
if (!consumeRateLimit(actor)) {
|
|
24
|
+
return Response.json({ error: "rate_limited" }, { status: 429 });
|
|
25
|
+
}
|
|
26
|
+
if (!beginInflight(actor)) {
|
|
27
|
+
return Response.json({ error: "rate_limited" }, { status: 429 });
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
let body;
|
|
31
|
+
try {
|
|
32
|
+
if (typeof req.json !== "function") {
|
|
33
|
+
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
body = (await req.json());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
39
|
+
}
|
|
40
|
+
if (typeof body.message !== "string" || body.message.trim() === "") {
|
|
41
|
+
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
const locale = nonEmpty(body.bridge?.locale) ?? nonEmpty(body.locale) ?? defaultLocale(req);
|
|
44
|
+
const messages = Array.isArray(body.messages)
|
|
45
|
+
? body.messages.filter(isChatMessage)
|
|
46
|
+
: [{ role: "user", text: body.message }];
|
|
47
|
+
const language = resolveChatLanguage(messages.filter((message) => message.role === "user").map((message) => message.text), nonEmpty(body.uiLanguage));
|
|
48
|
+
const result = await runAgentTurn(req, options, {
|
|
49
|
+
message: body.message,
|
|
50
|
+
messages,
|
|
51
|
+
bridge: {
|
|
52
|
+
collection: body.bridge?.collectionSlug,
|
|
53
|
+
global: body.bridge?.globalSlug,
|
|
54
|
+
documentId: body.bridge?.documentId,
|
|
55
|
+
title: typeof body.bridge?.title === "string" ? body.bridge.title : undefined,
|
|
56
|
+
locale,
|
|
57
|
+
language,
|
|
58
|
+
view: sanitizeAdminView(body.view, options),
|
|
59
|
+
},
|
|
60
|
+
formData: body.formData,
|
|
61
|
+
});
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
const status = result.error === "timeout" ? 504 : 503;
|
|
64
|
+
return Response.json({ error: result.error, message: result.message }, { status });
|
|
65
|
+
}
|
|
66
|
+
return Response.json({ ...result, language });
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
endInflight(actor);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function nonEmpty(value) {
|
|
75
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
76
|
+
}
|
|
77
|
+
function defaultLocale(req) {
|
|
78
|
+
const localization = req.payload.config.localization;
|
|
79
|
+
return (localization && localization.defaultLocale) || "en";
|
|
80
|
+
}
|
|
81
|
+
function isChatMessage(value) {
|
|
82
|
+
if (!value || typeof value !== "object") {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const record = value;
|
|
86
|
+
return ((record.role === "user" || record.role === "assistant" || record.role === "system") &&
|
|
87
|
+
typeof record.text === "string");
|
|
88
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Endpoint, PayloadRequest } from "payload";
|
|
2
|
+
import { CSRF_COOKIE, CSRF_HEADER } from "../package-name.ts";
|
|
3
|
+
export declare function createCsrfEndpoint(): Endpoint;
|
|
4
|
+
export declare function verifyCsrf(req: PayloadRequest): boolean;
|
|
5
|
+
export declare function verifyOrigin(req: PayloadRequest, trustedOrigins: string[]): boolean;
|
|
6
|
+
export { CSRF_COOKIE, CSRF_HEADER };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { CSRF_COOKIE, CSRF_HEADER, ENDPOINT_PREFIX } from "../package-name.js";
|
|
2
|
+
export function createCsrfEndpoint() {
|
|
3
|
+
return {
|
|
4
|
+
path: `${ENDPOINT_PREFIX}/csrf`,
|
|
5
|
+
method: "get",
|
|
6
|
+
handler: async (req) => {
|
|
7
|
+
if (!req.user) {
|
|
8
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
9
|
+
}
|
|
10
|
+
const token = crypto.randomUUID();
|
|
11
|
+
const proto = req.headers.get("x-forwarded-proto") ?? req.headers.get("origin") ?? "";
|
|
12
|
+
const secure = proto.includes("https");
|
|
13
|
+
const cookie = `${CSRF_COOKIE}=${token}; Path=/; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`;
|
|
14
|
+
return new Response(JSON.stringify({ token, header: CSRF_HEADER }), {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: {
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
"Set-Cookie": cookie,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function verifyCsrf(req) {
|
|
25
|
+
const header = req.headers.get(CSRF_HEADER);
|
|
26
|
+
const cookie = readCookie(req.headers.get("cookie"), CSRF_COOKIE);
|
|
27
|
+
return Boolean(header && cookie && header === cookie);
|
|
28
|
+
}
|
|
29
|
+
export function verifyOrigin(req, trustedOrigins) {
|
|
30
|
+
const origin = req.headers.get("origin");
|
|
31
|
+
if (!origin) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (trustedOrigins.length > 0) {
|
|
35
|
+
return trustedOrigins.includes(origin);
|
|
36
|
+
}
|
|
37
|
+
const host = req.headers.get("host");
|
|
38
|
+
try {
|
|
39
|
+
return new URL(origin).host === host;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function readCookie(header, name) {
|
|
46
|
+
if (!header) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
for (const part of header.split(";")) {
|
|
50
|
+
const [key, ...rest] = part.trim().split("=");
|
|
51
|
+
if (key === name) {
|
|
52
|
+
return rest.join("=");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
export { CSRF_COOKIE, CSRF_HEADER };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { decideChange } from "../domain/decide.js";
|
|
2
|
+
import { guardMutationRequest, readJsonBody } from "../domain/guard.js";
|
|
3
|
+
import { ENDPOINT_PREFIX } from "../package-name.js";
|
|
4
|
+
export function createDecideEndpoint(options) {
|
|
5
|
+
return {
|
|
6
|
+
path: `${ENDPOINT_PREFIX}/decide`,
|
|
7
|
+
method: "post",
|
|
8
|
+
handler: async (req) => {
|
|
9
|
+
const guard = await guardMutationRequest(req, options);
|
|
10
|
+
if (!guard.ok) {
|
|
11
|
+
return Response.json({ error: guard.error }, { status: guard.status });
|
|
12
|
+
}
|
|
13
|
+
const body = await readJsonBody(req);
|
|
14
|
+
if (!body?.action || !body.approvalId || !body.nonce || !body.locale) {
|
|
15
|
+
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
const result = await decideChange(req, options, {
|
|
18
|
+
action: body.action,
|
|
19
|
+
approvalId: body.approvalId,
|
|
20
|
+
nonce: body.nonce,
|
|
21
|
+
collection: body.collection,
|
|
22
|
+
global: body.global,
|
|
23
|
+
documentId: body.documentId,
|
|
24
|
+
locale: body.locale,
|
|
25
|
+
formData: body.formData,
|
|
26
|
+
});
|
|
27
|
+
if (!result.ok) {
|
|
28
|
+
return Response.json({ error: result.error, message: result.message }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
return Response.json(result);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { guardMutationRequest, readJsonBody } from "../domain/guard.js";
|
|
2
|
+
import { proposeChange } from "../domain/propose.js";
|
|
3
|
+
import { ENDPOINT_PREFIX } from "../package-name.js";
|
|
4
|
+
export function createProposeEndpoint(options) {
|
|
5
|
+
return {
|
|
6
|
+
path: `${ENDPOINT_PREFIX}/propose`,
|
|
7
|
+
method: "post",
|
|
8
|
+
handler: async (req) => {
|
|
9
|
+
const guard = await guardMutationRequest(req, options);
|
|
10
|
+
if (!guard.ok) {
|
|
11
|
+
return Response.json({ error: guard.error }, { status: guard.status });
|
|
12
|
+
}
|
|
13
|
+
const body = await readJsonBody(req);
|
|
14
|
+
if (!body?.capability || !body.locale) {
|
|
15
|
+
return Response.json({ error: "tool_denied" }, { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
const result = await proposeChange(req, options, {
|
|
18
|
+
capability: body.capability,
|
|
19
|
+
collection: body.collection,
|
|
20
|
+
global: body.global,
|
|
21
|
+
documentId: body.documentId,
|
|
22
|
+
locale: body.locale,
|
|
23
|
+
operations: body.operations,
|
|
24
|
+
formData: body.formData,
|
|
25
|
+
});
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
return Response.json({ error: result.error, message: result.message }, { status: 400 });
|
|
28
|
+
}
|
|
29
|
+
return Response.json(result);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { EditorAssistantAction } from "../components/EditorAssistantAction.tsx";
|
|
2
|
+
export { EditorAssistantAside } from "../components/EditorAssistantAside.tsx";
|
|
3
|
+
export { EditorAssistantDocumentBridge } from "../components/EditorAssistantDocumentBridge.tsx";
|
|
4
|
+
export { EditorAssistantProvider } from "../components/EditorAssistantProvider.tsx";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { EditorAssistantAction } from "../components/EditorAssistantAction.js";
|
|
2
|
+
export { EditorAssistantAside } from "../components/EditorAssistantAside.js";
|
|
3
|
+
export { EditorAssistantDocumentBridge } from "../components/EditorAssistantDocumentBridge.js";
|
|
4
|
+
export { EditorAssistantProvider } from "../components/EditorAssistantProvider.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FieldAllowlist } from "../types.ts";
|
|
2
|
+
export type CompileFailure = {
|
|
3
|
+
ok: false;
|
|
4
|
+
error: string;
|
|
5
|
+
};
|
|
6
|
+
export type CompileSuccess = {
|
|
7
|
+
ok: true;
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
export type CompileResult = CompileSuccess | CompileFailure;
|
|
11
|
+
type CompileOptions = {
|
|
12
|
+
allowlist: FieldAllowlist | undefined;
|
|
13
|
+
denyFields: string[];
|
|
14
|
+
generateId?: () => string;
|
|
15
|
+
};
|
|
16
|
+
export declare function compileOperations(current: Record<string, unknown>, operations: unknown, options: CompileOptions): CompileResult;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { isDeniedFieldName } from "../config/denied-fields.js";
|
|
2
|
+
import { isFormOperation } from "./operations.js";
|
|
3
|
+
import { cloneData, getAtPath, parseFieldPath, setAtPath } from "./path.js";
|
|
4
|
+
export function compileOperations(current, operations, options) {
|
|
5
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
6
|
+
return { ok: false, error: "A proposal must include at least one operation." };
|
|
7
|
+
}
|
|
8
|
+
const next = cloneData(current);
|
|
9
|
+
const generateId = options.generateId ?? (() => crypto.randomUUID());
|
|
10
|
+
try {
|
|
11
|
+
for (const raw of operations) {
|
|
12
|
+
if (!isFormOperation(raw)) {
|
|
13
|
+
return { ok: false, error: "Unknown or invalid operation." };
|
|
14
|
+
}
|
|
15
|
+
const result = applyOperation(next, raw, options, generateId);
|
|
16
|
+
if (!result.ok) {
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: error instanceof Error ? error.message : "Failed to compile operations.",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, data: next };
|
|
28
|
+
}
|
|
29
|
+
function applyOperation(data, operation, options, generateId) {
|
|
30
|
+
const path = "path" in operation ? parseFieldPath(operation.path) : [];
|
|
31
|
+
assertWritablePath(path, options);
|
|
32
|
+
switch (operation.op) {
|
|
33
|
+
case "set":
|
|
34
|
+
if (isLocaleMap(operation.value)) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: "Localized maps are not accepted; use the current locale value.",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
setAtPath(data, path, operation.value);
|
|
41
|
+
return { ok: true, data };
|
|
42
|
+
case "unset":
|
|
43
|
+
unsetAtPath(data, path);
|
|
44
|
+
return { ok: true, data };
|
|
45
|
+
case "array.insert":
|
|
46
|
+
insertAt(data, path, operation.index, cloneData(operation.value), false, generateId);
|
|
47
|
+
return { ok: true, data };
|
|
48
|
+
case "array.replace":
|
|
49
|
+
replaceAt(data, path, operation.index, cloneData(operation.value), false, generateId);
|
|
50
|
+
return { ok: true, data };
|
|
51
|
+
case "array.remove":
|
|
52
|
+
removeAt(data, path, operation.index);
|
|
53
|
+
return { ok: true, data };
|
|
54
|
+
case "array.move":
|
|
55
|
+
moveAt(data, path, operation.from, operation.to);
|
|
56
|
+
return { ok: true, data };
|
|
57
|
+
case "blocks.insert":
|
|
58
|
+
insertAt(data, path, operation.index, { ...stripId(operation.data), blockType: operation.blockType, id: generateId() }, true, generateId);
|
|
59
|
+
return { ok: true, data };
|
|
60
|
+
case "blocks.replace":
|
|
61
|
+
replaceAt(data, path, operation.index, { ...stripId(operation.data), blockType: operation.blockType, id: generateId() }, true, generateId);
|
|
62
|
+
return { ok: true, data };
|
|
63
|
+
case "blocks.remove":
|
|
64
|
+
removeAt(data, path, operation.index);
|
|
65
|
+
return { ok: true, data };
|
|
66
|
+
case "blocks.move":
|
|
67
|
+
moveAt(data, path, operation.from, operation.to);
|
|
68
|
+
return { ok: true, data };
|
|
69
|
+
default:
|
|
70
|
+
return { ok: false, error: "Unknown or invalid operation." };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function assertWritablePath(path, options) {
|
|
74
|
+
if (path.length === 0) {
|
|
75
|
+
throw new Error("A field path is required.");
|
|
76
|
+
}
|
|
77
|
+
const root = path[0];
|
|
78
|
+
if (typeof root !== "string") {
|
|
79
|
+
throw new Error("A field path must start with a field name.");
|
|
80
|
+
}
|
|
81
|
+
if (isDeniedFieldName(root) || options.denyFields.includes(root)) {
|
|
82
|
+
throw new Error(`Field "${root}" is not writable.`);
|
|
83
|
+
}
|
|
84
|
+
const allowlist = options.allowlist;
|
|
85
|
+
if (allowlist && allowlist !== true) {
|
|
86
|
+
if ("include" in allowlist) {
|
|
87
|
+
const allowed = allowlist.include.some((entry) => entry === root || entry === path.join(".") || path.join(".").startsWith(`${entry}.`));
|
|
88
|
+
if (!allowed) {
|
|
89
|
+
throw new Error(`Field "${root}" is not allowlisted.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if ("exclude" in allowlist && allowlist.exclude.includes(root)) {
|
|
93
|
+
throw new Error(`Field "${root}" is not writable.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const segment of path) {
|
|
97
|
+
if (typeof segment === "string" && isDeniedFieldName(segment)) {
|
|
98
|
+
throw new Error(`Field "${segment}" is not writable.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function requireArray(data, path) {
|
|
103
|
+
const value = getAtPath(data, path);
|
|
104
|
+
if (!Array.isArray(value)) {
|
|
105
|
+
throw new Error(`Field "${path.join(".")}" is not an array.`);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function insertAt(data, path, index, value, _isBlock, _generateId) {
|
|
110
|
+
// New drafts start empty, so the first insert creates the list.
|
|
111
|
+
if (getAtPath(data, path) === undefined) {
|
|
112
|
+
setAtPath(data, path, []);
|
|
113
|
+
}
|
|
114
|
+
const list = requireArray(data, path);
|
|
115
|
+
if (index < 0 || index > list.length) {
|
|
116
|
+
throw new Error("Array index is out of bounds.");
|
|
117
|
+
}
|
|
118
|
+
list.splice(index, 0, value);
|
|
119
|
+
}
|
|
120
|
+
function replaceAt(data, path, index, value, _isBlock, _generateId) {
|
|
121
|
+
const list = requireArray(data, path);
|
|
122
|
+
if (index < 0 || index >= list.length) {
|
|
123
|
+
throw new Error("Array index is out of bounds.");
|
|
124
|
+
}
|
|
125
|
+
list[index] = value;
|
|
126
|
+
}
|
|
127
|
+
function removeAt(data, path, index) {
|
|
128
|
+
const list = requireArray(data, path);
|
|
129
|
+
if (index < 0 || index >= list.length) {
|
|
130
|
+
throw new Error("Array index is out of bounds.");
|
|
131
|
+
}
|
|
132
|
+
list.splice(index, 1);
|
|
133
|
+
}
|
|
134
|
+
function moveAt(data, path, from, to) {
|
|
135
|
+
const list = requireArray(data, path);
|
|
136
|
+
if (from < 0 || from >= list.length || to < 0 || to >= list.length) {
|
|
137
|
+
throw new Error("Array index is out of bounds.");
|
|
138
|
+
}
|
|
139
|
+
const [item] = list.splice(from, 1);
|
|
140
|
+
list.splice(to, 0, item);
|
|
141
|
+
}
|
|
142
|
+
function unsetAtPath(data, path) {
|
|
143
|
+
const parentPath = path.slice(0, -1);
|
|
144
|
+
const last = path[path.length - 1];
|
|
145
|
+
const parent = parentPath.length === 0 ? data : getAtPath(data, parentPath);
|
|
146
|
+
if (typeof last === "number") {
|
|
147
|
+
if (!Array.isArray(parent)) {
|
|
148
|
+
throw new Error("Path expected an array.");
|
|
149
|
+
}
|
|
150
|
+
parent.splice(last, 1);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (typeof parent !== "object" || parent === null || Array.isArray(parent)) {
|
|
154
|
+
throw new Error("Path expected an object.");
|
|
155
|
+
}
|
|
156
|
+
delete parent[last];
|
|
157
|
+
}
|
|
158
|
+
function stripId(data) {
|
|
159
|
+
const { id: _id, blockType: _blockType, ...rest } = data;
|
|
160
|
+
return rest;
|
|
161
|
+
}
|
|
162
|
+
function isLocaleMap(value) {
|
|
163
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const keys = Object.keys(value);
|
|
167
|
+
return keys.length > 0 && keys.every((key) => /^[a-z]{2}(-[A-Z]{2})?$/.test(key));
|
|
168
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FormOperation } from "./operations.ts";
|
|
2
|
+
export type HumanDiffLine = {
|
|
3
|
+
path: string;
|
|
4
|
+
text: string;
|
|
5
|
+
};
|
|
6
|
+
export type DiffLanguage = "sv" | "en";
|
|
7
|
+
export declare function humanDiff(current: Record<string, unknown>, operations: FormOperation[], language?: DiffLanguage): HumanDiffLine[];
|
|
8
|
+
/** Sub-lines are indented two spaces; the approval panel nests them under the header. */
|
|
9
|
+
export declare const DIFF_SUBLINE_INDENT = " ";
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getAtPath, parseFieldPath } from "./path.js";
|
|
2
|
+
const COPY = {
|
|
3
|
+
en: {
|
|
4
|
+
empty: "empty",
|
|
5
|
+
clear: (field) => `Clear ${field}`,
|
|
6
|
+
addItem: (n, field) => `Add item at position ${n} in ${field}`,
|
|
7
|
+
removeItem: (n, field) => `Remove item ${n} from ${field}`,
|
|
8
|
+
moveItem: (from, to, field) => `Move item ${from} to ${to} in ${field}`,
|
|
9
|
+
replaceItem: (n, field) => `Replace item ${n} in ${field}`,
|
|
10
|
+
addBlock: (block, n) => `Add ${block} block at position ${n}`,
|
|
11
|
+
removeBlock: (n, field) => `Remove block ${n} from ${field}`,
|
|
12
|
+
moveBlock: (from, to) => `Move block ${from} to ${to}`,
|
|
13
|
+
replaceBlock: (n, block) => `Replace block ${n} with ${block}`,
|
|
14
|
+
unknown: "Unknown change",
|
|
15
|
+
},
|
|
16
|
+
sv: {
|
|
17
|
+
empty: "tomt",
|
|
18
|
+
clear: (field) => `Töm ${field}`,
|
|
19
|
+
addItem: (n, field) => `Lägg till rad ${n} i ${field}`,
|
|
20
|
+
removeItem: (n, field) => `Ta bort rad ${n} från ${field}`,
|
|
21
|
+
moveItem: (from, to, field) => `Flytta rad ${from} till ${to} i ${field}`,
|
|
22
|
+
replaceItem: (n, field) => `Ersätt rad ${n} i ${field}`,
|
|
23
|
+
addBlock: (block, n) => `Lägg till blocket ${block} på plats ${n}`,
|
|
24
|
+
removeBlock: (n, field) => `Ta bort block ${n} från ${field}`,
|
|
25
|
+
moveBlock: (from, to) => `Flytta block ${from} till ${to}`,
|
|
26
|
+
replaceBlock: (n, block) => `Ersätt block ${n} med ${block}`,
|
|
27
|
+
unknown: "Okänd ändring",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const HEADER_PREVIEW = 80;
|
|
31
|
+
const FIELD_PREVIEW = 300;
|
|
32
|
+
export function humanDiff(current, operations, language = "en") {
|
|
33
|
+
const copy = COPY[language];
|
|
34
|
+
return operations.map((operation) => {
|
|
35
|
+
switch (operation.op) {
|
|
36
|
+
case "set": {
|
|
37
|
+
const before = getAtPath(current, parseFieldPath(operation.path));
|
|
38
|
+
return {
|
|
39
|
+
path: operation.path,
|
|
40
|
+
text: `${label(operation.path)}: ${preview(before, copy)} → ${preview(operation.value, copy)}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
case "unset":
|
|
44
|
+
return { path: operation.path, text: copy.clear(label(operation.path)) };
|
|
45
|
+
case "array.insert":
|
|
46
|
+
return {
|
|
47
|
+
path: operation.path,
|
|
48
|
+
text: withFields(copy.addItem(operation.index + 1, label(operation.path)), operation.value, copy),
|
|
49
|
+
};
|
|
50
|
+
case "array.remove":
|
|
51
|
+
return {
|
|
52
|
+
path: operation.path,
|
|
53
|
+
text: copy.removeItem(operation.index + 1, label(operation.path)),
|
|
54
|
+
};
|
|
55
|
+
case "array.move":
|
|
56
|
+
return {
|
|
57
|
+
path: operation.path,
|
|
58
|
+
text: copy.moveItem(operation.from + 1, operation.to + 1, label(operation.path)),
|
|
59
|
+
};
|
|
60
|
+
case "array.replace":
|
|
61
|
+
return {
|
|
62
|
+
path: operation.path,
|
|
63
|
+
text: withFields(copy.replaceItem(operation.index + 1, label(operation.path)), operation.value, copy),
|
|
64
|
+
};
|
|
65
|
+
case "blocks.insert":
|
|
66
|
+
return {
|
|
67
|
+
path: operation.path,
|
|
68
|
+
text: withFields(copy.addBlock(blockName(operation.blockType), operation.index + 1), operation.data, copy),
|
|
69
|
+
};
|
|
70
|
+
case "blocks.remove":
|
|
71
|
+
return {
|
|
72
|
+
path: operation.path,
|
|
73
|
+
text: copy.removeBlock(operation.index + 1, label(operation.path)),
|
|
74
|
+
};
|
|
75
|
+
case "blocks.move":
|
|
76
|
+
return { path: operation.path, text: copy.moveBlock(operation.from + 1, operation.to + 1) };
|
|
77
|
+
case "blocks.replace":
|
|
78
|
+
return {
|
|
79
|
+
path: operation.path,
|
|
80
|
+
text: withFields(copy.replaceBlock(operation.index + 1, blockName(operation.blockType)), operation.data, copy),
|
|
81
|
+
};
|
|
82
|
+
default:
|
|
83
|
+
return { path: "", text: copy.unknown };
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Sub-lines are indented two spaces; the approval panel nests them under the header. */
|
|
88
|
+
export const DIFF_SUBLINE_INDENT = " ";
|
|
89
|
+
function withFields(header, value, copy) {
|
|
90
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
91
|
+
return header;
|
|
92
|
+
}
|
|
93
|
+
const lines = Object.entries(value)
|
|
94
|
+
.filter(([key, field]) => key !== "id" && key !== "blockType" && !isEmpty(field))
|
|
95
|
+
.map(([key, field]) => `${DIFF_SUBLINE_INDENT}${label(key)}: ${preview(field, copy, FIELD_PREVIEW)}`);
|
|
96
|
+
return [header, ...lines].join("\n");
|
|
97
|
+
}
|
|
98
|
+
function blockName(blockType) {
|
|
99
|
+
const words = blockType
|
|
100
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
101
|
+
.replace(/[-_]+/g, " ")
|
|
102
|
+
.toLowerCase();
|
|
103
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
104
|
+
}
|
|
105
|
+
function label(path) {
|
|
106
|
+
const segments = path.split(".").filter((segment) => !/^\d+$/.test(segment));
|
|
107
|
+
const last = segments.at(-1) ?? path;
|
|
108
|
+
return last.charAt(0).toUpperCase() + last.slice(1);
|
|
109
|
+
}
|
|
110
|
+
function isEmpty(value) {
|
|
111
|
+
return (value === undefined ||
|
|
112
|
+
value === null ||
|
|
113
|
+
value === "" ||
|
|
114
|
+
(isLexical(value) && !lexicalPlaintext(value)));
|
|
115
|
+
}
|
|
116
|
+
function preview(value, copy, max = HEADER_PREVIEW) {
|
|
117
|
+
if (isEmpty(value)) {
|
|
118
|
+
return copy.empty;
|
|
119
|
+
}
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
return truncate(value, max);
|
|
122
|
+
}
|
|
123
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
124
|
+
return String(value);
|
|
125
|
+
}
|
|
126
|
+
if (isLexical(value)) {
|
|
127
|
+
return truncate(lexicalPlaintext(value), max);
|
|
128
|
+
}
|
|
129
|
+
return truncate(JSON.stringify(value), max);
|
|
130
|
+
}
|
|
131
|
+
function isLexical(value) {
|
|
132
|
+
return Boolean(value && typeof value === "object" && "root" in value);
|
|
133
|
+
}
|
|
134
|
+
function lexicalPlaintext(value) {
|
|
135
|
+
const texts = [];
|
|
136
|
+
const visit = (node) => {
|
|
137
|
+
if (!node || typeof node !== "object") {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const record = node;
|
|
141
|
+
if (typeof record.text === "string") {
|
|
142
|
+
texts.push(record.text);
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(record.children)) {
|
|
145
|
+
for (const child of record.children) {
|
|
146
|
+
visit(child);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
visit(value.root);
|
|
151
|
+
return texts.join(" ").trim();
|
|
152
|
+
}
|
|
153
|
+
function truncate(value, max) {
|
|
154
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
155
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { isDeniedFieldName } from "../config/denied-fields.js";
|
|
2
|
+
import { getAtPath, parseFieldPath } from "./path.js";
|
|
3
|
+
const SKIP = new Set(["id", "slug", "blockType", "_status", "parent"]);
|
|
4
|
+
const MAX_PATHS = 12;
|
|
5
|
+
export function listEmptyTextPaths(data) {
|
|
6
|
+
const paths = [];
|
|
7
|
+
walk(data, "", paths);
|
|
8
|
+
return paths;
|
|
9
|
+
}
|
|
10
|
+
export function previewAtPath(data, path) {
|
|
11
|
+
try {
|
|
12
|
+
const value = getAtPath(data, parseFieldPath(path));
|
|
13
|
+
if (typeof value === "string" && value.trim()) {
|
|
14
|
+
return truncate(value.trim());
|
|
15
|
+
}
|
|
16
|
+
if (value && typeof value === "object" && "root" in value) {
|
|
17
|
+
const text = lexicalPlaintext(value);
|
|
18
|
+
return text ? truncate(text) : undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function walk(value, path, into) {
|
|
27
|
+
if (into.length >= MAX_PATHS) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const leaf = path.split(".").at(-1) ?? "";
|
|
31
|
+
if (leaf && (SKIP.has(leaf) || isDeniedFieldName(leaf))) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
if (value.trim() === "" && path) {
|
|
36
|
+
into.push(path);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (value && typeof value === "object" && "root" in value) {
|
|
41
|
+
if (path && lexicalPlaintext(value) === "") {
|
|
42
|
+
into.push(path);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
value.forEach((item, index) => {
|
|
48
|
+
walk(item, path ? `${path}.${index}` : String(index), into);
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (value && typeof value === "object") {
|
|
53
|
+
for (const [key, child] of Object.entries(value)) {
|
|
54
|
+
walk(child, path ? `${path}.${key}` : key, into);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function lexicalPlaintext(value) {
|
|
59
|
+
const texts = [];
|
|
60
|
+
const visit = (node) => {
|
|
61
|
+
if (!node || typeof node !== "object") {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const record = node;
|
|
65
|
+
if (typeof record.text === "string") {
|
|
66
|
+
texts.push(record.text);
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(record.children)) {
|
|
69
|
+
for (const child of record.children) {
|
|
70
|
+
visit(child);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
visit(value.root);
|
|
75
|
+
return texts.join(" ").trim();
|
|
76
|
+
}
|
|
77
|
+
function truncate(value) {
|
|
78
|
+
return value.length > 80 ? `${value.slice(0, 77)}…` : value;
|
|
79
|
+
}
|