Compare commits
3 Commits
6dfedd07fc
...
6aea6c7005
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aea6c7005 | ||
|
|
89dac47c02 | ||
|
|
5e61124c65 |
@@ -3,7 +3,7 @@ title: "Campos editables del builder"
|
||||
tags: [builder, twig, html, modules]
|
||||
load_priority: 80
|
||||
load_when: [always]
|
||||
summary: "Atributos data-field-* (textfield, headfield, link, upload, list, multiv2, checkbox), c-if/c-for/c-class, c-form, componentes built-in del builder Acai."
|
||||
summary: "Atributos data-field-* (textfield, headfield, link, upload, list, multiv2, checkbox), agrupar campos en pestañas con data-field-group, c-if/c-for/c-class, c-form, componentes built-in del builder Acai."
|
||||
---
|
||||
# Builder Fields — Campos editables del index-base.tpl
|
||||
|
||||
@@ -108,7 +108,25 @@ Devuelve un array. Acceso en Twig: `{{ imagen[0].urlPath }}`.
|
||||
Atributos disponibles:
|
||||
- `data-lazy="true"` — carga perezosa
|
||||
- `data-field-width="1400"` — ancho máximo sugerido
|
||||
- `data-field-info1="titulo"` — campo de información adicional (típicamente alt)
|
||||
- `data-field-info1` … `data-field-info5` — labels de los campos de información por imagen
|
||||
|
||||
Los `data-field-infoN` (hasta 5) definen los labels que el builder muestra como campos editables **en cada imagen subida** (van a `infoLabels` en el `builder.json`). Sus valores se leen luego como `info1`…`info4` dentro del array del var.
|
||||
|
||||
```html
|
||||
<img data-field-type="upload"
|
||||
data-field-label="Imagen Principal"
|
||||
data-field-info1="Texto alternativo"
|
||||
data-field-info2="Pie de foto"
|
||||
data-field-width="1400"
|
||||
alt="">
|
||||
```
|
||||
|
||||
```twig
|
||||
<img src="{{ imagenprincipal[0].urlPath }}" alt="{{ imagenprincipal[0].info1 }}">
|
||||
<figcaption>{{ imagenprincipal[0].info2 }}</figcaption>
|
||||
```
|
||||
|
||||
Los `data-field-infoN` funcionan igual en `uploadMulti`.
|
||||
|
||||
### uploadMulti
|
||||
|
||||
@@ -200,6 +218,41 @@ Devuelve `1` o `0` (número), nunca `true`/`false`.
|
||||
|
||||
Devuelve un string hexadecimal (`#ff0000`). Almacenado en config-vars (no en `builder_custom`).
|
||||
|
||||
## Agrupar campos en pestañas (`data-field-group`)
|
||||
|
||||
Los campos de un módulo se reparten en pestañas dentro del panel de configuración añadiendo `data-field-group="Nombre del grupo"` al mismo elemento que ya lleva `data-field-type`.
|
||||
|
||||
Mecanismo: cualquier atributo `data-field-*` extra —aparte de `data-field-type`, `data-field-label` y `data-field-value`— se recoge en el objeto `customDataField` del var, recortando el prefijo `data-field-`. Es decir, `data-field-group="Estilos"` acaba como `customDataField: { group: "Estilos" }` en el `builder.json`.
|
||||
|
||||
Comportamiento del panel:
|
||||
- Los vars se agrupan por `customDataField.group` y se muestran en pestañas laterales.
|
||||
- Los vars sin `data-field-group` caen en la pestaña **Principal**.
|
||||
- El orden de las pestañas es el de primera aparición de cada grupo en el template.
|
||||
- Con un solo grupo no se muestran pestañas.
|
||||
- Funciona igual en vars de primer nivel y dentro de `multiv2`.
|
||||
|
||||
```html
|
||||
<div data-field-type="textfield" data-field-label="Título" data-field-group="Contenido">
|
||||
Título de la sección
|
||||
</div>
|
||||
<div data-field-type="textbox" data-field-label="Descripción" data-field-group="Contenido">
|
||||
Texto de apoyo
|
||||
</div>
|
||||
|
||||
<div c-hidden="true">
|
||||
<div data-field-type="colorpicker" data-field-label="Color de fondo" data-field-group="Estilos"></div>
|
||||
<div data-field-type="list"
|
||||
data-field-label="Alineación"
|
||||
data-list-options="|Izquierda,1|Centro,2|Derecha"
|
||||
data-field-group="Estilos"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Recomendación de uso:
|
||||
- Agrupa cuando el módulo pase de ~6-8 vars; por debajo, una sola lista se lee mejor.
|
||||
- Reparto habitual: **Contenido** (textos e imágenes), **Estilos** (colores, alineación, bordes), **Ajustes** (opciones de comportamiento, límites, enlaces).
|
||||
- Mantén los nombres de grupo consistentes entre módulos y en español. Son literales: `Estilos` y `estilos` generan dos pestañas distintas.
|
||||
|
||||
## Atributos Acai
|
||||
|
||||
### `c-if` — Renderizado condicional
|
||||
|
||||
@@ -45,6 +45,8 @@ Resumen ejecutable de reglas críticas, tipos de campo, filtros y formatos de da
|
||||
| `checkbox` | `<input>` o `<div>` | `1` / `0` |
|
||||
| `colorpicker` | `<div>` | Hex color |
|
||||
|
||||
Pestañas en el panel del módulo: `data-field-group="Estilos"` en el elemento del campo (sin group → pestaña "Principal").
|
||||
|
||||
## Atributos Acai
|
||||
|
||||
| Atributo | Uso | Ejemplo |
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js";
|
||||
import {
|
||||
isProtectedLayoutPath,
|
||||
buildProtectedLayoutPathError,
|
||||
isProtectedSchemaPath,
|
||||
buildProtectedSchemaPathError,
|
||||
} from "./protectedPaths.js";
|
||||
|
||||
export function registerAcaiDeleteTool(server) {
|
||||
server.tool(
|
||||
@@ -21,6 +26,10 @@ export function registerAcaiDeleteTool(server) {
|
||||
return buildProtectedLayoutPathError(file_path);
|
||||
}
|
||||
|
||||
if (isProtectedSchemaPath(file_path)) {
|
||||
return buildProtectedSchemaPathError(file_path);
|
||||
}
|
||||
|
||||
const { projectSlug, projectDir } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("POST", "/api/files/delete", {
|
||||
project: projectSlug,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js";
|
||||
import {
|
||||
isProtectedLayoutPath,
|
||||
buildProtectedLayoutPathError,
|
||||
isProtectedSchemaPath,
|
||||
buildProtectedSchemaPathError,
|
||||
} from "./protectedPaths.js";
|
||||
|
||||
export function registerAcaiLineReplaceTool(server) {
|
||||
server.tool(
|
||||
@@ -29,6 +34,10 @@ export function registerAcaiLineReplaceTool(server) {
|
||||
return buildProtectedLayoutPathError(file_path);
|
||||
}
|
||||
|
||||
if (isProtectedSchemaPath(file_path)) {
|
||||
return buildProtectedSchemaPathError(file_path);
|
||||
}
|
||||
|
||||
const { projectSlug, projectDir } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("POST", "/api/files/line-replace", {
|
||||
project: projectSlug,
|
||||
|
||||
@@ -11,11 +11,26 @@ const PROTECTED_LAYOUT_PATHS = [
|
||||
"template/estandar/modulos/custom-footer/",
|
||||
];
|
||||
|
||||
// Table schemas live here. They are the .ini.php mirror of the real MySQL
|
||||
// structure: editing the file by hand does not run any DDL, so the schema and
|
||||
// the database drift apart (and the CMS keeps serving stale cached metadata).
|
||||
const SCHEMA_DIR_PREFIX = "cms/data/schema/";
|
||||
|
||||
// Normalizes a relative path: drops leading slashes and "./" segments so
|
||||
// "/cms/...", "./cms/..." and "cms/..." all compare equal.
|
||||
function normalizeRelPath(relPath) {
|
||||
let norm = String(relPath).replace(/^\/+/, "");
|
||||
while (norm.startsWith("./")) {
|
||||
norm = norm.slice(2).replace(/^\/+/, "");
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
// Returns true when `relPath` points at the layout.json or any of the
|
||||
// generated custom-{header,footer}[-twig] module folders.
|
||||
export function isProtectedLayoutPath(relPath) {
|
||||
if (!relPath) return false;
|
||||
const norm = String(relPath).replace(/^\/+/, "");
|
||||
const norm = normalizeRelPath(relPath);
|
||||
return PROTECTED_LAYOUT_PATHS.some(p => {
|
||||
// Folder entries end with "/" -> prefix match on the normalized path.
|
||||
// File entries (no trailing slash) -> exact match only.
|
||||
@@ -37,3 +52,26 @@ export function buildProtectedLayoutPathError(relPath) {
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Returns true when `relPath` points inside cms/data/schema/ (the table schema
|
||||
// directory), so file tools can bail out before hitting the Python endpoint.
|
||||
export function isProtectedSchemaPath(relPath) {
|
||||
if (!relPath) return false;
|
||||
const norm = normalizeRelPath(relPath);
|
||||
if (!norm) return false;
|
||||
return norm === SCHEMA_DIR_PREFIX.slice(0, -1) || norm.startsWith(SCHEMA_DIR_PREFIX);
|
||||
}
|
||||
|
||||
// Builds a consistent MCP error response pointing the agent to the table tools.
|
||||
export function buildProtectedSchemaPathError(relPath) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `Forbidden path: ${relPath} lives in ${SCHEMA_DIR_PREFIX} and table schemas are read-only through the file tools (acai-write, acai-line-replace, acai-delete). To change the structure use the table tools instead - create_table, create_field, update_field, delete_field, update_table_metadata, delete_table - which run the real DDL in MySQL and refresh the caches. To read a schema use get_table_schema or acai-view.`,
|
||||
}, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
|
||||
import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js";
|
||||
import {
|
||||
isProtectedLayoutPath,
|
||||
buildProtectedLayoutPathError,
|
||||
isProtectedSchemaPath,
|
||||
buildProtectedSchemaPathError,
|
||||
} from "./protectedPaths.js";
|
||||
|
||||
export function registerAcaiWriteTool(server) {
|
||||
server.tool(
|
||||
@@ -28,6 +33,10 @@ Before writing, check the matching documentation for the file type:
|
||||
return buildProtectedLayoutPathError(file_path);
|
||||
}
|
||||
|
||||
if (isProtectedSchemaPath(file_path)) {
|
||||
return buildProtectedSchemaPathError(file_path);
|
||||
}
|
||||
|
||||
const { projectSlug, projectDir } = getCurrentProjectInfo();
|
||||
const result = await callLocalFileEndpoint("POST", "/api/files/write", {
|
||||
project: projectSlug,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { registerCheckModuleTool } from './check.js';
|
||||
import { registerCheckModuleUsageTool } from './checkUsage.js';
|
||||
import { registerCompileModuleTool } from './compile.js';
|
||||
import { registerDeleteModuleTool } from './delete.js';
|
||||
import { registerUpdateModuleMetadataTool } from './updateMetadata.js';
|
||||
import { canEditCode } from '../helpers/roleCheck.js';
|
||||
|
||||
export function registerModuleTools(server) {
|
||||
@@ -10,5 +11,6 @@ export function registerModuleTools(server) {
|
||||
if (canEditCode()) {
|
||||
registerCompileModuleTool(server);
|
||||
registerDeleteModuleTool(server);
|
||||
registerUpdateModuleMetadataTool(server);
|
||||
}
|
||||
}
|
||||
|
||||
88
mcp-server/tools/modules/updateMetadata.js
Normal file
88
mcp-server/tools/modules/updateMetadata.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth } from "../../auth/index.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||
import { getCurrentProjectInfo } from "../files/helpers.js";
|
||||
|
||||
// Tool: update_module_metadata
|
||||
// Edita la metadata de builder.json de un modulo (label, description,
|
||||
// onlyAdminModule, MJMLModule) delegando en /api/modules/update-metadata.
|
||||
// El id/carpeta del modulo NO se puede renombrar y el endpoint rechaza los
|
||||
// modulos generados del layout (custom-header/footer[-twig]).
|
||||
|
||||
// Claves editables del builder.json. Se envian solo las que llegan definidas,
|
||||
// de modo que el endpoint hace un merge parcial y no pisa el resto.
|
||||
const EDITABLE_KEYS = ["label", "description", "onlyAdminModule", "MJMLModule"];
|
||||
|
||||
export function registerUpdateModuleMetadataTool(server) {
|
||||
server.tool(
|
||||
"update_module_metadata",
|
||||
`Edit a module's metadata in its builder.json: 'label' (display name shown in the visual builder), 'description' (short help text for editors), 'onlyAdminModule' (true = the module is only visible to admin users in the builder), 'MJMLModule' (true = the module is an email/MJML module).
|
||||
|
||||
The module id (its folder name under template/estandar/modulos/) CANNOT be renamed with this tool — there is no rename option at all. Only the fields above change; everything else in builder.json is preserved.
|
||||
|
||||
At least one of label, description, onlyAdminModule or MJMLModule is required. Fields you omit are left untouched.
|
||||
|
||||
Not applicable to the generated layout modules (custom-header, custom-footer, custom-header-twig, custom-footer-twig): those are artifacts of the global layout and the request will be rejected — use set_layout_field for them.`,
|
||||
withAuthParams({
|
||||
module: z.string().min(1).describe("Module id (the folder name under template/estandar/modulos/). Cannot be renamed."),
|
||||
label: z.string().optional().describe("Display name of the module in the visual builder"),
|
||||
description: z.string().optional().describe("Short description shown to editors in the builder"),
|
||||
onlyAdminModule: z.boolean().optional().describe("If true, the module is only visible to admin users in the builder"),
|
||||
MJMLModule: z.boolean().optional().describe("If true, the module is treated as an email (MJML) module"),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ module, label, description, onlyAdminModule, MJMLModule }, _extra) => {
|
||||
try {
|
||||
const provided = { label, description, onlyAdminModule, MJMLModule };
|
||||
|
||||
// Validacion temprana: sin ninguna clave editable no tiene
|
||||
// sentido llamar al endpoint. Ojo con los booleanos false —
|
||||
// se comprueba `undefined`, no truthiness.
|
||||
const changedKeys = EDITABLE_KEYS.filter((key) => provided[key] !== undefined);
|
||||
if (changedKeys.length === 0) {
|
||||
return handleToolError(
|
||||
`Nothing to update: provide at least one of ${EDITABLE_KEYS.join(", ")}. The module id cannot be renamed with this tool.`,
|
||||
"update_module_metadata",
|
||||
{ module, editableFields: EDITABLE_KEYS }
|
||||
);
|
||||
}
|
||||
|
||||
const { projectSlug } = getCurrentProjectInfo();
|
||||
|
||||
const body = { project: projectSlug, module };
|
||||
for (const key of changedKeys) body[key] = provided[key];
|
||||
|
||||
const result = await pythonPost("/api/modules/update-metadata", body);
|
||||
|
||||
if (!result?.success) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: result?.error || "Could not update module metadata",
|
||||
}),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
module: result.module || module,
|
||||
updatedFields: changedKeys,
|
||||
metadata: result.metadata || {},
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "update_module_metadata", { module });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user