@solumflow-app/crm-mcp-server 0.4.0 → 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 +12 -0
- package/dist/index.js +623 -204
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -6,10 +6,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
6
6
|
import { createClient } from "@solumflow-app/crm-client";
|
|
7
7
|
|
|
8
8
|
// package.json
|
|
9
|
-
var version = "0.
|
|
9
|
+
var version = "0.5.0";
|
|
10
10
|
|
|
11
|
-
// src/catalog-tools.ts
|
|
12
|
-
import { z as
|
|
11
|
+
// src/catalog-migration-tools.ts
|
|
12
|
+
import { z as z4 } from "zod";
|
|
13
13
|
|
|
14
14
|
// ../features/crm-invoicing/src/schema/catalog-api.schema.ts
|
|
15
15
|
import * as z from "zod";
|
|
@@ -43,6 +43,12 @@ var CatalogPriceSchema = z.object({
|
|
|
43
43
|
/** The price's own id at the source, so a renamed price stays itself. */
|
|
44
44
|
externalRef: CatalogExternalRefSchema.optional(),
|
|
45
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(),
|
|
46
52
|
amountCents: cents,
|
|
47
53
|
compareAtCents: cents.nullable().optional(),
|
|
48
54
|
kind: z.enum(["one_off", "recurring"]).optional(),
|
|
@@ -78,21 +84,64 @@ var pricesSchema = z.array(CatalogPriceSchema).min(1).max(50).superRefine((price
|
|
|
78
84
|
message: "Two prices carry the same externalRef."
|
|
79
85
|
});
|
|
80
86
|
}
|
|
81
|
-
const labels = prices.map(
|
|
87
|
+
const labels = prices.map(
|
|
88
|
+
(price) => `${price.currency ?? ""} ${price.label.toLowerCase()}`
|
|
89
|
+
);
|
|
82
90
|
if (new Set(labels).size !== labels.length) {
|
|
83
91
|
ctx.addIssue({
|
|
84
92
|
code: "custom",
|
|
85
93
|
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
86
|
-
message: "Two prices carry the same label."
|
|
94
|
+
message: "Two prices carry the same label in the same currency."
|
|
87
95
|
});
|
|
88
96
|
}
|
|
89
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
|
+
});
|
|
90
115
|
var CatalogImageSchema = z.object({
|
|
91
|
-
url:
|
|
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
|
+
{
|
|
92
133
|
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
93
|
-
message: "A picture
|
|
94
|
-
}
|
|
95
|
-
|
|
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()
|
|
96
145
|
}).strict();
|
|
97
146
|
var CatalogVariantAxisSchema = z.object({
|
|
98
147
|
slug: CatalogFieldSlugSchema,
|
|
@@ -108,6 +157,8 @@ var CatalogVariantSchema = z.object({
|
|
|
108
157
|
sku: text(100),
|
|
109
158
|
gtin: z.string().regex(/^[0-9]{8,14}$/).nullable().optional(),
|
|
110
159
|
prices: pricesSchema.optional(),
|
|
160
|
+
/** The variant's own shelf; a physical product counts per variant. */
|
|
161
|
+
stock: CatalogStockSchema.optional(),
|
|
111
162
|
position: z.number().int().min(0).max(1e4).optional(),
|
|
112
163
|
active: z.boolean().optional()
|
|
113
164
|
}).strict();
|
|
@@ -139,6 +190,15 @@ var CatalogProductUpsertSchema = z.object({
|
|
|
139
190
|
visibility: z.enum(["public", "unlisted"]).optional(),
|
|
140
191
|
metaTitle: text(300),
|
|
141
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(),
|
|
142
202
|
/** Replaces the product's categories, by slug. */
|
|
143
203
|
categories: z.array(CatalogWebSlugSchema).max(50).optional(),
|
|
144
204
|
/**
|
|
@@ -157,6 +217,11 @@ var CatalogProductUpsertSchema = z.object({
|
|
|
157
217
|
addMissingOptions: z.boolean().optional(),
|
|
158
218
|
/** Replaces the product's prices as a whole. */
|
|
159
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(),
|
|
160
225
|
/** Replaces the product's pictures, in this order. */
|
|
161
226
|
images: z.array(CatalogImageSchema).max(20).optional(),
|
|
162
227
|
variantAxes: z.array(CatalogVariantAxisSchema).min(1).max(3).optional(),
|
|
@@ -219,6 +284,14 @@ var CatalogProductUpsertSchema = z.object({
|
|
|
219
284
|
});
|
|
220
285
|
}
|
|
221
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
|
+
}
|
|
222
295
|
if (body.availability === "scheduled" && !body.publishedAt) {
|
|
223
296
|
ctx.addIssue({
|
|
224
297
|
code: "custom",
|
|
@@ -230,6 +303,15 @@ var CatalogProductUpsertSchema = z.object({
|
|
|
230
303
|
});
|
|
231
304
|
var CatalogProductArchiveSchema = z.object({ source: CatalogSourceSchema }).strict();
|
|
232
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(),
|
|
233
315
|
name: z.string().trim().min(1).max(200).optional(),
|
|
234
316
|
description: text(5e3),
|
|
235
317
|
/** `null` puts the category at the top of the tree. */
|
|
@@ -238,7 +320,14 @@ var CatalogCategoryUpsertSchema = z.object({
|
|
|
238
320
|
publishedAt: z.string().datetime({ offset: true }).nullable().optional(),
|
|
239
321
|
metaTitle: text(300),
|
|
240
322
|
metaDescription: text(1e3)
|
|
241
|
-
}).strict()
|
|
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
|
+
);
|
|
242
331
|
var CatalogObjectSlugSchema = z.string().max(64).regex(FIELD_SLUG, {
|
|
243
332
|
message: (
|
|
244
333
|
// i18n-exempt: a public API's field error, returned verbatim to an integration's own code and never shown in this app
|
|
@@ -256,6 +345,7 @@ var CatalogFieldTypeSchema = z.enum([
|
|
|
256
345
|
"number",
|
|
257
346
|
"select",
|
|
258
347
|
"checkbox",
|
|
348
|
+
"color",
|
|
259
349
|
"url",
|
|
260
350
|
"date",
|
|
261
351
|
"content_text",
|
|
@@ -286,7 +376,18 @@ var CatalogAttributeUpsertSchema = z.object({
|
|
|
286
376
|
* a configurator's whole structure. An item field keeps its type once it
|
|
287
377
|
* exists.
|
|
288
378
|
*/
|
|
289
|
-
config: z.record(z.string(), z.unknown()).optional()
|
|
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()
|
|
290
391
|
}).strict();
|
|
291
392
|
var CONFIGURATOR_FILE_KINDS = ["model", "texture"];
|
|
292
393
|
var CONFIGURATOR_TEXTURE_CONTENT_TYPES = [
|
|
@@ -312,6 +413,38 @@ var ConfiguratorFileUploadSchema = z.discriminatedUnion("step", [
|
|
|
312
413
|
uploadPath: z.string().max(200)
|
|
313
414
|
}).strict()
|
|
314
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
|
+
|
|
446
|
+
// src/catalog-tools.ts
|
|
447
|
+
import { z as z3 } from "zod";
|
|
315
448
|
|
|
316
449
|
// src/tool-answers.ts
|
|
317
450
|
import { CrmApiError } from "@solumflow-app/crm-client";
|
|
@@ -342,7 +475,7 @@ function refusal(error, scope) {
|
|
|
342
475
|
var NEXT_STEP = {
|
|
343
476
|
unauthorized: "The key is not recognised or was revoked. Ask the account owner for a new secret key.",
|
|
344
477
|
feature_unavailable: "The module this needs is switched off for the account. Ask somebody to switch it on in the app.",
|
|
345
|
-
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.",
|
|
346
479
|
internal_error: "Something went wrong in the app. Trying again is safe."
|
|
347
480
|
};
|
|
348
481
|
function codeBlock(snippet) {
|
|
@@ -357,8 +490,8 @@ var page = {
|
|
|
357
490
|
function more(nextCursor) {
|
|
358
491
|
return nextCursor ? `More on the next page: cursor ${nextCursor}` : "";
|
|
359
492
|
}
|
|
360
|
-
function money(cents2,
|
|
361
|
-
return `${(cents2 / 100).toFixed(2)} ${
|
|
493
|
+
function money(cents2, currency2) {
|
|
494
|
+
return `${(cents2 / 100).toFixed(2)} ${currency2.toUpperCase()}`;
|
|
362
495
|
}
|
|
363
496
|
|
|
364
497
|
// src/catalog-tools.ts
|
|
@@ -368,6 +501,9 @@ var objectInput = {
|
|
|
368
501
|
)
|
|
369
502
|
};
|
|
370
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.';
|
|
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
|
+
);
|
|
371
507
|
var slugOrId = z3.string().min(1).max(200).describe("The address (slug) or the id of the product.");
|
|
372
508
|
function registerCatalogTools(server, client) {
|
|
373
509
|
server.registerTool(
|
|
@@ -412,17 +548,46 @@ function registerCatalogTools(server, client) {
|
|
|
412
548
|
}
|
|
413
549
|
}
|
|
414
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");
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
);
|
|
415
577
|
server.registerTool(
|
|
416
578
|
"get_product",
|
|
417
579
|
{
|
|
418
580
|
title: "Read one product",
|
|
419
|
-
description: "One product as its page needs it: name, prices, pictures, categories, variants and `fields`, keyed by field slug.
|
|
420
|
-
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 },
|
|
421
583
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
422
584
|
},
|
|
423
|
-
async ({ slugOrId: wanted }) => {
|
|
585
|
+
async ({ slugOrId: wanted, currency: inCurrency }) => {
|
|
424
586
|
try {
|
|
425
|
-
const product = await client.getProduct(
|
|
587
|
+
const product = await client.getProduct(
|
|
588
|
+
wanted,
|
|
589
|
+
inCurrency ? { currency: inCurrency } : void 0
|
|
590
|
+
);
|
|
426
591
|
if (!product) {
|
|
427
592
|
return answer(`No product "${wanted}" on this account.`, null);
|
|
428
593
|
}
|
|
@@ -440,19 +605,37 @@ function registerCatalogTools(server, client) {
|
|
|
440
605
|
"list_products",
|
|
441
606
|
{
|
|
442
607
|
title: "List the published products",
|
|
443
|
-
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 (
|
|
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.",
|
|
444
609
|
inputSchema: {
|
|
445
610
|
...page,
|
|
446
|
-
category: z3.string().
|
|
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
|
+
),
|
|
447
621
|
object: z3.string().min(1).max(63).optional().describe(
|
|
448
622
|
'One catalogue, by the slug of its object, such as "product".'
|
|
449
|
-
)
|
|
623
|
+
),
|
|
624
|
+
currency
|
|
450
625
|
},
|
|
451
626
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
452
627
|
},
|
|
453
|
-
async (input) => {
|
|
628
|
+
async ({ categorySource, categoryRef, ...input }) => {
|
|
454
629
|
try {
|
|
455
|
-
const list = await client.getProducts(
|
|
630
|
+
const list = await client.getProducts({
|
|
631
|
+
...input,
|
|
632
|
+
...categorySource && categoryRef ? {
|
|
633
|
+
categoryRef: {
|
|
634
|
+
source: categorySource,
|
|
635
|
+
externalRef: categoryRef
|
|
636
|
+
}
|
|
637
|
+
} : {}
|
|
638
|
+
});
|
|
456
639
|
return answer(
|
|
457
640
|
list.data.length === 0 ? "No published products match." : [...list.data.map(describeProduct), more(list.nextCursor)].filter(Boolean).join("\n"),
|
|
458
641
|
list
|
|
@@ -542,7 +725,8 @@ function registerCatalogTools(server, client) {
|
|
|
542
725
|
}
|
|
543
726
|
function describeProduct(product) {
|
|
544
727
|
const stock = product.inStock ? "" : ", out of stock";
|
|
545
|
-
|
|
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}`;
|
|
546
730
|
}
|
|
547
731
|
function describeCategory(category) {
|
|
548
732
|
const path = category.path.map((step) => step.name).join(" / ");
|
|
@@ -558,18 +742,247 @@ function describeField(field) {
|
|
|
558
742
|
return `- ${field.slug}: ${field.label}, repeater (${field.display ?? "list"}${preset}; items: ${items}${title})`;
|
|
559
743
|
}
|
|
560
744
|
|
|
561
|
-
// src/catalog-
|
|
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
|
+
};
|
|
562
773
|
function target(object2) {
|
|
563
774
|
return object2 ? { object: object2 } : void 0;
|
|
564
775
|
}
|
|
565
|
-
|
|
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.';
|
|
566
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.';
|
|
567
980
|
function registerCatalogWriteTools(server, client) {
|
|
568
981
|
server.registerTool(
|
|
569
982
|
"upsert_product",
|
|
570
983
|
{
|
|
571
984
|
title: "Make or change a product",
|
|
572
|
-
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. Read list_catalog_fields first for the slugs `fields` takes.',
|
|
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.',
|
|
573
986
|
inputSchema: {
|
|
574
987
|
externalRef: CatalogExternalRefSchema.describe(
|
|
575
988
|
"The id of the product in the system named by `source`."
|
|
@@ -584,7 +997,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
584
997
|
const product = await client.upsertProduct(
|
|
585
998
|
externalRef,
|
|
586
999
|
input,
|
|
587
|
-
|
|
1000
|
+
target2(object2)
|
|
588
1001
|
);
|
|
589
1002
|
const verb = product.created ? "Made" : "Changed";
|
|
590
1003
|
return answer(
|
|
@@ -615,7 +1028,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
615
1028
|
const result = await client.archiveProduct(
|
|
616
1029
|
externalRef,
|
|
617
1030
|
source,
|
|
618
|
-
|
|
1031
|
+
target2(object2)
|
|
619
1032
|
);
|
|
620
1033
|
return answer(
|
|
621
1034
|
result.alreadyArchived ? "That product was already archived." : "Archived.",
|
|
@@ -630,7 +1043,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
630
1043
|
"upsert_category",
|
|
631
1044
|
{
|
|
632
1045
|
title: "Make or change a category",
|
|
633
|
-
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.",
|
|
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.",
|
|
634
1047
|
inputSchema: {
|
|
635
1048
|
slug: CatalogWebSlugSchema.describe(
|
|
636
1049
|
"The address of the category: lower case, digits and dashes."
|
|
@@ -645,7 +1058,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
645
1058
|
const category = await client.upsertCategory(
|
|
646
1059
|
slug,
|
|
647
1060
|
input,
|
|
648
|
-
|
|
1061
|
+
target2(object2)
|
|
649
1062
|
);
|
|
650
1063
|
return answer(
|
|
651
1064
|
`${category.created ? "Made" : "Changed"} ${category.name} (${category.slug}).`,
|
|
@@ -660,7 +1073,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
660
1073
|
"upsert_field",
|
|
661
1074
|
{
|
|
662
1075
|
title: "Make or change a catalogue field",
|
|
663
|
-
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.",
|
|
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.",
|
|
664
1077
|
inputSchema: {
|
|
665
1078
|
slug: CatalogFieldSlugSchema.describe(
|
|
666
1079
|
"The slug of the field: a lower-case letter, then lower case, digits and underscores."
|
|
@@ -672,7 +1085,12 @@ function registerCatalogWriteTools(server, client) {
|
|
|
672
1085
|
},
|
|
673
1086
|
async ({ slug, object: object2, ...input }) => {
|
|
674
1087
|
try {
|
|
675
|
-
|
|
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));
|
|
676
1094
|
return answer(
|
|
677
1095
|
`${field.created ? "Made" : "Changed"} ${field.label} (${field.slug}), ${field.type}, ${field.public ? "public" : "not public"}.`,
|
|
678
1096
|
field
|
|
@@ -710,7 +1128,7 @@ function registerCatalogWriteTools(server, client) {
|
|
|
710
1128
|
}
|
|
711
1129
|
|
|
712
1130
|
// src/configurator-tools.ts
|
|
713
|
-
import { z as
|
|
1131
|
+
import { z as z8 } from "zod";
|
|
714
1132
|
|
|
715
1133
|
// ../features/product-configurator/src/shared/configurator-limits.ts
|
|
716
1134
|
var CONFIGURATOR_LIMITS = {
|
|
@@ -1076,16 +1494,16 @@ function evaluateFormulaSource(source, scope) {
|
|
|
1076
1494
|
}
|
|
1077
1495
|
|
|
1078
1496
|
// ../features/product-configurator/src/shared/geometry-document.schema.ts
|
|
1079
|
-
import { z as
|
|
1497
|
+
import { z as z5 } from "zod";
|
|
1080
1498
|
var CONFIGURATOR_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
|
|
1081
1499
|
var CONFIGURATOR_CODE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/;
|
|
1082
|
-
var ConfiguratorNameSchema =
|
|
1083
|
-
var ConfiguratorCodeSchema =
|
|
1084
|
-
var FormulaSchema =
|
|
1085
|
-
|
|
1086
|
-
|
|
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")
|
|
1087
1505
|
]);
|
|
1088
|
-
var Vector3Schema =
|
|
1506
|
+
var Vector3Schema = z5.tuple([FormulaSchema, FormulaSchema, FormulaSchema]);
|
|
1089
1507
|
var PRIMITIVE_TYPES = [
|
|
1090
1508
|
"box",
|
|
1091
1509
|
"rounded_top_box",
|
|
@@ -1128,93 +1546,93 @@ var PrimitiveNodeFields = {
|
|
|
1128
1546
|
size: Vector3Schema,
|
|
1129
1547
|
at: Vector3Schema,
|
|
1130
1548
|
visible: FormulaSchema.optional(),
|
|
1131
|
-
params:
|
|
1549
|
+
params: z5.record(ConfiguratorNameSchema, FormulaSchema).optional()
|
|
1132
1550
|
};
|
|
1133
|
-
var PrimitiveNodeSchema =
|
|
1551
|
+
var PrimitiveNodeSchema = z5.strictObject({
|
|
1134
1552
|
...PrimitiveNodeFields,
|
|
1135
|
-
type:
|
|
1553
|
+
type: z5.enum(PRIMITIVE_TYPES).exclude(["model"])
|
|
1136
1554
|
});
|
|
1137
|
-
var ModelPathSchema =
|
|
1138
|
-
var ServedFileUrlSchema =
|
|
1555
|
+
var ModelPathSchema = z5.string().regex(CONFIGURATOR_MODEL_PATH_PATTERN, "configurator.invalidModelPath");
|
|
1556
|
+
var ServedFileUrlSchema = z5.url({ protocol: /^https?$/ });
|
|
1139
1557
|
function geometryNodeSchema(modelFile) {
|
|
1140
|
-
const ModelNodeSchema =
|
|
1558
|
+
const ModelNodeSchema = z5.strictObject({
|
|
1141
1559
|
...PrimitiveNodeFields,
|
|
1142
|
-
type:
|
|
1560
|
+
type: z5.literal("model"),
|
|
1143
1561
|
model: modelFile
|
|
1144
1562
|
});
|
|
1145
|
-
const ModelPerChoiceNodeSchema =
|
|
1563
|
+
const ModelPerChoiceNodeSchema = z5.strictObject({
|
|
1146
1564
|
...PrimitiveNodeFields,
|
|
1147
|
-
type:
|
|
1565
|
+
type: z5.literal("model"),
|
|
1148
1566
|
follows: ConfiguratorNameSchema,
|
|
1149
|
-
models:
|
|
1567
|
+
models: z5.record(ConfiguratorCodeSchema, modelFile).refine(
|
|
1150
1568
|
(models) => Object.keys(models).length <= CONFIGURATOR_LIMITS.presets,
|
|
1151
1569
|
{ message: "configurator.tooMany" }
|
|
1152
1570
|
)
|
|
1153
1571
|
});
|
|
1154
|
-
const node =
|
|
1155
|
-
() =>
|
|
1572
|
+
const node = z5.lazy(
|
|
1573
|
+
() => z5.union([
|
|
1156
1574
|
PrimitiveNodeSchema,
|
|
1157
1575
|
ModelNodeSchema,
|
|
1158
1576
|
ModelPerChoiceNodeSchema,
|
|
1159
|
-
|
|
1577
|
+
z5.strictObject({
|
|
1160
1578
|
id: ConfiguratorNameSchema,
|
|
1161
|
-
type:
|
|
1579
|
+
type: z5.literal("repeat"),
|
|
1162
1580
|
count: FormulaSchema,
|
|
1163
|
-
index:
|
|
1581
|
+
index: z5.enum(["i", "j"]).optional(),
|
|
1164
1582
|
visible: FormulaSchema.optional(),
|
|
1165
|
-
children:
|
|
1583
|
+
children: z5.array(node).min(1)
|
|
1166
1584
|
})
|
|
1167
1585
|
])
|
|
1168
1586
|
);
|
|
1169
1587
|
return node;
|
|
1170
1588
|
}
|
|
1171
1589
|
var GeometryNodeSchema = geometryNodeSchema(ModelPathSchema);
|
|
1172
|
-
var GeometryBindingSchema =
|
|
1590
|
+
var GeometryBindingSchema = z5.strictObject({
|
|
1173
1591
|
param: ConfiguratorNameSchema,
|
|
1174
|
-
map:
|
|
1592
|
+
map: z5.record(ConfiguratorCodeSchema, z5.number().finite()).optional()
|
|
1175
1593
|
});
|
|
1176
|
-
var GeometryInputSchema =
|
|
1594
|
+
var GeometryInputSchema = z5.strictObject({
|
|
1177
1595
|
name: ConfiguratorNameSchema,
|
|
1178
1596
|
binding: GeometryBindingSchema
|
|
1179
1597
|
});
|
|
1180
|
-
var GeometrySlotSchema =
|
|
1598
|
+
var GeometrySlotSchema = z5.strictObject({
|
|
1181
1599
|
name: ConfiguratorNameSchema,
|
|
1182
1600
|
param: ConfiguratorNameSchema.optional(),
|
|
1183
1601
|
material: ConfiguratorCodeSchema.optional(),
|
|
1184
|
-
color:
|
|
1602
|
+
color: z5.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor").optional(),
|
|
1185
1603
|
/**
|
|
1186
1604
|
* Which way the grain of a picture runs on this part: `horizontal` (the
|
|
1187
1605
|
* default) as the picture is drawn, `vertical` turned a quarter. It belongs
|
|
1188
1606
|
* to the part, not the material: the same oak lies on a cabinet and stands
|
|
1189
1607
|
* on a tall wall panel.
|
|
1190
1608
|
*/
|
|
1191
|
-
grain:
|
|
1609
|
+
grain: z5.enum(["horizontal", "vertical"]).optional()
|
|
1192
1610
|
});
|
|
1193
|
-
var GeometryAnnotationSchema =
|
|
1611
|
+
var GeometryAnnotationSchema = z5.strictObject({
|
|
1194
1612
|
id: ConfiguratorNameSchema,
|
|
1195
|
-
axis:
|
|
1613
|
+
axis: z5.enum(["x", "y"]),
|
|
1196
1614
|
from: FormulaSchema,
|
|
1197
1615
|
to: FormulaSchema,
|
|
1198
1616
|
at: FormulaSchema,
|
|
1199
|
-
label:
|
|
1617
|
+
label: z5.string().trim().min(1).max(80).optional()
|
|
1200
1618
|
});
|
|
1201
1619
|
function geometryDocumentSchema(modelFile) {
|
|
1202
|
-
return
|
|
1620
|
+
return z5.strictObject({
|
|
1203
1621
|
/** Rises with every new upload, so a product page can say which one it shows. */
|
|
1204
|
-
version:
|
|
1622
|
+
version: z5.number().int().min(1),
|
|
1205
1623
|
/** The starter document this one was copied from, for information only. */
|
|
1206
|
-
startedFrom:
|
|
1207
|
-
inputs:
|
|
1208
|
-
constants:
|
|
1209
|
-
slots:
|
|
1210
|
-
root:
|
|
1211
|
-
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({
|
|
1212
1630
|
/** Degrees around the vertical axis, 0 is straight on. */
|
|
1213
|
-
azimuth:
|
|
1631
|
+
azimuth: z5.number().min(-90).max(90),
|
|
1214
1632
|
/** Degrees above the horizon. */
|
|
1215
|
-
elevation:
|
|
1633
|
+
elevation: z5.number().min(-10).max(80)
|
|
1216
1634
|
}).optional(),
|
|
1217
|
-
annotations:
|
|
1635
|
+
annotations: z5.array(GeometryAnnotationSchema).max(CONFIGURATOR_LIMITS.annotations)
|
|
1218
1636
|
});
|
|
1219
1637
|
}
|
|
1220
1638
|
var GeometryDocumentSchema = geometryDocumentSchema(ModelPathSchema);
|
|
@@ -1504,98 +1922,98 @@ function followProblems(follows, path, context) {
|
|
|
1504
1922
|
}
|
|
1505
1923
|
|
|
1506
1924
|
// ../features/product-configurator/src/shared/configurator-structure.schema.ts
|
|
1507
|
-
import { z as
|
|
1508
|
-
var LabelSchema =
|
|
1925
|
+
import { z as z6 } from "zod";
|
|
1926
|
+
var LabelSchema = z6.string().trim().min(1).max(80);
|
|
1509
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)$/;
|
|
1510
|
-
var TexturePathSchema =
|
|
1928
|
+
var TexturePathSchema = z6.string().regex(CONFIGURATOR_TEXTURE_PATH_PATTERN, "configurator.invalidTexturePath");
|
|
1511
1929
|
var ParameterBase = {
|
|
1512
1930
|
key: ConfiguratorNameSchema,
|
|
1513
1931
|
label: LabelSchema,
|
|
1514
1932
|
tab: ConfiguratorNameSchema
|
|
1515
1933
|
};
|
|
1516
|
-
var ConfiguratorParameterSchema =
|
|
1517
|
-
|
|
1934
|
+
var ConfiguratorParameterSchema = z6.discriminatedUnion("kind", [
|
|
1935
|
+
z6.strictObject({
|
|
1518
1936
|
...ParameterBase,
|
|
1519
|
-
kind:
|
|
1937
|
+
kind: z6.literal("number"),
|
|
1520
1938
|
/** `mm` is a measure, `count` a number of things (compartments). */
|
|
1521
|
-
unit:
|
|
1522
|
-
step:
|
|
1939
|
+
unit: z6.enum(["mm", "count"]),
|
|
1940
|
+
step: z6.number().positive().finite(),
|
|
1523
1941
|
/** The widest range any model may offer; a model narrows it in its value. */
|
|
1524
|
-
min:
|
|
1525
|
-
max:
|
|
1942
|
+
min: z6.number().finite(),
|
|
1943
|
+
max: z6.number().finite()
|
|
1526
1944
|
}),
|
|
1527
|
-
|
|
1945
|
+
z6.strictObject({
|
|
1528
1946
|
...ParameterBase,
|
|
1529
|
-
kind:
|
|
1947
|
+
kind: z6.literal("choice"),
|
|
1530
1948
|
list: ConfiguratorNameSchema
|
|
1531
1949
|
}),
|
|
1532
|
-
|
|
1533
|
-
|
|
1950
|
+
z6.strictObject({ ...ParameterBase, kind: z6.literal("material") }),
|
|
1951
|
+
z6.strictObject({ ...ParameterBase, kind: z6.literal("toggle") })
|
|
1534
1952
|
]);
|
|
1535
|
-
var choiceListSchema = (picture) =>
|
|
1953
|
+
var choiceListSchema = (picture) => z6.strictObject({
|
|
1536
1954
|
key: ConfiguratorNameSchema,
|
|
1537
1955
|
label: LabelSchema,
|
|
1538
|
-
options:
|
|
1539
|
-
|
|
1956
|
+
options: z6.array(
|
|
1957
|
+
z6.strictObject({
|
|
1540
1958
|
code: ConfiguratorCodeSchema,
|
|
1541
1959
|
label: LabelSchema,
|
|
1542
1960
|
image: picture.optional()
|
|
1543
1961
|
})
|
|
1544
1962
|
).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
1545
1963
|
});
|
|
1546
|
-
var materialSchema = (picture) =>
|
|
1964
|
+
var materialSchema = (picture) => z6.strictObject({
|
|
1547
1965
|
code: ConfiguratorCodeSchema,
|
|
1548
1966
|
label: LabelSchema,
|
|
1549
|
-
color:
|
|
1967
|
+
color: z6.string().regex(/^#[0-9a-f]{6}$/i, "configurator.invalidColor"),
|
|
1550
1968
|
texture: picture.optional(),
|
|
1551
1969
|
/** Millimetres one repeat of the texture covers; the two maps below share it. */
|
|
1552
|
-
textureSize:
|
|
1970
|
+
textureSize: z6.number().positive().max(1e4).optional(),
|
|
1553
1971
|
/** Relief that moves with the light: a tangent-space normal map. */
|
|
1554
1972
|
normalMap: picture.optional(),
|
|
1555
1973
|
/** How deep the relief reads; 1 is the map as drawn. */
|
|
1556
|
-
normalScale:
|
|
1974
|
+
normalScale: z6.number().min(0).max(3).optional(),
|
|
1557
1975
|
/** Where the surface is matte and where it shines (the green channel), times `roughness`. */
|
|
1558
1976
|
roughnessMap: picture.optional(),
|
|
1559
|
-
roughness:
|
|
1560
|
-
metalness:
|
|
1977
|
+
roughness: z6.number().min(0).max(1).optional(),
|
|
1978
|
+
metalness: z6.number().min(0).max(1).optional(),
|
|
1561
1979
|
/** How much of what is behind it shows through: glass in front of a flame. Absent is solid. */
|
|
1562
|
-
opacity:
|
|
1980
|
+
opacity: z6.number().min(0.05).max(1).optional(),
|
|
1563
1981
|
/** Gives light rather than reflecting it: a flame, a LED. */
|
|
1564
|
-
emissive:
|
|
1982
|
+
emissive: z6.boolean().optional(),
|
|
1565
1983
|
priceGroup: ConfiguratorCodeSchema.optional()
|
|
1566
1984
|
});
|
|
1567
1985
|
var ConfiguratorChoiceListSchema = choiceListSchema(TexturePathSchema);
|
|
1568
1986
|
var ConfiguratorMaterialSchema = materialSchema(TexturePathSchema);
|
|
1569
|
-
var ConditionValueSchema =
|
|
1570
|
-
|
|
1571
|
-
|
|
1987
|
+
var ConditionValueSchema = z6.union([
|
|
1988
|
+
z6.number().finite(),
|
|
1989
|
+
z6.boolean(),
|
|
1572
1990
|
ConfiguratorCodeSchema,
|
|
1573
|
-
|
|
1991
|
+
z6.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList)
|
|
1574
1992
|
]);
|
|
1575
|
-
var ConfiguratorConditionSchema =
|
|
1993
|
+
var ConfiguratorConditionSchema = z6.strictObject({
|
|
1576
1994
|
param: ConfiguratorNameSchema,
|
|
1577
|
-
op:
|
|
1995
|
+
op: z6.enum(["eq", "neq", "in", "gte", "lte"]),
|
|
1578
1996
|
value: ConditionValueSchema
|
|
1579
1997
|
});
|
|
1580
|
-
var ConfiguratorRuleSchema =
|
|
1998
|
+
var ConfiguratorRuleSchema = z6.discriminatedUnion("kind", [
|
|
1581
1999
|
/** Show `param` only while `when` holds. */
|
|
1582
|
-
|
|
1583
|
-
kind:
|
|
2000
|
+
z6.strictObject({
|
|
2001
|
+
kind: z6.literal("visible"),
|
|
1584
2002
|
param: ConfiguratorNameSchema,
|
|
1585
2003
|
when: ConfiguratorConditionSchema
|
|
1586
2004
|
}),
|
|
1587
2005
|
/** Narrow a number parameter: `max cabinet_height = total_height - 300`. */
|
|
1588
|
-
|
|
1589
|
-
kind:
|
|
2006
|
+
z6.strictObject({
|
|
2007
|
+
kind: z6.literal("limit"),
|
|
1590
2008
|
param: ConfiguratorNameSchema,
|
|
1591
|
-
bound:
|
|
2009
|
+
bound: z6.enum(["min", "max"]),
|
|
1592
2010
|
formula: FormulaSchema
|
|
1593
2011
|
}),
|
|
1594
2012
|
/** While `when` holds, these codes of a choice or material are not offered. */
|
|
1595
|
-
|
|
1596
|
-
kind:
|
|
2013
|
+
z6.strictObject({
|
|
2014
|
+
kind: z6.literal("exclude"),
|
|
1597
2015
|
param: ConfiguratorNameSchema,
|
|
1598
|
-
codes:
|
|
2016
|
+
codes: z6.array(ConfiguratorCodeSchema).min(1).max(CONFIGURATOR_LIMITS.optionsPerList),
|
|
1599
2017
|
when: ConfiguratorConditionSchema
|
|
1600
2018
|
})
|
|
1601
2019
|
]);
|
|
@@ -1605,23 +2023,23 @@ var PriceRuleBase = {
|
|
|
1605
2023
|
/** How many times the amount counts, e.g. `compartments`. Absent is once. */
|
|
1606
2024
|
multiplier: FormulaSchema.optional()
|
|
1607
2025
|
};
|
|
1608
|
-
var ConfiguratorPriceRuleSchema =
|
|
2026
|
+
var ConfiguratorPriceRuleSchema = z6.discriminatedUnion("kind", [
|
|
1609
2027
|
/** The surcharge the model gives the chosen option. */
|
|
1610
|
-
|
|
2028
|
+
z6.strictObject({
|
|
1611
2029
|
...PriceRuleBase,
|
|
1612
|
-
kind:
|
|
2030
|
+
kind: z6.literal("choice"),
|
|
1613
2031
|
param: ConfiguratorNameSchema
|
|
1614
2032
|
}),
|
|
1615
2033
|
/** The surcharge of the chosen material's price group. */
|
|
1616
|
-
|
|
2034
|
+
z6.strictObject({
|
|
1617
2035
|
...PriceRuleBase,
|
|
1618
|
-
kind:
|
|
2036
|
+
kind: z6.literal("material"),
|
|
1619
2037
|
param: ConfiguratorNameSchema
|
|
1620
2038
|
}),
|
|
1621
2039
|
/** An amount, counted while a switch is on. */
|
|
1622
|
-
|
|
2040
|
+
z6.strictObject({
|
|
1623
2041
|
...PriceRuleBase,
|
|
1624
|
-
kind:
|
|
2042
|
+
kind: z6.literal("toggle"),
|
|
1625
2043
|
param: ConfiguratorNameSchema,
|
|
1626
2044
|
amount: ConfiguratorNameSchema
|
|
1627
2045
|
}),
|
|
@@ -1629,10 +2047,10 @@ var ConfiguratorPriceRuleSchema = z5.discriminatedUnion("kind", [
|
|
|
1629
2047
|
* A price on measure: `max(0, total_width - 1800) / 1000 * meter_price`.
|
|
1630
2048
|
* Reads number parameters (in their unit) and amounts (in cents).
|
|
1631
2049
|
*/
|
|
1632
|
-
|
|
2050
|
+
z6.strictObject({
|
|
1633
2051
|
key: ConfiguratorNameSchema,
|
|
1634
2052
|
label: LabelSchema,
|
|
1635
|
-
kind:
|
|
2053
|
+
kind: z6.literal("formula"),
|
|
1636
2054
|
formula: FormulaSchema
|
|
1637
2055
|
})
|
|
1638
2056
|
]);
|
|
@@ -1642,43 +2060,43 @@ var CONFIGURATOR_SCENE_LIGHTINGS = [
|
|
|
1642
2060
|
"daylight"
|
|
1643
2061
|
];
|
|
1644
2062
|
var CONFIGURATOR_SCENE_EDGES = ["soft", "sharp"];
|
|
1645
|
-
var ConfiguratorSceneSchema =
|
|
1646
|
-
lighting:
|
|
1647
|
-
edges:
|
|
2063
|
+
var ConfiguratorSceneSchema = z6.strictObject({
|
|
2064
|
+
lighting: z6.enum(CONFIGURATOR_SCENE_LIGHTINGS).optional(),
|
|
2065
|
+
edges: z6.enum(CONFIGURATOR_SCENE_EDGES).optional()
|
|
1648
2066
|
});
|
|
1649
|
-
var structureObjectSchema = (files) =>
|
|
1650
|
-
schemaVersion:
|
|
2067
|
+
var structureObjectSchema = (files) => z6.strictObject({
|
|
2068
|
+
schemaVersion: z6.literal(1),
|
|
1651
2069
|
geometry: geometryDocumentSchema(files.model),
|
|
1652
2070
|
/**
|
|
1653
2071
|
* How the scene is presented. Part of the structure and not of the shape
|
|
1654
2072
|
* document, so the owner sets it under `crm.manage` like a material.
|
|
1655
2073
|
*/
|
|
1656
2074
|
scene: ConfiguratorSceneSchema.optional(),
|
|
1657
|
-
tabs:
|
|
1658
|
-
|
|
2075
|
+
tabs: z6.array(
|
|
2076
|
+
z6.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
|
|
1659
2077
|
).min(1).max(CONFIGURATOR_LIMITS.tabs),
|
|
1660
|
-
parameters:
|
|
1661
|
-
choiceLists:
|
|
1662
|
-
materials:
|
|
1663
|
-
priceGroups:
|
|
1664
|
-
|
|
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 })
|
|
1665
2083
|
).max(CONFIGURATOR_LIMITS.priceGroups),
|
|
1666
2084
|
/** Named amounts a model fills in and price rules read (`meter_price`). */
|
|
1667
|
-
amounts:
|
|
1668
|
-
|
|
2085
|
+
amounts: z6.array(
|
|
2086
|
+
z6.strictObject({ key: ConfiguratorNameSchema, label: LabelSchema })
|
|
1669
2087
|
).max(CONFIGURATOR_LIMITS.amounts),
|
|
1670
2088
|
/**
|
|
1671
2089
|
* A list of fixed combinations a model offers, each setting some number
|
|
1672
2090
|
* parameters at once: a fireplace and the niche it needs.
|
|
1673
2091
|
*/
|
|
1674
|
-
presets:
|
|
2092
|
+
presets: z6.strictObject({
|
|
1675
2093
|
label: LabelSchema,
|
|
1676
2094
|
tab: ConfiguratorNameSchema,
|
|
1677
|
-
sets:
|
|
1678
|
-
required:
|
|
2095
|
+
sets: z6.array(ConfiguratorNameSchema).min(1).max(CONFIGURATOR_LIMITS.parameters),
|
|
2096
|
+
required: z6.boolean()
|
|
1679
2097
|
}).optional(),
|
|
1680
|
-
rules:
|
|
1681
|
-
priceRules:
|
|
2098
|
+
rules: z6.array(ConfiguratorRuleSchema).max(CONFIGURATOR_LIMITS.rules),
|
|
2099
|
+
priceRules: z6.array(ConfiguratorPriceRuleSchema).max(CONFIGURATOR_LIMITS.priceRules)
|
|
1682
2100
|
});
|
|
1683
2101
|
var ConfiguratorStructureObjectSchema = structureObjectSchema({
|
|
1684
2102
|
picture: TexturePathSchema,
|
|
@@ -1837,8 +2255,8 @@ function configuratorStructureProblems(structure) {
|
|
|
1837
2255
|
};
|
|
1838
2256
|
structure.rules.forEach((rule, index) => {
|
|
1839
2257
|
const path = ["rules", index];
|
|
1840
|
-
const
|
|
1841
|
-
if (!
|
|
2258
|
+
const target3 = parameters.get(rule.param);
|
|
2259
|
+
if (!target3) {
|
|
1842
2260
|
problems.push(
|
|
1843
2261
|
issue([...path, "param"], "configurator.unknownParam", {
|
|
1844
2262
|
key: rule.param
|
|
@@ -1847,7 +2265,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1847
2265
|
return;
|
|
1848
2266
|
}
|
|
1849
2267
|
if (rule.kind === "limit") {
|
|
1850
|
-
if (
|
|
2268
|
+
if (target3.kind !== "number") {
|
|
1851
2269
|
problems.push(
|
|
1852
2270
|
issue([...path, "param"], "configurator.notANumberParam", {
|
|
1853
2271
|
key: rule.param
|
|
@@ -1865,7 +2283,7 @@ function configuratorStructureProblems(structure) {
|
|
|
1865
2283
|
return;
|
|
1866
2284
|
}
|
|
1867
2285
|
if (rule.kind === "exclude") {
|
|
1868
|
-
const codes = codesOf(
|
|
2286
|
+
const codes = codesOf(target3, lists, materials);
|
|
1869
2287
|
if (!codes) {
|
|
1870
2288
|
problems.push(
|
|
1871
2289
|
issue([...path, "param"], "configurator.notAChoiceParam", {
|
|
@@ -1944,8 +2362,8 @@ function configuratorStructureProblems(structure) {
|
|
|
1944
2362
|
problems
|
|
1945
2363
|
);
|
|
1946
2364
|
}
|
|
1947
|
-
const
|
|
1948
|
-
if (!
|
|
2365
|
+
const target3 = parameters.get(rule.param);
|
|
2366
|
+
if (!target3 || target3.kind !== rule.kind) {
|
|
1949
2367
|
problems.push(
|
|
1950
2368
|
issue([...path, "param"], "configurator.priceRuleParamKind", {
|
|
1951
2369
|
key: rule.param,
|
|
@@ -2094,55 +2512,55 @@ function parameterResolutionOrder(structure) {
|
|
|
2094
2512
|
}
|
|
2095
2513
|
|
|
2096
2514
|
// ../features/product-configurator/src/shared/configurator-value.schema.ts
|
|
2097
|
-
import { z as
|
|
2098
|
-
var CentsSchema =
|
|
2515
|
+
import { z as z7 } from "zod";
|
|
2516
|
+
var CentsSchema = z7.number().int().min(0).max(1e8);
|
|
2099
2517
|
var PriceSchema = CentsSchema.nullable();
|
|
2100
|
-
var ConfiguratorValueObjectSchema =
|
|
2518
|
+
var ConfiguratorValueObjectSchema = z7.strictObject({
|
|
2101
2519
|
/** Null: the model has no price of its own, every quote is "open". */
|
|
2102
2520
|
basePriceCents: PriceSchema,
|
|
2103
|
-
numbers:
|
|
2521
|
+
numbers: z7.record(
|
|
2104
2522
|
ConfiguratorNameSchema,
|
|
2105
|
-
|
|
2106
|
-
min:
|
|
2107
|
-
max:
|
|
2108
|
-
default:
|
|
2523
|
+
z7.strictObject({
|
|
2524
|
+
min: z7.number().finite(),
|
|
2525
|
+
max: z7.number().finite(),
|
|
2526
|
+
default: z7.number().finite()
|
|
2109
2527
|
})
|
|
2110
2528
|
).default({}),
|
|
2111
2529
|
/** Per choice list: the options this model offers, with their surcharge. */
|
|
2112
|
-
choices:
|
|
2530
|
+
choices: z7.record(
|
|
2113
2531
|
ConfiguratorNameSchema,
|
|
2114
|
-
|
|
2115
|
-
options:
|
|
2532
|
+
z7.strictObject({
|
|
2533
|
+
options: z7.record(
|
|
2116
2534
|
ConfiguratorCodeSchema,
|
|
2117
|
-
|
|
2535
|
+
z7.strictObject({ priceCents: PriceSchema })
|
|
2118
2536
|
),
|
|
2119
2537
|
default: ConfiguratorCodeSchema.optional()
|
|
2120
2538
|
})
|
|
2121
2539
|
).default({}),
|
|
2122
2540
|
/** Per material parameter: the materials this model offers. */
|
|
2123
|
-
materials:
|
|
2541
|
+
materials: z7.record(
|
|
2124
2542
|
ConfiguratorNameSchema,
|
|
2125
|
-
|
|
2126
|
-
offered:
|
|
2543
|
+
z7.strictObject({
|
|
2544
|
+
offered: z7.array(ConfiguratorCodeSchema).max(CONFIGURATOR_LIMITS.materials),
|
|
2127
2545
|
default: ConfiguratorCodeSchema.optional()
|
|
2128
2546
|
})
|
|
2129
2547
|
).default({}),
|
|
2130
2548
|
/** Per switch: whether this model offers it, and how it starts. Absent is offered, off. */
|
|
2131
|
-
toggles:
|
|
2549
|
+
toggles: z7.record(
|
|
2132
2550
|
ConfiguratorNameSchema,
|
|
2133
|
-
|
|
2551
|
+
z7.strictObject({ offered: z7.boolean(), default: z7.boolean() })
|
|
2134
2552
|
).default({}),
|
|
2135
2553
|
/** The surcharge of each material price group. */
|
|
2136
|
-
priceGroups:
|
|
2554
|
+
priceGroups: z7.record(ConfiguratorCodeSchema, PriceSchema).default({}),
|
|
2137
2555
|
/** The amounts the structure names, such as a price per metre. */
|
|
2138
|
-
amounts:
|
|
2139
|
-
presets:
|
|
2140
|
-
|
|
2556
|
+
amounts: z7.record(ConfiguratorNameSchema, PriceSchema).default({}),
|
|
2557
|
+
presets: z7.array(
|
|
2558
|
+
z7.strictObject({
|
|
2141
2559
|
code: ConfiguratorCodeSchema,
|
|
2142
|
-
label:
|
|
2560
|
+
label: z7.string().trim().min(1).max(80),
|
|
2143
2561
|
/** A product of the same account this preset stands for, e.g. the fireplace. */
|
|
2144
|
-
productId:
|
|
2145
|
-
values:
|
|
2562
|
+
productId: z7.string().uuid().nullable(),
|
|
2563
|
+
values: z7.record(ConfiguratorNameSchema, z7.number().finite())
|
|
2146
2564
|
})
|
|
2147
2565
|
).max(CONFIGURATOR_LIMITS.presets).default([])
|
|
2148
2566
|
});
|
|
@@ -2601,9 +3019,9 @@ function modelOf(node, chosen) {
|
|
|
2601
3019
|
}
|
|
2602
3020
|
|
|
2603
3021
|
// src/configurator-tools.ts
|
|
2604
|
-
import { readFile, stat } from "fs/promises";
|
|
2605
|
-
import { extname } from "path";
|
|
2606
|
-
var
|
|
3022
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
3023
|
+
import { extname as extname2 } from "path";
|
|
3024
|
+
var FILE_RULES2 = {
|
|
2607
3025
|
model: { extensions: [".glb"], maxBytes: 10 * 1024 * 1024 },
|
|
2608
3026
|
texture: {
|
|
2609
3027
|
extensions: [".webp", ".jpg", ".jpeg"],
|
|
@@ -2611,7 +3029,7 @@ var FILE_RULES = {
|
|
|
2611
3029
|
}
|
|
2612
3030
|
};
|
|
2613
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.';
|
|
2614
|
-
var documentInput =
|
|
3032
|
+
var documentInput = z8.string().min(2).max(2e5).describe(
|
|
2615
3033
|
'The JSON text of a whole configurator structure (it has "schemaVersion"), or of a shape document alone (it has "root").'
|
|
2616
3034
|
);
|
|
2617
3035
|
function registerConfiguratorTools(server, client) {
|
|
@@ -2643,11 +3061,11 @@ function registerConfiguratorTools(server, client) {
|
|
|
2643
3061
|
slug: CatalogFieldSlugSchema.describe(
|
|
2644
3062
|
"The slug of the configurator field."
|
|
2645
3063
|
),
|
|
2646
|
-
label:
|
|
2647
|
-
structure:
|
|
3064
|
+
label: z8.string().trim().min(1).max(120).optional(),
|
|
3065
|
+
structure: z8.string().min(2).max(2e5).describe(
|
|
2648
3066
|
'The JSON text of the whole configurator structure (it has "schemaVersion").'
|
|
2649
3067
|
),
|
|
2650
|
-
public:
|
|
3068
|
+
public: z8.boolean().optional().describe("Whether product pages may show the configurator."),
|
|
2651
3069
|
...objectInput
|
|
2652
3070
|
},
|
|
2653
3071
|
annotations: { destructiveHint: true, idempotentHint: true }
|
|
@@ -2697,21 +3115,21 @@ function registerConfiguratorTools(server, client) {
|
|
|
2697
3115
|
fieldSlug: CatalogFieldSlugSchema.describe(
|
|
2698
3116
|
"The slug of the configurator field."
|
|
2699
3117
|
),
|
|
2700
|
-
kind:
|
|
2701
|
-
filePath:
|
|
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."),
|
|
2702
3120
|
...objectInput
|
|
2703
3121
|
},
|
|
2704
3122
|
annotations: { destructiveHint: false, idempotentHint: false }
|
|
2705
3123
|
},
|
|
2706
3124
|
async ({ fieldSlug, kind, filePath, object: object2 }) => {
|
|
2707
|
-
const rules =
|
|
2708
|
-
const extension =
|
|
3125
|
+
const rules = FILE_RULES2[kind];
|
|
3126
|
+
const extension = extname2(filePath).toLowerCase();
|
|
2709
3127
|
if (!rules.extensions.includes(extension)) {
|
|
2710
3128
|
return notUploaded(
|
|
2711
3129
|
`A ${kind} is a ${rules.extensions.join(" or ")} file; this is "${extension || "no extension"}".`
|
|
2712
3130
|
);
|
|
2713
3131
|
}
|
|
2714
|
-
const info = await
|
|
3132
|
+
const info = await stat2(filePath).catch(() => null);
|
|
2715
3133
|
if (!info?.isFile()) {
|
|
2716
3134
|
return notUploaded(`"${filePath}" is not a file on this machine.`);
|
|
2717
3135
|
}
|
|
@@ -2721,7 +3139,7 @@ function registerConfiguratorTools(server, client) {
|
|
|
2721
3139
|
);
|
|
2722
3140
|
}
|
|
2723
3141
|
try {
|
|
2724
|
-
const bytes = new Uint8Array(await
|
|
3142
|
+
const bytes = new Uint8Array(await readFile2(filePath));
|
|
2725
3143
|
const result = await client.uploadConfiguratorFile(
|
|
2726
3144
|
fieldSlug,
|
|
2727
3145
|
{
|
|
@@ -2751,8 +3169,8 @@ function registerConfiguratorTools(server, client) {
|
|
|
2751
3169
|
title: "Read the configurator of a product",
|
|
2752
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,
|
|
2753
3171
|
inputSchema: {
|
|
2754
|
-
slugOrId:
|
|
2755
|
-
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.")
|
|
2756
3174
|
},
|
|
2757
3175
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2758
3176
|
},
|
|
@@ -2834,8 +3252,8 @@ function isRecord(value) {
|
|
|
2834
3252
|
}
|
|
2835
3253
|
|
|
2836
3254
|
// src/event-tools.ts
|
|
2837
|
-
import { z as
|
|
2838
|
-
var moment =
|
|
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.");
|
|
2839
3257
|
function registerEventTools(server, client) {
|
|
2840
3258
|
server.registerTool(
|
|
2841
3259
|
"list_events",
|
|
@@ -2863,7 +3281,7 @@ function registerEventTools(server, client) {
|
|
|
2863
3281
|
title: "Read one event",
|
|
2864
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.",
|
|
2865
3283
|
inputSchema: {
|
|
2866
|
-
idOrSlug:
|
|
3284
|
+
idOrSlug: z9.string().min(1).max(200).describe("The id or the address (slug) of the event.")
|
|
2867
3285
|
},
|
|
2868
3286
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2869
3287
|
},
|
|
@@ -2888,7 +3306,7 @@ function registerEventTools(server, client) {
|
|
|
2888
3306
|
title: "Seats left for an event",
|
|
2889
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.",
|
|
2890
3308
|
inputSchema: {
|
|
2891
|
-
eventId:
|
|
3309
|
+
eventId: z9.string().uuid().describe("The id of the event.")
|
|
2892
3310
|
},
|
|
2893
3311
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
2894
3312
|
},
|
|
@@ -2916,9 +3334,9 @@ function describeEvent(event) {
|
|
|
2916
3334
|
}
|
|
2917
3335
|
|
|
2918
3336
|
// src/placement-tools.ts
|
|
2919
|
-
import { z as
|
|
3337
|
+
import { z as z10 } from "zod";
|
|
2920
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.";
|
|
2921
|
-
var origin =
|
|
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");
|
|
2922
3340
|
function registerPlacementTools(server, client) {
|
|
2923
3341
|
server.registerTool(
|
|
2924
3342
|
"list_forms",
|
|
@@ -2991,13 +3409,13 @@ function registerPlacementTools(server, client) {
|
|
|
2991
3409
|
title: "Make or change a chat widget",
|
|
2992
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.",
|
|
2993
3411
|
inputSchema: {
|
|
2994
|
-
slug:
|
|
3412
|
+
slug: z10.string().regex(/^[a-z][a-z0-9-]*$/).max(100).describe(
|
|
2995
3413
|
'The widget\u2019s address on this account, such as "website-chat".'
|
|
2996
3414
|
),
|
|
2997
|
-
name:
|
|
2998
|
-
status:
|
|
2999
|
-
addAllowedOrigins:
|
|
3000
|
-
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()
|
|
3001
3419
|
},
|
|
3002
3420
|
annotations: {
|
|
3003
3421
|
readOnlyHint: false,
|
|
@@ -3045,9 +3463,9 @@ ${describeChatWidget(widget)}`,
|
|
|
3045
3463
|
title: "Set up or change the tracking script",
|
|
3046
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.",
|
|
3047
3465
|
inputSchema: {
|
|
3048
|
-
addAllowedHosts:
|
|
3049
|
-
removeAllowedHosts:
|
|
3050
|
-
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([
|
|
3051
3469
|
"ad_storage",
|
|
3052
3470
|
"analytics_storage",
|
|
3053
3471
|
"personalization_storage",
|
|
@@ -3152,6 +3570,7 @@ async function main() {
|
|
|
3152
3570
|
registerPlacementTools(server, client);
|
|
3153
3571
|
registerCatalogTools(server, client);
|
|
3154
3572
|
registerCatalogWriteTools(server, client);
|
|
3573
|
+
registerCatalogMigrationTools(server, client);
|
|
3155
3574
|
registerEventTools(server, client);
|
|
3156
3575
|
registerConfiguratorTools(server, client);
|
|
3157
3576
|
await server.connect(new StdioServerTransport());
|