Compare commits
13 Commits
6dfedd07fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad2a6f87b | ||
|
|
2afc2cdc34 | ||
|
|
4835ff9467 | ||
|
|
49b52b0f9f | ||
|
|
76221ee1d4 | ||
|
|
f7e950694e | ||
|
|
bdee7665ec | ||
|
|
5c011ab7ef | ||
|
|
fb7ed09626 | ||
|
|
b9df5cbb54 | ||
|
|
6aea6c7005 | ||
|
|
89dac47c02 | ||
|
|
5e61124c65 |
@@ -3,11 +3,11 @@ 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, colors, colorpicker), agrupar campos en pestañas con data-field-group, mostrar u ocultar campos y pestañas segun otro campo con data-field-show y data-field-group-show, c-if/c-for/c-class, c-form, componentes built-in del builder Acai."
|
||||
---
|
||||
# Builder Fields — Campos editables del index-base.tpl
|
||||
|
||||
Este documento define los campos editables que el usuario rellena desde el panel del builder de Acai. Cubre el atributo `data-field-type` con todos sus tipos (`textfield`, `headfield`, `textbox`, `wysiwyg`, `link`, `upload`, `uploadMulti`, `list`, `multiv2`, `checkbox`, `colorpicker`), la regla `data-field-label` → nombre de variable, los atributos Acai (`c-if`, `c-else`, `c-for`, `c-class`, `c-hidden`, `c-required`), el tag `<set>`, la inclusión de módulos, los formularios `c-form` y los componentes built-in. Léelo antes de crear o modificar cualquier `index-base.tpl`.
|
||||
Este documento define los campos editables que el usuario rellena desde el panel del builder de Acai. Cubre el atributo `data-field-type` con todos sus tipos (`textfield`, `headfield`, `textbox`, `wysiwyg`, `link`, `upload`, `uploadMulti`, `list`, `multiv2`, `colors`, `colorpicker`, `corners`, `ratio`), la regla `data-field-label` → nombre de variable, el reparto en pestañas (`data-field-group`) y su visibilidad condicional (`data-field-show`, `data-field-group-show`), los atributos Acai (`c-if`, `c-else`, `c-for`, `c-class`, `c-hidden`, `c-required`), el tag `<set>`, la inclusión de módulos, los formularios `c-form` y los componentes built-in. Léelo antes de crear o modificar cualquier `index-base.tpl`.
|
||||
|
||||
## Reglas de nomenclatura de variables
|
||||
|
||||
@@ -39,7 +39,7 @@ Reglas obligatorias:
|
||||
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
|
||||
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
|
||||
| `multiv2` | `<li>` wrapper | Array de objetos repetibles |
|
||||
| `checkbox` | `<div>` o `<input>` | `1` o `0` (número) |
|
||||
| `colors` | `<div data-field-colors="fondo,titulo">` | Objeto de N colores por nombre: `{{ colores.fondo }}` |
|
||||
| `colorpicker` | `<div>` | Hex color string |
|
||||
|
||||
### textfield
|
||||
@@ -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
|
||||
|
||||
@@ -192,13 +210,137 @@ Uso en Twig:
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### checkbox
|
||||
### colors — paleta de colores
|
||||
|
||||
Devuelve `1` o `0` (número), nunca `true`/`false`.
|
||||
Un solo campo que agrupa VARIOS colores. Los nombres de cada color se declaran en
|
||||
`data-field-colors`, separados por comas:
|
||||
|
||||
### colorpicker
|
||||
```html
|
||||
<div c-hidden="true">
|
||||
<div data-field-type="colors"
|
||||
data-field-label="Colores"
|
||||
data-field-colors="fondo,titulo,boton"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Devuelve un string hexadecimal (`#ff0000`). Almacenado en config-vars (no en `builder_custom`).
|
||||
El valor guardado es un JSON con pares nombre/valor. Cada color puede ser sólido o un
|
||||
gradiente lineal:
|
||||
|
||||
```json
|
||||
{"fondo": "#ffffff", "titulo": "#111111", "boton": "linear-gradient(90deg, #aaa, #000)"}
|
||||
```
|
||||
|
||||
En Twig se accede a cada color por su nombre: `{{ colores.fondo }}`.
|
||||
|
||||
### colorpicker — un solo color
|
||||
|
||||
Cuando solo necesitas UN color, no una paleta. El valor es un string hexadecimal plano:
|
||||
|
||||
```html
|
||||
<div c-hidden="true">
|
||||
<div data-field-type="colorpicker" data-field-label="Color de fondo"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Valor guardado: `#ff0000`. En Twig: `{{ colordefondo }}`.
|
||||
|
||||
Usa `colorpicker` para un color suelto y `colors` cuando el módulo tenga varios: `colors`
|
||||
gasta UNA sola variable para N colores, mientras que N `colorpicker` gastan N.
|
||||
|
||||
### corners — radios de esquina
|
||||
|
||||
Selector de radio de borde en escala Tailwind. Mismo patrón que `colors`.
|
||||
|
||||
### ratio — proporción
|
||||
|
||||
Selector de proporción de imagen (16/9, 4/3, 1/1...).
|
||||
|
||||
> Para un valor booleano usa `list` con dos opciones: el builder no tiene un tipo de casilla.
|
||||
> (No lo confundas con el tipo `checkbox` de los campos de TABLA del CMS, que sí existe y se
|
||||
> documenta en `05-tables-and-fields.md`. Son dos vocabularios distintos.)
|
||||
|
||||
## 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.
|
||||
|
||||
## Mostrar u ocultar campos y pestañas (`data-field-show`)
|
||||
|
||||
Un campo o una pestaña entera pueden depender del valor de otro campo del mismo módulo. Se declara con dos atributos, que viajan al `builder.json` por el mismo mecanismo genérico que `data-field-group` (`customDataField.show` y `customDataField['group-show']`):
|
||||
|
||||
- `data-field-show="campo=valor"` — en el elemento del campo que se quiere condicionar.
|
||||
- `data-field-group-show="campo=valor"` — en **una** var del grupo; condiciona la pestaña completa.
|
||||
|
||||
Gramática:
|
||||
|
||||
| Condición | Se muestra cuando |
|
||||
|-----------|-------------------|
|
||||
| `modo=1` | el valor es `1` |
|
||||
| `modo=1,2` | el valor es `1` o `2` |
|
||||
| `modo=` | el campo está **vacío** — es la clave de la primera opción de todo `list` |
|
||||
| `modo!=1` | el valor NO es `1` |
|
||||
| `modo=1;otro=2` | se cumplen ambas (AND) |
|
||||
|
||||
El campo se referencia por su **nombre de variable**, no por su label: se aplican las [reglas de nomenclatura](#reglas-de-nomenclatura-de-variables), así que `Mostrar Estilos` se referencia como `mostrarestilos` y `Título` como `ttulo` (los acentos se borran, no se transliteran). Los valores no pueden contener coma ni punto y coma.
|
||||
|
||||
```html
|
||||
<div c-hidden="true">
|
||||
<!-- Pestaña completa: "Estilos" solo aparece si el usuario activa el switch -->
|
||||
<div data-field-type="list" data-field-label="Mostrar Estilos" data-list-options="|No,1|Si"></div>
|
||||
<div data-field-type="textfield"
|
||||
data-field-label="Texto de Estilos"
|
||||
data-field-group="Estilos"
|
||||
data-field-group-show="mostrarestilos=1"></div>
|
||||
|
||||
<!-- Campo a campo: cada opción del list muestra su propio campo -->
|
||||
<div data-field-type="list"
|
||||
data-field-label="Modo Modulo"
|
||||
data-list-options="|Opcion 1,1|Opcion 2,2|Opcion 3"
|
||||
data-field-group="Contenido"></div>
|
||||
<div data-field-type="textfield" data-field-label="Opcion 1" data-field-group="Contenido" data-field-show="modomodulo="></div>
|
||||
<div data-field-type="textfield" data-field-label="Opcion 2" data-field-group="Contenido" data-field-show="modomodulo=1"></div>
|
||||
<div data-field-type="textfield" data-field-label="Opcion 3" data-field-group="Contenido" data-field-show="modomodulo=2"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Comportamiento del panel:
|
||||
- **Ocultar no borra.** El valor sigue guardado y sigue llegando al Twig; si el usuario vuelve a mostrar el campo, lo escrito sigue ahí.
|
||||
- **Por eso la plantilla debe repetir la condición con `c-if`.** Ocultar el campo en el panel NO lo quita de la web: si `Texto de Estilos` no debe pintarse cuando `mostrarestilos` está a `0`, el `index-base.tpl` necesita su propio `c-if="mostrarestilos = '1'"`.
|
||||
- Una pestaña se oculta sola cuando todos sus campos han quedado ocultos por su `show`; en la mayoría de casos basta con condicionar los campos y no hace falta `data-field-group-show`.
|
||||
- Si varias vars del mismo grupo declaran `data-field-group-show`, **gana la primera** y las demás se ignoran.
|
||||
- Si una condición apunta a un campo que no existe (typo, o un label renombrado que cambió el nombre de variable), el campo **se muestra igualmente** y el aviso queda en la consola del navegador. Nunca desaparece en silencio.
|
||||
- Dentro de `multiv2` la condición se evalúa contra los valores **de ese item**, no contra los del módulo.
|
||||
- En modo traducción las condiciones se evalúan contra los valores del **idioma base**.
|
||||
|
||||
## Atributos Acai
|
||||
|
||||
@@ -447,3 +589,4 @@ Valores comunes: `fade-up`, `fade-down`, `fade-left`, `fade-right`, `zoom-in`, `
|
||||
8. Checkbox guarda `1` o `0` (número), nunca `true`/`false`.
|
||||
9. Evita Tailwind arbitrary-value en `index-base.tpl` — muévelos a `style.css`.
|
||||
10. `script.js` y `style.css` son estáticos: NO uses sintaxis Twig dentro. Pasa valores dinámicos vía `data-*`.
|
||||
11. Si el módulo lista registros de una tabla del CMS (`c-for` sobre ella, o su `hook.php` la consulta), declara esa tabla con `update_module_metadata({ cmsTables: [...] })`. No es un `data-field-*`: no se deduce del HTML, hay que declararla. Ver `03-modules-and-sections.md`.
|
||||
|
||||
@@ -28,6 +28,7 @@ Componentes visuales reutilizables. Viven en `template/estandar/modulos/<module-
|
||||
|
||||
Reglas duras:
|
||||
- **Solo se edita `index-base.tpl`.** `index.tpl`, `index-twig.tpl` y `builder.json` los genera el compilador y se sobrescriben automáticamente.
|
||||
- **Excepción del `builder.json`: la metadata SÍ se edita, con `update_module_metadata`** (`label`, `description`, `onlyAdminModule`, `MJMLModule`, `cmsTables`). Nunca la escribas con tools de archivo: el compilador regenera el fichero entero y perderías el cambio.
|
||||
- Editar `index-base.tpl` con `acai-write` o `acai-line-replace` **dispara la compilación automática**.
|
||||
- `script.js` y `style.css` son **estáticos** — NO uses sintaxis Twig dentro. Pasa valores dinámicos vía atributos `data-*`.
|
||||
- `index-base.tpl` solo contiene HTML/Twig. **Nunca** embebas etiquetas `<script>` con lógica del módulo, **nunca** PHP.
|
||||
@@ -203,6 +204,32 @@ Acceso en Twig:
|
||||
|
||||
Las variables son **propiedades del objeto iterado**, no variables sueltas.
|
||||
|
||||
## `cmsTables` — qué tablas del CMS muestra el módulo
|
||||
|
||||
Un módulo puede pintar contenido que NO vive en sus variables: un listado de noticias, una parrilla de productos, un carrusel del blog. Esos registros están en tablas del CMS, y `cmsTables` es donde el módulo declara cuáles.
|
||||
|
||||
```json
|
||||
{ "label": "Listado de noticias", "cmsTables": ["noticias"] }
|
||||
```
|
||||
|
||||
**No confundir con `tables`**, que también está en el `builder.json` y se parece demasiado:
|
||||
|
||||
| Clave | Qué es | Quién la pone |
|
||||
|-------|--------|---------------|
|
||||
| `tables` | Dónde se guardan los VALORES de las variables del módulo. Siempre `["builder_custom"]` | El compilador. No la toques |
|
||||
| `cmsTables` | Qué contenido MUESTRA el módulo | Una persona desde el editor, o tú con `update_module_metadata` |
|
||||
|
||||
Para qué sirve: el editor pinta un acceso directo al CMS de cada tabla declarada desde cualquier página que incluya el módulo, para que quien edita esa página llegue al contenido sin buscarlo.
|
||||
|
||||
Cuándo declararla:
|
||||
- **Sí**: el módulo hace `c-for` sobre registros de una tabla, o su `hook.php` los consulta.
|
||||
- **No**: el módulo solo muestra sus propias variables (un banner con título e imagen). Deja la lista vacía.
|
||||
|
||||
Cómo usarla tú:
|
||||
- Al crear un módulo que lista contenido, decláralas: `update_module_metadata({ module, cmsTables: ["noticias"] })`.
|
||||
- Al recibirlas en `get_module_config_vars`, te dicen **dónde está de verdad el contenido visible**. Si el usuario pide cambiar lo que muestra un listado de noticias, los registros están en `noticias` — no en las variables del módulo. Ve a esa tabla con `list_table_records` / `create_or_update_record`.
|
||||
- Pasar `cmsTables` reemplaza la lista entera: incluye las que quieras conservar. `[]` la vacía.
|
||||
|
||||
## Traducir las variables de un módulo
|
||||
|
||||
Los valores textuales de las variables de un módulo (títulos, descripciones, wysiwyg, etc.) NO se guardan en la fila de la página, sino en la tabla `builder_custom`. Por eso una traducción de módulo apunta siempre a `builder_custom`, no a `apartados` ni a la tabla de la página.
|
||||
|
||||
@@ -37,6 +37,7 @@ Reglas:
|
||||
| `check_module_usage` | Lista páginas que usan el módulo | **OBLIGATORIO antes de `delete_module`** |
|
||||
| `delete_module` | Elimina la carpeta del módulo | Destructivo. Si `inUse=true`, deniega — el usuario debe quitarlo de las páginas primero |
|
||||
| `set_module_example_data` | Define datos de ejemplo para preview en el editor | Pasar valores para TODAS las variables del schema |
|
||||
| `update_module_metadata` | Edita metadata del `builder.json` | `label`, `description`, `onlyAdminModule`, `MJMLModule`, `cmsTables`. NO renombra el módulo. Rechaza los módulos de layout (`custom-header/footer[-twig]`) |
|
||||
|
||||
### Registros (records)
|
||||
|
||||
@@ -51,7 +52,7 @@ Reglas:
|
||||
| `remove_module_from_record` | Quita módulo de la página | Por `sectionId` (preferido) o `modulePosition` |
|
||||
| `reorder_module` | Mueve módulo a otra posición | `fromPosition` → `toPosition` |
|
||||
| `toggle_module_visibility` | Muestra/oculta sin borrar | Por `sectionId` |
|
||||
| `get_module_config_vars` | Lee valores actuales de las variables | Por `tableName` + `recordNum` + `sectionId` |
|
||||
| `get_module_config_vars` | Lee valores actuales de las variables | Por `tableName` + `recordNum` + `sectionId`. Devuelve además `cmsTables`: las tablas cuyo contenido MUESTRA el módulo (ahí están sus registros, no en las vars) |
|
||||
| `set_module_config_vars` | Escribe variables del módulo | Devuelve `uploadFields` con `recordNum`+`fieldName` listos para subir imágenes |
|
||||
|
||||
### Tablas y campos (schema)
|
||||
@@ -109,8 +110,16 @@ Ver `06-hooks-and-cmsapi.md` para uso. Crear/editar el `.php` del hook se hace c
|
||||
| `replace_record_image` | Reemplaza un upload existente por uno nuevo | Necesita `uploadId` (de `list_record_uploads`). Borra el viejo + sube el nuevo, ambos con sync a producción |
|
||||
| `delete_record_upload` | Borra un upload concreto del campo | Necesita `uploadId`. Sincroniza el borrado a producción |
|
||||
| `reorder_record_uploads` | Cambia el orden de los uploads de un campo | Lista de `uploadIds` en el orden deseado |
|
||||
| `set_upload_info` | Escribe los metadatos `info1`..`info5` de un upload YA subido | Necesita `uploadId` (de `list_record_uploads`) + al menos un `infoN`. Solo escribe las claves enviadas; `""` vacía ese info. Máx 1000 caracteres por campo |
|
||||
| `upload_image_to_assets` | Sube imagen a `/images/` del template (assets globales) | Acepta base64, data URI, URL. Permite resize/quality/format |
|
||||
|
||||
`set_upload_info` es la única vía para rellenar `info2`..`info5` (al subir solo se escribe `info1`, vía el param `alt`). Como `uploadId` es la PK de la tabla central `uploads`, la MISMA tool sirve para los dos tipos de upload — no se pasa tabla/registro/campo, el `uploadId` ya identifica la fila:
|
||||
|
||||
- **Uploads de un registro normal**: el `uploadId` sale de `list_record_uploads({ tableName, recordId, fieldName })`.
|
||||
- **Uploads de variables de módulo**: `get_module_config_vars` devuelve `uploadFields` (`varName` → `{ tableName: "builder_custom", recordNum, fieldName }`; `set_module_config_vars` devuelve lo mismo y además cubre las vars `multi`, con clave `"varName.subVarName"` → array `[{index, fieldName, recordNum}]`). Con eso llama a `list_record_uploads({ tableName: "builder_custom", recordId: recordNum, fieldName })` para sacar los `uploadId`.
|
||||
|
||||
`info1` es por convención el **alt text**: la misma columna que escribe el param `alt` de `upload_record_image`/`replace_record_image`, así que escribirlo aquí lo sobrescribe. El significado de `info2`..`info5` lo define **cada módulo**: los declara en su `index-base.tpl` con `data-field-info1`..`data-field-info5` y quedan guardados como `infoLabels` en su `builder.json`. No hace falta abrir el fichero: cada entrada de `uploadFields` incluye `infoLabels` cuando el módulo los define — array donde la posición 0 es `info1`, la 1 es `info2`, etc. (para uploads dentro de vars `multi`, los labels salen en `varsMeta.<varName>.subFields.<subVar>.infoLabels`). El `builder.json` (`vars.<varName>.infoLabels`) sigue siendo la fuente canónica si necesitas comprobarlo. Consulta esos labels antes de escribir para no inventarte el significado de cada info. Ver `01-builder-fields.md`.
|
||||
|
||||
### Navegación
|
||||
|
||||
| Tool | Acción |
|
||||
@@ -211,6 +220,8 @@ Generar imagen primero:
|
||||
2. Usa la URL recomendada que devuelve (`uploadUrl` o `fullUrl` en Forge; `dockerUrl` solo en local).
|
||||
3. `upload_record_image` con esa URL.
|
||||
|
||||
Para rellenar los metadatos `info1`..`info5` de una imagen ya subida (alt text y los campos que el módulo define en sus `infoLabels`): `set_upload_info` — ver su entrada en la sección Media.
|
||||
|
||||
### 4. Crear funcionalidad nueva con tabla + detalle
|
||||
|
||||
Ejemplo: implementar "Vacantes".
|
||||
@@ -286,6 +297,7 @@ Según lo que pida el usuario:
|
||||
- **Reemplazar** una imagen concreta: `replace_record_image({ tableName, recordId, fieldName, uploadId, imageUrl, alt? })` — borra el viejo + sube el nuevo, ambos con sync a producción.
|
||||
- **Borrar** una imagen: `delete_record_upload({ uploadId, table? })` — sync de borrado a producción.
|
||||
- **Reordenar**: `reorder_record_uploads({ tableName, recordId, fieldName, uploadIds: [...] })` con la lista en el orden deseado.
|
||||
- **Editar metadatos** (alt text, pie de foto, crédito…) sin tocar el fichero: `set_upload_info({ uploadId, info1?..info5? })` — ver su entrada en la sección Media.
|
||||
|
||||
Para AÑADIR un upload nuevo (sin reemplazar nada existente), usa `upload_record_image` directamente.
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ Tabla decisional para mapear la intención del usuario a la herramienta correcta
|
||||
| Reemplazar imagen existente | `list_record_uploads` → `replace_record_image({ uploadId, imageUrl })` |
|
||||
| Borrar una imagen | `list_record_uploads` → `delete_record_upload({ uploadId })` |
|
||||
| Reordenar galería | `list_record_uploads` → `reorder_record_uploads({ uploadIds: [...] })` |
|
||||
| Editar alt/metadatos de una imagen ya subida | `list_record_uploads` → `set_upload_info({ uploadId, info1?..info5? })` |
|
||||
| Subir imagen a `/images/` (assets globales del template) | `upload_image_to_assets({ imageUrl, fileName })` |
|
||||
|
||||
## Tablas y campos (schema)
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Reglas inmutables y cheat-sheet de tipos"
|
||||
tags: [reference, rules, cheat]
|
||||
load_priority: 90
|
||||
load_when: [cheatsheet]
|
||||
summary: "Reglas no negociables (cms_, num, _num, upload arrays, c-if/{% if %}), tipos de builder field, atributos Acai, filtros Twig, formato de datos para insert/update, errores comunes."
|
||||
summary: "Reglas no negociables (cms_, num, _num, upload arrays, c-if/{% if %}), tipos de builder field, pestañas y visibilidad condicional de campos (data-field-group, data-field-show), atributos Acai, filtros Twig, formato de datos para insert/update, errores comunes."
|
||||
---
|
||||
# Reglas inmutables y cheat-sheet
|
||||
|
||||
@@ -42,9 +42,13 @@ Resumen ejecutable de reglas críticas, tipos de campo, filtros y formatos de da
|
||||
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
|
||||
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
|
||||
| `multiv2` | `<li>` wrapper | Array de objetos |
|
||||
| `checkbox` | `<input>` o `<div>` | `1` / `0` |
|
||||
| `colors` | `<div data-field-colors="fondo,titulo">` | Objeto de N colores por nombre |
|
||||
| `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").
|
||||
|
||||
Visibilidad condicional: `data-field-show="modo=1"` en el campo, y `data-field-group-show="modo=1"` en **una** var del grupo para condicionar la pestaña entera. Se referencia el NOMBRE DE VARIABLE, no el label. `modo=` significa vacío (clave de la primera opción de todo `list`), `,` es OR, `!=` niega y `;` encadena condiciones (AND).
|
||||
|
||||
## Atributos Acai
|
||||
|
||||
| Atributo | Uso | Ejemplo |
|
||||
|
||||
@@ -61,7 +61,7 @@ Definiciones cortas de los términos que aparecen en docs y prompts. Si te pierd
|
||||
|
||||
**`c-form`** — atributo que convierte un `<form>` en un formulario que persiste a una tabla del CMS. Sintaxis: `<c-form tableName="'contacto'" captcha="true">`. Se renderiza como form HTML con submit a un endpoint Acai.
|
||||
|
||||
**`data-field-*`** — familia de atributos que marca un elemento como editable en el builder visual. Tipos: `textfield`, `headfield`, `textbox`, `wysiwyg`, `link`, `upload`, `uploadMulti`, `list`, `multiv2`, `checkbox`, `colorpicker`.
|
||||
**`data-field-*`** — familia de atributos que marca un elemento como editable en el builder visual. Tipos: `textfield`, `headfield`, `textbox`, `wysiwyg`, `link`, `upload`, `uploadMulti`, `list`, `multiv2`, `colors`, `colorpicker`.
|
||||
|
||||
**`c-if`, `c-for`, `c-class`, `c-hidden`, `c-required`** — atributos de lógica visual. **`c-if` usa un solo `=`** (`c-if="x = 1"`), Twig `{% if %}` usa **doble** `==`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -129,16 +129,130 @@ Examples:
|
||||
);
|
||||
}
|
||||
|
||||
function registerGetHookEntryParamsTool(server) {
|
||||
server.tool(
|
||||
"get_hook_entryparams",
|
||||
`Read the declared entry parameters (entryParams) of a global hook. entryParams are the input parameters a hook expects — each has a required 'variable' name plus optional 'value' and 'valueType'. They are passed to the hook when it is invoked.
|
||||
|
||||
Use this when the user asks about a hook's inputs, or to inspect the current params before editing them.
|
||||
|
||||
hookEndPoint format: starts and ends with '/', with '/' as separator. E.g. "/hooks/appListado/".
|
||||
|
||||
Returns:
|
||||
- entryParams: array of { variable, value?, valueType? }.
|
||||
Example: [{ "variable": "action" }, { "variable": "data", "value": "", "valueType": "Integer" }].`,
|
||||
withAuthParams({
|
||||
hookEndPoint: z.string().describe('Hook endpoint path, e.g. "/hooks/appListado/"'),
|
||||
}),
|
||||
{ readOnlyHint: true, destructiveHint: false },
|
||||
withAuth(async ({ hookEndPoint }, extra) => {
|
||||
try {
|
||||
const { projectSlug } = getCurrentProjectInfo();
|
||||
const result = await pythonGet("/api/creator/hook-entryparams", {
|
||||
project: projectSlug,
|
||||
endPoint: hookEndPoint,
|
||||
});
|
||||
if (!result?.success) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: result?.error || "No se pudieron leer los entryParams",
|
||||
}),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
exists: !!result.exists,
|
||||
entryParams: result.entryParams || [],
|
||||
hookEndPoint,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "get_hook_entryparams", { hookEndPoint });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function registerSetHookEntryParamsTool(server) {
|
||||
server.tool(
|
||||
"set_hook_entryparams",
|
||||
`Set the declared entry parameters (entryParams) of a global hook. entryParams describe the inputs a hook expects — each has a required 'variable' name plus optional 'value' and 'valueType'.
|
||||
|
||||
IMPORTANT: this OVERWRITES the entire entryParams list of the hook (it does NOT merge). You must pass the COMPLETE set of params every time, because the whole array is replaced.
|
||||
|
||||
Use this AFTER creating or editing the hook file (via acai-write) to declare which inputs it accepts.
|
||||
|
||||
entryParams format: array of { variable, value?, valueType? }.
|
||||
Example: [{ "variable": "action" }, { "variable": "data", "value": "", "valueType": "Integer" }].`,
|
||||
withAuthParams({
|
||||
hookEndPoint: z.string().describe('Hook endpoint path, e.g. "/hooks/appListado/"'),
|
||||
entryParams: z.array(z.object({
|
||||
variable: z.string(),
|
||||
value: z.string().optional(),
|
||||
valueType: z.string().optional(),
|
||||
})).describe('Complete list of entry params. Each item: { variable (required), value? (string), valueType? (string) }. Replaces the whole array. E.g. [{"variable":"action"},{"variable":"data","value":"","valueType":"Integer"}]'),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ hookEndPoint, entryParams }, extra) => {
|
||||
try {
|
||||
const { projectSlug } = getCurrentProjectInfo();
|
||||
const result = await pythonPost("/api/creator/hook-entryparams", {
|
||||
project: projectSlug,
|
||||
endPoint: hookEndPoint,
|
||||
entryParams,
|
||||
});
|
||||
if (!result?.success) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: result?.error || "No se pudo guardar",
|
||||
}),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: result.message || "entryParams actualizados",
|
||||
entryParams: result.entryParams || [],
|
||||
hookEndPoint,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "set_hook_entryparams", { hookEndPoint, entryParams });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra las tools de configuracion de hooks globales.
|
||||
*
|
||||
* `get_hook_middleware` es de solo lectura y se registra siempre. El set
|
||||
* modifica el layout y solo se expone si el rol puede editar codigo — sigue
|
||||
* el mismo criterio que otras tools de escritura (ver project/index.js).
|
||||
* Las tools de solo lectura (`get_hook_middleware`, `get_hook_entryparams`) se
|
||||
* registran siempre. Las de escritura modifican el layout y solo se exponen si
|
||||
* el rol puede editar codigo — sigue el mismo criterio que otras tools de
|
||||
* escritura (ver project/index.js).
|
||||
*/
|
||||
export function registerHookTools(server) {
|
||||
registerGetHookMiddlewareTool(server);
|
||||
registerGetHookEntryParamsTool(server);
|
||||
if (canEditCode()) {
|
||||
registerSetHookMiddlewareTool(server);
|
||||
registerSetHookEntryParamsTool(server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ import { registerUploadRecordImageTool } from './upload.js';
|
||||
import { registerUploadImageToAssetsTool } from './uploadImageToAssets.js';
|
||||
import { registerGenerateImageTool } from './generateImage.js';
|
||||
import { registerAnalyzeImageTool } from './analyze_image.js';
|
||||
import { registerSetUploadInfoTool } from './setUploadInfo.js';
|
||||
|
||||
export function registerMediaTools(server) {
|
||||
registerUploadRecordImageTool(server);
|
||||
registerUploadImageToAssetsTool(server);
|
||||
registerGenerateImageTool(server);
|
||||
registerAnalyzeImageTool(server);
|
||||
// Metadatos info1..info5 de uploads: son datos de contenido, no codigo,
|
||||
// asi que va sin gate de canEditCode() como el resto de tools de media.
|
||||
registerSetUploadInfoTool(server);
|
||||
}
|
||||
|
||||
115
mcp-server/tools/media/setUploadInfo.js
Normal file
115
mcp-server/tools/media/setUploadInfo.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth } from "../../auth/index.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||
import { getCurrentProjectInfo } from "../files/helpers.js";
|
||||
|
||||
// Tool: set_upload_info
|
||||
//
|
||||
// Rellena los metadatos info1..info5 de una imagen YA subida, delegando en
|
||||
// /api/uploads/set-info del server Python.
|
||||
//
|
||||
// El uploadId es la PK (`num`) de la tabla CENTRAL `uploads`, asi que la misma
|
||||
// tool sirve tanto para uploads de registros normales como para uploads que
|
||||
// viven en vars de modulo (que fisicamente cuelgan de `builder_custom`). Por
|
||||
// eso no hace falta pasar tabla/registro/campo: el uploadId ya identifica la
|
||||
// fila de forma univoca.
|
||||
//
|
||||
// Ojo con el filtrado de claves: se comprueba `!== undefined`, NO truthiness,
|
||||
// para que una cadena vacia ("") viaje en el body y sirva para VACIAR un info.
|
||||
|
||||
const INFO_KEYS = ["info1", "info2", "info3", "info4", "info5"];
|
||||
|
||||
// Tope por campo que ya impone el endpoint (_UPLOAD_INFO_MAX_LEN en cms_db.py).
|
||||
// Se replica en el zod para que el agente reciba el error sin round-trip.
|
||||
const INFO_MAX_LEN = 1000;
|
||||
|
||||
export function registerSetUploadInfoTool(server) {
|
||||
server.tool(
|
||||
"set_upload_info",
|
||||
`Set the metadata fields info1..info5 of an image that has ALREADY been uploaded. This is the only way to fill infoN metadata: uploading only ever writes info1 (via the 'alt' param of upload_record_image / replace_record_image).
|
||||
|
||||
uploadId is the primary key ('num') of the CENTRAL 'uploads' table, so THIS SAME TOOL WORKS FOR BOTH KINDS OF UPLOAD — you never pass table/record/field here, the uploadId alone identifies the file:
|
||||
|
||||
1) Uploads of a NORMAL record: get the uploadId with list_record_uploads(tableName, recordId, fieldName).
|
||||
|
||||
2) Uploads stored in MODULE VARS: call get_module_config_vars first. Its 'uploadFields' is a map varName -> { tableName: "builder_custom", recordNum, fieldName, infoLabels? } (set_module_config_vars returns the same map and ALSO covers uploads nested inside a 'multi' var, where the key is "varName.subVarName" — e.g. "slides.imagen" — and the value is an array of { index, fieldName, recordNum }). Then call list_record_uploads with tableName: "builder_custom", recordId: <recordNum> and fieldName: <fieldName> to get the uploadIds.
|
||||
|
||||
Meaning of each infoN:
|
||||
- info1 is BY CONVENTION the alt text — the very same column the 'alt' param writes when uploading. Setting info1 here overwrites that alt text.
|
||||
- info2..info5 are free metadata slots whose meaning is defined PER MODULE: the module declares them in its index-base.tpl with the attributes data-field-info1..data-field-info5, and they are stored as 'infoLabels' in the module's builder.json (e.g. info2 = "caption", info3 = "credit/author", info4 = "link"). You do NOT need to open that file: get_module_config_vars returns them inside each 'uploadFields' entry as 'infoLabels' whenever the module defines them — an array where position 0 is info1, position 1 is info2, etc. (for uploads inside a 'multi' var the labels come in varsMeta.<varName>.subFields.<subVar>.infoLabels). The module's builder.json (vars.<varName>.infoLabels) is still the canonical source if you need to double-check. Read those labels before writing — do NOT invent a meaning.
|
||||
|
||||
At least one of info1..info5 is required. Only the keys you send are written; the ones you omit are left untouched. Passing an empty string ("") CLEARS that info field. Max 1000 characters per field.`,
|
||||
withAuthParams({
|
||||
uploadId: z.string().describe("Upload ID: the 'num' PK of the central 'uploads' table (from list_record_uploads, for a normal record or for builder_custom in the case of module vars)"),
|
||||
info1: z.string().max(INFO_MAX_LEN).optional().describe("info1 — by convention the ALT TEXT of the image (same column the 'alt' upload param writes). Empty string clears it. Max 1000 chars."),
|
||||
info2: z.string().max(INFO_MAX_LEN).optional().describe("info2 — module-defined metadata (label at position 1 of 'infoLabels', returned by get_module_config_vars in uploadFields). Empty string clears it. Max 1000 chars."),
|
||||
info3: z.string().max(INFO_MAX_LEN).optional().describe("info3 — module-defined metadata (label at position 2 of 'infoLabels', returned by get_module_config_vars in uploadFields). Empty string clears it. Max 1000 chars."),
|
||||
info4: z.string().max(INFO_MAX_LEN).optional().describe("info4 — module-defined metadata (label at position 3 of 'infoLabels', returned by get_module_config_vars in uploadFields). Empty string clears it. Max 1000 chars."),
|
||||
info5: z.string().max(INFO_MAX_LEN).optional().describe("info5 — module-defined metadata (label at position 4 of 'infoLabels', returned by get_module_config_vars in uploadFields). Empty string clears it. Max 1000 chars."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ uploadId, info1, info2, info3, info4, info5 }, _extra) => {
|
||||
try {
|
||||
const validationError = validateRequired(
|
||||
{ uploadId },
|
||||
["uploadId"],
|
||||
"set_upload_info"
|
||||
);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const provided = { info1, info2, info3, info4, info5 };
|
||||
|
||||
// Validacion temprana: sin ninguna clave infoN no hay nada que
|
||||
// escribir, asi que ni llamamos al endpoint. Se filtra por
|
||||
// `undefined` y NO por truthiness, para que "" (vaciar un info)
|
||||
// se considere un valor enviado.
|
||||
const changedKeys = INFO_KEYS.filter((key) => provided[key] !== undefined);
|
||||
if (changedKeys.length === 0) {
|
||||
return handleToolError(
|
||||
`Nothing to update: provide at least one of ${INFO_KEYS.join(", ")}. ` +
|
||||
`info1 is the alt text; the meaning of info2..info5 is defined by the module ` +
|
||||
`(read 'infoLabels' with get_module_config_vars). Pass "" to clear a field.`,
|
||||
"set_upload_info",
|
||||
{ uploadId, editableFields: INFO_KEYS }
|
||||
);
|
||||
}
|
||||
|
||||
const { projectSlug } = getCurrentProjectInfo();
|
||||
|
||||
const body = { project: projectSlug, uploadId };
|
||||
for (const key of changedKeys) body[key] = provided[key];
|
||||
|
||||
const result = await pythonPost("/api/uploads/set-info", body);
|
||||
|
||||
if (!result?.success) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: result?.error || "Could not set upload info",
|
||||
}),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
uploadId: result.uploadId || uploadId,
|
||||
updatedFields: changedKeys,
|
||||
info: result.info || {},
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return handleToolError(error, "set_upload_info", { uploadId });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials, getApiClient, getCommonParams } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { withAuth } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||
import { getCurrentProjectInfo } from "../files/helpers.js";
|
||||
|
||||
// Antes esta tool llamaba a `action_ws=setStaticVars`, que hacia un
|
||||
// file_put_contents del builder.json ENTERO en la web para cambiar una sola
|
||||
// clave. Eso se saltaba el bloqueo de escritura de builder.json y, si caia una
|
||||
// compilacion entre su lectura y su escritura, devolvia el mapeo var->columna
|
||||
// anterior encima del recien generado (contenido rotado en todas las paginas
|
||||
// que usan el modulo). Ahora delega en el endpoint quirurgico de Forge, que
|
||||
// escribe SOLO las claves de su allowlist.
|
||||
|
||||
export function registerSetModuleExampleDataTool(server) {
|
||||
server.tool(
|
||||
@@ -10,6 +20,8 @@ export function registerSetModuleExampleDataTool(server) {
|
||||
|
||||
Reglas críticas:
|
||||
- Uploads SIEMPRE como [{ urlPath: "..." }] (nunca strings ni objetos sueltos).
|
||||
- 'colors' como STRING con un JSON de pares nombre/color, usando los nombres declarados en data-field-colors: "{\\"fondo\\":\\"#ffffff\\",\\"titulo\\":\\"#111111\\"}". Cada color admite hex o linear-gradient(...).
|
||||
- 'colorpicker' como un hex plano ("#ff0000"), no como objeto.
|
||||
- 'multiv2' como array con al menos 2 items para que el preview se vea representativo.
|
||||
- Los nombres de variables se derivan de 'data-field-label' (minúsculas, sin espacios ni acentos).
|
||||
- Para URLs de imagen usa 'generate_image' o un placeholder (e.g. https://placehold.co/800x600).
|
||||
@@ -50,42 +62,36 @@ Si dudas del formato exacto, lee 'read_doc({ name: "01-builder-fields" })'.`,
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const client = await getApiClient(extra.sessionId);
|
||||
console.error(`[set_module_example_data] Module ID: ${moduleId}, vars: ${Object.keys(exampleData).length}`);
|
||||
|
||||
// Log data for debugging
|
||||
console.error(`[set_module_example_data] Module ID: ${moduleId}`);
|
||||
console.error(`[set_module_example_data] Module Schema:`, JSON.stringify(moduleSchema, null, 2));
|
||||
console.error(`[set_module_example_data] Example Data:`, JSON.stringify(exampleData, null, 2));
|
||||
|
||||
// Prepare payload for setStaticVars action
|
||||
const payload = await getCommonParams(extra.sessionId, {
|
||||
action_ws: "setStaticVars",
|
||||
moduleId: moduleId,
|
||||
const { projectSlug } = getCurrentProjectInfo();
|
||||
const result = await pythonPost("/api/modules/update-metadata", {
|
||||
project: projectSlug,
|
||||
module: moduleId,
|
||||
staticVars: exampleData,
|
||||
schema: moduleSchema
|
||||
});
|
||||
|
||||
console.error(`[set_module_example_data] Full Payload:`, JSON.stringify(payload, null, 2));
|
||||
|
||||
// Send to viewer_functions
|
||||
const response = await client.post("/cms/lib/viewer_functions.php", payload);
|
||||
|
||||
console.error(`[set_module_example_data] Response:`, JSON.stringify(response.data, null, 2));
|
||||
|
||||
// Check for API errors in response
|
||||
const apiError = handleApiResponse(response.data, 'set_module_example_data');
|
||||
if (apiError) return apiError;
|
||||
if (!result?.success) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: result?.error || "Could not set module example data",
|
||||
}),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text", text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Example data set successfully for module '${moduleId}'`,
|
||||
moduleId: moduleId,
|
||||
moduleId: result.module || moduleId,
|
||||
dataCount: Object.keys(exampleData).length,
|
||||
schemaVarsCount: moduleSchema?.codeVars ? Object.keys(moduleSchema.codeVars).length : 0,
|
||||
response: response.data
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
|
||||
94
mcp-server/tools/modules/updateMetadata.js
Normal file
94
mcp-server/tools/modules/updateMetadata.js
Normal file
@@ -0,0 +1,94 @@
|
||||
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, cmsTables) 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", "cmsTables"];
|
||||
|
||||
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), 'cmsTables' (CMS tables whose content this module displays).
|
||||
|
||||
Use 'cmsTables' when the module renders records from a CMS table — a news list, a product grid, a blog carousel. Declare the tables it reads (e.g. ["noticias"]). The editor then shows a direct link to each of those tables from any page that includes the module, so whoever edits that page can reach the content without hunting for it. A module that only shows its own variables (a banner with a title and an image) needs no cmsTables.
|
||||
|
||||
Do NOT confuse 'cmsTables' with the 'tables' key of builder.json: 'tables' is where the module's VARIABLE VALUES are stored (always builder_custom) and is managed by the compiler. 'cmsTables' is what content the module DISPLAYS, and is chosen by a human or by you.
|
||||
|
||||
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, MJMLModule or cmsTables is required. Fields you omit are left untouched. Passing cmsTables replaces the whole list, so include the tables you want to keep; pass [] to clear it.
|
||||
|
||||
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"),
|
||||
cmsTables: z.array(z.string()).optional().describe("CMS tables whose records this module displays, e.g. [\"noticias\"]. Replaces the whole list; [] clears it. Only real tables of the project — they become direct links to the CMS."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ module, label, description, onlyAdminModule, MJMLModule, cmsTables }, _extra) => {
|
||||
try {
|
||||
const provided = { label, description, onlyAdminModule, MJMLModule, cmsTables };
|
||||
|
||||
// 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,74 @@
|
||||
import { z } from "zod";
|
||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { table } from "console";
|
||||
import { withAuth } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { canAccessTable } from "../helpers/accessControl.js";
|
||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||
import { getCurrentProjectInfo } from "../files/helpers.js";
|
||||
|
||||
// Tool: create_or_update_record
|
||||
//
|
||||
// TRANSPORTE: escribe SIEMPRE a traves del server Python
|
||||
// (/api/cms/create-record y /api/cms/update-record), nunca contra el cmsApi de
|
||||
// la web. Esos endpoints son el mismo camino que usa el dashboard, asi que la
|
||||
// tool hereda gratis toda la logica de escritura que ya vive en Python:
|
||||
//
|
||||
// * auto-relleno y normalizacion de `enlace` (slug derivado de title/name).
|
||||
// * hasheo sha1 de los campos `editor_password` (el cmsApi no ejecuta hooks
|
||||
// de plugin; la regla canonica vive en server/password_fields.py).
|
||||
// * metadatos de tablas `category`: regeneracion del arbol solo cuando hace
|
||||
// falta (jerarquia real) y derivados calculados para las tablas planas.
|
||||
// * defaults del schema en INSERT (fill_schema_defaults) y filtrado de
|
||||
// campos `adminOnly` para usuarios no admin.
|
||||
//
|
||||
// Duplicar todo eso en JS era inviable: un solo camino de escritura.
|
||||
//
|
||||
// LOTES: el endpoint Python de creacion acepta UN registro, asi que un `fields`
|
||||
// array se resuelve con N llamadas secuenciales. Ver BATCH_POLICY.
|
||||
|
||||
// El endpoint de creacion escribe de uno en uno y NO hay transaccion que
|
||||
// envuelva el lote: si la llamada k falla, las k-1 anteriores ya estan en BD.
|
||||
// Politica: ABORTAR en el primer fallo y devolver los `num` ya creados, el
|
||||
// indice que fallo y cuantos quedaron sin intentar. Preferimos un lote a medias
|
||||
// EXPLICITO (el agente puede continuar o borrar) a seguir insertando a ciegas o
|
||||
// a callarnoslo con un success:true enganoso.
|
||||
const BATCH_POLICY = "abort-on-first-error";
|
||||
|
||||
// Campos que nunca deben cambiar en un registro existente. El server Python NO
|
||||
// los filtra (su `autofill_enlace` en update solo normaliza el `enlace` que le
|
||||
// llegue), asi que el strip se mantiene aqui: es lo que la descripcion de la
|
||||
// tool le promete al agente.
|
||||
const PROTECTED_UPDATE_FIELDS = ["enlace", "controlador", "precontrolador"];
|
||||
|
||||
/**
|
||||
* POST al server Python normalizando el error.
|
||||
* Los handlers responden {success:false, error, errorCode} con status 4xx/5xx, y
|
||||
* las validaciones tempranas responden {error: "..."} con 400 — axios lanza en
|
||||
* ambos casos, asi que aqui se aplanan a { ok, data, error, errorCode, status }.
|
||||
*/
|
||||
async function postToPython(path, body) {
|
||||
try {
|
||||
const data = await pythonPost(path, body);
|
||||
if (data && data.success === true) return { ok: true, data };
|
||||
return {
|
||||
ok: false,
|
||||
error: (data && (data.error || data.message)) || "El server Python no confirmo la escritura",
|
||||
errorCode: data?.errorCode,
|
||||
status: 200,
|
||||
};
|
||||
} catch (error) {
|
||||
const payload = error?.response?.data;
|
||||
const message = (payload && typeof payload === "object" && (payload.error || payload.message))
|
||||
|| error?.message
|
||||
|| "Error desconocido escribiendo en el server Python";
|
||||
return {
|
||||
ok: false,
|
||||
error: typeof message === "string" ? message : JSON.stringify(message),
|
||||
errorCode: payload?.errorCode,
|
||||
status: error?.response?.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCreateOrUpdateRecordTool(server) {
|
||||
server.tool(
|
||||
@@ -13,7 +77,15 @@ export function registerCreateOrUpdateRecordTool(server) {
|
||||
|
||||
Reglas clave: tablas sin prefijo 'cms_'; PK es 'num' (nunca 'id'); foreign keys con sufijo '_num'; uploads son arrays — NO los envíes en 'fields', sube después con 'upload_record_image'; fechas en formato YYYY-MM-DD HH:mm:ss; checkboxes como 1/0 (números).
|
||||
|
||||
Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null, builder:"[]", controlador, precontrolador, breadcrumb, enlace. NUNCA modifiques 'enlace' ni 'controlador' de un registro existente — los stripeo automáticamente en updates.`,
|
||||
Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null, builder:"[]", controlador, precontrolador, breadcrumb. NUNCA modifiques 'enlace' ni 'controlador' de un registro existente — los stripeo automáticamente en updates.
|
||||
|
||||
Enlace: NO hace falta que lo inventes al crear. Si la tabla tiene campo 'enlace' y no lo envías, se genera un slug legible a partir de 'title' o 'name' (y si no hay ninguno, uno aleatorio); si lo envías, se normaliza a la forma /.../. El valor final lo decide el servidor, así que si necesitas la URL del registro léela después con 'get_record'.
|
||||
|
||||
Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se hashean automáticamente con SHA1 en el servidor antes de guardarse — envía la contraseña en texto plano y NO la hashees tú. Su valor no se puede leer/descifrar después (solo verás el hash), así que no intentes recuperar contraseñas existentes ni reenviarlas. Si envías el campo vacío ('' o null) se omite del guardado y la contraseña actual se mantiene.
|
||||
|
||||
Alta múltiple ('fields' como array): los registros se crean UNO A UNO y no hay transacción. Si uno falla, se aborta ahí: la respuesta te dice qué 'num' se llegaron a crear (createdIds), en qué índice falló y cuántos quedaron sin intentar. Los ya creados NO se revierten — decide tú si reintentas el resto o los borras.
|
||||
|
||||
Campos restringidos: los campos marcados como 'adminOnly' en el schema se descartan silenciosamente si el usuario del proyecto no es admin (solo aplica en producción).`,
|
||||
withAuthParams({
|
||||
tableName: z.string().describe("Nombre de la tabla sin prefijo 'cms_' (e.g. 'productos', 'apartados')"),
|
||||
recordId: z.any().optional().describe("'num' del registro a actualizar. Omitir para crear nuevo. NO se usa cuando 'fields' es array."),
|
||||
@@ -21,7 +93,7 @@ Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null
|
||||
tableSchema: z.any().describe("Schema de la tabla para validar tipos antes de enviar (opcional)."),
|
||||
}),
|
||||
{ readOnlyHint: false, destructiveHint: false },
|
||||
withAuth(async ({ tableName, recordId, fields }, extra) => {
|
||||
withAuth(async ({ tableName, recordId, fields }, _extra) => {
|
||||
try {
|
||||
// Validate required parameters
|
||||
const validationError = validateRequired({ tableName, fields }, ['tableName', 'fields'], 'create_or_update_record');
|
||||
@@ -56,90 +128,126 @@ Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null
|
||||
};
|
||||
}
|
||||
|
||||
// Protect critical fields during updates — these should never be changed by AI
|
||||
const PROTECTED_UPDATE_FIELDS = ['enlace', 'controlador', 'precontrolador'];
|
||||
if (recordId) {
|
||||
// On update: strip protected fields silently
|
||||
recordsArray.forEach(record => {
|
||||
PROTECTED_UPDATE_FIELDS.forEach(f => {
|
||||
if (f in record) delete record[f];
|
||||
});
|
||||
});
|
||||
// Un array vacio no es un alta de 0 registros: es una llamada sin
|
||||
// sentido. Antes acababa en un insert vacio; ahora se corta aqui
|
||||
// para no devolver un success enganoso.
|
||||
if (recordsArray.length === 0) {
|
||||
return handleToolError(
|
||||
"Error: 'fields' is an empty array — there is nothing to create.",
|
||||
'create_or_update_record',
|
||||
{ tableName }
|
||||
);
|
||||
}
|
||||
|
||||
// Process enlace field for new records only
|
||||
let processedRecords = recordsArray;
|
||||
if (!recordId) {
|
||||
processedRecords = recordsArray.map(record => {
|
||||
let enlaceValue = record.enlace;
|
||||
|
||||
if (!enlaceValue) {
|
||||
// Generate random enlace if not provided to ensure uniqueness
|
||||
enlaceValue = '/' + Math.random().toString(36).substring(2, 10) + '/';
|
||||
} else {
|
||||
// Ensure format /.../
|
||||
enlaceValue = String(enlaceValue);
|
||||
if (!enlaceValue.startsWith('/')) enlaceValue = '/' + enlaceValue;
|
||||
if (!enlaceValue.endsWith('/')) enlaceValue = enlaceValue + '/';
|
||||
}
|
||||
|
||||
return { ...record, enlace: enlaceValue };
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare payload for CMS API
|
||||
const credentials = await getSessionCredentials(extra.sessionId);
|
||||
const recordPayload = {
|
||||
tableName: tableName,
|
||||
records: processedRecords,
|
||||
functions: [],
|
||||
options: {}
|
||||
};
|
||||
|
||||
// Determine action: insert for new records, update for existing
|
||||
const { projectSlug } = getCurrentProjectInfo();
|
||||
const isNewRecord = !recordId;
|
||||
let response;
|
||||
|
||||
if (isNewRecord) {
|
||||
// Insert new record(s)
|
||||
response = await AcaiHttpClient.postCmsApi(
|
||||
credentials,
|
||||
'insert',
|
||||
recordPayload,
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
);
|
||||
} else {
|
||||
// Update existing record (only single record, not array)
|
||||
response = await AcaiHttpClient.postCmsApi(
|
||||
credentials,
|
||||
'update',
|
||||
{
|
||||
...recordPayload,
|
||||
where: `num = ${recordId}`
|
||||
},
|
||||
credentials.token,
|
||||
credentials.tokenHash
|
||||
// ---------- UPDATE: un registro, una llamada ----------
|
||||
if (!isNewRecord) {
|
||||
// Protege los campos criticos: se eliminan en silencio (contrato
|
||||
// publico de la tool). Python no hace este strip.
|
||||
const record = { ...recordsArray[0] };
|
||||
const stripped = PROTECTED_UPDATE_FIELDS.filter(f => f in record);
|
||||
stripped.forEach(f => { delete record[f]; });
|
||||
|
||||
// El endpoint exige `fields` no vacio; si el strip lo dejo seco
|
||||
// devolvemos un error accionable en vez del generico de Python.
|
||||
if (Object.keys(record).length === 0) {
|
||||
return handleToolError(
|
||||
`Nothing to update: after stripping protected fields (${PROTECTED_UPDATE_FIELDS.join(', ')}) there are no fields left. ` +
|
||||
`Those fields cannot be modified on an existing record.`,
|
||||
'create_or_update_record',
|
||||
{ tableName, recordId, strippedFields: stripped }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for API errors
|
||||
const apiError = handleApiResponse(response.data, 'create_or_update_record');
|
||||
if (apiError) return apiError;
|
||||
const res = await postToPython("/api/cms/update-record", {
|
||||
project: projectSlug,
|
||||
table: tableName,
|
||||
num: recordId,
|
||||
fields: record,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return handleToolError(res.error, 'create_or_update_record', {
|
||||
tableName,
|
||||
recordId,
|
||||
errorCode: res.errorCode,
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: isNewRecord
|
||||
? `${isArray ? recordsArray.length : 1} record(s) created successfully`
|
||||
: `Record ${recordId} updated successfully`,
|
||||
tableName: tableName,
|
||||
recordIds: response.data?.data || (recordId || 'new'),
|
||||
recordsCount: isArray ? recordsArray.length : 1,
|
||||
createdIds: response.data?.data,
|
||||
suggestion: isNewRecord && !isArray ? `You can verify the record by fetching: ${credentials.web_url}${processedRecords[0].enlace}` : undefined
|
||||
message: `Record ${recordId} updated successfully`,
|
||||
tableName,
|
||||
recordIds: recordId,
|
||||
recordsCount: 1,
|
||||
strippedFields: stripped.length > 0 ? stripped : undefined,
|
||||
// El server responde skipped cuando el filtrado
|
||||
// (adminOnly / password vacia) dejo el UPDATE sin columnas.
|
||||
skipped: res.data?.skipped === true ? true : undefined,
|
||||
skippedReason: res.data?.skipped === true
|
||||
? "El servidor descartó todos los campos enviados (adminOnly o contraseña vacía): no se escribió nada."
|
||||
: undefined,
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- INSERT: N registros, N llamadas ----------
|
||||
const createdIds = [];
|
||||
for (let i = 0; i < recordsArray.length; i++) {
|
||||
const res = await postToPython("/api/cms/create-record", {
|
||||
project: projectSlug,
|
||||
table: tableName,
|
||||
fields: recordsArray[i],
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// BATCH_POLICY: abortar y reportar el estado real del lote.
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: res.error,
|
||||
errorCode: res.errorCode,
|
||||
httpStatus: res.status,
|
||||
tableName,
|
||||
batchPolicy: BATCH_POLICY,
|
||||
failedIndex: i,
|
||||
createdIds,
|
||||
createdCount: createdIds.length,
|
||||
notAttemptedCount: recordsArray.length - i - 1,
|
||||
hint: createdIds.length > 0
|
||||
? `Los ${createdIds.length} registro(s) anteriores YA se crearon (num: ${createdIds.join(', ')}) y NO se han revertido. Corrige el registro del índice ${i} y reintenta solo los que faltan, o bórralos con delete_record.`
|
||||
: `No se creó ningún registro. Corrige el registro del índice ${i} y reintenta.`,
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
createdIds.push(res.data.num);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `${recordsArray.length} record(s) created successfully`,
|
||||
tableName,
|
||||
recordIds: isArray ? createdIds : createdIds[0],
|
||||
recordsCount: recordsArray.length,
|
||||
createdIds,
|
||||
suggestion: !isArray
|
||||
? `Puedes verificar el registro con get_record({ tableName: "${tableName}", recordId: ${JSON.stringify(createdIds[0])} }) — ahí verás el 'enlace' definitivo que generó el servidor.`
|
||||
: undefined,
|
||||
}, null, 2)
|
||||
}],
|
||||
};
|
||||
@@ -149,4 +257,3 @@ Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ export function registerGetModuleConfigVarsTool(server) {
|
||||
`Get the current configuration variable values for a module instance on a page record. Returns:
|
||||
|
||||
- vars: resolved values (text, HTML, etc.) for simple vars and arrays for multi/repeater vars
|
||||
- varsMeta: per-var physical location { tableName: 'builder_custom', recordNum, fieldName, type }. USE THIS to know exactly which row + column to update with create_or_update_record. The variable's display name (e.g. 'titulo') is NOT the same as the physical column name (e.g. 'title2').
|
||||
- uploadFields: per-var upload location for upload_record_image / replace_record_image
|
||||
- varsMeta: per-var physical location { tableName: 'builder_custom', recordNum, fieldName, type }. USE THIS to know exactly which row + column to update with create_or_update_record. The variable's display name (e.g. 'titulo') is NOT the same as the physical column name (e.g. 'title2'). Uploads inside a 'multi' var carry their labels in subFields.<subVar>.infoLabels.
|
||||
- uploadFields: per-var upload location for upload_record_image / replace_record_image / set_upload_info. Each entry includes infoLabels when the module defines them (array; position 0 = info1).
|
||||
- cmsTables: CMS tables whose records this module DISPLAYS (e.g. ["noticias"]), or [] if it only shows its own variables. This is where the module's visible content really lives: if the user asks to change what a news-list module shows, the records are in those tables, NOT in the module's vars. Use list_table_records / create_or_update_record against them. Empty list means nothing to look up elsewhere.
|
||||
- moduleId, sectionId
|
||||
|
||||
Required params:
|
||||
|
||||
Reference in New Issue
Block a user