@solumflow-app/crm-mcp-server 0.3.1 → 0.5.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/README.md +119 -0
- package/dist/index.js +1446 -208
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -5,23 +5,462 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { createClient } from "@solumflow-app/crm-client";
|
|
7
7
|
|
|
8
|
+
// package.json
|
|
9
|
+
var version = "0.5.0";
|
|
10
|
+
|
|
11
|
+
// src/catalog-migration-tools.ts
|
|
12
|
+
import { z as z4 } from "zod";
|
|
13
|
+
|
|
14
|
+
// ../features/crm-invoicing/src/schema/catalog-api.schema.ts
|
|
15
|
+
import * as z from "zod";
|
|
16
|
+
|
|
17
|
+
// ../features/crm-invoicing/src/schema/amount-ceiling.ts
|
|
18
|
+
var MAX_AMOUNT_CENTS = 99999999;
|
|
19
|
+
|
|
20
|
+
// ../features/crm-invoicing/src/schema/catalog-api.schema.ts
|
|
21
|
+
var WEB_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
22
|
+
var FIELD_SLUG = /^[a-z][a-z0-9_]*$/;
|
|
23
|
+
var CatalogSourceSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, {
|
|
24
|
+
message: (
|
|
25
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
26
|
+
'A source is lower-case letters, digits and hyphens, at most 64 characters (for example "coolflames-payload").'
|
|
27
|
+
)
|
|
28
|
+
});
|
|
29
|
+
var CatalogExternalRefSchema = z.string().trim().min(1).max(255);
|
|
30
|
+
var CatalogWebSlugSchema = z.string().min(2).max(64).regex(WEB_SLUG, {
|
|
31
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
32
|
+
message: "An address is lower-case letters and digits joined by hyphens."
|
|
33
|
+
});
|
|
34
|
+
var CatalogFieldSlugSchema = z.string().max(63).regex(FIELD_SLUG, {
|
|
35
|
+
message: (
|
|
36
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
37
|
+
"A field slug starts with a letter and holds lower-case letters, digits and underscores."
|
|
38
|
+
)
|
|
39
|
+
});
|
|
40
|
+
var cents = z.number().int().min(0).max(MAX_AMOUNT_CENTS);
|
|
41
|
+
var text = (max) => z.string().trim().max(max).nullable().optional();
|
|
42
|
+
var CatalogPriceSchema = z.object({
|
|
43
|
+
/** The price's own id at the source, so a renamed price stays itself. */
|
|
44
|
+
externalRef: CatalogExternalRefSchema.optional(),
|
|
45
|
+
label: z.string().trim().min(1).max(120),
|
|
46
|
+
/**
|
|
47
|
+
* ISO 4217. Left out, the price is in the product's own currency. A
|
|
48
|
+
* price in another currency is the fixed price for that currency: it
|
|
49
|
+
* wins over a converted one and is never the default.
|
|
50
|
+
*/
|
|
51
|
+
currency: z.string().regex(/^[A-Z]{3}$/).optional(),
|
|
52
|
+
amountCents: cents,
|
|
53
|
+
compareAtCents: cents.nullable().optional(),
|
|
54
|
+
kind: z.enum(["one_off", "recurring"]).optional(),
|
|
55
|
+
recurringInterval: z.enum(["day", "week", "month", "year"]).optional(),
|
|
56
|
+
recurringIntervalCount: z.number().int().min(1).max(365).optional(),
|
|
57
|
+
isDefault: z.boolean().optional(),
|
|
58
|
+
active: z.boolean().optional()
|
|
59
|
+
}).strict().superRefine((price, ctx) => {
|
|
60
|
+
if ((price.kind ?? "one_off") === "recurring" && !price.recurringInterval) {
|
|
61
|
+
ctx.addIssue({
|
|
62
|
+
code: "custom",
|
|
63
|
+
path: ["recurringInterval"],
|
|
64
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
65
|
+
message: "A recurring price needs an interval."
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
var pricesSchema = z.array(CatalogPriceSchema).min(1).max(50).superRefine((prices, ctx) => {
|
|
70
|
+
if (prices.filter((price) => price.isDefault).length > 1) {
|
|
71
|
+
ctx.addIssue({
|
|
72
|
+
code: "custom",
|
|
73
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
74
|
+
message: "At most one price can be the default."
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const refs = prices.flatMap(
|
|
78
|
+
(price) => price.externalRef ? [price.externalRef] : []
|
|
79
|
+
);
|
|
80
|
+
if (new Set(refs).size !== refs.length) {
|
|
81
|
+
ctx.addIssue({
|
|
82
|
+
code: "custom",
|
|
83
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
84
|
+
message: "Two prices carry the same externalRef."
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const labels = prices.map(
|
|
88
|
+
(price) => `${price.currency ?? ""} ${price.label.toLowerCase()}`
|
|
89
|
+
);
|
|
90
|
+
if (new Set(labels).size !== labels.length) {
|
|
91
|
+
ctx.addIssue({
|
|
92
|
+
code: "custom",
|
|
93
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
94
|
+
message: "Two prices carry the same label in the same currency."
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
var HttpsUrlSchema = z.string().max(2048).url().refine((url) => url.startsWith("https://"), {
|
|
99
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
100
|
+
message: "A file is fetched over https only."
|
|
101
|
+
});
|
|
102
|
+
var UploadPathSchema = z.string().min(1).max(200);
|
|
103
|
+
var CatalogFileRefSchema = z.union([
|
|
104
|
+
z.object({ url: HttpsUrlSchema }).strict(),
|
|
105
|
+
z.object({ upload: UploadPathSchema }).strict()
|
|
106
|
+
]);
|
|
107
|
+
var IMAGE_ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
108
|
+
var CatalogImageDetailsSchema = z.record(
|
|
109
|
+
z.string().min(1).max(64),
|
|
110
|
+
z.union([z.string().max(500), z.number().finite(), z.boolean()])
|
|
111
|
+
).refine((details) => JSON.stringify(details).length <= 1800, {
|
|
112
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
113
|
+
message: "Details are at most about 2 KB."
|
|
114
|
+
});
|
|
115
|
+
var CatalogImageSchema = z.object({
|
|
116
|
+
url: HttpsUrlSchema.optional(),
|
|
117
|
+
upload: UploadPathSchema.optional(),
|
|
118
|
+
alt: text(300),
|
|
119
|
+
/**
|
|
120
|
+
* What the picture is of, in the account's own words: `work`, `detail`,
|
|
121
|
+
* `insitu`. Left out keeps the role a stored picture has; `null` clears it.
|
|
122
|
+
*/
|
|
123
|
+
role: z.string().regex(IMAGE_ROLE, {
|
|
124
|
+
message: (
|
|
125
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
126
|
+
"A role starts with a letter and holds lower-case letters, digits, hyphens and underscores, at most 32."
|
|
127
|
+
)
|
|
128
|
+
}).nullable().optional(),
|
|
129
|
+
details: CatalogImageDetailsSchema.optional()
|
|
130
|
+
}).strict().refine(
|
|
131
|
+
(image) => image.url === void 0 !== (image.upload === void 0),
|
|
132
|
+
{
|
|
133
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
134
|
+
message: "A picture names either a url or an upload, not both."
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
var CatalogStockSchema = z.object({
|
|
138
|
+
managed: z.boolean(),
|
|
139
|
+
/** Left out, the count stays as it is. */
|
|
140
|
+
quantity: z.number().int().min(0).max(1e9).optional(),
|
|
141
|
+
/** Below this a product counts as running low; `null` warns never. */
|
|
142
|
+
lowThreshold: z.number().int().min(0).max(1e9).nullable().optional(),
|
|
143
|
+
/** Whether it can still be ordered at zero: `deny`, `allow`, or `allow_notify`. */
|
|
144
|
+
backorders: z.enum(["deny", "allow", "allow_notify"]).optional()
|
|
145
|
+
}).strict();
|
|
146
|
+
var CatalogVariantAxisSchema = z.object({
|
|
147
|
+
slug: CatalogFieldSlugSchema,
|
|
148
|
+
label: z.string().trim().min(1).max(120).optional()
|
|
149
|
+
}).strict();
|
|
150
|
+
var CatalogVariantSchema = z.object({
|
|
151
|
+
externalRef: CatalogExternalRefSchema,
|
|
152
|
+
/** The value per axis, keyed by the axis slug. */
|
|
153
|
+
options: z.record(
|
|
154
|
+
CatalogFieldSlugSchema,
|
|
155
|
+
z.string().trim().min(1).max(120)
|
|
156
|
+
),
|
|
157
|
+
sku: text(100),
|
|
158
|
+
gtin: z.string().regex(/^[0-9]{8,14}$/).nullable().optional(),
|
|
159
|
+
prices: pricesSchema.optional(),
|
|
160
|
+
/** The variant's own shelf; a physical product counts per variant. */
|
|
161
|
+
stock: CatalogStockSchema.optional(),
|
|
162
|
+
position: z.number().int().min(0).max(1e4).optional(),
|
|
163
|
+
active: z.boolean().optional()
|
|
164
|
+
}).strict();
|
|
165
|
+
var CATALOG_AVAILABILITIES = [
|
|
166
|
+
"draft",
|
|
167
|
+
"internal",
|
|
168
|
+
"scheduled",
|
|
169
|
+
"public"
|
|
170
|
+
];
|
|
171
|
+
var CatalogProductUpsertSchema = z.object({
|
|
172
|
+
source: CatalogSourceSchema,
|
|
173
|
+
/** Required to create a product; optional to update one. */
|
|
174
|
+
name: z.string().trim().min(1).max(500).optional(),
|
|
175
|
+
slug: CatalogWebSlugSchema.nullable().optional(),
|
|
176
|
+
description: text(5e3),
|
|
177
|
+
/** HTML; cleaned against the same allow-list the app uses. */
|
|
178
|
+
longDescription: text(1e5),
|
|
179
|
+
sku: text(100),
|
|
180
|
+
gtin: z.string().regex(/^[0-9]{8,14}$/).nullable().optional(),
|
|
181
|
+
productType: z.enum(["digital", "physical"]).optional(),
|
|
182
|
+
/**
|
|
183
|
+
* Where the product stands. A new product is a `draft` unless this says
|
|
184
|
+
* otherwise -- an import should not put a hundred products on somebody's
|
|
185
|
+
* website because a run finished.
|
|
186
|
+
*/
|
|
187
|
+
availability: z.enum(CATALOG_AVAILABILITIES).optional(),
|
|
188
|
+
/** The moment for `scheduled`; ignored for the other three. */
|
|
189
|
+
publishedAt: z.string().datetime({ offset: true }).nullable().optional(),
|
|
190
|
+
visibility: z.enum(["public", "unlisted"]).optional(),
|
|
191
|
+
metaTitle: text(300),
|
|
192
|
+
metaDescription: text(1e3),
|
|
193
|
+
/**
|
|
194
|
+
* What a shared link to the product shows (the og tags): its own title,
|
|
195
|
+
* description and picture. Left out, a link falls back to the name, the
|
|
196
|
+
* description and the first picture. `socialImage` is fetched after the
|
|
197
|
+
* answer like `images`; `null` takes it away.
|
|
198
|
+
*/
|
|
199
|
+
socialTitle: text(300),
|
|
200
|
+
socialDescription: text(1e3),
|
|
201
|
+
socialImage: CatalogFileRefSchema.nullable().optional(),
|
|
202
|
+
/** Replaces the product's categories, by slug. */
|
|
203
|
+
categories: z.array(CatalogWebSlugSchema).max(50).optional(),
|
|
204
|
+
/**
|
|
205
|
+
* Make a category that does not exist yet instead of refusing. Off unless
|
|
206
|
+
* asked for, so a typo cannot become a category on a customer's website.
|
|
207
|
+
*/
|
|
208
|
+
createMissingCategories: z.boolean().optional(),
|
|
209
|
+
/** Field values by field slug; `null` empties one field. */
|
|
210
|
+
fields: z.record(CatalogFieldSlugSchema, z.unknown()).optional(),
|
|
211
|
+
/** Which fields the product's own page shows, in that order. */
|
|
212
|
+
publicFields: z.array(CatalogFieldSlugSchema).max(100).optional(),
|
|
213
|
+
/**
|
|
214
|
+
* Add a choice a select field does not offer yet instead of refusing.
|
|
215
|
+
* Off unless asked for, for the same reason as categories.
|
|
216
|
+
*/
|
|
217
|
+
addMissingOptions: z.boolean().optional(),
|
|
218
|
+
/** Replaces the product's prices as a whole. */
|
|
219
|
+
prices: pricesSchema.optional(),
|
|
220
|
+
/**
|
|
221
|
+
* The product's own shelf. Physical products only, and only one without
|
|
222
|
+
* variants: a product with variants counts per variant.
|
|
223
|
+
*/
|
|
224
|
+
stock: CatalogStockSchema.optional(),
|
|
225
|
+
/** Replaces the product's pictures, in this order. */
|
|
226
|
+
images: z.array(CatalogImageSchema).max(20).optional(),
|
|
227
|
+
variantAxes: z.array(CatalogVariantAxisSchema).min(1).max(3).optional(),
|
|
228
|
+
/** Replaces the product's variants; one missing here is archived. */
|
|
229
|
+
variants: z.array(CatalogVariantSchema).max(100).optional(),
|
|
230
|
+
/** `false` brings back a product the API archived earlier. */
|
|
231
|
+
archived: z.literal(false).optional()
|
|
232
|
+
}).strict().superRefine((body, ctx) => {
|
|
233
|
+
if (body.variants !== void 0 && body.variantAxes === void 0) {
|
|
234
|
+
ctx.addIssue({
|
|
235
|
+
code: "custom",
|
|
236
|
+
path: ["variantAxes"],
|
|
237
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
238
|
+
message: "Variants need the axes they differ along."
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (body.variants && body.variantAxes) {
|
|
242
|
+
const axes = body.variantAxes.map((axis) => axis.slug);
|
|
243
|
+
const seen = /* @__PURE__ */ new Set();
|
|
244
|
+
const refs = /* @__PURE__ */ new Set();
|
|
245
|
+
body.variants.forEach((variant, index) => {
|
|
246
|
+
const named = Object.keys(variant.options);
|
|
247
|
+
if (named.length !== axes.length || !axes.every((axis) => named.includes(axis))) {
|
|
248
|
+
ctx.addIssue({
|
|
249
|
+
code: "custom",
|
|
250
|
+
path: ["variants", index, "options"],
|
|
251
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
252
|
+
message: `A variant names a value for exactly these axes: ${axes.join(", ")}.`
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const combination = axes.map((axis) => variant.options[axis] ?? "").join("\0");
|
|
256
|
+
if (seen.has(combination)) {
|
|
257
|
+
ctx.addIssue({
|
|
258
|
+
code: "custom",
|
|
259
|
+
path: ["variants", index, "options"],
|
|
260
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
261
|
+
message: "Two variants stand for the same combination."
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (refs.has(variant.externalRef)) {
|
|
265
|
+
ctx.addIssue({
|
|
266
|
+
code: "custom",
|
|
267
|
+
path: ["variants", index, "externalRef"],
|
|
268
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
269
|
+
message: "Two variants carry the same externalRef."
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
seen.add(combination);
|
|
273
|
+
refs.add(variant.externalRef);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (body.variantAxes) {
|
|
277
|
+
const slugs = body.variantAxes.map((axis) => axis.slug);
|
|
278
|
+
if (new Set(slugs).size !== slugs.length) {
|
|
279
|
+
ctx.addIssue({
|
|
280
|
+
code: "custom",
|
|
281
|
+
path: ["variantAxes"],
|
|
282
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
283
|
+
message: "An axis is named twice."
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (body.stock !== void 0 && body.variantAxes !== void 0) {
|
|
288
|
+
ctx.addIssue({
|
|
289
|
+
code: "custom",
|
|
290
|
+
path: ["stock"],
|
|
291
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
292
|
+
message: "A product with variants counts its stock per variant."
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (body.availability === "scheduled" && !body.publishedAt) {
|
|
296
|
+
ctx.addIssue({
|
|
297
|
+
code: "custom",
|
|
298
|
+
path: ["publishedAt"],
|
|
299
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
300
|
+
message: "A scheduled product needs the moment it appears."
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
var CatalogProductArchiveSchema = z.object({ source: CatalogSourceSchema }).strict();
|
|
305
|
+
var CatalogCategoryUpsertSchema = z.object({
|
|
306
|
+
/**
|
|
307
|
+
* The category's id in the system it came from -- a series in a site's
|
|
308
|
+
* own CMS -- with `source` naming whose id it is. With both, a category
|
|
309
|
+
* that carries this reference is changed, and renamed to the address in
|
|
310
|
+
* the path when somebody gave it another one in the app; without, the
|
|
311
|
+
* address alone finds it, as before.
|
|
312
|
+
*/
|
|
313
|
+
source: CatalogSourceSchema.optional(),
|
|
314
|
+
externalRef: CatalogExternalRefSchema.optional(),
|
|
315
|
+
name: z.string().trim().min(1).max(200).optional(),
|
|
316
|
+
description: text(5e3),
|
|
317
|
+
/** `null` puts the category at the top of the tree. */
|
|
318
|
+
parentSlug: CatalogWebSlugSchema.nullable().optional(),
|
|
319
|
+
availability: z.enum(CATALOG_AVAILABILITIES).optional(),
|
|
320
|
+
publishedAt: z.string().datetime({ offset: true }).nullable().optional(),
|
|
321
|
+
metaTitle: text(300),
|
|
322
|
+
metaDescription: text(1e3)
|
|
323
|
+
}).strict().refine(
|
|
324
|
+
(body) => body.source === void 0 === (body.externalRef === void 0),
|
|
325
|
+
{
|
|
326
|
+
path: ["externalRef"],
|
|
327
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
328
|
+
message: "A category reference names both source and externalRef."
|
|
329
|
+
}
|
|
330
|
+
);
|
|
331
|
+
var CatalogObjectSlugSchema = z.string().max(64).regex(FIELD_SLUG, {
|
|
332
|
+
message: (
|
|
333
|
+
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
334
|
+
"A catalogue slug starts with a letter and holds lower-case letters, digits and underscores."
|
|
335
|
+
)
|
|
336
|
+
});
|
|
337
|
+
var CatalogCatalogueUpsertSchema = z.object({
|
|
338
|
+
nameSingular: z.string().trim().min(1).max(120).optional(),
|
|
339
|
+
namePlural: z.string().trim().min(1).max(120).optional(),
|
|
340
|
+
/** A lucide icon name, as the object settings offer them. */
|
|
341
|
+
icon: z.string().trim().max(64).nullable().optional()
|
|
342
|
+
}).strict();
|
|
343
|
+
var CatalogFieldTypeSchema = z.enum([
|
|
344
|
+
"text",
|
|
345
|
+
"number",
|
|
346
|
+
"select",
|
|
347
|
+
"checkbox",
|
|
348
|
+
"color",
|
|
349
|
+
"url",
|
|
350
|
+
"date",
|
|
351
|
+
"content_text",
|
|
352
|
+
"repeater",
|
|
353
|
+
"cta",
|
|
354
|
+
"configurator"
|
|
355
|
+
]);
|
|
356
|
+
var CATALOG_FIELD_TYPES = CatalogFieldTypeSchema.options;
|
|
357
|
+
var CatalogFieldOptionSchema = z.object({
|
|
358
|
+
value: z.string().trim().min(1).max(120),
|
|
359
|
+
label: z.string().trim().min(1).max(120).optional()
|
|
360
|
+
}).strict();
|
|
361
|
+
var CatalogAttributeUpsertSchema = z.object({
|
|
362
|
+
label: z.string().trim().min(1).max(120).optional(),
|
|
363
|
+
type: CatalogFieldTypeSchema.optional(),
|
|
364
|
+
/** For a select: whether a product may carry more than one choice. */
|
|
365
|
+
multiple: z.boolean().optional(),
|
|
366
|
+
/** Choices to make sure exist; never removes one that is not listed. */
|
|
367
|
+
options: z.array(CatalogFieldOptionSchema).max(500).optional(),
|
|
368
|
+
/** Choices to take off the list, by value. */
|
|
369
|
+
removeOptions: z.array(z.string().trim().min(1).max(120)).max(500).optional(),
|
|
370
|
+
/** Whether the public API may show this field at all. */
|
|
371
|
+
public: z.boolean().optional(),
|
|
372
|
+
/**
|
|
373
|
+
* The field's settings, replaced as a whole. Checked against the same
|
|
374
|
+
* schema the object builder saves with, per type: a repeater's `display`,
|
|
375
|
+
* `preset`, `itemFields` and `titleKey`; a call to action's `secondary`;
|
|
376
|
+
* a configurator's whole structure. An item field keeps its type once it
|
|
377
|
+
* exists.
|
|
378
|
+
*/
|
|
379
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
380
|
+
/**
|
|
381
|
+
* Say what this body would change -- a new field, a new label, choices
|
|
382
|
+
* added, choices taken away and how many products hold each -- and
|
|
383
|
+
* write nothing.
|
|
384
|
+
*/
|
|
385
|
+
dryRun: z.boolean().optional(),
|
|
386
|
+
/**
|
|
387
|
+
* Bring the field back from the trash under its own id, with every value
|
|
388
|
+
* the products kept. Nothing else may be sent with it.
|
|
389
|
+
*/
|
|
390
|
+
restore: z.literal(true).optional()
|
|
391
|
+
}).strict();
|
|
392
|
+
var CONFIGURATOR_FILE_KINDS = ["model", "texture"];
|
|
393
|
+
var CONFIGURATOR_TEXTURE_CONTENT_TYPES = [
|
|
394
|
+
"image/webp",
|
|
395
|
+
"image/jpeg"
|
|
396
|
+
];
|
|
397
|
+
var ConfiguratorFileUploadSchema = z.discriminatedUnion("step", [
|
|
398
|
+
z.object({
|
|
399
|
+
step: z.literal("start"),
|
|
400
|
+
/** The slug of the configurator field the file is for. */
|
|
401
|
+
field: CatalogFieldSlugSchema,
|
|
402
|
+
kind: z.enum(CONFIGURATOR_FILE_KINDS),
|
|
403
|
+
/** The bytes the caller is about to send, so a too-large file is refused before it travels. */
|
|
404
|
+
size: z.number().int().min(1),
|
|
405
|
+
/** For a texture: what kind of picture it is. A model is always glTF binary. */
|
|
406
|
+
contentType: z.enum(CONFIGURATOR_TEXTURE_CONTENT_TYPES).optional()
|
|
407
|
+
}).strict(),
|
|
408
|
+
z.object({
|
|
409
|
+
step: z.literal("finish"),
|
|
410
|
+
field: CatalogFieldSlugSchema,
|
|
411
|
+
kind: z.enum(CONFIGURATOR_FILE_KINDS),
|
|
412
|
+
/** The `uploadPath` `start` answered with. */
|
|
413
|
+
uploadPath: z.string().max(200)
|
|
414
|
+
}).strict()
|
|
415
|
+
]);
|
|
416
|
+
var CATALOG_FILE_KINDS = ["image", "file"];
|
|
417
|
+
var CATALOG_FILE_CONTENT_TYPES = [
|
|
418
|
+
"image/png",
|
|
419
|
+
"image/jpeg",
|
|
420
|
+
"image/webp",
|
|
421
|
+
"application/pdf",
|
|
422
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
423
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
424
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
425
|
+
"text/plain",
|
|
426
|
+
"text/csv",
|
|
427
|
+
"application/zip"
|
|
428
|
+
];
|
|
429
|
+
var CatalogFileUploadSchema = z.discriminatedUnion("step", [
|
|
430
|
+
z.object({
|
|
431
|
+
step: z.literal("start"),
|
|
432
|
+
/** A picture (2 MB, png/jpeg/webp) or a download for a list item (20 MB). */
|
|
433
|
+
kind: z.enum(CATALOG_FILE_KINDS),
|
|
434
|
+
/** The bytes about to be sent, so a too-large file is refused before it travels. */
|
|
435
|
+
size: z.number().int().min(1),
|
|
436
|
+
contentType: z.enum(CATALOG_FILE_CONTENT_TYPES)
|
|
437
|
+
}).strict(),
|
|
438
|
+
z.object({
|
|
439
|
+
step: z.literal("finish"),
|
|
440
|
+
kind: z.enum(CATALOG_FILE_KINDS),
|
|
441
|
+
/** The `uploadPath` `start` answered with. */
|
|
442
|
+
uploadPath: z.string().max(200)
|
|
443
|
+
}).strict()
|
|
444
|
+
]);
|
|
445
|
+
|
|
8
446
|
// src/catalog-tools.ts
|
|
9
|
-
import { z } from "zod";
|
|
447
|
+
import { z as z3 } from "zod";
|
|
10
448
|
|
|
11
449
|
// src/tool-answers.ts
|
|
12
450
|
import { CrmApiError } from "@solumflow-app/crm-client";
|
|
13
|
-
|
|
451
|
+
import { z as z2 } from "zod";
|
|
452
|
+
function answer(text2, data) {
|
|
14
453
|
return {
|
|
15
|
-
content: [{ type: "text", text }],
|
|
454
|
+
content: [{ type: "text", text: text2 }],
|
|
16
455
|
structuredContent: { result: data }
|
|
17
456
|
};
|
|
18
457
|
}
|
|
19
|
-
function refusal(error) {
|
|
458
|
+
function refusal(error, scope) {
|
|
20
459
|
if (!(error instanceof CrmApiError)) {
|
|
21
460
|
throw error;
|
|
22
461
|
}
|
|
23
462
|
const lines = [`${error.code}: ${error.message}`];
|
|
24
|
-
const next = NEXT_STEP[error.code];
|
|
463
|
+
const next = error.code === "forbidden" ? `The key may not do this. It needs to be a secret key with the scope "${scope}".` : NEXT_STEP[error.code];
|
|
25
464
|
if (next) {
|
|
26
465
|
lines.push(next);
|
|
27
466
|
}
|
|
@@ -35,9 +474,8 @@ function refusal(error) {
|
|
|
35
474
|
}
|
|
36
475
|
var NEXT_STEP = {
|
|
37
476
|
unauthorized: "The key is not recognised or was revoked. Ask the account owner for a new secret key.",
|
|
38
|
-
forbidden: 'The key may not do this. It needs to be a secret key with the scope "website:manage".',
|
|
39
477
|
feature_unavailable: "The module this needs is switched off for the account. Ask somebody to switch it on in the app.",
|
|
40
|
-
rate_limited: "Too many requests. Wait
|
|
478
|
+
rate_limited: "Too many requests: 120 a minute per key, 600 per account, and 5 MB of bodies a day per key. Wait the seconds the answer names and try again.",
|
|
41
479
|
internal_error: "Something went wrong in the app. Trying again is safe."
|
|
42
480
|
};
|
|
43
481
|
function codeBlock(snippet) {
|
|
@@ -45,28 +483,94 @@ function codeBlock(snippet) {
|
|
|
45
483
|
${snippet}
|
|
46
484
|
\`\`\``;
|
|
47
485
|
}
|
|
486
|
+
var page = {
|
|
487
|
+
cursor: z2.string().optional().describe("The nextCursor of the previous page; leave out for the first."),
|
|
488
|
+
limit: z2.number().int().min(1).max(100).optional()
|
|
489
|
+
};
|
|
490
|
+
function more(nextCursor) {
|
|
491
|
+
return nextCursor ? `More on the next page: cursor ${nextCursor}` : "";
|
|
492
|
+
}
|
|
493
|
+
function money(cents2, currency2) {
|
|
494
|
+
return `${(cents2 / 100).toFixed(2)} ${currency2.toUpperCase()}`;
|
|
495
|
+
}
|
|
48
496
|
|
|
49
497
|
// src/catalog-tools.ts
|
|
498
|
+
var objectInput = {
|
|
499
|
+
object: CatalogObjectSlugSchema.optional().describe(
|
|
500
|
+
"The slug of the catalogue (see list_catalogs). Leave out for the account\u2019s own catalogue."
|
|
501
|
+
)
|
|
502
|
+
};
|
|
50
503
|
var CONTENT_BLOCKS = 'Besides plain values (text, number, choice), a product can carry content blocks, three field types: "content_text" is { html, links? } with already-sanitised html; "cta" is { heading, body, primary: {label,url}, secondary? }, a call to action; "repeater" is { items: [{ id, ...one value per item field }] } and covers lists, accordions (FAQ), downloads and testimonials. A repeater\u2019s field says how to draw it: "display" is "list" or "accordion", "preset" names where it started (list, faq, downloads, testimonials), "itemFields" is the structure of one item (key, label, type: text, richtext, url {label,url}, icon {set,name}, image or file {url,name,size,contentType}) and "titleKey" is the item field that names an item, such as the question of a FAQ. A "configurator" is a 3D product configurator: a listing carries only { configurator: true }, a product\u2019s own page the whole document (see read_configurator). A block that is public but empty is null; a block that is not public is not there at all.';
|
|
51
|
-
var
|
|
504
|
+
var currency = z3.string().regex(/^[A-Z]{3}$/).optional().describe(
|
|
505
|
+
"Every price in this currency (see list_currencies), each with its origin: base, fixed or converted. Left out, every price in its own currency."
|
|
506
|
+
);
|
|
507
|
+
var slugOrId = z3.string().min(1).max(200).describe("The address (slug) or the id of the product.");
|
|
52
508
|
function registerCatalogTools(server, client) {
|
|
53
509
|
server.registerTool(
|
|
54
510
|
"list_catalog_fields",
|
|
55
511
|
{
|
|
56
512
|
title: "List the public fields of the catalogue",
|
|
57
513
|
description: "The custom fields the catalogue publishes on its products, with their type, choices and, for a content block, how it is built. Read this once before building a product page: it says which blocks exist and what each one looks like. " + CONTENT_BLOCKS,
|
|
58
|
-
inputSchema: {},
|
|
514
|
+
inputSchema: { ...objectInput },
|
|
59
515
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
60
516
|
},
|
|
61
|
-
async () => {
|
|
517
|
+
async ({ object: object2 }) => {
|
|
62
518
|
try {
|
|
63
|
-
const fields = await client.getFields();
|
|
519
|
+
const fields = await client.getFields(object2 ? { object: object2 } : void 0);
|
|
64
520
|
return answer(
|
|
65
521
|
fields.length === 0 ? "The catalogue publishes no custom fields." : fields.map(describeField).join("\n"),
|
|
66
522
|
fields
|
|
67
523
|
);
|
|
68
524
|
} catch (error) {
|
|
69
|
-
return refusal(error);
|
|
525
|
+
return refusal(error, "catalog:read");
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
);
|
|
529
|
+
server.registerTool(
|
|
530
|
+
"list_catalogs",
|
|
531
|
+
{
|
|
532
|
+
title: "List the product catalogues",
|
|
533
|
+
description: "The product catalogues this key can write to, the account\u2019s own first. A catalogue other than the account\u2019s own is named by its slug in `object` on every catalogue tool; upsert_catalog makes one.",
|
|
534
|
+
inputSchema: {},
|
|
535
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
536
|
+
},
|
|
537
|
+
async () => {
|
|
538
|
+
try {
|
|
539
|
+
const catalogs = await client.getCatalogs();
|
|
540
|
+
return answer(
|
|
541
|
+
catalogs.map(
|
|
542
|
+
(catalog) => `${catalog.name} (${catalog.slug})${catalog.isDefault ? ", the account\u2019s own" : ""}`
|
|
543
|
+
).join("\n") || "This account has no product catalogue.",
|
|
544
|
+
catalogs
|
|
545
|
+
);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
return refusal(error, "catalog:read");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
);
|
|
551
|
+
server.registerTool(
|
|
552
|
+
"list_currencies",
|
|
553
|
+
{
|
|
554
|
+
title: "List the currencies the shop sells in",
|
|
555
|
+
description: "The account\u2019s own currency first, then the ones it switched on, with a name and symbol: what a currency menu on a website is built from. Pass one as `currency` to get_product or list_products for prices in it. Which currencies are offered, and at what rate, is set in the app (Settings \u2192 Verkoop \u2192 Valuta), not through this tool.",
|
|
556
|
+
inputSchema: {
|
|
557
|
+
locale: z3.string().min(2).max(35).optional().describe('The language names are written in, such as "nl".')
|
|
558
|
+
},
|
|
559
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
560
|
+
},
|
|
561
|
+
async ({ locale }) => {
|
|
562
|
+
try {
|
|
563
|
+
const currencies = await client.getCurrencies(
|
|
564
|
+
locale ? { locale } : void 0
|
|
565
|
+
);
|
|
566
|
+
return answer(
|
|
567
|
+
currencies.map(
|
|
568
|
+
(option) => `${option.code} (${option.symbol}) ${option.name}${option.isDefault ? ", the account\u2019s own" : ""}`
|
|
569
|
+
).join("\n"),
|
|
570
|
+
currencies
|
|
571
|
+
);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
return refusal(error, "catalog:read");
|
|
70
574
|
}
|
|
71
575
|
}
|
|
72
576
|
);
|
|
@@ -74,13 +578,16 @@ function registerCatalogTools(server, client) {
|
|
|
74
578
|
"get_product",
|
|
75
579
|
{
|
|
76
580
|
title: "Read one product",
|
|
77
|
-
description: "One product as its page needs it: name, prices, pictures, categories, variants and `fields`, keyed by field slug.
|
|
78
|
-
inputSchema: { slugOrId },
|
|
581
|
+
description: "One product as its page needs it: name, prices, pictures, categories, variants and `fields`, keyed by field slug. Published products only, and may be a minute old: to read back what you just wrote, use get_product_by_ref. Answers that it is not found for an unknown product. " + CONTENT_BLOCKS,
|
|
582
|
+
inputSchema: { slugOrId, currency },
|
|
79
583
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
80
584
|
},
|
|
81
|
-
async ({ slugOrId: wanted }) => {
|
|
585
|
+
async ({ slugOrId: wanted, currency: inCurrency }) => {
|
|
82
586
|
try {
|
|
83
|
-
const product = await client.getProduct(
|
|
587
|
+
const product = await client.getProduct(
|
|
588
|
+
wanted,
|
|
589
|
+
inCurrency ? { currency: inCurrency } : void 0
|
|
590
|
+
);
|
|
84
591
|
if (!product) {
|
|
85
592
|
return answer(`No product "${wanted}" on this account.`, null);
|
|
86
593
|
}
|
|
@@ -90,11 +597,141 @@ function registerCatalogTools(server, client) {
|
|
|
90
597
|
product
|
|
91
598
|
);
|
|
92
599
|
} catch (error) {
|
|
93
|
-
return refusal(error);
|
|
600
|
+
return refusal(error, "catalog:read");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
);
|
|
604
|
+
server.registerTool(
|
|
605
|
+
"list_products",
|
|
606
|
+
{
|
|
607
|
+
title: "List the published products",
|
|
608
|
+
description: "One page of the catalogue: name, address (slug), lowest price, whether it is in stock, its first picture and the public fields a listing shows. Narrow it to one category\u2019s whole branch by its id or address, or by the reference upsert_category recorded (`categorySource` + `categoryRef`), to one catalogue by the slug of its object, or to the product carrying an article number (`sku`). Read get_product for everything a product page needs. May be a minute old.",
|
|
609
|
+
inputSchema: {
|
|
610
|
+
...page,
|
|
611
|
+
category: z3.string().regex(
|
|
612
|
+
/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[a-z0-9]+(-[a-z0-9]+)*)$/i
|
|
613
|
+
).max(64).optional().describe(
|
|
614
|
+
"A category id or address (slug); includes its subcategories."
|
|
615
|
+
),
|
|
616
|
+
categorySource: z3.string().max(64).optional().describe("With categoryRef: whose id the category reference is."),
|
|
617
|
+
categoryRef: z3.string().max(255).optional().describe("A category by the id it has in your own system."),
|
|
618
|
+
sku: z3.string().min(1).max(100).optional().describe(
|
|
619
|
+
"The product carrying this article number, or whose variant does."
|
|
620
|
+
),
|
|
621
|
+
object: z3.string().min(1).max(63).optional().describe(
|
|
622
|
+
'One catalogue, by the slug of its object, such as "product".'
|
|
623
|
+
),
|
|
624
|
+
currency
|
|
625
|
+
},
|
|
626
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
627
|
+
},
|
|
628
|
+
async ({ categorySource, categoryRef, ...input }) => {
|
|
629
|
+
try {
|
|
630
|
+
const list = await client.getProducts({
|
|
631
|
+
...input,
|
|
632
|
+
...categorySource && categoryRef ? {
|
|
633
|
+
categoryRef: {
|
|
634
|
+
source: categorySource,
|
|
635
|
+
externalRef: categoryRef
|
|
636
|
+
}
|
|
637
|
+
} : {}
|
|
638
|
+
});
|
|
639
|
+
return answer(
|
|
640
|
+
list.data.length === 0 ? "No published products match." : [...list.data.map(describeProduct), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
641
|
+
list
|
|
642
|
+
);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
return refusal(error, "catalog:read");
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
server.registerTool(
|
|
649
|
+
"list_categories",
|
|
650
|
+
{
|
|
651
|
+
title: "List the published categories",
|
|
652
|
+
description: 'The categories of the catalogue, each with its path from the top and how many products are in it. Left without `parent`, every category comes back, which is what building a menu in one go needs; "root" asks for the top level, a category id for its children.',
|
|
653
|
+
inputSchema: {
|
|
654
|
+
...page,
|
|
655
|
+
parent: z3.union([z3.literal("root"), z3.string().uuid()]).optional().describe('"root" or the id of a category.')
|
|
656
|
+
},
|
|
657
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
658
|
+
},
|
|
659
|
+
async (input) => {
|
|
660
|
+
try {
|
|
661
|
+
const list = await client.getCategories(input);
|
|
662
|
+
return answer(
|
|
663
|
+
list.data.length === 0 ? "No published categories." : [...list.data.map(describeCategory), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
664
|
+
list
|
|
665
|
+
);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
return refusal(error, "catalog:read");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
);
|
|
671
|
+
server.registerTool(
|
|
672
|
+
"get_category",
|
|
673
|
+
{
|
|
674
|
+
title: "Read one category page",
|
|
675
|
+
description: "One category as its page needs it: name, description, the texts for search engines and social media, and the first page of its products. Pass the nextCursor back to page through the products.",
|
|
676
|
+
inputSchema: {
|
|
677
|
+
slugOrId: z3.string().min(1).max(200).describe("The address (slug) or the id of the category."),
|
|
678
|
+
...page
|
|
679
|
+
},
|
|
680
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
681
|
+
},
|
|
682
|
+
async ({ slugOrId: wanted, ...listOptions }) => {
|
|
683
|
+
try {
|
|
684
|
+
const category = await client.getCategory(wanted, listOptions);
|
|
685
|
+
if (!category) {
|
|
686
|
+
return answer(`No category "${wanted}" on this account.`, null);
|
|
687
|
+
}
|
|
688
|
+
const { data, nextCursor } = category;
|
|
689
|
+
return answer(
|
|
690
|
+
[
|
|
691
|
+
`${data.name} (${data.slug}), ${data.productCount} products.`,
|
|
692
|
+
...data.products.map(describeProduct),
|
|
693
|
+
more(nextCursor)
|
|
694
|
+
].filter(Boolean).join("\n"),
|
|
695
|
+
category
|
|
696
|
+
);
|
|
697
|
+
} catch (error) {
|
|
698
|
+
return refusal(error, "catalog:read");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
);
|
|
702
|
+
server.registerTool(
|
|
703
|
+
"get_product_availability",
|
|
704
|
+
{
|
|
705
|
+
title: "Whether a product can be bought now",
|
|
706
|
+
description: "Live, not cached: whether a visitor can buy the product right now. Never how many are left. For a stock badge that must be current; the inStock of a listing may be a minute old.",
|
|
707
|
+
inputSchema: { slugOrId },
|
|
708
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
709
|
+
},
|
|
710
|
+
async ({ slugOrId: wanted }) => {
|
|
711
|
+
try {
|
|
712
|
+
const status = await client.getAvailability(wanted);
|
|
713
|
+
if (!status) {
|
|
714
|
+
return answer(`No product "${wanted}" on this account.`, null);
|
|
715
|
+
}
|
|
716
|
+
return answer(
|
|
717
|
+
`${status.slug}: ${status.inStock ? "in stock" : "out of stock"}.`,
|
|
718
|
+
status
|
|
719
|
+
);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
return refusal(error, "catalog:read");
|
|
94
722
|
}
|
|
95
723
|
}
|
|
96
724
|
);
|
|
97
725
|
}
|
|
726
|
+
function describeProduct(product) {
|
|
727
|
+
const stock = product.inStock ? "" : ", out of stock";
|
|
728
|
+
const from = product.priceFromCents === null ? `no price in ${product.currency}` : `from ${money(product.priceFromCents, product.currency)}`;
|
|
729
|
+
return `- ${product.name} (${product.slug}): ${from}${stock}`;
|
|
730
|
+
}
|
|
731
|
+
function describeCategory(category) {
|
|
732
|
+
const path = category.path.map((step) => step.name).join(" / ");
|
|
733
|
+
return `- ${path || category.name} (${category.slug}, id ${category.id}): ${category.productCount} products`;
|
|
734
|
+
}
|
|
98
735
|
function describeField(field) {
|
|
99
736
|
if (field.type !== "repeater") {
|
|
100
737
|
return `- ${field.slug}: ${field.label}, ${field.type}`;
|
|
@@ -105,8 +742,393 @@ function describeField(field) {
|
|
|
105
742
|
return `- ${field.slug}: ${field.label}, repeater (${field.display ?? "list"}${preset}; items: ${items}${title})`;
|
|
106
743
|
}
|
|
107
744
|
|
|
745
|
+
// src/catalog-migration-tools.ts
|
|
746
|
+
import { readFile, stat } from "fs/promises";
|
|
747
|
+
import { extname } from "path";
|
|
748
|
+
var BULK_LIMIT = 25;
|
|
749
|
+
var LIMITS = "Limits: 120 requests a minute per key and 600 per account; 5 MB of request bodies a day per key and 20 MB per account (a long HTML description counts). A refusal for speed says how long to wait.";
|
|
750
|
+
var FILE_RULES = {
|
|
751
|
+
image: {
|
|
752
|
+
maxBytes: 2 * 1024 * 1024,
|
|
753
|
+
types: {
|
|
754
|
+
".png": "image/png",
|
|
755
|
+
".jpg": "image/jpeg",
|
|
756
|
+
".jpeg": "image/jpeg",
|
|
757
|
+
".webp": "image/webp"
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
file: {
|
|
761
|
+
maxBytes: 20 * 1024 * 1024,
|
|
762
|
+
types: {
|
|
763
|
+
".pdf": "application/pdf",
|
|
764
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
765
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
766
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
767
|
+
".txt": "text/plain",
|
|
768
|
+
".csv": "text/csv",
|
|
769
|
+
".zip": "application/zip"
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
function target(object2) {
|
|
774
|
+
return object2 ? { object: object2 } : void 0;
|
|
775
|
+
}
|
|
776
|
+
function notDone(text2) {
|
|
777
|
+
return { isError: true, content: [{ type: "text", text: text2 }] };
|
|
778
|
+
}
|
|
779
|
+
function registerCatalogMigrationTools(server, client) {
|
|
780
|
+
server.registerTool(
|
|
781
|
+
"get_product_by_ref",
|
|
782
|
+
{
|
|
783
|
+
title: "Read back a product you wrote",
|
|
784
|
+
description: "One product by the reference it was written under (`source` + `externalRef`), a draft too: its page, its sku and shelf per variant, the shared-link texts and picture, and `files` -- for every picture or file of the last write whether it is `pending`, `stored` or `refused`, with a reason code. Live, never cached: read this after upsert_product to see what arrived.",
|
|
785
|
+
inputSchema: {
|
|
786
|
+
externalRef: CatalogExternalRefSchema,
|
|
787
|
+
source: CatalogSourceSchema,
|
|
788
|
+
...objectInput
|
|
789
|
+
},
|
|
790
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
791
|
+
},
|
|
792
|
+
async ({ externalRef, source, object: object2 }) => {
|
|
793
|
+
try {
|
|
794
|
+
const product = await client.getProductByRef(
|
|
795
|
+
externalRef,
|
|
796
|
+
source,
|
|
797
|
+
target(object2)
|
|
798
|
+
);
|
|
799
|
+
if (!product) {
|
|
800
|
+
return answer(
|
|
801
|
+
`No product with reference "${externalRef}" from "${source}".`,
|
|
802
|
+
null
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
const refused = product.files.filter(
|
|
806
|
+
(file) => file.status === "refused"
|
|
807
|
+
);
|
|
808
|
+
const pending = product.files.filter(
|
|
809
|
+
(file) => file.status === "pending"
|
|
810
|
+
);
|
|
811
|
+
return answer(
|
|
812
|
+
`${product.name} (${product.slug || "no address"}), ${product.availability}; ${product.images.length} pictures; files: ${pending.length} pending, ${refused.length} refused${refused.length ? ` (${refused.map((file) => `${file.ref}: ${file.reason}`).join("; ")})` : ""}.`,
|
|
813
|
+
product
|
|
814
|
+
);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
return refusal(error, "catalog:write");
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
);
|
|
820
|
+
server.registerTool(
|
|
821
|
+
"upsert_products",
|
|
822
|
+
{
|
|
823
|
+
title: "Make or change many products",
|
|
824
|
+
description: `Up to ${BULK_LIMIT} products, one after another, each exactly as upsert_product takes it (\`externalRef\` beside its body). A refusal is that product\u2019s outcome and the others go on; a refusal for speed is waited out and the product sent again; a key that is refused stops the run and the rest is \`skipped\`. Answers per product \`created\`, \`changed\`, \`refused\` (with the fields) or \`skipped\`. ` + LIMITS,
|
|
825
|
+
inputSchema: {
|
|
826
|
+
products: z4.array(
|
|
827
|
+
z4.object({
|
|
828
|
+
externalRef: CatalogExternalRefSchema,
|
|
829
|
+
product: CatalogProductUpsertSchema
|
|
830
|
+
})
|
|
831
|
+
).min(1).max(BULK_LIMIT),
|
|
832
|
+
...objectInput
|
|
833
|
+
},
|
|
834
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
835
|
+
},
|
|
836
|
+
async ({ products, object: object2 }) => {
|
|
837
|
+
try {
|
|
838
|
+
const outcomes = await client.upsertProducts(
|
|
839
|
+
products.map((item) => ({
|
|
840
|
+
externalRef: item.externalRef,
|
|
841
|
+
input: item.product
|
|
842
|
+
})),
|
|
843
|
+
target(object2)
|
|
844
|
+
);
|
|
845
|
+
const count = (kind) => outcomes.filter((outcome) => outcome.outcome === kind).length;
|
|
846
|
+
const refusals = outcomes.filter((outcome) => outcome.outcome === "refused").map(
|
|
847
|
+
(outcome) => `- ${outcome.externalRef}: ${outcome.error?.message ?? "refused"}` + (outcome.error?.fields.length ? ` (${outcome.error.fields.map((field) => `${field.path}: ${field.message}`).join("; ")})` : "")
|
|
848
|
+
);
|
|
849
|
+
return answer(
|
|
850
|
+
[
|
|
851
|
+
`${count("created")} made, ${count("changed")} changed, ${count("refused")} refused, ${count("skipped")} skipped.`,
|
|
852
|
+
...refusals
|
|
853
|
+
].join("\n"),
|
|
854
|
+
outcomes
|
|
855
|
+
);
|
|
856
|
+
} catch (error) {
|
|
857
|
+
return refusal(error, "catalog:write");
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
);
|
|
861
|
+
server.registerTool(
|
|
862
|
+
"delete_field",
|
|
863
|
+
{
|
|
864
|
+
title: "Take a catalogue field away",
|
|
865
|
+
description: "Moves a field to the trash, as the app does: for thirty days it can come back with restore_field, and every product keeps its value meanwhile. Use `permanent` only when the user explicitly asks to delete for good, and first without `confirm`: that only counts the products that would lose their value. Show the user that count; send `confirm` (the slug again) only after they agree. A field of the app\u2019s own, or one a product varies along, is refused.",
|
|
866
|
+
inputSchema: {
|
|
867
|
+
slug: CatalogFieldSlugSchema,
|
|
868
|
+
permanent: z4.boolean().optional(),
|
|
869
|
+
confirm: z4.string().max(63).optional(),
|
|
870
|
+
...objectInput
|
|
871
|
+
},
|
|
872
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
873
|
+
},
|
|
874
|
+
async ({ slug, permanent, confirm, object: object2 }) => {
|
|
875
|
+
try {
|
|
876
|
+
const result = await client.removeField(slug, {
|
|
877
|
+
...target(object2),
|
|
878
|
+
...permanent ? { permanent } : {},
|
|
879
|
+
...confirm ? { confirm } : {}
|
|
880
|
+
});
|
|
881
|
+
const text2 = result.state === "trashed" ? `${slug} is in the trash until ${result.restorableUntil ?? "thirty days from now"}; ${result.productsWithValue} products keep their value.` : result.state === "would_delete" ? `Nothing changed. Deleting ${slug} for good would clear its value on ${result.productsWithValue} products. ${result.confirm}` : `${slug} is deleted; its value on ${result.productsWithValue} products is being cleared.`;
|
|
882
|
+
return answer(text2, result);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
return refusal(error, "catalog:write");
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
);
|
|
888
|
+
server.registerTool(
|
|
889
|
+
"restore_field",
|
|
890
|
+
{
|
|
891
|
+
title: "Bring a catalogue field back from the trash",
|
|
892
|
+
description: "A field moved to the trash in the last thirty days comes back under its own id, with every value the products kept.",
|
|
893
|
+
inputSchema: { slug: CatalogFieldSlugSchema, ...objectInput },
|
|
894
|
+
annotations: { destructiveHint: false, idempotentHint: true }
|
|
895
|
+
},
|
|
896
|
+
async ({ slug, object: object2 }) => {
|
|
897
|
+
try {
|
|
898
|
+
const field = await client.restoreField(slug, target(object2));
|
|
899
|
+
return answer(`${field.label} (${field.slug}) is back.`, field);
|
|
900
|
+
} catch (error) {
|
|
901
|
+
return refusal(error, "catalog:write");
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
);
|
|
905
|
+
server.registerTool(
|
|
906
|
+
"get_category_by_ref",
|
|
907
|
+
{
|
|
908
|
+
title: "Find a category by its reference",
|
|
909
|
+
description: "The category upsert_category recorded with this `source` and `externalRef` -- the id of a series in your own system -- also after its address was changed in the app. Only a published category.",
|
|
910
|
+
inputSchema: {
|
|
911
|
+
source: CatalogSourceSchema,
|
|
912
|
+
externalRef: CatalogExternalRefSchema
|
|
913
|
+
},
|
|
914
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
915
|
+
},
|
|
916
|
+
async ({ source, externalRef }) => {
|
|
917
|
+
try {
|
|
918
|
+
const category = await client.getCategoryByRef(source, externalRef);
|
|
919
|
+
return answer(
|
|
920
|
+
category ? `${category.name} (${category.slug}), id ${category.id}.` : `No published category with reference "${externalRef}" from "${source}".`,
|
|
921
|
+
category
|
|
922
|
+
);
|
|
923
|
+
} catch (error) {
|
|
924
|
+
return refusal(error, "catalog:read");
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
);
|
|
928
|
+
server.registerTool(
|
|
929
|
+
"upload_catalog_file",
|
|
930
|
+
{
|
|
931
|
+
title: "Upload a picture or a file from disk",
|
|
932
|
+
description: 'Reads one file from the user\u2019s disk and uploads it, for a product write to name as { "upload": "<uploadPath>" } instead of a url: a picture (png, jpeg, webp, at most 2 MB) for `images`, `socialImage` or a list item\u2019s image, or a "file" (pdf, Office, txt, csv, zip, at most 20 MB) for a list item\u2019s download. An upload is kept a day.',
|
|
933
|
+
inputSchema: {
|
|
934
|
+
kind: z4.enum(["image", "file"]),
|
|
935
|
+
filePath: z4.string().min(1).max(1e3).describe("The absolute path of the file on this machine.")
|
|
936
|
+
},
|
|
937
|
+
annotations: { destructiveHint: false, idempotentHint: false }
|
|
938
|
+
},
|
|
939
|
+
async ({ kind, filePath }) => {
|
|
940
|
+
const rules = FILE_RULES[kind];
|
|
941
|
+
const extension = extname(filePath).toLowerCase();
|
|
942
|
+
const contentType = rules.types[extension];
|
|
943
|
+
if (!contentType) {
|
|
944
|
+
return notDone(
|
|
945
|
+
`A ${kind} is a ${Object.keys(rules.types).join(", ")} file; this is "${extension || "no extension"}".`
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
const info = await stat(filePath).catch(() => null);
|
|
949
|
+
if (!info?.isFile()) {
|
|
950
|
+
return notDone(`"${filePath}" is not a file on this machine.`);
|
|
951
|
+
}
|
|
952
|
+
if (info.size > rules.maxBytes) {
|
|
953
|
+
return notDone(
|
|
954
|
+
`The file is ${info.size} bytes; a ${kind} may be at most ${rules.maxBytes}.`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const result = await client.uploadCatalogFile({
|
|
959
|
+
kind,
|
|
960
|
+
bytes: new Uint8Array(await readFile(filePath)),
|
|
961
|
+
contentType
|
|
962
|
+
});
|
|
963
|
+
return answer(
|
|
964
|
+
`Uploaded. Name it as { "upload": "${result.uploadPath}" }.`,
|
|
965
|
+
result
|
|
966
|
+
);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
return refusal(error, "catalog:write");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/catalog-write-tools.ts
|
|
975
|
+
function target2(object2) {
|
|
976
|
+
return object2 ? { object: object2 } : void 0;
|
|
977
|
+
}
|
|
978
|
+
var FIELD_KINDS = 'Kinds: text, number, select, checkbox, color (a value "#rrggbb"; upper case is stored lower case), url, date, and three content blocks for a product page: "content_text" (formatted text, no config), "cta" (a call to action; config { secondary: true } offers a second button) and "repeater" (a list, an accordion such as a FAQ, downloads or testimonials; config { display: "list" | "accordion", preset?: "list" | "faq" | "downloads" | "testimonials", itemFields: [{ key, label, type: text | richtext | url | icon | image | file | number | select }], titleKey?: the item field that names an item }). A number item may name { min?, max?, suffix? }, a select item names { options: [{ value, label }] } (at most 100); its value is one of those values. An image or file item is filled with { url } or { upload } in upsert_product, fetched after the answer. A number may take config { prefix?, suffix? }. A configurator is made with upsert_configurator. The kind is fixed once the field exists, and so is the type of a repeater\u2019s item field.';
|
|
979
|
+
var IDENTITY = 'A product is known by `externalRef`, its id in the system it comes from, together with `source`, the name of that system (for example "acme-webshop"). The same pair always means the same product: sending it again changes it instead of making a second one, so use references that stay the same, never a name that may change.';
|
|
980
|
+
function registerCatalogWriteTools(server, client) {
|
|
981
|
+
server.registerTool(
|
|
982
|
+
"upsert_product",
|
|
983
|
+
{
|
|
984
|
+
title: "Make or change a product",
|
|
985
|
+
description: IDENTITY + ' Every field but `source` may be left out, and left out means "leave it as it is"; null empties a field. A new product needs a `name` and is a draft unless `availability` says otherwise: do not publish without the user asking. `prices`, `categories`, `images` and `variants` each replace what the product had as a whole, and a variant left out is archived. Categories are named by slug and a missing one is refused unless `createMissingCategories` is true; a choice a select field does not offer is refused unless `addMissingOptions` is true. Pictures are https URLs, fetched after the answer (`images: "pending"`). Amounts are in cents. A price without `currency` is in the product\'s own; one with another `currency` is the fixed price for that currency, may share its label with the euro price, and can never be the default. `stock` (physical only; per variant when there are variants) is a count, not a movement: a second run writes it again over a correction made in the app, so send it only when the source keeps the count. A picture is { url } or { upload } (upload_catalog_file), with `alt`, a `role` in your own words ("insitu") and flat `details` ({ widthCm: 120 }); `socialImage` is the shared-link picture beside `socialTitle` and `socialDescription`. Files arrive after the answer: get_product_by_ref says per file whether it came. `description` is plain text (5,000 characters); `longDescription` is HTML, cleaned to headings, paragraphs, lists, links, bold and italic. To write many products use upsert_products. Read list_catalog_fields first for the slugs `fields` takes.',
|
|
986
|
+
inputSchema: {
|
|
987
|
+
externalRef: CatalogExternalRefSchema.describe(
|
|
988
|
+
"The id of the product in the system named by `source`."
|
|
989
|
+
),
|
|
990
|
+
...CatalogProductUpsertSchema.shape,
|
|
991
|
+
...objectInput
|
|
992
|
+
},
|
|
993
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
994
|
+
},
|
|
995
|
+
async ({ externalRef, object: object2, ...input }) => {
|
|
996
|
+
try {
|
|
997
|
+
const product = await client.upsertProduct(
|
|
998
|
+
externalRef,
|
|
999
|
+
input,
|
|
1000
|
+
target2(object2)
|
|
1001
|
+
);
|
|
1002
|
+
const verb = product.created ? "Made" : "Changed";
|
|
1003
|
+
return answer(
|
|
1004
|
+
`${verb} ${product.name} (${product.slug ?? "no address"}), ${product.availability}; ${product.variants.length} variants; pictures ${product.images}.`,
|
|
1005
|
+
product
|
|
1006
|
+
);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
return refusal(error, "catalog:write");
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
);
|
|
1012
|
+
server.registerTool(
|
|
1013
|
+
"archive_product",
|
|
1014
|
+
{
|
|
1015
|
+
title: "Take a product out of the catalogue",
|
|
1016
|
+
description: IDENTITY + " Archiving takes the product and its variants off the website and out of the shop at once. It stays in the database for every invoice that names it, and upsert_product with `archived: false` brings it back. Ask the user before archiving anything they did not name.",
|
|
1017
|
+
inputSchema: {
|
|
1018
|
+
externalRef: CatalogExternalRefSchema.describe(
|
|
1019
|
+
"The id of the product in the system named by `source`."
|
|
1020
|
+
),
|
|
1021
|
+
...CatalogProductArchiveSchema.shape,
|
|
1022
|
+
...objectInput
|
|
1023
|
+
},
|
|
1024
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
1025
|
+
},
|
|
1026
|
+
async ({ externalRef, source, object: object2 }) => {
|
|
1027
|
+
try {
|
|
1028
|
+
const result = await client.archiveProduct(
|
|
1029
|
+
externalRef,
|
|
1030
|
+
source,
|
|
1031
|
+
target2(object2)
|
|
1032
|
+
);
|
|
1033
|
+
return answer(
|
|
1034
|
+
result.alreadyArchived ? "That product was already archived." : "Archived.",
|
|
1035
|
+
result
|
|
1036
|
+
);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
return refusal(error, "catalog:write");
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
);
|
|
1042
|
+
server.registerTool(
|
|
1043
|
+
"upsert_category",
|
|
1044
|
+
{
|
|
1045
|
+
title: "Make or change a category",
|
|
1046
|
+
description: "A category, known by its address (slug): sending the same slug again changes it. Left-out fields stay as they are; a new category needs a `name`. `parentSlug` places it under another category, null at the top. A new category is a draft unless `availability` says otherwise. Categories belong to one catalogue: a product names only categories of its own catalogue. Send `source` and `externalRef` -- the id of the series in your own system -- and the category keeps that identity: it is found by it even after its address was changed in the app (and gets the address of this call back), and list_products narrows by it. Content of a series (long texts, galleries) stays in your own CMS.",
|
|
1047
|
+
inputSchema: {
|
|
1048
|
+
slug: CatalogWebSlugSchema.describe(
|
|
1049
|
+
"The address of the category: lower case, digits and dashes."
|
|
1050
|
+
),
|
|
1051
|
+
...CatalogCategoryUpsertSchema.shape,
|
|
1052
|
+
...objectInput
|
|
1053
|
+
},
|
|
1054
|
+
annotations: { destructiveHint: false, idempotentHint: true }
|
|
1055
|
+
},
|
|
1056
|
+
async ({ slug, object: object2, ...input }) => {
|
|
1057
|
+
try {
|
|
1058
|
+
const category = await client.upsertCategory(
|
|
1059
|
+
slug,
|
|
1060
|
+
input,
|
|
1061
|
+
target2(object2)
|
|
1062
|
+
);
|
|
1063
|
+
return answer(
|
|
1064
|
+
`${category.created ? "Made" : "Changed"} ${category.name} (${category.slug}).`,
|
|
1065
|
+
category
|
|
1066
|
+
);
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
return refusal(error, "catalog:write");
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
);
|
|
1072
|
+
server.registerTool(
|
|
1073
|
+
"upsert_field",
|
|
1074
|
+
{
|
|
1075
|
+
title: "Make or change a catalogue field",
|
|
1076
|
+
description: "A custom field on products, known by its slug: sending the same slug again changes it. A new field needs a `label`. " + FIELD_KINDS + " `config` replaces the settings as a whole. `options` makes sure choices exist and never removes one; `removeOptions` takes choices off the list, which empties them on every product that had them. `public` decides whether the website may show the field at all. `dryRun: true` answers what would change -- and how many products hold each choice that would go -- and writes nothing. A slug in the trash is taken until restore_field or a permanent delete_field.",
|
|
1077
|
+
inputSchema: {
|
|
1078
|
+
slug: CatalogFieldSlugSchema.describe(
|
|
1079
|
+
"The slug of the field: a lower-case letter, then lower case, digits and underscores."
|
|
1080
|
+
),
|
|
1081
|
+
...CatalogAttributeUpsertSchema.shape,
|
|
1082
|
+
...objectInput
|
|
1083
|
+
},
|
|
1084
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
1085
|
+
},
|
|
1086
|
+
async ({ slug, object: object2, ...input }) => {
|
|
1087
|
+
try {
|
|
1088
|
+
if (input.dryRun) {
|
|
1089
|
+
const { dryRun: _dryRun, ...rest } = input;
|
|
1090
|
+
const preview = await client.previewField(slug, rest, target2(object2));
|
|
1091
|
+
return answer(`Nothing written. ${JSON.stringify(preview)}`, preview);
|
|
1092
|
+
}
|
|
1093
|
+
const field = await client.upsertField(slug, input, target2(object2));
|
|
1094
|
+
return answer(
|
|
1095
|
+
`${field.created ? "Made" : "Changed"} ${field.label} (${field.slug}), ${field.type}, ${field.public ? "public" : "not public"}.`,
|
|
1096
|
+
field
|
|
1097
|
+
);
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
return refusal(error, "catalog:write");
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
);
|
|
1103
|
+
server.registerTool(
|
|
1104
|
+
"upsert_catalog",
|
|
1105
|
+
{
|
|
1106
|
+
title: "Make or rename a product catalogue",
|
|
1107
|
+
description: 'A product catalogue of its own beside the account\u2019s products, such as "Fireplaces", known by its slug: sending the same slug again renames it. A new one needs both names, one product and many. Its fields, categories and products are then written with `object` set to this slug. Make one only when the user asks for a separate catalogue; most products belong in the account\u2019s own.',
|
|
1108
|
+
inputSchema: {
|
|
1109
|
+
slug: CatalogObjectSlugSchema.describe(
|
|
1110
|
+
"The slug of the catalogue: a lower-case letter, then lower case, digits and underscores."
|
|
1111
|
+
),
|
|
1112
|
+
...CatalogCatalogueUpsertSchema.shape
|
|
1113
|
+
},
|
|
1114
|
+
annotations: { destructiveHint: false, idempotentHint: true }
|
|
1115
|
+
},
|
|
1116
|
+
async ({ slug, ...input }) => {
|
|
1117
|
+
try {
|
|
1118
|
+
const catalog = await client.upsertCatalog(slug, input);
|
|
1119
|
+
return answer(
|
|
1120
|
+
`${catalog.created ? "Made" : "Changed"} ${catalog.namePlural} (${catalog.slug}).`,
|
|
1121
|
+
catalog
|
|
1122
|
+
);
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
return refusal(error, "catalog:write");
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
108
1130
|
// src/configurator-tools.ts
|
|
109
|
-
import { z as
|
|
1131
|
+
import { z as z8 } from "zod";
|
|
110
1132
|
|
|
111
1133
|
// ../features/product-configurator/src/shared/configurator-limits.ts
|
|
112
1134
|
var CONFIGURATOR_LIMITS = {
|
|
@@ -155,11 +1177,11 @@ function issue(path, code, params) {
|
|
|
155
1177
|
return params ? { path, code, params } : { path, code };
|
|
156
1178
|
}
|
|
157
1179
|
function formatIssuePath(path) {
|
|
158
|
-
return path.reduce((
|
|
1180
|
+
return path.reduce((text2, part) => {
|
|
159
1181
|
if (typeof part === "number") {
|
|
160
|
-
return `${
|
|
1182
|
+
return `${text2}[${part}]`;
|
|
161
1183
|
}
|
|
162
|
-
return
|
|
1184
|
+
return text2 ? `${text2}.${part}` : part;
|
|
163
1185
|
}, "");
|
|
164
1186
|
}
|
|
165
1187
|
|
|
@@ -181,11 +1203,11 @@ function tokenize(source) {
|
|
|
181
1203
|
while (end < source.length && /[0-9.]/.test(source.charAt(end))) {
|
|
182
1204
|
end += 1;
|
|
183
1205
|
}
|
|
184
|
-
const
|
|
185
|
-
if (!/^(\d+(\.\d+)?|\.\d+)$/.test(
|
|
1206
|
+
const text2 = source.slice(index, end);
|
|
1207
|
+
if (!/^(\d+(\.\d+)?|\.\d+)$/.test(text2)) {
|
|
186
1208
|
return null;
|
|
187
1209
|
}
|
|
188
|
-
tokens.push({ type: "number", value: Number(
|
|
1210
|
+
tokens.push({ type: "number", value: Number(text2) });
|
|
189
1211
|
index = end;
|
|
190
1212
|
continue;
|
|
191
1213
|
}
|
|
@@ -472,16 +1494,16 @@ function evaluateFormulaSource(source, scope) {
|
|
|
472
1494
|
}
|
|
473
1495
|
|
|
474
1496
|
// ../features/product-configurator/src/shared/geometry-document.schema.ts
|
|
475
|
-
import { z as
|
|
1497
|
+
import { z as z5 } from "zod";
|
|
476
1498
|
var CONFIGURATOR_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
|
|
477
1499
|
var CONFIGURATOR_CODE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
478
|
-
var ConfiguratorNameSchema =
|
|
479
|
-
var ConfiguratorCodeSchema =
|
|
480
|
-
var FormulaSchema =
|
|
481
|
-
|
|
482
|
-
|
|
1500
|
+
var ConfiguratorNameSchema = z5.string().regex(CONFIGURATOR_NAME_PATTERN, "configurator.invalidName");
|
|
1501
|
+
var ConfiguratorCodeSchema = z5.string().regex(CONFIGURATOR_CODE_PATTERN, "configurator.invalidCode");
|
|
1502
|
+
var FormulaSchema = z5.union([
|
|
1503
|
+
z5.number().finite(),
|
|
1504
|
+
z5.string().trim().min(1).max(CONFIGURATOR_LIMITS.formulaChars, "configurator.formulaTooLong")
|
|
483
1505
|
]);
|
|
484
|
-
var Vector3Schema =
|
|
1506
|
+
var Vector3Schema = z5.tuple([FormulaSchema, FormulaSchema, FormulaSchema]);
|
|
485
1507
|
var PRIMITIVE_TYPES = [
|
|
486
1508
|
"box",
|
|
487
1509
|
"rounded_top_box",
|
|
@@ -524,86 +1546,93 @@ var PrimitiveNodeFields = {
|
|
|
524
1546
|
size: Vector3Schema,
|
|
525
1547
|
at: Vector3Schema,
|
|
526
1548
|
visible: FormulaSchema.optional(),
|
|
527
|
-
params:
|
|
1549
|
+
params: z5.record(ConfiguratorNameSchema, FormulaSchema).optional()
|
|
528
1550
|
};
|
|
529
|
-
var PrimitiveNodeSchema =
|
|
1551
|
+
var PrimitiveNodeSchema = z5.strictObject({
|
|
530
1552
|
...PrimitiveNodeFields,
|
|
531
|
-
type:
|
|
1553
|
+
type: z5.enum(PRIMITIVE_TYPES).exclude(["model"])
|
|
532
1554
|
});
|
|
533
|
-
var ModelPathSchema =
|
|
534
|
-
var ServedFileUrlSchema =
|
|
1555
|
+
var ModelPathSchema = z5.string().regex(CONFIGURATOR_MODEL_PATH_PATTERN, "configurator.invalidModelPath");
|
|
1556
|
+
var ServedFileUrlSchema = z5.url({ protocol: /^https?$/ });
|
|
535
1557
|
function geometryNodeSchema(modelFile) {
|
|
536
|
-
const ModelNodeSchema =
|
|
1558
|
+
const ModelNodeSchema = z5.strictObject({
|
|
537
1559
|
...PrimitiveNodeFields,
|
|
538
|
-
type:
|
|
1560
|
+
type: z5.literal("model"),
|
|
539
1561
|
model: modelFile
|
|
540
1562
|
});
|
|
541
|
-
const ModelPerChoiceNodeSchema =
|
|
1563
|
+
const ModelPerChoiceNodeSchema = z5.strictObject({
|
|
542
1564
|
...PrimitiveNodeFields,
|
|
543
|
-
type:
|
|
1565
|
+
type: z5.literal("model"),
|
|
544
1566
|
follows: ConfiguratorNameSchema,
|
|
545
|
-
models:
|
|
1567
|
+
models: z5.record(ConfiguratorCodeSchema, modelFile).refine(
|
|
546
1568
|
(models) => Object.keys(models).length <= CONFIGURATOR_LIMITS.presets,
|
|
547
1569
|
{ message: "configurator.tooMany" }
|
|
548
1570
|
)
|
|
549
1571
|
});
|
|
550
|
-
const node =
|
|
551
|
-
() =>
|
|
1572
|
+
const node = z5.lazy(
|
|
1573
|
+
() => z5.union([
|
|
552
1574
|
PrimitiveNodeSchema,
|
|
553
1575
|
ModelNodeSchema,
|
|
554
1576
|
ModelPerChoiceNodeSchema,
|
|
555
|
-
|
|
1577
|
+
z5.strictObject({
|
|
556
1578
|
id: ConfiguratorNameSchema,
|
|
557
|
-
type:
|
|
1579
|
+
type: z5.literal("repeat"),
|
|
558
1580
|
count: FormulaSchema,
|
|
559
|
-
index:
|
|
1581
|
+
index: z5.enum(["i", "j"]).optional(),
|
|
560
1582
|
visible: FormulaSchema.optional(),
|
|
561
|
-
children:
|
|
1583
|
+
children: z5.array(node).min(1)
|
|
562
1584
|
})
|
|
563
1585
|
])
|
|
564
1586
|
);
|
|
565
1587
|
return node;
|
|
566
1588
|
}
|
|
567
1589
|
var GeometryNodeSchema = geometryNodeSchema(ModelPathSchema);
|
|
568
|
-
var GeometryBindingSchema =
|
|
1590
|
+
var GeometryBindingSchema = z5.strictObject({
|
|
569
1591
|
param: ConfiguratorNameSchema,
|
|
570
|
-
map:
|
|
1592
|
+
map: z5.record(ConfiguratorCodeSchema, z5.number().finite()).optional()
|
|
571
1593
|
});
|
|
572
|
-
var GeometryInputSchema =
|
|
1594
|
+
var GeometryInputSchema = z5.strictObject({
|
|
573
1595
|
name: ConfiguratorNameSchema,
|
|
574
1596
|
binding: GeometryBindingSchema
|
|
575
1597
|
});
|
|
576
|
-
var GeometrySlotSchema =
|
|
1598
|
+
var GeometrySlotSchema = z5.strictObject({
|
|
577
1599
|
name: ConfiguratorNameSchema,
|
|
578
1600
|
param: ConfiguratorNameSchema.optional(),
|
|
579
1601
|
material: ConfiguratorCodeSchema.optional(),
|
|
580
|
-
color:
|
|
1602
|
+
color: z5.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor").optional(),
|
|
1603
|
+
/**
|
|
1604
|
+
* Which way the grain of a picture runs on this part: `horizontal` (the
|
|
1605
|
+
* default) as the picture is drawn, `vertical` turned a quarter. It belongs
|
|
1606
|
+
* to the part, not the material: the same oak lies on a cabinet and stands
|
|
1607
|
+
* on a tall wall panel.
|
|
1608
|
+
*/
|
|
1609
|
+
grain: z5.enum(["horizontal", "vertical"]).optional()
|
|
581
1610
|
});
|
|
582
|
-
var GeometryAnnotationSchema =
|
|
1611
|
+
var GeometryAnnotationSchema = z5.strictObject({
|
|
583
1612
|
id: ConfiguratorNameSchema,
|
|
584
|
-
axis:
|
|
1613
|
+
axis: z5.enum(["x", "y"]),
|
|
585
1614
|
from: FormulaSchema,
|
|
586
1615
|
to: FormulaSchema,
|
|
587
1616
|
at: FormulaSchema,
|
|
588
|
-
label:
|
|
1617
|
+
label: z5.string().trim().min(1).max(80).optional()
|
|
589
1618
|
});
|
|
590
1619
|
function geometryDocumentSchema(modelFile) {
|
|
591
|
-
return
|
|
1620
|
+
return z5.strictObject({
|
|
592
1621
|
/** Rises with every new upload, so a product page can say which one it shows. */
|
|
593
|
-
version:
|
|
1622
|
+
version: z5.number().int().min(1),
|
|
594
1623
|
/** The starter document this one was copied from, for information only. */
|
|
595
|
-
startedFrom:
|
|
596
|
-
inputs:
|
|
597
|
-
constants:
|
|
598
|
-
slots:
|
|
599
|
-
root:
|
|
600
|
-
camera:
|
|
1624
|
+
startedFrom: z5.string().trim().max(80).optional(),
|
|
1625
|
+
inputs: z5.array(GeometryInputSchema).max(CONFIGURATOR_LIMITS.geometryInputs),
|
|
1626
|
+
constants: z5.record(ConfiguratorNameSchema, z5.number().finite()),
|
|
1627
|
+
slots: z5.array(GeometrySlotSchema).min(1).max(CONFIGURATOR_LIMITS.geometrySlots),
|
|
1628
|
+
root: z5.array(geometryNodeSchema(modelFile)).min(1),
|
|
1629
|
+
camera: z5.strictObject({
|
|
601
1630
|
/** Degrees around the vertical axis, 0 is straight on. */
|
|
602
|
-
azimuth:
|
|
1631
|
+
azimuth: z5.number().min(-90).max(90),
|
|
603
1632
|
/** Degrees above the horizon. */
|
|
604
|
-
elevation:
|
|
1633
|
+
elevation: z5.number().min(-10).max(80)
|
|
605
1634
|
}).optional(),
|
|
606
|
-
annotations:
|
|
1635
|
+
annotations: z5.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.annotations)
|
|
607
1636
|
});
|
|
608
1637
|
}
|
|
609
1638
|
var GeometryDocumentSchema = geometryDocumentSchema(ModelPathSchema);
|
|
@@ -893,98 +1922,98 @@ function followProblems(follows, path, context) {
|
|
|
893
1922
|
}
|
|
894
1923
|
|
|
895
1924
|
// ../features/product-configurator/src/shared/configurator-structure.schema.ts
|
|
896
|
-
import { z as
|
|
897
|
-
var LabelSchema =
|
|
1925
|
+
import { z as z6 } from "zod";
|
|
1926
|
+
var LabelSchema = z6.string().trim().min(1).max(80);
|
|
898
1927
|
var CONFIGURATOR_TEXTURE_PATH_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(webp|jpg|jpeg)$/;
|
|
899
|
-
var TexturePathSchema =
|
|
1928
|
+
var TexturePathSchema = z6.string().regex(CONFIGURATOR_TEXTURE_PATH_PATTERN, "configurator.invalidTexturePath");
|
|
900
1929
|
var ParameterBase = {
|
|
901
1930
|
key: ConfiguratorNameSchema,
|
|
902
1931
|
label: LabelSchema,
|
|
903
1932
|
tab: ConfiguratorNameSchema
|
|
904
1933
|
};
|
|
905
|
-
var ConfiguratorParameterSchema =
|
|
906
|
-
|
|
1934
|
+
var ConfiguratorParameterSchema = z6.discriminatedUnion("kind", [
|
|
1935
|
+
z6.strictObject({
|
|
907
1936
|
...ParameterBase,
|
|
908
|
-
kind:
|
|
1937
|
+
kind: z6.literal("number"),
|
|
909
1938
|
/** `mm` is a measure, `count` a number of things (compartments). */
|
|
910
|
-
unit:
|
|
911
|
-
step:
|
|
1939
|
+
unit: z6.enum(["mm", "count"]),
|
|
1940
|
+
step: z6.number().positive().finite(),
|
|
912
1941
|
/** The widest range any model may offer; a model narrows it in its value. */
|
|
913
|
-
min:
|
|
914
|
-
max:
|
|
1942
|
+
min: z6.number().finite(),
|
|
1943
|
+
max: z6.number().finite()
|
|
915
1944
|
}),
|
|
916
|
-
|
|
1945
|
+
z6.strictObject({
|
|
917
1946
|
...ParameterBase,
|
|
918
|
-
kind:
|
|
1947
|
+
kind: z6.literal("choice"),
|
|
919
1948
|
list: ConfiguratorNameSchema
|
|
920
1949
|
}),
|
|
921
|
-
|
|
922
|
-
|
|
1950
|
+
z6.strictObject({ ...ParameterBase, kind: z6.literal("material") }),
|
|
1951
|
+
z6.strictObject({ ...ParameterBase, kind: z6.literal("toggle") })
|
|
923
1952
|
]);
|
|
924
|
-
var choiceListSchema = (picture) =>
|
|
1953
|
+
var choiceListSchema = (picture) => z6.strictObject({
|
|
925
1954
|
key: ConfiguratorNameSchema,
|
|
926
1955
|
label: LabelSchema,
|
|
927
|
-
options:
|
|
928
|
-
|
|
1956
|
+
options: z6.array(
|
|
1957
|
+
z6.strictObject({
|
|
929
1958
|
code: ConfiguratorCodeSchema,
|
|
930
1959
|
label: LabelSchema,
|
|
931
1960
|
image: picture.optional()
|
|
932
1961
|
})
|
|
933
1962
|
).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
934
1963
|
});
|
|
935
|
-
var materialSchema = (picture) =>
|
|
1964
|
+
var materialSchema = (picture) => z6.strictObject({
|
|
936
1965
|
code: ConfiguratorCodeSchema,
|
|
937
1966
|
label: LabelSchema,
|
|
938
|
-
color:
|
|
1967
|
+
color: z6.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor"),
|
|
939
1968
|
texture: picture.optional(),
|
|
940
1969
|
/** Millimetres one repeat of the texture covers; the two maps below share it. */
|
|
941
|
-
textureSize:
|
|
1970
|
+
textureSize: z6.number().positive().max(1e4).optional(),
|
|
942
1971
|
/** Relief that moves with the light: a tangent-space normal map. */
|
|
943
1972
|
normalMap: picture.optional(),
|
|
944
1973
|
/** How deep the relief reads; 1 is the map as drawn. */
|
|
945
|
-
normalScale:
|
|
1974
|
+
normalScale: z6.number().min(0).max(3).optional(),
|
|
946
1975
|
/** Where the surface is matte and where it shines (the green channel), times `roughness`. */
|
|
947
1976
|
roughnessMap: picture.optional(),
|
|
948
|
-
roughness:
|
|
949
|
-
metalness:
|
|
1977
|
+
roughness: z6.number().min(0).max(1).optional(),
|
|
1978
|
+
metalness: z6.number().min(0).max(1).optional(),
|
|
950
1979
|
/** How much of what is behind it shows through: glass in front of a flame. Absent is solid. */
|
|
951
|
-
opacity:
|
|
1980
|
+
opacity: z6.number().min(0.05).max(1).optional(),
|
|
952
1981
|
/** Gives light rather than reflecting it: a flame, a LED. */
|
|
953
|
-
emissive:
|
|
1982
|
+
emissive: z6.boolean().optional(),
|
|
954
1983
|
priceGroup: ConfiguratorCodeSchema.optional()
|
|
955
1984
|
});
|
|
956
1985
|
var ConfiguratorChoiceListSchema = choiceListSchema(TexturePathSchema);
|
|
957
1986
|
var ConfiguratorMaterialSchema = materialSchema(TexturePathSchema);
|
|
958
|
-
var ConditionValueSchema =
|
|
959
|
-
|
|
960
|
-
|
|
1987
|
+
var ConditionValueSchema = z6.union([
|
|
1988
|
+
z6.number().finite(),
|
|
1989
|
+
z6.boolean(),
|
|
961
1990
|
ConfiguratorCodeSchema,
|
|
962
|
-
|
|
1991
|
+
z6.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
963
1992
|
]);
|
|
964
|
-
var ConfiguratorConditionSchema =
|
|
1993
|
+
var ConfiguratorConditionSchema = z6.strictObject({
|
|
965
1994
|
param: ConfiguratorNameSchema,
|
|
966
|
-
op:
|
|
1995
|
+
op: z6.enum(["eq", "neq", "in", "gte", "lte"]),
|
|
967
1996
|
value: ConditionValueSchema
|
|
968
1997
|
});
|
|
969
|
-
var ConfiguratorRuleSchema =
|
|
1998
|
+
var ConfiguratorRuleSchema = z6.discriminatedUnion("kind", [
|
|
970
1999
|
/** Show `param` only while `when` holds. */
|
|
971
|
-
|
|
972
|
-
kind:
|
|
2000
|
+
z6.strictObject({
|
|
2001
|
+
kind: z6.literal("visible"),
|
|
973
2002
|
param: ConfiguratorNameSchema,
|
|
974
2003
|
when: ConfiguratorConditionSchema
|
|
975
2004
|
}),
|
|
976
2005
|
/** Narrow a number parameter: `max cabinet_height = total_height - 300`. */
|
|
977
|
-
|
|
978
|
-
kind:
|
|
2006
|
+
z6.strictObject({
|
|
2007
|
+
kind: z6.literal("limit"),
|
|
979
2008
|
param: ConfiguratorNameSchema,
|
|
980
|
-
bound:
|
|
2009
|
+
bound: z6.enum(["min", "max"]),
|
|
981
2010
|
formula: FormulaSchema
|
|
982
2011
|
}),
|
|
983
2012
|
/** While `when` holds, these codes of a choice or material are not offered. */
|
|
984
|
-
|
|
985
|
-
kind:
|
|
2013
|
+
z6.strictObject({
|
|
2014
|
+
kind: z6.literal("exclude"),
|
|
986
2015
|
param: ConfiguratorNameSchema,
|
|
987
|
-
codes:
|
|
2016
|
+
codes: z6.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList),
|
|
988
2017
|
when: ConfiguratorConditionSchema
|
|
989
2018
|
})
|
|
990
2019
|
]);
|
|
@@ -994,23 +2023,23 @@ var PriceRuleBase = {
|
|
|
994
2023
|
/** How many times the amount counts, e.g. `compartments`. Absent is once. */
|
|
995
2024
|
multiplier: FormulaSchema.optional()
|
|
996
2025
|
};
|
|
997
|
-
var ConfiguratorPriceRuleSchema =
|
|
2026
|
+
var ConfiguratorPriceRuleSchema = z6.discriminatedUnion("kind", [
|
|
998
2027
|
/** The surcharge the model gives the chosen option. */
|
|
999
|
-
|
|
2028
|
+
z6.strictObject({
|
|
1000
2029
|
...PriceRuleBase,
|
|
1001
|
-
kind:
|
|
2030
|
+
kind: z6.literal("choice"),
|
|
1002
2031
|
param: ConfiguratorNameSchema
|
|
1003
2032
|
}),
|
|
1004
2033
|
/** The surcharge of the chosen material's price group. */
|
|
1005
|
-
|
|
2034
|
+
z6.strictObject({
|
|
1006
2035
|
...PriceRuleBase,
|
|
1007
|
-
kind:
|
|
2036
|
+
kind: z6.literal("material"),
|
|
1008
2037
|
param: ConfiguratorNameSchema
|
|
1009
2038
|
}),
|
|
1010
2039
|
/** An amount, counted while a switch is on. */
|
|
1011
|
-
|
|
2040
|
+
z6.strictObject({
|
|
1012
2041
|
...PriceRuleBase,
|
|
1013
|
-
kind:
|
|
2042
|
+
kind: z6.literal("toggle"),
|
|
1014
2043
|
param: ConfiguratorNameSchema,
|
|
1015
2044
|
amount: ConfiguratorNameSchema
|
|
1016
2045
|
}),
|
|
@@ -1018,10 +2047,10 @@ var ConfiguratorPriceRuleSchema = z3.discriminatedUnion("kind", [
|
|
|
1018
2047
|
* A price on measure: `max(0, total_width - 1800) / 1000 * meter_price`.
|
|
1019
2048
|
* Reads number parameters (in their unit) and amounts (in cents).
|
|
1020
2049
|
*/
|
|
1021
|
-
|
|
2050
|
+
z6.strictObject({
|
|
1022
2051
|
key: ConfiguratorNameSchema,
|
|
1023
2052
|
label: LabelSchema,
|
|
1024
|
-
kind:
|
|
2053
|
+
kind: z6.literal("formula"),
|
|
1025
2054
|
formula: FormulaSchema
|
|
1026
2055
|
})
|
|
1027
2056
|
]);
|
|
@@ -1031,43 +2060,43 @@ var CONFIGURATOR_SCENE_LIGHTINGS = [
|
|
|
1031
2060
|
"daylight"
|
|
1032
2061
|
];
|
|
1033
2062
|
var CONFIGURATOR_SCENE_EDGES = ["soft", "sharp"];
|
|
1034
|
-
var ConfiguratorSceneSchema =
|
|
1035
|
-
lighting:
|
|
1036
|
-
edges:
|
|
2063
|
+
var ConfiguratorSceneSchema = z6.strictObject({
|
|
2064
|
+
lighting: z6.enum(CONFIGURATOR_SCENE_LIGHTINGS).optional(),
|
|
2065
|
+
edges: z6.enum(CONFIGURATOR_SCENE_EDGES).optional()
|
|
1037
2066
|
});
|
|
1038
|
-
var structureObjectSchema = (files) =>
|
|
1039
|
-
schemaVersion:
|
|
2067
|
+
var structureObjectSchema = (files) => z6.strictObject({
|
|
2068
|
+
schemaVersion: z6.literal(1),
|
|
1040
2069
|
geometry: geometryDocumentSchema(files.model),
|
|
1041
2070
|
/**
|
|
1042
2071
|
* How the scene is presented. Part of the structure and not of the shape
|
|
1043
2072
|
* document, so the owner sets it under `crm.manage` like a material.
|
|
1044
2073
|
*/
|
|
1045
2074
|
scene: ConfiguratorSceneSchema.optional(),
|
|
1046
|
-
tabs:
|
|
1047
|
-
|
|
2075
|
+
tabs: z6.array(
|
|
2076
|
+
z6.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
|
|
1048
2077
|
).min(1).max(CONFIGURATOR_LIMITS.tabs),
|
|
1049
|
-
parameters:
|
|
1050
|
-
choiceLists:
|
|
1051
|
-
materials:
|
|
1052
|
-
priceGroups:
|
|
1053
|
-
|
|
2078
|
+
parameters: z6.array(ConfiguratorParameterSchema).max(CONFIGURATOR_LIMITS.parameters),
|
|
2079
|
+
choiceLists: z6.array(choiceListSchema(files.picture)).max(CONFIGURATOR_LIMITS.choiceLists),
|
|
2080
|
+
materials: z6.array(materialSchema(files.picture)).max(CONFIGURATOR_LIMITS.materials),
|
|
2081
|
+
priceGroups: z6.array(
|
|
2082
|
+
z6.strictObject({ code: ConfiguratorCodeSchema, label: LabelSchema })
|
|
1054
2083
|
).max(CONFIGURATOR_LIMITS.priceGroups),
|
|
1055
2084
|
/** Named amounts a model fills in and price rules read (`meter_price`). */
|
|
1056
|
-
amounts:
|
|
1057
|
-
|
|
2085
|
+
amounts: z6.array(
|
|
2086
|
+
z6.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
|
|
1058
2087
|
).max(CONFIGURATOR_LIMITS.amounts),
|
|
1059
2088
|
/**
|
|
1060
2089
|
* A list of fixed combinations a model offers, each setting some number
|
|
1061
2090
|
* parameters at once: a fireplace and the niche it needs.
|
|
1062
2091
|
*/
|
|
1063
|
-
presets:
|
|
2092
|
+
presets: z6.strictObject({
|
|
1064
2093
|
label: LabelSchema,
|
|
1065
2094
|
tab: ConfiguratorNameSchema,
|
|
1066
|
-
sets:
|
|
1067
|
-
required:
|
|
2095
|
+
sets: z6.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
|
|
2096
|
+
required: z6.boolean()
|
|
1068
2097
|
}).optional(),
|
|
1069
|
-
rules:
|
|
1070
|
-
priceRules:
|
|
2098
|
+
rules: z6.array(ConfiguratorRuleSchema).max(CONFIGURATOR_LIMITS.rules),
|
|
2099
|
+
priceRules: z6.array(ConfiguratorPriceRuleSchema).max(CONFIGURATOR_LIMITS.priceRules)
|
|
1071
2100
|
});
|
|
1072
2101
|
var ConfiguratorStructureObjectSchema = structureObjectSchema({
|
|
1073
2102
|
picture: TexturePathSchema,
|
|
@@ -1226,8 +2255,8 @@ function configuratorStructureProblems(structure) {
|
|
|
1226
2255
|
};
|
|
1227
2256
|
structure.rules.forEach((rule, index) => {
|
|
1228
2257
|
const path = ["rules", index];
|
|
1229
|
-
const
|
|
1230
|
-
if (!
|
|
2258
|
+
const target3 = parameters.get(rule.param);
|
|
2259
|
+
if (!target3) {
|
|
1231
2260
|
problems.push(
|
|
1232
2261
|
issue([...path, "param"], "configurator.unknownParam", {
|
|
1233
2262
|
key: rule.param
|
|
@@ -1236,7 +2265,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1236
2265
|
return;
|
|
1237
2266
|
}
|
|
1238
2267
|
if (rule.kind === "limit") {
|
|
1239
|
-
if (
|
|
2268
|
+
if (target3.kind !== "number") {
|
|
1240
2269
|
problems.push(
|
|
1241
2270
|
issue([...path, "param"], "configurator.notANumberParam", {
|
|
1242
2271
|
key: rule.param
|
|
@@ -1254,7 +2283,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1254
2283
|
return;
|
|
1255
2284
|
}
|
|
1256
2285
|
if (rule.kind === "exclude") {
|
|
1257
|
-
const codes = codesOf(
|
|
2286
|
+
const codes = codesOf(target3, lists, materials);
|
|
1258
2287
|
if (!codes) {
|
|
1259
2288
|
problems.push(
|
|
1260
2289
|
issue([...path, "param"], "configurator.notAChoiceParam", {
|
|
@@ -1333,8 +2362,8 @@ function configuratorStructureProblems(structure) {
|
|
|
1333
2362
|
problems
|
|
1334
2363
|
);
|
|
1335
2364
|
}
|
|
1336
|
-
const
|
|
1337
|
-
if (!
|
|
2365
|
+
const target3 = parameters.get(rule.param);
|
|
2366
|
+
if (!target3 || target3.kind !== rule.kind) {
|
|
1338
2367
|
problems.push(
|
|
1339
2368
|
issue([...path, "param"], "configurator.priceRuleParamKind", {
|
|
1340
2369
|
key: rule.param,
|
|
@@ -1483,55 +2512,55 @@ function parameterResolutionOrder(structure) {
|
|
|
1483
2512
|
}
|
|
1484
2513
|
|
|
1485
2514
|
// ../features/product-configurator/src/shared/configurator-value.schema.ts
|
|
1486
|
-
import { z as
|
|
1487
|
-
var CentsSchema =
|
|
2515
|
+
import { z as z7 } from "zod";
|
|
2516
|
+
var CentsSchema = z7.number().int().min(0).max(1e8);
|
|
1488
2517
|
var PriceSchema = CentsSchema.nullable();
|
|
1489
|
-
var ConfiguratorValueObjectSchema =
|
|
2518
|
+
var ConfiguratorValueObjectSchema = z7.strictObject({
|
|
1490
2519
|
/** Null: the model has no price of its own, every quote is "open". */
|
|
1491
2520
|
basePriceCents: PriceSchema,
|
|
1492
|
-
numbers:
|
|
2521
|
+
numbers: z7.record(
|
|
1493
2522
|
ConfiguratorNameSchema,
|
|
1494
|
-
|
|
1495
|
-
min:
|
|
1496
|
-
max:
|
|
1497
|
-
default:
|
|
2523
|
+
z7.strictObject({
|
|
2524
|
+
min: z7.number().finite(),
|
|
2525
|
+
max: z7.number().finite(),
|
|
2526
|
+
default: z7.number().finite()
|
|
1498
2527
|
})
|
|
1499
2528
|
).default({}),
|
|
1500
2529
|
/** Per choice list: the options this model offers, with their surcharge. */
|
|
1501
|
-
choices:
|
|
2530
|
+
choices: z7.record(
|
|
1502
2531
|
ConfiguratorNameSchema,
|
|
1503
|
-
|
|
1504
|
-
options:
|
|
2532
|
+
z7.strictObject({
|
|
2533
|
+
options: z7.record(
|
|
1505
2534
|
ConfiguratorCodeSchema,
|
|
1506
|
-
|
|
2535
|
+
z7.strictObject({ priceCents: PriceSchema })
|
|
1507
2536
|
),
|
|
1508
2537
|
default: ConfiguratorCodeSchema.optional()
|
|
1509
2538
|
})
|
|
1510
2539
|
).default({}),
|
|
1511
2540
|
/** Per material parameter: the materials this model offers. */
|
|
1512
|
-
materials:
|
|
2541
|
+
materials: z7.record(
|
|
1513
2542
|
ConfiguratorNameSchema,
|
|
1514
|
-
|
|
1515
|
-
offered:
|
|
2543
|
+
z7.strictObject({
|
|
2544
|
+
offered: z7.array(ConfiguratorCodeSchema).max(CONFIGURATOR_LIMITS.materials),
|
|
1516
2545
|
default: ConfiguratorCodeSchema.optional()
|
|
1517
2546
|
})
|
|
1518
2547
|
).default({}),
|
|
1519
2548
|
/** Per switch: whether this model offers it, and how it starts. Absent is offered, off. */
|
|
1520
|
-
toggles:
|
|
2549
|
+
toggles: z7.record(
|
|
1521
2550
|
ConfiguratorNameSchema,
|
|
1522
|
-
|
|
2551
|
+
z7.strictObject({ offered: z7.boolean(), default: z7.boolean() })
|
|
1523
2552
|
).default({}),
|
|
1524
2553
|
/** The surcharge of each material price group. */
|
|
1525
|
-
priceGroups:
|
|
2554
|
+
priceGroups: z7.record(ConfiguratorCodeSchema, PriceSchema).default({}),
|
|
1526
2555
|
/** The amounts the structure names, such as a price per metre. */
|
|
1527
|
-
amounts:
|
|
1528
|
-
presets:
|
|
1529
|
-
|
|
2556
|
+
amounts: z7.record(ConfiguratorNameSchema, PriceSchema).default({}),
|
|
2557
|
+
presets: z7.array(
|
|
2558
|
+
z7.strictObject({
|
|
1530
2559
|
code: ConfiguratorCodeSchema,
|
|
1531
|
-
label:
|
|
2560
|
+
label: z7.string().trim().min(1).max(80),
|
|
1532
2561
|
/** A product of the same account this preset stands for, e.g. the fireplace. */
|
|
1533
|
-
productId:
|
|
1534
|
-
values:
|
|
2562
|
+
productId: z7.string().uuid().nullable(),
|
|
2563
|
+
values: z7.record(ConfiguratorNameSchema, z7.number().finite())
|
|
1535
2564
|
})
|
|
1536
2565
|
).max(CONFIGURATOR_LIMITS.presets).default([])
|
|
1537
2566
|
});
|
|
@@ -1671,7 +2700,8 @@ function resolveConfiguration(params) {
|
|
|
1671
2700
|
max,
|
|
1672
2701
|
step: parameter.step,
|
|
1673
2702
|
unit: parameter.unit,
|
|
1674
|
-
fixed: fixedValue !== void 0
|
|
2703
|
+
fixed: fixedValue !== void 0,
|
|
2704
|
+
settled: fixedValue === void 0 && min === max
|
|
1675
2705
|
});
|
|
1676
2706
|
continue;
|
|
1677
2707
|
}
|
|
@@ -1739,7 +2769,8 @@ function resolveConfiguration(params) {
|
|
|
1739
2769
|
...base,
|
|
1740
2770
|
visible,
|
|
1741
2771
|
value: visible ? current : null,
|
|
1742
|
-
options
|
|
2772
|
+
options,
|
|
2773
|
+
settled: options.length === 1
|
|
1743
2774
|
});
|
|
1744
2775
|
}
|
|
1745
2776
|
const parameters = structure.parameters.map((parameter) => resolved.get(parameter.key)).filter(
|
|
@@ -1770,7 +2801,8 @@ function resolveConfiguration(params) {
|
|
|
1770
2801
|
}
|
|
1771
2802
|
slots[slot.name] = {
|
|
1772
2803
|
color: material?.color ?? slot.color ?? NEUTRAL_SLOT_COLOR,
|
|
1773
|
-
material
|
|
2804
|
+
material,
|
|
2805
|
+
grain: slot.grain === "vertical" ? "vertical" : "horizontal"
|
|
1774
2806
|
};
|
|
1775
2807
|
}
|
|
1776
2808
|
const normalised = { values: {} };
|
|
@@ -1809,8 +2841,8 @@ function expandGeometry(document, inputs, chosen = { choices: {} }) {
|
|
|
1809
2841
|
const parts = [];
|
|
1810
2842
|
const baseScope = { ...document.constants };
|
|
1811
2843
|
for (const input of document.inputs) {
|
|
1812
|
-
const
|
|
1813
|
-
if (
|
|
2844
|
+
const number2 = inputs[input.name];
|
|
2845
|
+
if (number2 === void 0 || !Number.isFinite(number2)) {
|
|
1814
2846
|
issues.push(
|
|
1815
2847
|
issue(["inputs", input.name], "configurator.inputMissing", {
|
|
1816
2848
|
name: input.name
|
|
@@ -1818,7 +2850,7 @@ function expandGeometry(document, inputs, chosen = { choices: {} }) {
|
|
|
1818
2850
|
);
|
|
1819
2851
|
continue;
|
|
1820
2852
|
}
|
|
1821
|
-
baseScope[input.name] =
|
|
2853
|
+
baseScope[input.name] = number2;
|
|
1822
2854
|
}
|
|
1823
2855
|
if (issues.length > 0) {
|
|
1824
2856
|
return { ok: false, issues };
|
|
@@ -1910,11 +2942,11 @@ function expandGeometry(document, inputs, chosen = { choices: {} }) {
|
|
|
1910
2942
|
}
|
|
1911
2943
|
const params = {};
|
|
1912
2944
|
for (const [key, formula] of Object.entries(node.params ?? {})) {
|
|
1913
|
-
const
|
|
1914
|
-
if (
|
|
2945
|
+
const number2 = evaluate(formula, scope, [...nodePath, "params", key]);
|
|
2946
|
+
if (number2 === null) {
|
|
1915
2947
|
return;
|
|
1916
2948
|
}
|
|
1917
|
-
params[key] =
|
|
2949
|
+
params[key] = number2;
|
|
1918
2950
|
}
|
|
1919
2951
|
parts.push({
|
|
1920
2952
|
key: `${node.id}${keySuffix}`,
|
|
@@ -1987,8 +3019,17 @@ function modelOf(node, chosen) {
|
|
|
1987
3019
|
}
|
|
1988
3020
|
|
|
1989
3021
|
// src/configurator-tools.ts
|
|
1990
|
-
|
|
1991
|
-
|
|
3022
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
3023
|
+
import { extname as extname2 } from "path";
|
|
3024
|
+
var FILE_RULES2 = {
|
|
3025
|
+
model: { extensions: [".glb"], maxBytes: 10 * 1024 * 1024 },
|
|
3026
|
+
texture: {
|
|
3027
|
+
extensions: [".webp", ".jpg", ".jpeg"],
|
|
3028
|
+
maxBytes: 2 * 1024 * 1024
|
|
3029
|
+
}
|
|
3030
|
+
};
|
|
3031
|
+
var CONFIGURATOR = 'A "configurator" field holds a 3D product configurator. Its structure has "geometry" (the shape document: "inputs" bound to parameters, "constants", material "slots", a "root" tree of building blocks and "annotations" for dimension lines), "parameters", "choiceLists", "materials", "priceGroups", "amounts", optional "presets", optional "scene" ({ "lighting": "studio" | "warm" | "daylight" }), "rules" and "priceRules". Building blocks: box, rounded_top_box (params.radius), stadium_prism, arc_panel (params.thickness, params.mirror), led_strip, niche_panel (params.niche_x, niche_y, niche_width, niche_height), model (a "model" path to a .glb uploaded with upload_configurator_file, params.fit 0 contain or 1 stretch, params.paint 1 to use the slot material) and repeat (count, children, index i or j). Every measure is millimetres; "at" is the minimum corner, x left to right, y bottom to top, z from the wall into the room; "size" is width, height, depth. A measure is a number or a formula with + - * / ( ) and min, max, floor, ceil, round, abs over the shape\u2019s inputs, constants and the repeat index. Texture paths in materials come from upload_configurator_file too. Making a configurator, or changing its "geometry", needs a key with the configurator:shape scope; everything around the shape needs only catalog:write.';
|
|
3032
|
+
var documentInput = z8.string().min(2).max(2e5).describe(
|
|
1992
3033
|
'The JSON text of a whole configurator structure (it has "schemaVersion"), or of a shape document alone (it has "root").'
|
|
1993
3034
|
);
|
|
1994
3035
|
function registerConfiguratorTools(server, client) {
|
|
@@ -1996,14 +3037,14 @@ function registerConfiguratorTools(server, client) {
|
|
|
1996
3037
|
"check_configurator_shape",
|
|
1997
3038
|
{
|
|
1998
3039
|
title: "Check a configurator structure or shape document",
|
|
1999
|
-
description: "Checks a configurator structure or a shape document exactly as the
|
|
3040
|
+
description: "Checks a configurator structure or a shape document exactly as the API will when it is saved: the outline, every name a formula reads, bindings, cycles in the rules and the limits. A whole structure is also expanded at its smallest measures, so a part that would come out negative is named. Answers with every problem and where it is, or that the document is sound. " + CONFIGURATOR,
|
|
2000
3041
|
inputSchema: { document: documentInput },
|
|
2001
3042
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2002
3043
|
},
|
|
2003
3044
|
({ document }) => {
|
|
2004
3045
|
const problems = checkDocument(document);
|
|
2005
3046
|
return answer(
|
|
2006
|
-
problems.length === 0 ? "The document is sound.
|
|
3047
|
+
problems.length === 0 ? "The document is sound. upsert_configurator saves it." : [
|
|
2007
3048
|
`${problems.length} problem(s):`,
|
|
2008
3049
|
...problems.map(describeIssue)
|
|
2009
3050
|
].join("\n"),
|
|
@@ -2011,14 +3052,125 @@ function registerConfiguratorTools(server, client) {
|
|
|
2011
3052
|
);
|
|
2012
3053
|
}
|
|
2013
3054
|
);
|
|
3055
|
+
server.registerTool(
|
|
3056
|
+
"upsert_configurator",
|
|
3057
|
+
{
|
|
3058
|
+
title: "Save a configurator",
|
|
3059
|
+
description: 'Makes a configurator field, or changes one, with its whole structure. The structure is checked here first, exactly as check_configurator_shape does, and nothing is sent while it has a problem. Making the field, or changing its "geometry", needs a key with configurator:shape; changing parameters, lists, materials, prices or rules of an existing one needs only catalog:write. A new field needs a `label`. Upload models and textures first with upload_configurator_file and put the paths it answers in the structure. ' + CONFIGURATOR,
|
|
3060
|
+
inputSchema: {
|
|
3061
|
+
slug: CatalogFieldSlugSchema.describe(
|
|
3062
|
+
"The slug of the configurator field."
|
|
3063
|
+
),
|
|
3064
|
+
label: z8.string().trim().min(1).max(120).optional(),
|
|
3065
|
+
structure: z8.string().min(2).max(2e5).describe(
|
|
3066
|
+
'The JSON text of the whole configurator structure (it has "schemaVersion").'
|
|
3067
|
+
),
|
|
3068
|
+
public: z8.boolean().optional().describe("Whether product pages may show the configurator."),
|
|
3069
|
+
...objectInput
|
|
3070
|
+
},
|
|
3071
|
+
annotations: { destructiveHint: true, idempotentHint: true }
|
|
3072
|
+
},
|
|
3073
|
+
async ({ slug, label, structure, public: isPublic, object: object2 }) => {
|
|
3074
|
+
const problems = checkDocument(structure);
|
|
3075
|
+
if (problems.length > 0 || !structure.includes('"schemaVersion"')) {
|
|
3076
|
+
return {
|
|
3077
|
+
isError: true,
|
|
3078
|
+
content: [
|
|
3079
|
+
{
|
|
3080
|
+
type: "text",
|
|
3081
|
+
text: [
|
|
3082
|
+
problems.length > 0 ? `Not saved. ${problems.length} problem(s):` : 'Not saved: this is a shape document alone; save a whole structure (it has "schemaVersion").',
|
|
3083
|
+
...problems.map(describeIssue)
|
|
3084
|
+
].join("\n")
|
|
3085
|
+
}
|
|
3086
|
+
]
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
try {
|
|
3090
|
+
const field = await client.upsertField(
|
|
3091
|
+
slug,
|
|
3092
|
+
{
|
|
3093
|
+
...label ? { label } : {},
|
|
3094
|
+
type: "configurator",
|
|
3095
|
+
config: JSON.parse(structure),
|
|
3096
|
+
...isPublic === void 0 ? {} : { public: isPublic }
|
|
3097
|
+
},
|
|
3098
|
+
object2 ? { object: object2 } : void 0
|
|
3099
|
+
);
|
|
3100
|
+
return answer(
|
|
3101
|
+
`${field.created ? "Made" : "Changed"} the configurator ${field.label} (${field.slug}).`,
|
|
3102
|
+
field
|
|
3103
|
+
);
|
|
3104
|
+
} catch (error) {
|
|
3105
|
+
return refusal(error, "configurator:shape");
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
);
|
|
3109
|
+
server.registerTool(
|
|
3110
|
+
"upload_configurator_file",
|
|
3111
|
+
{
|
|
3112
|
+
title: "Upload a model or a texture for a configurator",
|
|
3113
|
+
description: 'Reads one file from the user\u2019s disk and uploads it for a configurator field: a "model" is a binary glTF (.glb, at most 10 MB, everything inside the file, no Draco, no lights of its own, at most 200,000 triangles) and part of the shape, so it needs configurator:shape; a "texture" is a .webp or .jpg picture of at most 2 MB. Answers with the path to put in the structure. The field must exist first (upsert_configurator).',
|
|
3114
|
+
inputSchema: {
|
|
3115
|
+
fieldSlug: CatalogFieldSlugSchema.describe(
|
|
3116
|
+
"The slug of the configurator field."
|
|
3117
|
+
),
|
|
3118
|
+
kind: z8.enum(CONFIGURATOR_FILE_KINDS),
|
|
3119
|
+
filePath: z8.string().min(1).max(1e3).describe("The absolute path of the file on this machine."),
|
|
3120
|
+
...objectInput
|
|
3121
|
+
},
|
|
3122
|
+
annotations: { destructiveHint: false, idempotentHint: false }
|
|
3123
|
+
},
|
|
3124
|
+
async ({ fieldSlug, kind, filePath, object: object2 }) => {
|
|
3125
|
+
const rules = FILE_RULES2[kind];
|
|
3126
|
+
const extension = extname2(filePath).toLowerCase();
|
|
3127
|
+
if (!rules.extensions.includes(extension)) {
|
|
3128
|
+
return notUploaded(
|
|
3129
|
+
`A ${kind} is a ${rules.extensions.join(" or ")} file; this is "${extension || "no extension"}".`
|
|
3130
|
+
);
|
|
3131
|
+
}
|
|
3132
|
+
const info = await stat2(filePath).catch(() => null);
|
|
3133
|
+
if (!info?.isFile()) {
|
|
3134
|
+
return notUploaded(`"${filePath}" is not a file on this machine.`);
|
|
3135
|
+
}
|
|
3136
|
+
if (info.size > rules.maxBytes) {
|
|
3137
|
+
return notUploaded(
|
|
3138
|
+
`The file is ${info.size} bytes; a ${kind} may be at most ${rules.maxBytes}.`
|
|
3139
|
+
);
|
|
3140
|
+
}
|
|
3141
|
+
try {
|
|
3142
|
+
const bytes = new Uint8Array(await readFile2(filePath));
|
|
3143
|
+
const result = await client.uploadConfiguratorFile(
|
|
3144
|
+
fieldSlug,
|
|
3145
|
+
{
|
|
3146
|
+
kind,
|
|
3147
|
+
bytes,
|
|
3148
|
+
...kind === "texture" ? {
|
|
3149
|
+
contentType: extension === ".webp" ? "image/webp" : "image/jpeg"
|
|
3150
|
+
} : {}
|
|
3151
|
+
},
|
|
3152
|
+
object2 ? { object: object2 } : void 0
|
|
3153
|
+
);
|
|
3154
|
+
return answer(
|
|
3155
|
+
`Uploaded. Put this path in the structure: ${result.path}`,
|
|
3156
|
+
result
|
|
3157
|
+
);
|
|
3158
|
+
} catch (error) {
|
|
3159
|
+
return refusal(
|
|
3160
|
+
error,
|
|
3161
|
+
kind === "model" ? "configurator:shape" : "catalog:write"
|
|
3162
|
+
);
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
);
|
|
2014
3166
|
server.registerTool(
|
|
2015
3167
|
"read_configurator",
|
|
2016
3168
|
{
|
|
2017
3169
|
title: "Read the configurator of a product",
|
|
2018
3170
|
description: "The configurator field of one product as its page receives it: the structure (texture paths already URLs), the model\u2019s value (ranges, offered options, amounts, presets), the tax a quote uses and the two hashes a request must send back. Useful to draft a new shape from an existing one. " + CONFIGURATOR,
|
|
2019
3171
|
inputSchema: {
|
|
2020
|
-
slugOrId:
|
|
2021
|
-
field:
|
|
3172
|
+
slugOrId: z8.string().min(1).max(200).describe("The address (slug) or the id of the product."),
|
|
3173
|
+
field: z8.string().min(1).max(64).describe("The slug of the configurator field.")
|
|
2022
3174
|
},
|
|
2023
3175
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2024
3176
|
},
|
|
@@ -2037,15 +3189,15 @@ function registerConfiguratorTools(server, client) {
|
|
|
2037
3189
|
}
|
|
2038
3190
|
return answer(`The configurator "${field}" of ${product.name}.`, value);
|
|
2039
3191
|
} catch (error) {
|
|
2040
|
-
return refusal(error);
|
|
3192
|
+
return refusal(error, "catalog:read");
|
|
2041
3193
|
}
|
|
2042
3194
|
}
|
|
2043
3195
|
);
|
|
2044
3196
|
}
|
|
2045
|
-
function checkDocument(
|
|
3197
|
+
function checkDocument(text2) {
|
|
2046
3198
|
let parsed;
|
|
2047
3199
|
try {
|
|
2048
|
-
parsed = JSON.parse(
|
|
3200
|
+
parsed = JSON.parse(text2);
|
|
2049
3201
|
} catch {
|
|
2050
3202
|
return [{ path: [], code: "configurator.notJson" }];
|
|
2051
3203
|
}
|
|
@@ -2089,18 +3241,102 @@ function describeIssue(problem) {
|
|
|
2089
3241
|
const params = problem.params ? ` (${Object.entries(problem.params).map(([key, value]) => `${key}: ${value}`).join(", ")})` : "";
|
|
2090
3242
|
return `- ${where}: ${problem.code}${params}`;
|
|
2091
3243
|
}
|
|
3244
|
+
function notUploaded(text2) {
|
|
3245
|
+
return {
|
|
3246
|
+
isError: true,
|
|
3247
|
+
content: [{ type: "text", text: `Not uploaded. ${text2}` }]
|
|
3248
|
+
};
|
|
3249
|
+
}
|
|
2092
3250
|
function isRecord(value) {
|
|
2093
3251
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2094
3252
|
}
|
|
2095
3253
|
|
|
3254
|
+
// src/event-tools.ts
|
|
3255
|
+
import { z as z9 } from "zod";
|
|
3256
|
+
var moment = z9.string().datetime({ offset: true }).optional().describe("An ISO timestamp with its offset, such as 2026-11-01T00:00:00Z.");
|
|
3257
|
+
function registerEventTools(server, client) {
|
|
3258
|
+
server.registerTool(
|
|
3259
|
+
"list_events",
|
|
3260
|
+
{
|
|
3261
|
+
title: "List upcoming events",
|
|
3262
|
+
description: "The published events of the account, soonest first: title, when and where, the lowest ticket price, whether it is sold out and the link to its ticket page. Both ends of the window are optional and inclusive. Events are made in the app; this cannot create one. May be a minute old.",
|
|
3263
|
+
inputSchema: { ...page, from: moment, to: moment },
|
|
3264
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
3265
|
+
},
|
|
3266
|
+
async (input) => {
|
|
3267
|
+
try {
|
|
3268
|
+
const list = await client.getEvents(input);
|
|
3269
|
+
return answer(
|
|
3270
|
+
list.events.length === 0 ? "No published events in this window." : [...list.events.map(describeEvent), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
3271
|
+
list
|
|
3272
|
+
);
|
|
3273
|
+
} catch (error) {
|
|
3274
|
+
return refusal(error, "events:read");
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
);
|
|
3278
|
+
server.registerTool(
|
|
3279
|
+
"get_event",
|
|
3280
|
+
{
|
|
3281
|
+
title: "Read one event",
|
|
3282
|
+
description: "One event as its page needs it: description, doors and sales times, address, ticket types with their prices, and the texts for search engines. May be a minute old; ask get_event_availability for seats that must be current.",
|
|
3283
|
+
inputSchema: {
|
|
3284
|
+
idOrSlug: z9.string().min(1).max(200).describe("The id or the address (slug) of the event.")
|
|
3285
|
+
},
|
|
3286
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
3287
|
+
},
|
|
3288
|
+
async ({ idOrSlug }) => {
|
|
3289
|
+
try {
|
|
3290
|
+
const event = await client.getEvent(idOrSlug);
|
|
3291
|
+
if (!event) {
|
|
3292
|
+
return answer(`No event "${idOrSlug}" on this account.`, null);
|
|
3293
|
+
}
|
|
3294
|
+
return answer(
|
|
3295
|
+
`${event.title} (${event.slug}), ${event.status}, ${event.ticketTypes.length} ticket types.`,
|
|
3296
|
+
event
|
|
3297
|
+
);
|
|
3298
|
+
} catch (error) {
|
|
3299
|
+
return refusal(error, "events:read");
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
);
|
|
3303
|
+
server.registerTool(
|
|
3304
|
+
"get_event_availability",
|
|
3305
|
+
{
|
|
3306
|
+
title: "Seats left for an event",
|
|
3307
|
+
description: "Live, not cached: how many seats are left for the event and for each ticket type, and whether it is sold out. A remaining of null means there is no ceiling.",
|
|
3308
|
+
inputSchema: {
|
|
3309
|
+
eventId: z9.string().uuid().describe("The id of the event.")
|
|
3310
|
+
},
|
|
3311
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
3312
|
+
},
|
|
3313
|
+
async ({ eventId }) => {
|
|
3314
|
+
try {
|
|
3315
|
+
const seats = await client.getEventAvailability(eventId);
|
|
3316
|
+
if (!seats) {
|
|
3317
|
+
return answer(`No event "${eventId}" on this account.`, null);
|
|
3318
|
+
}
|
|
3319
|
+
return answer(
|
|
3320
|
+
seats.soldOut ? "Sold out." : `${seats.remaining ?? "No limit on"} seats left.`,
|
|
3321
|
+
seats
|
|
3322
|
+
);
|
|
3323
|
+
} catch (error) {
|
|
3324
|
+
return refusal(error, "events:read");
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
);
|
|
3328
|
+
}
|
|
3329
|
+
function describeEvent(event) {
|
|
3330
|
+
const where = event.locationName ? `, ${event.locationName}` : "";
|
|
3331
|
+
const price = event.priceFromCents !== null && event.currency ? `, from ${money(event.priceFromCents, event.currency)}` : "";
|
|
3332
|
+
const sold = event.soldOut ? ", sold out" : "";
|
|
3333
|
+
return `- ${event.title} (id ${event.id}): ${event.startsAt}${where}${price}${sold} \u2014 ${event.url}`;
|
|
3334
|
+
}
|
|
3335
|
+
|
|
2096
3336
|
// src/placement-tools.ts
|
|
2097
|
-
import { z as
|
|
3337
|
+
import { z as z10 } from "zod";
|
|
2098
3338
|
var REACT_HINT = "On a React or Next.js site, prefer the components in `@solumflow-app/crm-client/react` (CrmForm, CrmBooking, CrmChatWidget, CrmSiteTracking) over pasting HTML.";
|
|
2099
|
-
var
|
|
2100
|
-
cursor: z6.string().optional().describe("The nextCursor of the previous page; leave out for the first."),
|
|
2101
|
-
limit: z6.number().int().min(1).max(100).optional()
|
|
2102
|
-
};
|
|
2103
|
-
var origin = z6.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
|
|
3339
|
+
var origin = z10.string().regex(/^https?:\/\/[a-zA-Z0-9.-]+(:[0-9]{1,5})?$/).describe("Scheme, host and optional port; no path: https://www.example.com");
|
|
2104
3340
|
function registerPlacementTools(server, client) {
|
|
2105
3341
|
server.registerTool(
|
|
2106
3342
|
"list_forms",
|
|
@@ -2120,7 +3356,7 @@ function registerPlacementTools(server, client) {
|
|
|
2120
3356
|
list
|
|
2121
3357
|
);
|
|
2122
3358
|
} catch (error) {
|
|
2123
|
-
return refusal(error);
|
|
3359
|
+
return refusal(error, "website:manage");
|
|
2124
3360
|
}
|
|
2125
3361
|
}
|
|
2126
3362
|
);
|
|
@@ -2140,7 +3376,7 @@ function registerPlacementTools(server, client) {
|
|
|
2140
3376
|
pages
|
|
2141
3377
|
);
|
|
2142
3378
|
} catch (error) {
|
|
2143
|
-
return refusal(error);
|
|
3379
|
+
return refusal(error, "website:manage");
|
|
2144
3380
|
}
|
|
2145
3381
|
}
|
|
2146
3382
|
);
|
|
@@ -2163,7 +3399,7 @@ function registerPlacementTools(server, client) {
|
|
|
2163
3399
|
list
|
|
2164
3400
|
);
|
|
2165
3401
|
} catch (error) {
|
|
2166
|
-
return refusal(error);
|
|
3402
|
+
return refusal(error, "website:manage");
|
|
2167
3403
|
}
|
|
2168
3404
|
}
|
|
2169
3405
|
);
|
|
@@ -2173,13 +3409,13 @@ function registerPlacementTools(server, client) {
|
|
|
2173
3409
|
title: "Make or change a chat widget",
|
|
2174
3410
|
description: "Makes the chat widget at this address when there is none, or changes it. Sets its name, whether it is live, and which sites it may run on. How it looks and what it says is done in the app\u2019s builder. Sites are added and removed, never replaced. Careful: an empty list means ANY site, so adding the first origin narrows the widget to that site only, and removing an origin takes the chat off a site that may be live.",
|
|
2175
3411
|
inputSchema: {
|
|
2176
|
-
slug:
|
|
3412
|
+
slug: z10.string().regex(/^[a-z][a-z0-9-]*$/).max(100).describe(
|
|
2177
3413
|
'The widget\u2019s address on this account, such as "website-chat".'
|
|
2178
3414
|
),
|
|
2179
|
-
name:
|
|
2180
|
-
status:
|
|
2181
|
-
addAllowedOrigins:
|
|
2182
|
-
removeAllowedOrigins:
|
|
3415
|
+
name: z10.string().min(1).max(200).optional().describe("Required when the widget does not exist yet."),
|
|
3416
|
+
status: z10.enum(["draft", "published"]).optional().describe("Only a published widget answers on a site."),
|
|
3417
|
+
addAllowedOrigins: z10.array(origin).max(20).optional(),
|
|
3418
|
+
removeAllowedOrigins: z10.array(origin).max(20).optional()
|
|
2183
3419
|
},
|
|
2184
3420
|
annotations: {
|
|
2185
3421
|
readOnlyHint: false,
|
|
@@ -2197,7 +3433,7 @@ ${describeChatWidget(widget)}`,
|
|
|
2197
3433
|
widget
|
|
2198
3434
|
);
|
|
2199
3435
|
} catch (error) {
|
|
2200
|
-
return refusal(error);
|
|
3436
|
+
return refusal(error, "website:manage");
|
|
2201
3437
|
}
|
|
2202
3438
|
}
|
|
2203
3439
|
);
|
|
@@ -2217,7 +3453,7 @@ ${describeChatWidget(widget)}`,
|
|
|
2217
3453
|
site
|
|
2218
3454
|
);
|
|
2219
3455
|
} catch (error) {
|
|
2220
|
-
return refusal(error);
|
|
3456
|
+
return refusal(error, "website:manage");
|
|
2221
3457
|
}
|
|
2222
3458
|
}
|
|
2223
3459
|
);
|
|
@@ -2227,9 +3463,9 @@ ${describeChatWidget(widget)}`,
|
|
|
2227
3463
|
title: "Set up or change the tracking script",
|
|
2228
3464
|
description: "Sets website tracking up when it is not there, adds or removes the hosts it counts visits from, and changes the consent it waits for. An empty list of hosts counts nothing, so add the host of every site the script is placed on. Hosts are added and removed, never replaced.",
|
|
2229
3465
|
inputSchema: {
|
|
2230
|
-
addAllowedHosts:
|
|
2231
|
-
removeAllowedHosts:
|
|
2232
|
-
consentSignal:
|
|
3466
|
+
addAllowedHosts: z10.array(z10.string()).max(20).optional().describe('Hosts such as "www.example.com" or "*.example.com".'),
|
|
3467
|
+
removeAllowedHosts: z10.array(z10.string()).max(20).optional(),
|
|
3468
|
+
consentSignal: z10.enum([
|
|
2233
3469
|
"ad_storage",
|
|
2234
3470
|
"analytics_storage",
|
|
2235
3471
|
"personalization_storage",
|
|
@@ -2254,7 +3490,7 @@ ${describeSiteTracking(site)}`,
|
|
|
2254
3490
|
site
|
|
2255
3491
|
);
|
|
2256
3492
|
} catch (error) {
|
|
2257
|
-
return refusal(error);
|
|
3493
|
+
return refusal(error, "website:manage");
|
|
2258
3494
|
}
|
|
2259
3495
|
}
|
|
2260
3496
|
);
|
|
@@ -2288,9 +3524,6 @@ function describeSiteTracking(site) {
|
|
|
2288
3524
|
codeBlock(site.embed)
|
|
2289
3525
|
].join("\n");
|
|
2290
3526
|
}
|
|
2291
|
-
function more(nextCursor) {
|
|
2292
|
-
return nextCursor ? `More on the next page: cursor ${nextCursor}` : "";
|
|
2293
|
-
}
|
|
2294
3527
|
|
|
2295
3528
|
// src/server-config.ts
|
|
2296
3529
|
function readServerConfig(env) {
|
|
@@ -2299,13 +3532,13 @@ function readServerConfig(env) {
|
|
|
2299
3532
|
if (!apiKey) {
|
|
2300
3533
|
return {
|
|
2301
3534
|
ok: false,
|
|
2302
|
-
error: 'SOLUMFLOW_API_KEY is not set. Make a secret key with the
|
|
3535
|
+
error: 'SOLUMFLOW_API_KEY is not set. Make a secret key in the app (Settings \u2192 API keys) with the scopes the agent needs: "website:manage" to place things on a site, "catalog:read" and "events:read" to read, "catalog:write" to fill the catalogue, and "configurator:shape" -- which only a reseller can give -- to make a 3D configurator and upload its models. Settings \u2192 Integrations \u2192 MCP server makes such a key and the command to add this server in one go. Pass it in the environment.'
|
|
2303
3536
|
};
|
|
2304
3537
|
}
|
|
2305
3538
|
if (!apiKey.startsWith("crms_")) {
|
|
2306
3539
|
return {
|
|
2307
3540
|
ok: false,
|
|
2308
|
-
error:
|
|
3541
|
+
error: "SOLUMFLOW_API_KEY is not a secret key. This server needs a `crms_` key: a `crmp_` key is meant for a browser and can never place things on a website or write the catalogue."
|
|
2309
3542
|
};
|
|
2310
3543
|
}
|
|
2311
3544
|
if (!/^https?:\/\/[^/]+$/.test(baseUrl)) {
|
|
@@ -2326,7 +3559,9 @@ async function main() {
|
|
|
2326
3559
|
}
|
|
2327
3560
|
const server = new McpServer({
|
|
2328
3561
|
name: "crm-mcp-server",
|
|
2329
|
-
version
|
|
3562
|
+
// Read from the package, so the version an agent is told is the one
|
|
3563
|
+
// that was published.
|
|
3564
|
+
version
|
|
2330
3565
|
});
|
|
2331
3566
|
const client = createClient({
|
|
2332
3567
|
apiKey: config.apiKey,
|
|
@@ -2334,6 +3569,9 @@ async function main() {
|
|
|
2334
3569
|
});
|
|
2335
3570
|
registerPlacementTools(server, client);
|
|
2336
3571
|
registerCatalogTools(server, client);
|
|
3572
|
+
registerCatalogWriteTools(server, client);
|
|
3573
|
+
registerCatalogMigrationTools(server, client);
|
|
3574
|
+
registerEventTools(server, client);
|
|
2337
3575
|
registerConfiguratorTools(server, client);
|
|
2338
3576
|
await server.connect(new StdioServerTransport());
|
|
2339
3577
|
}
|