x
This commit is contained in:
1 parent
ae3799d8ce
commit
0341b30b6d
9 files changed
+947
-51
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Category" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Category_deletedAt_idx" ON "Category"("deletedAt");
|
||||||
@@ -37,8 +37,11 @@ model Category {
|
|||||||
products Product[]
|
products Product[]
|
||||||
attributes AttributeDefinition[]
|
attributes AttributeDefinition[]
|
||||||
translations CategoryTranslation[]
|
translations CategoryTranslation[]
|
||||||
|
deletedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([deletedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model CategoryTranslation {
|
model CategoryTranslation {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
.get("/", async ({ query }) => {
|
.get("/", async ({ query }) => {
|
||||||
const lang = (query as any).lang || 'th';
|
const lang = (query as any).lang || 'th';
|
||||||
const categories = await prisma.category.findMany({
|
const categories = await prisma.category.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
include: {
|
include: {
|
||||||
translations: true,
|
translations: true,
|
||||||
@@ -45,7 +46,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
|
|
||||||
const [category, total] = await Promise.all([
|
const [category, total] = await Promise.all([
|
||||||
prisma.category.findUnique({
|
prisma.category.findUnique({
|
||||||
where: { id },
|
where: { id, deletedAt: null },
|
||||||
include: {
|
include: {
|
||||||
translations: true,
|
translations: true,
|
||||||
products: {
|
products: {
|
||||||
@@ -118,7 +119,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const categoryExists = await prisma.category.findUnique({ where: { id } });
|
const categoryExists = await prisma.category.findFirst({ where: { id, deletedAt: null } });
|
||||||
if (!categoryExists) return status(404, { message: "Category not found" });
|
if (!categoryExists) return status(404, { message: "Category not found" });
|
||||||
|
|
||||||
const payload = body as any;
|
const payload = body as any;
|
||||||
@@ -170,7 +171,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const category = await prisma.category.update({
|
const category = await prisma.category.update({
|
||||||
where: { id },
|
where: { id, deletedAt: null },
|
||||||
data: {
|
data: {
|
||||||
imageUrl: body.imageUrl,
|
imageUrl: body.imageUrl,
|
||||||
},
|
},
|
||||||
@@ -217,7 +218,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const category = await prisma.category.update({
|
const category = await prisma.category.update({
|
||||||
where: { id },
|
where: { id, deletedAt: null },
|
||||||
data: {
|
data: {
|
||||||
name: body.name,
|
name: body.name,
|
||||||
imageUrl: body.imageUrl,
|
imageUrl: body.imageUrl,
|
||||||
@@ -246,7 +247,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const category = await prisma.category.findUnique({
|
const category = await prisma.category.findUnique({
|
||||||
where: { id },
|
where: { id, deletedAt: null },
|
||||||
include: { _count: { select: { products: true } } }
|
include: { _count: { select: { products: true } } }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,8 +256,12 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
|||||||
return status(400, { message: "Cannot delete category with existing products" });
|
return status(400, { message: "Cannot delete category with existing products" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.category.delete({ where: { id } });
|
const deletedAt = new Date();
|
||||||
return { success: true };
|
await prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt },
|
||||||
|
});
|
||||||
|
return { success: true, softDeleted: true, id, deletedAt };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return status(500, { message: "Failed to delete category" });
|
return status(500, { message: "Failed to delete category" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,7 +734,7 @@ export async function hydrateSectionPayload(section: any, lang: string) {
|
|||||||
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
|
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
|
||||||
const ids = payload.categoryIds.map(Number);
|
const ids = payload.categoryIds.map(Number);
|
||||||
categories = await prisma.category.findMany({
|
categories = await prisma.category.findMany({
|
||||||
where: { id: { in: ids } },
|
where: { id: { in: ids }, deletedAt: null },
|
||||||
include: {
|
include: {
|
||||||
translations: true,
|
translations: true,
|
||||||
_count: {
|
_count: {
|
||||||
@@ -745,6 +745,7 @@ export async function hydrateSectionPayload(section: any, lang: string) {
|
|||||||
categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||||
} else {
|
} else {
|
||||||
categories = await prisma.category.findMany({
|
categories = await prisma.category.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
include: {
|
include: {
|
||||||
translations: true,
|
translations: true,
|
||||||
_count: {
|
_count: {
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ function defaultProductDetailSections(productId: number) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertActiveCategory(categoryId: number, client: any = prisma) {
|
||||||
|
const category = await client.category.findFirst({
|
||||||
|
where: { id: categoryId, deletedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!category) {
|
||||||
|
const error = new Error("Category not found");
|
||||||
|
(error as any).code = "CATEGORY_NOT_FOUND";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||||
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
|
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
|
||||||
if (options.length > 3) throw new Error("Options array length cannot exceed 3.");
|
if (options.length > 3) throw new Error("Options array length cannot exceed 3.");
|
||||||
@@ -522,7 +534,10 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
const updatedProduct = await prisma.$transaction(async (tx) => {
|
const updatedProduct = await prisma.$transaction(async (tx) => {
|
||||||
// 1. Update basic product info
|
// 1. Update basic product info
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
if (categoryId !== undefined) updateData.categoryId = categoryId;
|
if (categoryId !== undefined) {
|
||||||
|
await assertActiveCategory(categoryId, tx);
|
||||||
|
updateData.categoryId = categoryId;
|
||||||
|
}
|
||||||
if (body.options !== undefined) updateData.options = body.options;
|
if (body.options !== undefined) updateData.options = body.options;
|
||||||
|
|
||||||
const prod = await tx.product.update({
|
const prod = await tx.product.update({
|
||||||
@@ -632,6 +647,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
|
|
||||||
return { success: true, product: updatedProduct };
|
return { success: true, product: updatedProduct };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||||
|
return status(400, { success: false, message: "Category not found" });
|
||||||
|
}
|
||||||
console.error("Update product error:", err);
|
console.error("Update product error:", err);
|
||||||
return status(500, { success: false, message: err?.message || "Failed to update product" });
|
return status(500, { success: false, message: err?.message || "Failed to update product" });
|
||||||
}
|
}
|
||||||
@@ -690,12 +708,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
const product = await prisma.$transaction(async (tx) => {
|
const product = await prisma.$transaction(async (tx) => {
|
||||||
let categoryId = payload.categoryId;
|
let categoryId = payload.categoryId;
|
||||||
if (!categoryId && payload.categoryName) {
|
if (!categoryId && payload.categoryName) {
|
||||||
let cat = await tx.category.findUnique({ where: { name: payload.categoryName } });
|
let cat = await tx.category.findFirst({
|
||||||
|
where: { name: payload.categoryName, deletedAt: null },
|
||||||
|
});
|
||||||
if (!cat) {
|
if (!cat) {
|
||||||
cat = await tx.category.create({ data: { name: payload.categoryName } });
|
cat = await tx.category.create({ data: { name: payload.categoryName } });
|
||||||
}
|
}
|
||||||
categoryId = cat.id;
|
categoryId = cat.id;
|
||||||
}
|
}
|
||||||
|
if (categoryId) await assertActiveCategory(categoryId, tx);
|
||||||
|
|
||||||
let brandId = payload.brandId;
|
let brandId = payload.brandId;
|
||||||
if (!brandId && payload.brandName) {
|
if (!brandId && payload.brandName) {
|
||||||
@@ -775,6 +796,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
|
|
||||||
return { success: true, product };
|
return { success: true, product };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||||
|
return status(400, { success: false, message: "Category not found" });
|
||||||
|
}
|
||||||
console.error("Create product error:", err);
|
console.error("Create product error:", err);
|
||||||
return status(500, { success: false, message: err?.message || "Failed to create product" });
|
return status(500, { success: false, message: err?.message || "Failed to create product" });
|
||||||
}
|
}
|
||||||
@@ -806,12 +830,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
|
|
||||||
let categoryId = payload.categoryId;
|
let categoryId = payload.categoryId;
|
||||||
if (!categoryId && payload.categoryName) {
|
if (!categoryId && payload.categoryName) {
|
||||||
let cat = await tx.category.findUnique({ where: { name: payload.categoryName } });
|
let cat = await tx.category.findFirst({
|
||||||
|
where: { name: payload.categoryName, deletedAt: null },
|
||||||
|
});
|
||||||
if (!cat) {
|
if (!cat) {
|
||||||
cat = await tx.category.create({ data: { name: payload.categoryName } });
|
cat = await tx.category.create({ data: { name: payload.categoryName } });
|
||||||
}
|
}
|
||||||
categoryId = cat.id;
|
categoryId = cat.id;
|
||||||
}
|
}
|
||||||
|
if (categoryId) await assertActiveCategory(categoryId, tx);
|
||||||
|
|
||||||
let brandId = payload.brandId;
|
let brandId = payload.brandId;
|
||||||
if (!brandId && payload.brandName) {
|
if (!brandId && payload.brandName) {
|
||||||
@@ -892,6 +919,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
|
|
||||||
return { success: true, products: createdProducts };
|
return { success: true, products: createdProducts };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||||
|
return status(400, { success: false, message: "Category not found" });
|
||||||
|
}
|
||||||
console.error("Bulk create products error:", err);
|
console.error("Bulk create products error:", err);
|
||||||
return status(500, { success: false, message: err?.message || "Failed to bulk create products" });
|
return status(500, { success: false, message: err?.message || "Failed to bulk create products" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,86 @@ bun run dev
|
|||||||
- `update_store_settings_translation` อัปเดตข้อความแปลของ store settings
|
- `update_store_settings_translation` อัปเดตข้อความแปลของ store settings
|
||||||
- `update_footer_content` อัปเดตเนื้อหา footer และลิงก์ที่เกี่ยวข้อง
|
- `update_footer_content` อัปเดตเนื้อหา footer และลิงก์ที่เกี่ยวข้อง
|
||||||
|
|
||||||
|
## Mega Menu LLM Workflow
|
||||||
|
|
||||||
|
Header Mega Menu uses the `main` menu payload tree. The website renders this structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Root -> Column -> Link -> Child Link
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported localized text keys are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
th, en, ja, zh, vi, ko, lo, it
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM agents should use this workflow for every menu edit:
|
||||||
|
|
||||||
|
1. `capture_menu_snapshot` for `main`
|
||||||
|
2. `get_mega_menu_tree`
|
||||||
|
3. `validate_mega_menu_tree`
|
||||||
|
4. Plan operations from diagnostics and current `nodeId` values
|
||||||
|
5. `bulk_menu_operations` with `dryRun: true`
|
||||||
|
6. Review `preview.diff`, `preview.before`, `preview.after`, `issues`, and `warnings`
|
||||||
|
7. Apply only after approval
|
||||||
|
8. Run `validate_mega_menu_tree` again
|
||||||
|
9. Use `restore_menu_snapshot` if the applied result is wrong
|
||||||
|
|
||||||
|
Do not add a Link directly under root. Add a Column under Root first, then add Links under that Column.
|
||||||
|
|
||||||
|
Dry-run responses are expected to include path-based diffs such as:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "payload[0].columns[0].links[0].label.it",
|
||||||
|
"before": null,
|
||||||
|
"after": "Candele"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`validate_mega_menu_tree` reports:
|
||||||
|
|
||||||
|
- missing locale keys, especially `lo` and `it`
|
||||||
|
- unsupported translation keys
|
||||||
|
- duplicate or missing `nodeId`
|
||||||
|
- invalid URL shape
|
||||||
|
- placeholder text
|
||||||
|
- empty columns
|
||||||
|
- featured card issues
|
||||||
|
- desktop render limits such as hidden columns or links
|
||||||
|
|
||||||
|
## Category Management LLM Workflow
|
||||||
|
|
||||||
|
Product categories are managed by backend `/categories` APIs through MCP. Supported localized text keys are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
th, en, ja, zh, vi, ko, lo, it
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM agents should use this workflow for category edits:
|
||||||
|
|
||||||
|
1. `list_categories` with `lang: "all"` to discover category IDs and current translations
|
||||||
|
2. `create_category`, `update_category`, `update_category_translations`, or `delete_category` with `dryRun: true`
|
||||||
|
3. Review the preview and product counts
|
||||||
|
4. Apply only after the intended change is clear
|
||||||
|
5. Run `list_categories` again to verify
|
||||||
|
|
||||||
|
Backend category support currently covers:
|
||||||
|
|
||||||
|
- base `name`
|
||||||
|
- `imageUrl`
|
||||||
|
- localized names
|
||||||
|
- product-count guarded soft delete (`deletedAt`)
|
||||||
|
|
||||||
|
Backend category support does not currently cover:
|
||||||
|
|
||||||
|
- slug
|
||||||
|
- nested parent/child hierarchy
|
||||||
|
- sort order
|
||||||
|
- active/inactive state
|
||||||
|
- restore/undelete workflow
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `bun run dev`
|
- `bun run dev`
|
||||||
|
|||||||
+308
-4
@@ -14,6 +14,7 @@ const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https:
|
|||||||
let BACKEND_URL = process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL;
|
let BACKEND_URL = process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL;
|
||||||
const ADMIN_USERNAME = process.env.MCP_ADMIN_USERNAME || process.env.ADMIN_USERNAME || "admin";
|
const ADMIN_USERNAME = process.env.MCP_ADMIN_USERNAME || process.env.ADMIN_USERNAME || "admin";
|
||||||
const ADMIN_PASSWORD = process.env.MCP_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || "homaroma123";
|
const ADMIN_PASSWORD = process.env.MCP_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || "homaroma123";
|
||||||
|
const CATEGORY_LOCALES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
|
||||||
|
|
||||||
let authToken = "";
|
let authToken = "";
|
||||||
|
|
||||||
@@ -49,6 +50,37 @@ async function performLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateCategoryLocaleMap(translations: any) {
|
||||||
|
if (!translations || typeof translations !== "object" || Array.isArray(translations)) {
|
||||||
|
throw new Error("translations must be an object keyed by locale codes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(translations);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
throw new Error("translations must include at least one locale.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [locale, value] of entries) {
|
||||||
|
if (!CATEGORY_LOCALES.includes(locale as any)) {
|
||||||
|
throw new Error(`Unsupported locale "${locale}". Supported locales: ${CATEGORY_LOCALES.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
throw new Error(`translations.${locale} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactCategory(category: any) {
|
||||||
|
if (!category) return null;
|
||||||
|
return {
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
imageUrl: category.imageUrl ?? null,
|
||||||
|
productCount: category._count?.products ?? category.productCount ?? undefined,
|
||||||
|
translations: category.translations ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{
|
||||||
name: "homaroma-mcp",
|
name: "homaroma-mcp",
|
||||||
@@ -96,8 +128,74 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "list_categories",
|
name: "list_categories",
|
||||||
description: "List all product categories with their translations",
|
description: "List all product categories with translations and product counts. Use lang=all when you need every localized name.",
|
||||||
inputSchema: { type: "object", properties: {} }
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_category_products",
|
||||||
|
description: "Fetch one category plus its products. Use this before delete_category to see whether products block deletion.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number", description: "Category ID" },
|
||||||
|
lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" },
|
||||||
|
page: { type: "number", description: "Page number, default 1" },
|
||||||
|
limit: { type: "number", description: "Products per page, default 12" }
|
||||||
|
},
|
||||||
|
required: ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_category",
|
||||||
|
description: "Create a product category. Backend supports base name, imageUrl, and translations. It does not support slug, hierarchy, sort order, or active state for categories.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Base category name. Use Thai or the primary display name." },
|
||||||
|
imageUrl: { type: ["string", "null"], description: "Optional image URL." },
|
||||||
|
translations: {
|
||||||
|
type: "object",
|
||||||
|
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it."
|
||||||
|
},
|
||||||
|
dryRun: { type: "boolean", description: "Preview only. No category is created." }
|
||||||
|
},
|
||||||
|
required: ["name"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_category",
|
||||||
|
description: "Update category base name, image URL, and/or translations. Use dryRun first. Backend supports name and imageUrl only for base category fields.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number", description: "Category ID" },
|
||||||
|
name: { type: "string", description: "Optional base category name." },
|
||||||
|
imageUrl: { type: ["string", "null"], description: "Optional image URL. Pass null to remove." },
|
||||||
|
translations: {
|
||||||
|
type: "object",
|
||||||
|
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it."
|
||||||
|
},
|
||||||
|
dryRun: { type: "boolean", description: "Preview only. No category is updated." }
|
||||||
|
},
|
||||||
|
required: ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_category",
|
||||||
|
description: "Soft-delete a category by setting deletedAt. Backend refuses deletion when products still reference the category. Use dryRun first to check product count.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number", description: "Category ID" },
|
||||||
|
dryRun: { type: "boolean", description: "Preview only. No category is deleted." }
|
||||||
|
},
|
||||||
|
required: ["id"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "search_products",
|
name: "search_products",
|
||||||
@@ -131,12 +229,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: "number", description: "Category ID" },
|
id: { type: "number", description: "Category ID" },
|
||||||
lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" },
|
lang: { type: "string", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language code" },
|
||||||
name: { type: "string", description: "Translated category name" }
|
name: { type: "string", description: "Translated category name" }
|
||||||
},
|
},
|
||||||
required: ["id", "lang", "name"]
|
required: ["id", "lang", "name"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "update_category_translations",
|
||||||
|
description: "Add or update multiple category translations in one call. Supported locales: th, en, ja, zh, vi, ko, lo, it.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number", description: "Category ID" },
|
||||||
|
translations: {
|
||||||
|
type: "object",
|
||||||
|
description: "Locale map, for example { \"th\": \"ชุดโซ่สเตอร์\", \"en\": \"Chain & Sprocket Kits\" }"
|
||||||
|
},
|
||||||
|
dryRun: { type: "boolean", description: "Preview only. No translations are updated." }
|
||||||
|
},
|
||||||
|
required: ["id", "translations"]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "update_category_image",
|
name: "update_category_image",
|
||||||
description: "Update the image URL for a specific category.",
|
description: "Update the image URL for a specific category.",
|
||||||
@@ -855,9 +969,161 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "list_categories": {
|
case "list_categories": {
|
||||||
const res = await api.get(`/categories`);
|
const lang = params.lang ? `?lang=${encodeURIComponent(String(params.lang))}` : "";
|
||||||
|
const res = await api.get(`/categories${lang}`);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
}
|
}
|
||||||
|
case "get_category_products": {
|
||||||
|
const { id } = params as any;
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.lang) query.set("lang", String(params.lang));
|
||||||
|
if (params.page) query.set("page", String(params.page));
|
||||||
|
if (params.limit) query.set("limit", String(params.limit));
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : "";
|
||||||
|
const res = await api.get(`/categories/${id}/products${suffix}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "create_category": {
|
||||||
|
const { name, imageUrl = null, translations, dryRun } = params as any;
|
||||||
|
if (typeof name !== "string" || name.trim().length === 0) {
|
||||||
|
throw new Error("name must be a non-empty string.");
|
||||||
|
}
|
||||||
|
if (translations !== undefined) validateCategoryLocaleMap(translations);
|
||||||
|
|
||||||
|
const payload = { name: name.trim(), imageUrl };
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "create_category",
|
||||||
|
preview: {
|
||||||
|
create: payload,
|
||||||
|
translations: translations ?? null,
|
||||||
|
backendSupport: {
|
||||||
|
fields: ["name", "imageUrl", "translations"],
|
||||||
|
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await api.post(`/categories`, payload);
|
||||||
|
let translationResult: any = null;
|
||||||
|
if (translations) {
|
||||||
|
const mergedTranslations = { th: name.trim(), ...translations };
|
||||||
|
const translationRes = await api.patch(`/categories/${created.data.id}/translations`, { name: mergedTranslations });
|
||||||
|
translationResult = translationRes.data;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
operation: "create_category",
|
||||||
|
category: translationResult?.category ?? created.data,
|
||||||
|
translations: translationResult?.translations ?? [],
|
||||||
|
}, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "update_category": {
|
||||||
|
const { id, name, imageUrl, translations, dryRun } = params as any;
|
||||||
|
if (name !== undefined && (typeof name !== "string" || name.trim().length === 0)) {
|
||||||
|
throw new Error("name must be a non-empty string when provided.");
|
||||||
|
}
|
||||||
|
if (translations !== undefined) validateCategoryLocaleMap(translations);
|
||||||
|
|
||||||
|
const beforeList = await api.get(`/categories?lang=all`);
|
||||||
|
const before = Array.isArray(beforeList.data)
|
||||||
|
? beforeList.data.find((cat: any) => cat.id === id)
|
||||||
|
: null;
|
||||||
|
if (!before) {
|
||||||
|
throw new Error(`Category with id ${id} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUpdate: any = {};
|
||||||
|
if (name !== undefined) baseUpdate.name = name.trim();
|
||||||
|
if (imageUrl !== undefined) baseUpdate.imageUrl = imageUrl;
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "update_category",
|
||||||
|
categoryId: id,
|
||||||
|
preview: {
|
||||||
|
before: compactCategory(before),
|
||||||
|
baseUpdate,
|
||||||
|
translations: translations ?? null,
|
||||||
|
backendSupport: {
|
||||||
|
fields: ["name", "imageUrl", "translations"],
|
||||||
|
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated: any = before;
|
||||||
|
if (Object.keys(baseUpdate).length > 0) {
|
||||||
|
const updateRes = await api.put(`/categories/${id}`, baseUpdate);
|
||||||
|
updated = updateRes.data;
|
||||||
|
}
|
||||||
|
let translationResult: any = null;
|
||||||
|
if (translations) {
|
||||||
|
const translationRes = await api.patch(`/categories/${id}/translations`, { name: translations });
|
||||||
|
translationResult = translationRes.data;
|
||||||
|
updated = translationResult?.category ?? updated;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
operation: "update_category",
|
||||||
|
category: updated,
|
||||||
|
translations: translationResult?.translations ?? [],
|
||||||
|
}, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "delete_category": {
|
||||||
|
const { id, dryRun } = params as any;
|
||||||
|
const details = await api.get(`/categories/${id}/products?lang=all&limit=1`);
|
||||||
|
const productCount = details.data?.category?._count?.products ?? details.data?.meta?.total ?? 0;
|
||||||
|
const category = details.data?.category ?? { id };
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: productCount === 0,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "delete_category",
|
||||||
|
category,
|
||||||
|
productCount,
|
||||||
|
blocked: productCount > 0,
|
||||||
|
message: productCount > 0
|
||||||
|
? "Backend will refuse soft deletion because this category has products."
|
||||||
|
: "Category can be soft-deleted. The record will be hidden from category lists but kept in the database.",
|
||||||
|
}, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.delete(`/categories/${id}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ ...res.data, operation: "soft_delete_category", categoryId: id }, null, 2) }] };
|
||||||
|
}
|
||||||
case "search_products": {
|
case "search_products": {
|
||||||
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
|
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
|
||||||
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
|
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
|
||||||
@@ -889,6 +1155,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
}
|
}
|
||||||
case "update_category_translation": {
|
case "update_category_translation": {
|
||||||
const { id, lang, name } = params as any;
|
const { id, lang, name } = params as any;
|
||||||
|
validateCategoryLocaleMap({ [lang]: name });
|
||||||
const res = await api.patch(`/categories/${id}/translations`, {
|
const res = await api.patch(`/categories/${id}/translations`, {
|
||||||
name: { [lang]: name }
|
name: { [lang]: name }
|
||||||
});
|
});
|
||||||
@@ -896,6 +1163,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "update_category_translations": {
|
||||||
|
const { id, translations, dryRun } = params as any;
|
||||||
|
validateCategoryLocaleMap(translations);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
const beforeList = await api.get(`/categories?lang=all`);
|
||||||
|
const before = Array.isArray(beforeList.data)
|
||||||
|
? beforeList.data.find((cat: any) => cat.id === id)
|
||||||
|
: null;
|
||||||
|
if (!before) {
|
||||||
|
throw new Error(`Category with id ${id} not found.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "update_category_translations",
|
||||||
|
categoryId: id,
|
||||||
|
preview: {
|
||||||
|
before: compactCategory(before),
|
||||||
|
translations,
|
||||||
|
changedLocales: Object.keys(translations),
|
||||||
|
},
|
||||||
|
}, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.patch(`/categories/${id}/translations`, {
|
||||||
|
name: translations
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
case "update_category_image": {
|
case "update_category_image": {
|
||||||
const { id, imageUrl } = params as any;
|
const { id, imageUrl } = params as any;
|
||||||
const res = await api.patch(`/categories/${id}/image`, {
|
const res = await api.patch(`/categories/${id}/image`, {
|
||||||
|
|||||||
+228
-26
@@ -5,19 +5,88 @@ import {
|
|||||||
applyMenuMoveNode,
|
applyMenuMoveNode,
|
||||||
applyMenuRemoveNode,
|
applyMenuRemoveNode,
|
||||||
applyMenuUpdateTranslation,
|
applyMenuUpdateTranslation,
|
||||||
|
buildMenuMutationPreview,
|
||||||
collectMenuLinksFromTree,
|
collectMenuLinksFromTree,
|
||||||
createMenuSnapshot,
|
createMenuSnapshot,
|
||||||
describeMenuNode,
|
|
||||||
normalizeMenuPayloadForWrite,
|
normalizeMenuPayloadForWrite,
|
||||||
restoreMenuSnapshot,
|
restoreMenuSnapshot,
|
||||||
validateMenuItems,
|
validateMenuItems,
|
||||||
validateMenuNodeData,
|
validateMenuNodeData,
|
||||||
validateMenuTranslations,
|
validateMenuTranslations,
|
||||||
validateMenuTreeSnapshot,
|
validateMenuTreeSnapshot,
|
||||||
cloneMenuValue,
|
|
||||||
isPlainObject,
|
isPlainObject,
|
||||||
|
SUPPORTED_MENU_LOCALES,
|
||||||
} from "./menu-tools";
|
} from "./menu-tools";
|
||||||
|
|
||||||
|
const localizedTextSchema = {
|
||||||
|
type: "object",
|
||||||
|
description: `Localized text. Allowed locale keys: ${SUPPORTED_MENU_LOCALES.join(", ")}. Provide at least th or en; include lo and it when creating production-ready header menu content.`,
|
||||||
|
properties: Object.fromEntries(SUPPORTED_MENU_LOCALES.map((locale) => [locale, { type: "string" }])),
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const menuLinkNodeSchema = {
|
||||||
|
type: "object",
|
||||||
|
description: "Mega Menu Link node. Valid under a Column or another Link. Renders as a clickable link; child links are shown inline up to the desktop limit.",
|
||||||
|
properties: {
|
||||||
|
nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." },
|
||||||
|
label: localizedTextSchema,
|
||||||
|
url: { type: "string", description: "Internal path starting with /, or http(s)://, mailto:, tel:" },
|
||||||
|
icon: { type: "string", description: "Optional icon registry key" },
|
||||||
|
imageUrl: { type: "string", description: "Optional hover/preview image URL" },
|
||||||
|
links: { type: "array", description: "Optional child links", items: { type: "object" } },
|
||||||
|
},
|
||||||
|
required: ["label", "url"],
|
||||||
|
additionalProperties: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const menuColumnNodeSchema = {
|
||||||
|
type: "object",
|
||||||
|
description: "Mega Menu Column node. Valid only under Root. Contains links; it is not directly clickable.",
|
||||||
|
properties: {
|
||||||
|
nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." },
|
||||||
|
title: localizedTextSchema,
|
||||||
|
icon: { type: "string", description: "Optional icon registry key" },
|
||||||
|
links: { type: "array", items: menuLinkNodeSchema },
|
||||||
|
},
|
||||||
|
required: ["title", "links"],
|
||||||
|
additionalProperties: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const rootMenuNodeSchema = {
|
||||||
|
type: "object",
|
||||||
|
description: "Mega Menu Root node. Valid only under root. Desktop renders root labels in the header; columns open in the mega panel on hover.",
|
||||||
|
properties: {
|
||||||
|
nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." },
|
||||||
|
label: localizedTextSchema,
|
||||||
|
url: { type: "string", description: "Internal path starting with /, or http(s)://, mailto:, tel:" },
|
||||||
|
rootImageUrl: { type: "string", description: "Optional fallback image for featured preview" },
|
||||||
|
columns: { type: "array", items: menuColumnNodeSchema },
|
||||||
|
featured: {
|
||||||
|
type: "object",
|
||||||
|
description: "Optional desktop featured card. If enabled, provide meaningful localized text and imageUrl/url where possible.",
|
||||||
|
properties: {
|
||||||
|
enabled: { type: "boolean" },
|
||||||
|
sectionTitle: localizedTextSchema,
|
||||||
|
badge: localizedTextSchema,
|
||||||
|
title: localizedTextSchema,
|
||||||
|
subtitle: localizedTextSchema,
|
||||||
|
ctaLabel: localizedTextSchema,
|
||||||
|
price: { type: "string" },
|
||||||
|
imageUrl: { type: "string" },
|
||||||
|
url: { type: "string" },
|
||||||
|
focalPoint: {
|
||||||
|
type: "object",
|
||||||
|
properties: { x: { type: "string" }, y: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["label", "url", "columns"],
|
||||||
|
additionalProperties: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const MENU_TOOL_DEFINITIONS = [
|
export const MENU_TOOL_DEFINITIONS = [
|
||||||
{
|
{
|
||||||
name: "get_menu",
|
name: "get_menu",
|
||||||
@@ -82,9 +151,19 @@ export const MENU_TOOL_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "get_mega_menu_tree",
|
name: "get_mega_menu_tree",
|
||||||
description: "Read Mega Menu (main) payload, auto-generate nodeId for all nodes if missing, and return the tree.",
|
description: `Read Mega Menu (main) payload, auto-generate nodeId for all structural nodes if missing, and return the tree. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`,
|
||||||
inputSchema: { type: "object", properties: {} }
|
inputSchema: { type: "object", properties: {} }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "validate_mega_menu_tree",
|
||||||
|
description: "Validate the current Mega Menu (main) tree for LLM-safe editing. Reports missing locales, invalid node shape, duplicate nodeId, unsupported translation keys, placeholder text, URL issues, and desktop render limits.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
includeTree: { type: "boolean", description: "Include the normalized menu tree in the response. Default false." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "capture_menu_snapshot",
|
name: "capture_menu_snapshot",
|
||||||
description: "Capture the current state of a menu so it can be restored later.",
|
description: "Capture the current state of a menu so it can be restored later.",
|
||||||
@@ -110,7 +189,7 @@ export const MENU_TOOL_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bulk_menu_operations",
|
name: "bulk_menu_operations",
|
||||||
description: "Apply multiple menu tree operations in a single transaction. Supports add_node, update_translation, move_node, and remove_node.",
|
description: `Apply multiple Mega Menu operations in a single transaction. Workflow: capture_menu_snapshot -> validate_mega_menu_tree -> bulk_menu_operations with dryRun true -> user approval -> apply. Supports add_node, update_translation, move_node, and remove_node. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -127,8 +206,11 @@ export const MENU_TOOL_DEFINITIONS = [
|
|||||||
nodeId: { type: "string", description: "Target node ID for update/move/remove" },
|
nodeId: { type: "string", description: "Target node ID for update/move/remove" },
|
||||||
newParentId: { type: "string", description: "New parent node ID for move_node" },
|
newParentId: { type: "string", description: "New parent node ID for move_node" },
|
||||||
orderIndex: { type: "number", description: "Insertion index for move_node" },
|
orderIndex: { type: "number", description: "Insertion index for move_node" },
|
||||||
translations: { type: "object", description: "Translation map for update_translation" },
|
translations: { ...localizedTextSchema, description: "Translation map for update_translation. Only locale keys are accepted." },
|
||||||
nodeData: { type: "object", description: "Node data for add_node" }
|
nodeData: {
|
||||||
|
oneOf: [rootMenuNodeSchema, menuColumnNodeSchema, menuLinkNodeSchema],
|
||||||
|
description: "Node data for add_node. Shape depends on parent: parentId=root => Root node; parent is Root => Column node; parent is Column/Link => Link node."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
required: ["type"]
|
required: ["type"]
|
||||||
}
|
}
|
||||||
@@ -139,28 +221,31 @@ export const MENU_TOOL_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "add_menu_node",
|
name: "add_menu_node",
|
||||||
description: "Add a new node to the Mega Menu tree. CRITICAL RULE: Mega Menu structure is Root -> Column -> Link. Do NOT add a Link directly under Root. If you want to add a Link, you MUST check if a Column exists first. If no Column exists, create a Column node first, then add the Link under that Column.",
|
description: `Add a new node to the Mega Menu tree. Structure is Root -> Column -> Link -> Child Link. Do not add a Link directly under root. Use dryRun first. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
parentId: { type: "string", description: "UUID of parent node, or 'root'" },
|
parentId: { type: "string", description: "UUID of parent node, or 'root'" },
|
||||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||||
nodeData: { type: "object", description: "Node data including label, url, translations, etc." }
|
nodeData: {
|
||||||
|
oneOf: [rootMenuNodeSchema, menuColumnNodeSchema, menuLinkNodeSchema],
|
||||||
|
description: "Node data. If parentId is root/omitted, provide a Root node with label, url, and columns. If parent is a Root node, provide a Column node with title and links. If parent is a Column/Link, provide a Link node with label and url."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
required: ["nodeData"]
|
required: ["nodeData"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_menu_translation",
|
name: "update_menu_translation",
|
||||||
description: "Update 6-language translations for a specific node in the Mega Menu",
|
description: `Update localized text for a specific node in the Mega Menu. Accepts only supported locale keys: ${SUPPORTED_MENU_LOCALES.join(", ")}. This updates label for root/link nodes or title for column nodes.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
nodeId: { type: "string", description: "UUID of the node" },
|
nodeId: { type: "string", description: "UUID of the node" },
|
||||||
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||||
translations: {
|
translations: {
|
||||||
type: "object",
|
...localizedTextSchema,
|
||||||
description: "Object containing language codes as keys (th, en, etc.) and translated strings as values."
|
description: "Object containing locale codes as keys and translated strings as values. Unsupported keys are rejected."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
required: ["nodeId", "translations"]
|
required: ["nodeId", "translations"]
|
||||||
@@ -359,6 +444,39 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
}
|
}
|
||||||
return { content: [{ type: "text", text: JSON.stringify(normalized.payload, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(normalized.payload, null, 2) }] };
|
||||||
}
|
}
|
||||||
|
case "validate_mega_menu_tree": {
|
||||||
|
const res = await api.get(`/menus/main`);
|
||||||
|
const menu = res.data;
|
||||||
|
const normalized = normalizeMenuPayloadForWrite(menu.payload);
|
||||||
|
if (normalized.modified) {
|
||||||
|
await api.put(`/menus/main`, { payload: normalized.payload, items: menu.items || [] });
|
||||||
|
}
|
||||||
|
const validation = validateMenuTreeSnapshot(normalized.payload);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: validation.issues.length === 0,
|
||||||
|
operation: "validate_mega_menu_tree",
|
||||||
|
menuName: "main",
|
||||||
|
supportedLocales: validation.supportedLocales,
|
||||||
|
summary: validation.summary,
|
||||||
|
issueCount: validation.issues.length,
|
||||||
|
warningCount: validation.warnings.length,
|
||||||
|
issues: validation.issues,
|
||||||
|
warnings: validation.warnings,
|
||||||
|
diagnostics: validation.diagnostics,
|
||||||
|
tree: params.includeTree ? normalized.payload : undefined,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
case "capture_menu_snapshot": {
|
case "capture_menu_snapshot": {
|
||||||
const res = await api.get(`/menus/${params.name}`);
|
const res = await api.get(`/menus/${params.name}`);
|
||||||
const snapshot = createMenuSnapshot(res.data);
|
const snapshot = createMenuSnapshot(res.data);
|
||||||
@@ -413,7 +531,9 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
|
|
||||||
const res = await api.get(`/menus/${params.name}`);
|
const res = await api.get(`/menus/${params.name}`);
|
||||||
const menu = res.data;
|
const menu = res.data;
|
||||||
const result = applyBulkMenuOperations(Array.isArray(menu.payload) ? menu.payload : [], params.operations);
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
||||||
|
const result = applyBulkMenuOperations(beforePayload, params.operations);
|
||||||
|
const validation = validateMenuTreeSnapshot(result.payload);
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return {
|
return {
|
||||||
@@ -421,7 +541,17 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(
|
text: JSON.stringify(
|
||||||
{ success: true, dryRun: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, preview: { nodeCount: result.payload.length } },
|
{
|
||||||
|
success: validation.issues.length === 0,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "bulk_menu_operations",
|
||||||
|
menuName: params.name,
|
||||||
|
warnings: [...result.warnings, ...validation.warnings],
|
||||||
|
issues: validation.issues,
|
||||||
|
diagnostics: validation.diagnostics,
|
||||||
|
applied: result.applied,
|
||||||
|
preview: buildMenuMutationPreview(beforePayload, result.payload),
|
||||||
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)
|
)
|
||||||
@@ -436,11 +566,23 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
writeBody.isActive = menu.isActive;
|
writeBody.isActive = menu.isActive;
|
||||||
}
|
}
|
||||||
const writeRes = await api.put(`/menus/${params.name}`, writeBody);
|
const writeRes = await api.put(`/menus/${params.name}`, writeBody);
|
||||||
|
const writtenCheck = validateMenuTreeSnapshot(normalized.payload);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, nodeCount: normalized.payload.length }, null, 2)
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
operation: "bulk_menu_operations",
|
||||||
|
menuName: params.name,
|
||||||
|
warnings: [...result.warnings, ...writtenCheck.warnings],
|
||||||
|
applied: result.applied,
|
||||||
|
summary: writtenCheck.summary,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -454,21 +596,36 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
if (validation.issues.length > 0) {
|
if (validation.issues.length > 0) {
|
||||||
throw new Error(`Invalid menu node: ${validation.issues.join("; ")}`);
|
throw new Error(`Invalid menu node: ${validation.issues.join("; ")}`);
|
||||||
}
|
}
|
||||||
const result = applyMenuAddNode(Array.isArray(menu.payload) ? menu.payload : [], parentId, nodeData);
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
||||||
|
const result = applyMenuAddNode(beforePayload, parentId, nodeData);
|
||||||
|
const treeCheck = validateMenuTreeSnapshot(result.payload);
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, dryRun: true, operation: "add_menu_node", nodeId: result.nodeId, warnings: validation.warnings, preview: { nodeCount: result.payload.length } }, null, 2)
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: treeCheck.issues.length === 0,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "add_menu_node",
|
||||||
|
nodeId: result.nodeId,
|
||||||
|
warnings: [...validation.warnings, ...treeCheck.warnings],
|
||||||
|
issues: treeCheck.issues,
|
||||||
|
diagnostics: treeCheck.diagnostics,
|
||||||
|
preview: buildMenuMutationPreview(beforePayload, result.payload, result.nodeId),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId: result.nodeId, nodeCount: result.payload.length }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "add_menu_node", nodeId: result.nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "update_menu_translation": {
|
case "update_menu_translation": {
|
||||||
const { nodeId, translations } = params;
|
const { nodeId, translations } = params;
|
||||||
@@ -479,63 +636,108 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
}
|
}
|
||||||
const res = await api.get(`/menus/main`);
|
const res = await api.get(`/menus/main`);
|
||||||
const menu = res.data;
|
const menu = res.data;
|
||||||
const result = applyMenuUpdateTranslation(Array.isArray(menu.payload) ? menu.payload : [], nodeId, translations);
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
||||||
|
const result = applyMenuUpdateTranslation(beforePayload, nodeId, translations);
|
||||||
|
const treeCheck = validateMenuTreeSnapshot(result.payload);
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, dryRun: true, operation: "update_menu_translation", warnings: validation.warnings, preview: { nodeCount: result.payload.length } }, null, 2)
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: treeCheck.issues.length === 0,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "update_menu_translation",
|
||||||
|
nodeId,
|
||||||
|
warnings: [...validation.warnings, ...treeCheck.warnings],
|
||||||
|
issues: treeCheck.issues,
|
||||||
|
diagnostics: treeCheck.diagnostics,
|
||||||
|
preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId, nodeCount: result.payload.length }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "update_menu_translation", nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "move_menu_node": {
|
case "move_menu_node": {
|
||||||
const { nodeId, newParentId, orderIndex } = params;
|
const { nodeId, newParentId, orderIndex } = params;
|
||||||
const dryRun = !!params.dryRun;
|
const dryRun = !!params.dryRun;
|
||||||
const res = await api.get(`/menus/main`);
|
const res = await api.get(`/menus/main`);
|
||||||
const menu = res.data;
|
const menu = res.data;
|
||||||
const result = applyMenuMoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId, newParentId, orderIndex);
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
||||||
|
const result = applyMenuMoveNode(beforePayload, nodeId, newParentId, orderIndex);
|
||||||
|
const treeCheck = validateMenuTreeSnapshot(result.payload);
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, dryRun: true, operation: "move_menu_node", preview: { nodeCount: result.payload.length } }, null, 2)
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: treeCheck.issues.length === 0,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "move_menu_node",
|
||||||
|
nodeId,
|
||||||
|
warnings: treeCheck.warnings,
|
||||||
|
issues: treeCheck.issues,
|
||||||
|
diagnostics: treeCheck.diagnostics,
|
||||||
|
preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeCount: result.payload.length }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "move_menu_node", nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "remove_menu_node": {
|
case "remove_menu_node": {
|
||||||
const { nodeId } = params;
|
const { nodeId } = params;
|
||||||
const dryRun = !!params.dryRun;
|
const dryRun = !!params.dryRun;
|
||||||
const res = await api.get(`/menus/main`);
|
const res = await api.get(`/menus/main`);
|
||||||
const menu = res.data;
|
const menu = res.data;
|
||||||
const result = applyMenuRemoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId);
|
const beforePayload = Array.isArray(menu.payload) ? menu.payload : [];
|
||||||
|
const result = applyMenuRemoveNode(beforePayload, nodeId);
|
||||||
|
const treeCheck = validateMenuTreeSnapshot(result.payload);
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, dryRun: true, operation: "remove_menu_node", removedNode: result.removedNode, preview: { nodeCount: result.payload.length } }, null, 2)
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: treeCheck.issues.length === 0,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "remove_menu_node",
|
||||||
|
removedNode: result.removedNode,
|
||||||
|
warnings: treeCheck.warnings,
|
||||||
|
issues: treeCheck.issues,
|
||||||
|
diagnostics: treeCheck.diagnostics,
|
||||||
|
preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeCount: result.payload.length }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "remove_menu_node", removedNode: result.removedNode, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] };
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+276
-10
@@ -2,6 +2,11 @@ import crypto from "crypto";
|
|||||||
|
|
||||||
const MENU_NODE_ARRAY_KEYS = ["columns", "links", "items", "children"] as const;
|
const MENU_NODE_ARRAY_KEYS = ["columns", "links", "items", "children"] as const;
|
||||||
const MENU_NODE_VALUE_KEYS = ["label", "title", "url", ...MENU_NODE_ARRAY_KEYS] as const;
|
const MENU_NODE_VALUE_KEYS = ["label", "title", "url", ...MENU_NODE_ARRAY_KEYS] as const;
|
||||||
|
const SUPPORTED_MENU_LOCALES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
|
||||||
|
const PLACEHOLDER_TEXTS = new Set(["Badge", "Card title", "CTA label", "Section title", "Card subtitle", "New Menu", "New Column", "New Link"]);
|
||||||
|
const MAX_DESKTOP_COLUMNS = 2;
|
||||||
|
const MAX_DESKTOP_TOP_LEVEL_LINKS = 5;
|
||||||
|
const MAX_DESKTOP_INLINE_CHILD_LINKS = 3;
|
||||||
|
|
||||||
function isPlainObject(value: any) {
|
function isPlainObject(value: any) {
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
@@ -95,6 +100,196 @@ function describeMenuNode(node: any) {
|
|||||||
return "menu node";
|
return "menu node";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMenuNodeType(node: any, parentType?: string) {
|
||||||
|
if (!isPlainObject(node)) return "invalid";
|
||||||
|
if (Array.isArray(node.columns)) return "root";
|
||||||
|
if (parentType === "root") return "column";
|
||||||
|
if (parentType === "column" || parentType === "link") return "link";
|
||||||
|
if (Array.isArray(node.links)) return "column";
|
||||||
|
if (typeof node.url === "string") return "link";
|
||||||
|
return "node";
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLocalizedIssues(value: any, path: string, requiredBase = true) {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (value === undefined) {
|
||||||
|
issues.push(`${path}: missing localized text`);
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (!value.trim()) issues.push(`${path}: text must not be empty`);
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
issues.push(`${path}: text must be a string or localized object`);
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (!(SUPPORTED_MENU_LOCALES as readonly string[]).includes(key)) {
|
||||||
|
issues.push(`${path}.${key}: unsupported key; allowed locale keys are ${SUPPORTED_MENU_LOCALES.join(", ")}`);
|
||||||
|
} else if (typeof value[key] !== "string" || !value[key].trim()) {
|
||||||
|
issues.push(`${path}.${key}: translation must be a non-empty string`);
|
||||||
|
} else if (PLACEHOLDER_TEXTS.has(value[key].trim())) {
|
||||||
|
warnings.push(`${path}.${key}: placeholder text "${value[key]}" should be replaced`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredBase && !value.th && !value.en) {
|
||||||
|
issues.push(`${path}: provide at least th or en so website fallback has a stable label`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of SUPPORTED_MENU_LOCALES) {
|
||||||
|
if (!value[locale]) {
|
||||||
|
warnings.push(`${path}: missing ${locale} translation`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrlIssue(url: any, path: string) {
|
||||||
|
if (typeof url !== "string" || !url.trim()) {
|
||||||
|
return `${path}: url must be a non-empty string`;
|
||||||
|
}
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (
|
||||||
|
!trimmed.startsWith("/") &&
|
||||||
|
!trimmed.startsWith("https://") &&
|
||||||
|
!trimmed.startsWith("http://") &&
|
||||||
|
!trimmed.startsWith("mailto:") &&
|
||||||
|
!trimmed.startsWith("tel:")
|
||||||
|
) {
|
||||||
|
return `${path}: url must start with /, http(s)://, mailto:, or tel:`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodePath(payload: any[], nodeId?: string) {
|
||||||
|
if (!nodeId) return null;
|
||||||
|
const found = findNodeAndParent(payload, nodeId);
|
||||||
|
if (!found) return null;
|
||||||
|
const segments: string[] = [];
|
||||||
|
let current: any = found.node;
|
||||||
|
|
||||||
|
const findPath = (list: any[], target: any, base: string): boolean => {
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const item = list[i];
|
||||||
|
const currentPath = `${base}[${i}]`;
|
||||||
|
if (item === target) {
|
||||||
|
segments.push(currentPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||||
|
if (Array.isArray(item?.[key]) && findPath(item[key], target, `${currentPath}.${key}`)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
findPath(payload, current, "payload");
|
||||||
|
return segments[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeMenuTree(payload: any[]) {
|
||||||
|
let totalNodes = 0;
|
||||||
|
let rootCount = 0;
|
||||||
|
let columnCount = 0;
|
||||||
|
let linkCount = 0;
|
||||||
|
let featuredCount = 0;
|
||||||
|
let hiddenDesktopColumns = 0;
|
||||||
|
let hiddenDesktopLinks = 0;
|
||||||
|
let hiddenDesktopChildLinks = 0;
|
||||||
|
|
||||||
|
const walk = (nodes: any[], parentType?: string) => {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (!isPlainObject(node)) return;
|
||||||
|
const type = getMenuNodeType(node, parentType);
|
||||||
|
totalNodes += 1;
|
||||||
|
if (type === "root") {
|
||||||
|
rootCount += 1;
|
||||||
|
if (isPlainObject(node.featured) && node.featured.enabled !== false) featuredCount += 1;
|
||||||
|
const columns = Array.isArray(node.columns) ? node.columns : [];
|
||||||
|
hiddenDesktopColumns += Math.max(columns.length - MAX_DESKTOP_COLUMNS, 0);
|
||||||
|
walk(columns, "root");
|
||||||
|
} else if (type === "column") {
|
||||||
|
columnCount += 1;
|
||||||
|
const links = Array.isArray(node.links) ? node.links : [];
|
||||||
|
hiddenDesktopLinks += Math.max(links.length - MAX_DESKTOP_TOP_LEVEL_LINKS, 0);
|
||||||
|
walk(links, "column");
|
||||||
|
} else if (type === "link") {
|
||||||
|
linkCount += 1;
|
||||||
|
const childLinks = Array.isArray(node.links) ? node.links : [];
|
||||||
|
hiddenDesktopChildLinks += Math.max(childLinks.length - MAX_DESKTOP_INLINE_CHILD_LINKS, 0);
|
||||||
|
walk(childLinks, "link");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(payload);
|
||||||
|
return {
|
||||||
|
rootCount,
|
||||||
|
columnCount,
|
||||||
|
linkCount,
|
||||||
|
totalNodes,
|
||||||
|
featuredCount,
|
||||||
|
desktopLimits: {
|
||||||
|
maxVisibleColumnsPerRoot: MAX_DESKTOP_COLUMNS,
|
||||||
|
maxVisibleLinksPerColumn: MAX_DESKTOP_TOP_LEVEL_LINKS,
|
||||||
|
maxVisibleChildLinksPerLink: MAX_DESKTOP_INLINE_CHILD_LINKS,
|
||||||
|
hiddenColumns: hiddenDesktopColumns,
|
||||||
|
hiddenLinks: hiddenDesktopLinks,
|
||||||
|
hiddenChildLinks: hiddenDesktopChildLinks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMenuDiff(before: any, after: any, basePath = "payload", limit = 80) {
|
||||||
|
const diff: any[] = [];
|
||||||
|
|
||||||
|
const walk = (a: any, b: any, path: string) => {
|
||||||
|
if (diff.length >= limit) return;
|
||||||
|
if (JSON.stringify(a) === JSON.stringify(b)) return;
|
||||||
|
|
||||||
|
if (Array.isArray(a) || Array.isArray(b)) {
|
||||||
|
const aArr = Array.isArray(a) ? a : [];
|
||||||
|
const bArr = Array.isArray(b) ? b : [];
|
||||||
|
const max = Math.max(aArr.length, bArr.length);
|
||||||
|
for (let i = 0; i < max; i++) walk(aArr[i], bArr[i], `${path}[${i}]`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(a) || isPlainObject(b)) {
|
||||||
|
const keys = new Set([...Object.keys(isPlainObject(a) ? a : {}), ...Object.keys(isPlainObject(b) ? b : {})]);
|
||||||
|
for (const key of keys) walk(a?.[key], b?.[key], `${path}.${key}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
diff.push({ path, before: a ?? null, after: b ?? null });
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(before, after, basePath);
|
||||||
|
return { entries: diff, truncated: diff.length >= limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMenuMutationPreview(before: any[], after: any[], changedNodeId?: string) {
|
||||||
|
const beforePayload = ensureMenuPayloadArray(before);
|
||||||
|
const afterPayload = ensureMenuPayloadArray(after);
|
||||||
|
const diff = buildMenuDiff(beforePayload, afterPayload);
|
||||||
|
const changedPath = getNodePath(afterPayload, changedNodeId) || getNodePath(beforePayload, changedNodeId);
|
||||||
|
return {
|
||||||
|
before: summarizeMenuTree(beforePayload),
|
||||||
|
after: summarizeMenuTree(afterPayload),
|
||||||
|
changedNodeId: changedNodeId || null,
|
||||||
|
changedPath,
|
||||||
|
diff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function validateMenuNodeData(nodeData: any, parentId?: string | null) {
|
function validateMenuNodeData(nodeData: any, parentId?: string | null) {
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@@ -110,13 +305,18 @@ function validateMenuNodeData(nodeData: any, parentId?: string | null) {
|
|||||||
|
|
||||||
for (const key of ["label", "title"] as const) {
|
for (const key of ["label", "title"] as const) {
|
||||||
const value = nodeData[key];
|
const value = nodeData[key];
|
||||||
if (value !== undefined && !isPlainObject(value) && typeof value !== "string") {
|
if (value !== undefined) {
|
||||||
issues.push(`${key} must be a string or localized object`);
|
const textCheck = listLocalizedIssues(value, key, false);
|
||||||
|
issues.push(...textCheck.issues);
|
||||||
|
warnings.push(...textCheck.warnings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodeData.url !== undefined && typeof nodeData.url !== "string") {
|
if (nodeData.url !== undefined && typeof nodeData.url !== "string") {
|
||||||
issues.push("url must be a string when provided");
|
issues.push("url must be a string when provided");
|
||||||
|
} else if (nodeData.url !== undefined) {
|
||||||
|
const urlIssue = normalizeUrlIssue(nodeData.url, "url");
|
||||||
|
if (urlIssue) issues.push(urlIssue);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of MENU_NODE_ARRAY_KEYS) {
|
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||||
@@ -148,8 +348,14 @@ function validateMenuTranslations(translations: any) {
|
|||||||
return { issues, warnings };
|
return { issues, warnings };
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(translations)) {
|
for (const [key, value] of Object.entries(translations)) {
|
||||||
|
if (!(SUPPORTED_MENU_LOCALES as readonly string[]).includes(key)) {
|
||||||
|
issues.push(`unsupported locale "${key}"; allowed locale keys are ${SUPPORTED_MENU_LOCALES.join(", ")}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
issues.push(`translation for "${key}" must be a string`);
|
issues.push(`translation for "${key}" must be a string`);
|
||||||
|
} else if (!value.trim()) {
|
||||||
|
issues.push(`translation for "${key}" must not be empty`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { issues, warnings };
|
return { issues, warnings };
|
||||||
@@ -229,44 +435,102 @@ function ensureMenuPayloadArray(value: any) {
|
|||||||
function validateMenuTreeSnapshot(payload: any[]) {
|
function validateMenuTreeSnapshot(payload: any[]) {
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const diagnostics: Array<{ severity: "critical" | "high" | "medium" | "low"; path: string; nodeId?: string; message: string; recommendedFix: string }> = [];
|
||||||
const seenNodeIds = new Set<string>();
|
const seenNodeIds = new Set<string>();
|
||||||
|
|
||||||
const walk = (value: any, path: string) => {
|
const addIssue = (severity: "critical" | "high" | "medium" | "low", path: string, nodeId: string | undefined, message: string, recommendedFix: string) => {
|
||||||
|
const text = `${path}: ${message}`;
|
||||||
|
if (severity === "critical" || severity === "high") issues.push(text);
|
||||||
|
else warnings.push(text);
|
||||||
|
diagnostics.push({ severity, path, nodeId, message, recommendedFix });
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (value: any, path: string, parentType?: string) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((child, index) => walk(child, `${path}[${index}]`));
|
value.forEach((child, index) => walk(child, `${path}[${index}]`, parentType));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlainObject(value)) return;
|
if (!isPlainObject(value)) return;
|
||||||
|
|
||||||
if (!isStructuralMenuNode(value)) {
|
if (!isStructuralMenuNode(value)) {
|
||||||
warnings.push(`${path}: non-structural object present in menu tree`);
|
addIssue("low", path, undefined, "non-structural object present in menu tree", "Remove this object or convert it into a root, column, or link node.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nodeId = typeof value.nodeId === "string" ? value.nodeId : undefined;
|
||||||
|
const nodeType = getMenuNodeType(value, parentType);
|
||||||
|
|
||||||
if (value.nodeId && typeof value.nodeId === "string") {
|
if (value.nodeId && typeof value.nodeId === "string") {
|
||||||
if (seenNodeIds.has(value.nodeId)) {
|
if (seenNodeIds.has(value.nodeId)) {
|
||||||
issues.push(`${path}: duplicate nodeId ${value.nodeId}`);
|
addIssue("critical", path, value.nodeId, `duplicate nodeId ${value.nodeId}`, "Assign a unique nodeId or let get_mega_menu_tree normalize IDs.");
|
||||||
}
|
}
|
||||||
seenNodeIds.add(value.nodeId);
|
seenNodeIds.add(value.nodeId);
|
||||||
} else {
|
} else {
|
||||||
warnings.push(`${path}: missing nodeId`);
|
addIssue("medium", path, undefined, "missing nodeId", "Run get_mega_menu_tree once or update this node through MCP so a nodeId is generated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeType === "root") {
|
||||||
|
const labelCheck = listLocalizedIssues(value.label, `${path}.label`);
|
||||||
|
labelCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.label: `, ""), "Provide a localized root label with at least th or en."));
|
||||||
|
labelCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.label: `, ""), "Fill missing locale labels, especially lo and it."));
|
||||||
|
|
||||||
|
const urlIssue = normalizeUrlIssue(value.url, `${path}.url`);
|
||||||
|
if (urlIssue) addIssue("high", path, nodeId, urlIssue.replace(`${path}.url: `, ""), "Set a valid root URL such as /shop.");
|
||||||
|
|
||||||
|
const columns = Array.isArray(value.columns) ? value.columns : [];
|
||||||
|
if (columns.length === 0 && isPlainObject(value.featured) && value.featured.enabled !== false) {
|
||||||
|
addIssue("medium", path, nodeId, "featured card is enabled on a root item with no columns", "Disable featured.enabled or add at least one column.");
|
||||||
|
}
|
||||||
|
if (columns.length > MAX_DESKTOP_COLUMNS) {
|
||||||
|
addIssue("medium", path, nodeId, `${columns.length - MAX_DESKTOP_COLUMNS} columns are hidden on desktop`, "Keep the most important columns first or reduce to two columns.");
|
||||||
|
}
|
||||||
|
if (isPlainObject(value.featured)) {
|
||||||
|
for (const key of ["sectionTitle", "badge", "title", "subtitle", "ctaLabel"] as const) {
|
||||||
|
if (value.featured[key] !== undefined) {
|
||||||
|
const check = listLocalizedIssues(value.featured[key], `${path}.featured.${key}`, false);
|
||||||
|
check.issues.forEach((message) => addIssue("medium", path, nodeId, message.replace(`${path}.featured.${key}: `, `featured.${key}: `), "Use only supported locale keys and non-empty strings."));
|
||||||
|
check.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.featured.${key}: `, `featured.${key}: `), "Fill missing featured-card locales or remove placeholder text."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (nodeType === "column") {
|
||||||
|
const titleCheck = listLocalizedIssues(value.title, `${path}.title`);
|
||||||
|
titleCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.title: `, ""), "Provide a localized column title with at least th or en."));
|
||||||
|
titleCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.title: `, ""), "Fill missing locale titles, especially lo and it."));
|
||||||
|
const links = Array.isArray(value.links) ? value.links : [];
|
||||||
|
if (links.length === 0) {
|
||||||
|
addIssue("medium", path, nodeId, "column has no links", "Add at least one link or remove the column.");
|
||||||
|
}
|
||||||
|
if (links.length > MAX_DESKTOP_TOP_LEVEL_LINKS) {
|
||||||
|
addIssue("medium", path, nodeId, `${links.length - MAX_DESKTOP_TOP_LEVEL_LINKS} links are hidden on desktop`, "Keep the most important links in the first five positions.");
|
||||||
|
}
|
||||||
|
} else if (nodeType === "link") {
|
||||||
|
const labelCheck = listLocalizedIssues(value.label, `${path}.label`);
|
||||||
|
labelCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.label: `, ""), "Provide a localized link label with at least th or en."));
|
||||||
|
labelCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.label: `, ""), "Fill missing locale labels, especially lo and it."));
|
||||||
|
const urlIssue = normalizeUrlIssue(value.url, `${path}.url`);
|
||||||
|
if (urlIssue) addIssue("high", path, nodeId, urlIssue.replace(`${path}.url: `, ""), "Set a valid link URL.");
|
||||||
|
const childLinks = Array.isArray(value.links) ? value.links : [];
|
||||||
|
if (childLinks.length > MAX_DESKTOP_INLINE_CHILD_LINKS) {
|
||||||
|
addIssue("medium", path, nodeId, `${childLinks.length - MAX_DESKTOP_INLINE_CHILD_LINKS} child links are collapsed behind '+ more' on desktop`, "Keep the most important child links in the first three positions.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasChildren = MENU_NODE_ARRAY_KEYS.some((key) => Array.isArray(value[key]));
|
const hasChildren = MENU_NODE_ARRAY_KEYS.some((key) => Array.isArray(value[key]));
|
||||||
if (!hasChildren && !value.url) {
|
if (!hasChildren && !value.url) {
|
||||||
warnings.push(`${path}: container node has no children and no url`);
|
addIssue("medium", path, nodeId, "container node has no children and no url", "Add children, set a url, or remove the node.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of MENU_NODE_ARRAY_KEYS) {
|
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||||
if (Array.isArray(value[key])) {
|
if (Array.isArray(value[key])) {
|
||||||
walk(value[key], `${path}.${key}`);
|
walk(value[key], `${path}.${key}`, nodeType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
walk(payload, "payload");
|
walk(payload, "payload");
|
||||||
return { issues, warnings };
|
return { issues, warnings, diagnostics, summary: summarizeMenuTree(payload), supportedLocales: SUPPORTED_MENU_LOCALES };
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyMenuAddNode(tree: any[], parentId: string | undefined, nodeData: any): MenuTreeMutationResult {
|
function applyMenuAddNode(tree: any[], parentId: string | undefined, nodeData: any): MenuTreeMutationResult {
|
||||||
@@ -464,6 +728,7 @@ function applyBulkMenuOperations(tree: any[], operations: any[]) {
|
|||||||
export {
|
export {
|
||||||
MENU_NODE_ARRAY_KEYS,
|
MENU_NODE_ARRAY_KEYS,
|
||||||
MENU_NODE_VALUE_KEYS,
|
MENU_NODE_VALUE_KEYS,
|
||||||
|
SUPPORTED_MENU_LOCALES,
|
||||||
type MenuTreeMutationResult,
|
type MenuTreeMutationResult,
|
||||||
applyBulkMenuOperations,
|
applyBulkMenuOperations,
|
||||||
applyMenuAddNode,
|
applyMenuAddNode,
|
||||||
@@ -474,6 +739,7 @@ export {
|
|||||||
collectMenuLinksFromTree,
|
collectMenuLinksFromTree,
|
||||||
createMenuSnapshot,
|
createMenuSnapshot,
|
||||||
describeMenuNode,
|
describeMenuNode,
|
||||||
|
buildMenuMutationPreview,
|
||||||
ensureMenuPayloadArray,
|
ensureMenuPayloadArray,
|
||||||
findNodeAndParent,
|
findNodeAndParent,
|
||||||
getChildrenArray,
|
getChildrenArray,
|
||||||
|
|||||||
Reference in new issue
Block a user