Compare commits
10
Commits
8659715132
...
local
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0060f3eb8 | ||
|
|
80b6541e30 | ||
|
|
775ae1c95d | ||
|
|
49522987dd | ||
|
|
15c3db882d | ||
|
|
17f5d91c8d | ||
|
|
7097b79111 | ||
|
|
4e46322b02 | ||
|
|
184038b786 | ||
|
|
14ddf1608e |
+494
-11
@@ -15,6 +15,7 @@ class AcaiAPI
|
|||||||
private static $schemaCache = [];
|
private static $schemaCache = [];
|
||||||
private static $queryCaches = []; // list/query relation results
|
private static $queryCaches = []; // list/query relation results
|
||||||
private static $requestGetCache = []; // unconditional per-request deduplication
|
private static $requestGetCache = []; // unconditional per-request deduplication
|
||||||
|
private static $uploadsColumnsCache = []; // prefix => [columna => true] de {prefix}uploads
|
||||||
|
|
||||||
// ── Public caches (mirror CocoDB interface) ───────────────────────
|
// ── Public caches (mirror CocoDB interface) ───────────────────────
|
||||||
public static $getCaches = []; // in-memory result cache for get()
|
public static $getCaches = []; // in-memory result cache for get()
|
||||||
@@ -87,9 +88,14 @@ class AcaiAPI
|
|||||||
$lastId = 0;
|
$lastId = 0;
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
|
||||||
|
// Columnas que MySQL exige en un INSERT (NOT NULL sin DEFAULT): solo a
|
||||||
|
// esas se les pone valor si no vienen. null = no se pudo saber.
|
||||||
|
$requiredCols = $ignoreSchema ? [] : self::requiredColumns($db, $prefix . $table);
|
||||||
|
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
|
$record = self::uniqueEnlace($db, $prefix . $table, $record, [], $options);
|
||||||
list($setParts, $params, $uploadFields) = self::buildSetClause(
|
list($setParts, $params, $uploadFields) = self::buildSetClause(
|
||||||
$record, $schema, $functions, $ignoreSchema, true, $forceNum
|
$record, $schema, $functions, $ignoreSchema, true, $forceNum, $requiredCols
|
||||||
);
|
);
|
||||||
|
|
||||||
if (empty($setParts)) continue;
|
if (empty($setParts)) continue;
|
||||||
@@ -102,7 +108,7 @@ class AcaiAPI
|
|||||||
|
|
||||||
$result = $db->execute($sql, $params ? $params : null);
|
$result = $db->execute($sql, $params ? $params : null);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
self::error(['error' => "Error al insertar en `$table`"]);
|
self::error(['error' => "Error al insertar en `$table`" . self::dbErrorSuffix($db)]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +130,7 @@ class AcaiAPI
|
|||||||
'order' => time() + $i,
|
'order' => time() + $i,
|
||||||
'width' => 640,
|
'width' => 640,
|
||||||
'height' => 480,
|
'height' => 480,
|
||||||
], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
|
], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +155,15 @@ class AcaiAPI
|
|||||||
self::updateCategoryMetadata($table, $prefix);
|
self::updateCategoryMetadata($table, $prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fotos nuevas = registro cambiado (ver "FOTOS -> FECHA DEL REGISTRO").
|
||||||
|
// skipTouchParent: las que registra el propio insert/update del padre,
|
||||||
|
// que ya sello su fecha en el mismo SET.
|
||||||
|
if ($table === 'uploads' && $count && empty($options['skipTouchParent'])) {
|
||||||
|
$pairs = [];
|
||||||
|
foreach ($records as $r) $pairs[] = [$r['tableName'] ?? '', $r['recordNum'] ?? ''];
|
||||||
|
self::touchUploadParents($pairs, $prefix);
|
||||||
|
}
|
||||||
|
|
||||||
return !empty($options['return_last_id']) ? $lastId : $count;
|
return !empty($options['return_last_id']) ? $lastId : $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,10 +208,31 @@ class AcaiAPI
|
|||||||
|
|
||||||
if ($whereSql === null || $whereSql === '') return 0;
|
if ($whereSql === null || $whereSql === '') return 0;
|
||||||
|
|
||||||
|
// Fotos cambiadas = registro cambiado: los padres de ANTES (y abajo los
|
||||||
|
// de despues, por si la foto cambia de registro).
|
||||||
|
$touchParents = ($table === 'uploads' && empty($options['skipTouchParent']));
|
||||||
|
$parents = $touchParents ? self::uploadParents($where, $prefix) : [];
|
||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$lastNum = isset($record['num']) ? $record['num'] : null;
|
$lastNum = isset($record['num']) ? $record['num'] : null;
|
||||||
|
if (isset($record['enlace'])) {
|
||||||
|
// Solo si el enlace CAMBIA: el formulario reenvia todos los
|
||||||
|
// campos al guardar, y un registro que ya compartia enlace
|
||||||
|
// (duplicado anterior a esta regla) no debe quedar bloqueado
|
||||||
|
// por editar otra cosa.
|
||||||
|
$targetNums = [];
|
||||||
|
$sinCambio = true;
|
||||||
|
foreach ((array) $db->executeS("SELECT num, enlace FROM `{$prefix}{$table}` WHERE " . $whereSql, $whereParams ?: null) as $row) {
|
||||||
|
if (!isset($row['num'])) continue;
|
||||||
|
$targetNums[] = (string) $row['num'];
|
||||||
|
if ((string) $row['enlace'] !== trim((string) $record['enlace'])) $sinCambio = false;
|
||||||
|
}
|
||||||
|
if ($targetNums && !$sinCambio) {
|
||||||
|
$record = self::uniqueEnlace($db, $prefix . $table, $record, $targetNums, $options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
list($setParts, $setParams, $uploadFields) = self::buildSetClause(
|
list($setParts, $setParams, $uploadFields) = self::buildSetClause(
|
||||||
$record, $schema, $functions, $ignoreSchema, false, false
|
$record, $schema, $functions, $ignoreSchema, false, false
|
||||||
@@ -214,7 +250,7 @@ class AcaiAPI
|
|||||||
|
|
||||||
$result = $db->execute($sql, $allParams ? $allParams : null);
|
$result = $db->execute($sql, $allParams ? $allParams : null);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
self::error(['error' => "Error al actualizar `$table`"]);
|
self::error(['error' => "Error al actualizar `$table`" . self::dbErrorSuffix($db)]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$count++;
|
$count++;
|
||||||
@@ -252,7 +288,7 @@ class AcaiAPI
|
|||||||
'order' => time() + $i,
|
'order' => time() + $i,
|
||||||
'width' => 640,
|
'width' => 640,
|
||||||
'height' => 480,
|
'height' => 480,
|
||||||
], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
|
], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,6 +298,10 @@ class AcaiAPI
|
|||||||
self::updateCategoryMetadata($table, $prefix);
|
self::updateCategoryMetadata($table, $prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($touchParents && $count) {
|
||||||
|
self::touchUploadParents(array_merge($parents, self::uploadParents($where, $prefix)), $prefix);
|
||||||
|
}
|
||||||
|
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,8 +332,14 @@ class AcaiAPI
|
|||||||
self::error(['info' => $sql, 'params' => $params]);
|
self::error(['info' => $sql, 'params' => $params]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fotos borradas = registro cambiado: hay que saber de quien eran ANTES.
|
||||||
|
$parents = ($table === 'uploads' && empty($options['skipTouchParent']))
|
||||||
|
? self::uploadParents($where, $prefix) : [];
|
||||||
|
|
||||||
$db = Db::getInstance();
|
$db = Db::getInstance();
|
||||||
return $db->execute($sql, $params ? $params : null) !== false;
|
$ok = $db->execute($sql, $params ? $params : null) !== false;
|
||||||
|
if ($ok && $parents) self::touchUploadParents($parents, $prefix);
|
||||||
|
return $ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -810,7 +856,15 @@ class AcaiAPI
|
|||||||
$db = Db::getInstance();
|
$db = Db::getInstance();
|
||||||
$ph = implode(',', array_fill(0, count($nums), '?'));
|
$ph = implode(',', array_fill(0, count($nums), '?'));
|
||||||
$fieldPh = implode(',', array_fill(0, count($uploadFields), '?'));
|
$fieldPh = implode(',', array_fill(0, count($uploadFields), '?'));
|
||||||
|
// width/height y las miniaturas (URL + dimensiones de los 4 huecos) van
|
||||||
|
// con cada foto: sin ellas el front no puede reservar el hueco (CLS) ni
|
||||||
|
// montar un srcset. Las rutas de disco (thumbFilePath*) no salen. Solo
|
||||||
|
// las que existan en la tabla: una web antigua sin alguna no se queda
|
||||||
|
// sin fotos por un SELECT roto.
|
||||||
|
$extra = self::existingUploadsColumns(
|
||||||
|
array_merge(['width', 'height'], self::uploadThumbColumns()), $prefix);
|
||||||
$selectCols = 'num, `order`, tableName, fieldName, recordNum, filePath, urlPath, '
|
$selectCols = 'num, `order`, tableName, fieldName, recordNum, filePath, urlPath, '
|
||||||
|
. ($extra ? '`' . implode('`, `', $extra) . '`, ' : '')
|
||||||
. 'info1, info2, info3, info4, info5, alt';
|
. 'info1, info2, info3, info4, info5, alt';
|
||||||
|
|
||||||
$params = array_merge(array_values($nums), [$tableName], array_values($uploadFields));
|
$params = array_merge(array_values($nums), [$tableName], array_values($uploadFields));
|
||||||
@@ -831,10 +885,12 @@ class AcaiAPI
|
|||||||
// Re-index for O(1) lookup
|
// Re-index for O(1) lookup
|
||||||
$index = [];
|
$index = [];
|
||||||
foreach ($uploads as $upload) {
|
foreach ($uploads as $upload) {
|
||||||
if ($options['useAbsoluteUrls'] && isset($upload['urlPath'])
|
if ($options['useAbsoluteUrls']) {
|
||||||
&& strpos($upload['urlPath'], '/cms') === 0
|
foreach (array_merge(['urlPath'], self::uploadThumbUrlColumns()) as $col) {
|
||||||
) {
|
if (isset($upload[$col]) && strpos($upload[$col], '/cms') === 0) {
|
||||||
$upload['urlPath'] = 'https://' . $_SERVER['HTTP_HOST'] . $upload['urlPath'];
|
$upload[$col] = 'https://' . $_SERVER['HTTP_HOST'] . $upload[$col];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$field = $upload['fieldName'];
|
$field = $upload['fieldName'];
|
||||||
$recNum = $upload['recordNum'];
|
$recNum = $upload['recordNum'];
|
||||||
@@ -843,6 +899,47 @@ class AcaiAPI
|
|||||||
return $index;
|
return $index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sufijos de los 4 huecos de miniatura de `uploads` ('' = el primero). */
|
||||||
|
const UPLOAD_THUMB_SLOTS = ['', '2', '3', '4'];
|
||||||
|
|
||||||
|
/** Columnas de URL de las miniaturas: thumbUrlPath, thumbUrlPath2..4. */
|
||||||
|
private static function uploadThumbUrlColumns()
|
||||||
|
{
|
||||||
|
return array_map(function ($n) { return "thumbUrlPath$n"; }, self::UPLOAD_THUMB_SLOTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL + ancho + alto de cada hueco de miniatura (sin rutas de disco). */
|
||||||
|
private static function uploadThumbColumns()
|
||||||
|
{
|
||||||
|
$cols = [];
|
||||||
|
foreach (self::UPLOAD_THUMB_SLOTS as $n) {
|
||||||
|
array_push($cols, "thumbUrlPath$n", "thumbWidth$n", "thumbHeight$n");
|
||||||
|
}
|
||||||
|
return $cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De `$wanted`, las columnas que existen en `{$prefix}uploads` (en su
|
||||||
|
* orden). Un SHOW COLUMNS por prefijo y petición; si falla, ninguna: el
|
||||||
|
* SELECT vuelve a las columnas de siempre en vez de romperse.
|
||||||
|
*/
|
||||||
|
private static function existingUploadsColumns(array $wanted, $prefix)
|
||||||
|
{
|
||||||
|
if (!isset(self::$uploadsColumnsCache[$prefix])) {
|
||||||
|
$existing = [];
|
||||||
|
try {
|
||||||
|
foreach ((array) Db::getInstance()->executeS("SHOW COLUMNS FROM `{$prefix}uploads`") as $col) {
|
||||||
|
if (isset($col['Field'])) $existing[$col['Field']] = true;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('[AcaiAPI] SHOW COLUMNS de ' . $prefix . 'uploads fallido: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
self::$uploadsColumnsCache[$prefix] = $existing;
|
||||||
|
}
|
||||||
|
$cols = self::$uploadsColumnsCache[$prefix];
|
||||||
|
return array_values(array_filter($wanted, function ($c) use ($cols) { return isset($cols[$c]); }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injects upload arrays from the pre-built index into a single record row.
|
* Injects upload arrays from the pre-built index into a single record row.
|
||||||
* Each upload field becomes an array of upload objects (same as CocoDB).
|
* Each upload field becomes an array of upload objects (same as CocoDB).
|
||||||
@@ -1322,6 +1419,126 @@ class AcaiAPI
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// FOTOS -> FECHA DEL REGISTRO
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// Cambiar las fotos de un registro (filas de `uploads` con
|
||||||
|
// tableName/recordNum) NO tocaba su `updatedDate`: el sync de Forge decide
|
||||||
|
// por esa fecha que esta pendiente, y un registro al que solo se le
|
||||||
|
// cambiaban las fotos (desde VSCode, el agente o el panel de fotos) nunca
|
||||||
|
// se publicaba (reydimobel, 2026-09-26: siete categorias con las fotos
|
||||||
|
// sustituidas en local y produccion enseñando las antiguas, en 404).
|
||||||
|
// Regla: toda escritura en `uploads` sella la fecha de los registros a los
|
||||||
|
// que pertenecen esas fotos. insert/update/delete de AcaiAPI lo hacen
|
||||||
|
// solos; quien escriba `uploads` por SQL directo llama a
|
||||||
|
// `touchUploadParents` (builder_saas/mcp_respond.php).
|
||||||
|
|
||||||
|
/** Cache por peticion: tabla completa => [columna => true] | null. */
|
||||||
|
private static $auditColsCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Los registros `[tableName, recordNum]` (sin repetir) a los que pertenecen
|
||||||
|
* las fotos de `uploads` que cumplen `$where` (mismos formatos que
|
||||||
|
* buildWhere). Las fotos sin registro todavia (recordNum vacio, las
|
||||||
|
* temporales de preSaveTempId) no cuentan. Nunca lanza: [] si falla.
|
||||||
|
*/
|
||||||
|
static function uploadParents($where, $prefix = null)
|
||||||
|
{
|
||||||
|
global $TABLE_PREFIX;
|
||||||
|
$prefix = $prefix === null ? $TABLE_PREFIX : $prefix;
|
||||||
|
$params = [];
|
||||||
|
$whereSql = self::buildWhere($where, $params);
|
||||||
|
if (!$whereSql) return [];
|
||||||
|
try {
|
||||||
|
$rows = Db::getInstance()->executeS(
|
||||||
|
"SELECT DISTINCT tableName, recordNum FROM `{$prefix}uploads` WHERE {$whereSql}",
|
||||||
|
$params ?: null
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$pairs = [];
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
if (is_array($row)) $pairs[] = [$row['tableName'] ?? '', $row['recordNum'] ?? ''];
|
||||||
|
}
|
||||||
|
return $pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sella `updatedDate` (y `updatedByUserNum` si existe) de los registros
|
||||||
|
* `[[tableName, recordNum], ...]`. Salta lo que no es un registro de
|
||||||
|
* verdad: tabla con nombre raro, recordNum no numerico o 0, tabla sin
|
||||||
|
* columna `updatedDate`. Nunca lanza ni corta al llamador: un fallo aqui
|
||||||
|
* no puede impedir guardar una foto. Devuelve cuantos registros sello.
|
||||||
|
*/
|
||||||
|
static function touchUploadParents($pairs, $prefix = null)
|
||||||
|
{
|
||||||
|
global $TABLE_PREFIX;
|
||||||
|
$prefix = $prefix === null ? $TABLE_PREFIX : $prefix;
|
||||||
|
$porTabla = [];
|
||||||
|
foreach ((array) $pairs as $pair) {
|
||||||
|
$tabla = is_array($pair) ? (string) ($pair[0] ?? '') : '';
|
||||||
|
$num = is_array($pair) ? (string) ($pair[1] ?? '') : '';
|
||||||
|
if (!preg_match('/^[A-Za-z0-9_]+$/', $tabla) || !ctype_digit($num) || (int) $num === 0) continue;
|
||||||
|
$porTabla[$tabla][(int) $num] = true;
|
||||||
|
}
|
||||||
|
$sellados = 0;
|
||||||
|
foreach ($porTabla as $tabla => $nums) {
|
||||||
|
$sellados += self::touchRecords($prefix . $tabla, array_keys($nums));
|
||||||
|
}
|
||||||
|
if ($sellados) self::$requestGetCache = [];
|
||||||
|
return $sellados;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function touchRecords($fullTable, array $nums)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$db = Db::getInstance();
|
||||||
|
$cols = self::auditColumns($db, $fullTable);
|
||||||
|
if (!$cols || !isset($cols['updatedDate'])) return 0;
|
||||||
|
$set = ['`updatedDate` = ?'];
|
||||||
|
$params = [date('Y-m-d H:i:s')];
|
||||||
|
if (isset($cols['updatedByUserNum'])) {
|
||||||
|
$user = (class_exists('API') && isset(API::$user['num'])) ? ((int) API::$user['num'] ?: 1) : 1;
|
||||||
|
$set[] = '`updatedByUserNum` = ?';
|
||||||
|
$params[] = $user;
|
||||||
|
}
|
||||||
|
$ph = implode(',', array_fill(0, count($nums), '?'));
|
||||||
|
$ok = $db->execute(
|
||||||
|
"UPDATE `{$fullTable}` SET " . implode(', ', $set) . " WHERE num IN ($ph)",
|
||||||
|
array_merge($params, $nums)
|
||||||
|
);
|
||||||
|
return $ok === false ? 0 : count($nums);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `[columna => true]` de las columnas de auditoria de `$fullTable`, o null. */
|
||||||
|
private static function auditColumns($db, $fullTable)
|
||||||
|
{
|
||||||
|
if (array_key_exists($fullTable, self::$auditColsCache)) {
|
||||||
|
return self::$auditColsCache[$fullTable];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$rows = $db->executeS(
|
||||||
|
"SELECT COLUMN_NAME FROM information_schema.COLUMNS"
|
||||||
|
. " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
|
||||||
|
. " AND COLUMN_NAME IN ('updatedDate', 'updatedByUserNum')",
|
||||||
|
[$fullTable]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return self::$auditColsCache[$fullTable] = null;
|
||||||
|
}
|
||||||
|
$cols = [];
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
if (is_array($row) && !empty($row['COLUMN_NAME'])) $cols[$row['COLUMN_NAME']] = true;
|
||||||
|
}
|
||||||
|
return self::$auditColsCache[$fullTable] = $cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// WRITE HELPERS (private)
|
// WRITE HELPERS (private)
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -1366,7 +1583,8 @@ class AcaiAPI
|
|||||||
array $functions,
|
array $functions,
|
||||||
$ignoreSchema,
|
$ignoreSchema,
|
||||||
$isInsert,
|
$isInsert,
|
||||||
$forceNum
|
$forceNum,
|
||||||
|
$requiredCols = null
|
||||||
) {
|
) {
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$user = 1;
|
$user = 1;
|
||||||
@@ -1438,6 +1656,7 @@ class AcaiAPI
|
|||||||
$setParts[] = '`dragSortOrder` = ?';
|
$setParts[] = '`dragSortOrder` = ?';
|
||||||
$params[] = time();
|
$params[] = time();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── User-supplied fields ─────────────────────────────────────────
|
// ── User-supplied fields ─────────────────────────────────────────
|
||||||
@@ -1477,9 +1696,14 @@ class AcaiAPI
|
|||||||
// - INSERT: mismo comportamiento que el Admin -> fecha actual.
|
// - INSERT: mismo comportamiento que el Admin -> fecha actual.
|
||||||
// - UPDATE: se omite la columna, para no pisar con "hoy" un valor
|
// - UPDATE: se omite la columna, para no pisar con "hoy" un valor
|
||||||
// ya guardado solo porque el caller mando la clave vacia.
|
// ya guardado solo porque el caller mando la clave vacia.
|
||||||
|
// - Pero SOLO si la columna la exige (NOT NULL sin DEFAULT). En una
|
||||||
|
// que admite NULL, vacio significa "sin fecha": se omite y queda
|
||||||
|
// NULL (antes quedaba "ahora": una nota con deleted_at vacio
|
||||||
|
// nacia borrada). Si no se sabe ($requiredCols null), como antes.
|
||||||
if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'date'
|
if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'date'
|
||||||
&& ($value === null || (is_string($value) && trim($value) === ''))) {
|
&& ($value === null || (is_string($value) && trim($value) === ''))) {
|
||||||
if (!$isInsert) continue;
|
if (!$isInsert) continue;
|
||||||
|
if (is_array($requiredCols) && !isset($requiredCols[$key])) continue;
|
||||||
$value = $now;
|
$value = $now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1490,9 +1714,111 @@ class AcaiAPI
|
|||||||
$params[] = $value;
|
$params[] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Valores por defecto del INSERT (regla UNICA) ──────────────────
|
||||||
|
if ($isInsert && !$ignoreSchema && $schema) {
|
||||||
|
list($defParts, $defParams) = self::insertDefaults(
|
||||||
|
$setParts, $schema, $requiredCols, $now);
|
||||||
|
$setParts = array_merge($setParts, $defParts);
|
||||||
|
$params = array_merge($params, $defParams);
|
||||||
|
}
|
||||||
|
|
||||||
return [$setParts, $params, $uploadFields];
|
return [$setParts, $params, $uploadFields];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valores de las columnas que un INSERT no trae. UNICA fuente de la regla
|
||||||
|
* (antes repartida entre el Admin clasico, el server Python y aqui):
|
||||||
|
*
|
||||||
|
* 1. Default EXPLICITO del schema, siempre que el campo falte: checkbox
|
||||||
|
* marcado por defecto -> checkedValue; defaultContent (textbox,
|
||||||
|
* wysiwyg, codigo) o defaultValue. Nunca se evalua como PHP: un
|
||||||
|
* default con `<?` se ignora.
|
||||||
|
* 2. Sin default explicito, SOLO si la columna es NOT NULL sin DEFAULT
|
||||||
|
* (MySQL rechazaria el INSERT): checkbox -> uncheckedValue; si no, por
|
||||||
|
* tipo de columna (fecha -> ahora, numero -> 0, enum -> primer valor,
|
||||||
|
* resto -> ''), como el Admin clasico. Incluye columnas obligatorias
|
||||||
|
* que el schema no declara (p.ej. flags de `accounts`).
|
||||||
|
* 3. Nada mas: una columna que admite NULL o tiene DEFAULT se deja a MySQL
|
||||||
|
* (vacio puede significar algo: deleted_at = no borrada).
|
||||||
|
*
|
||||||
|
* $requiredCols null = no se pudo consultar la tabla: solo se aplica (1).
|
||||||
|
*/
|
||||||
|
private static function insertDefaults(array $setParts, array $schema, $requiredCols, $now)
|
||||||
|
{
|
||||||
|
$present = [];
|
||||||
|
foreach ($setParts as $part) {
|
||||||
|
if (preg_match('/^`([^`]+)`/', $part, $m)) $present[$m[1]] = true;
|
||||||
|
}
|
||||||
|
$parts = [];
|
||||||
|
$params = [];
|
||||||
|
$add = function ($col, $val) use (&$parts, &$params, &$present) {
|
||||||
|
$parts[] = "`$col` = ?";
|
||||||
|
$params[] = $val;
|
||||||
|
$present[$col] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($schema as $field => $def) {
|
||||||
|
if (!is_array($def) || isset($present[$field])) continue;
|
||||||
|
$type = isset($def['type']) ? $def['type'] : '';
|
||||||
|
if (in_array($type, ['upload', 'separator', 'none'], true)) continue;
|
||||||
|
if (!empty($def['isSystemField'])) continue;
|
||||||
|
|
||||||
|
$explicit = self::schemaDefault($def);
|
||||||
|
if ($explicit !== null) {
|
||||||
|
$add($field, $explicit);
|
||||||
|
} elseif (is_array($requiredCols) && isset($requiredCols[$field])) {
|
||||||
|
$add($field, $type === 'checkbox'
|
||||||
|
? (string) (isset($def['uncheckedValue']) && $def['uncheckedValue'] !== '' ? $def['uncheckedValue'] : '0')
|
||||||
|
: self::columnFallback($requiredCols[$field], $now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($requiredCols)) {
|
||||||
|
foreach ($requiredCols as $col => $meta) {
|
||||||
|
if (isset($present[$col]) || $col === 'num') continue;
|
||||||
|
$add($col, self::columnFallback($meta, $now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [$parts, $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default explicito del schema para un campo, o null si no declara uno. */
|
||||||
|
private static function schemaDefault(array $def)
|
||||||
|
{
|
||||||
|
$type = isset($def['type']) ? $def['type'] : '';
|
||||||
|
if ($type === 'checkbox') {
|
||||||
|
$checked = isset($def['checkedByDefault']) ? $def['checkedByDefault'] : 0;
|
||||||
|
if (!in_array($checked, [1, '1', true], true)) return null;
|
||||||
|
return (string) (isset($def['checkedValue']) && $def['checkedValue'] !== '' ? $def['checkedValue'] : '1');
|
||||||
|
}
|
||||||
|
$keys = in_array($type, ['textbox', 'wysiwyg', 'codigo'], true)
|
||||||
|
? ['defaultContent', 'defaultValue'] : ['defaultValue'];
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
if (!isset($def[$k]) || !is_scalar($def[$k])) continue;
|
||||||
|
$v = (string) $def[$k];
|
||||||
|
if ($v === '' || strpos($v, '<?') !== false) continue;
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valor por tipo de columna para una columna NOT NULL sin DEFAULT. */
|
||||||
|
private static function columnFallback(array $meta, $now)
|
||||||
|
{
|
||||||
|
$t = isset($meta['data_type']) ? $meta['data_type'] : '';
|
||||||
|
if (in_array($t, ['datetime', 'timestamp'], true)) return $now;
|
||||||
|
if ($t === 'date') return substr($now, 0, 10);
|
||||||
|
if ($t === 'time') return '00:00:00';
|
||||||
|
if ($t === 'year') return substr($now, 0, 4);
|
||||||
|
if (in_array($t, ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint',
|
||||||
|
'decimal', 'numeric', 'float', 'double', 'real', 'bit'], true)) return 0;
|
||||||
|
if ($t === 'enum' && preg_match("/^enum\('((?:[^'\\\\]|\\\\.|'')*)'/i",
|
||||||
|
isset($meta['column_type']) ? $meta['column_type'] : '', $m)) {
|
||||||
|
return str_replace("''", "'", $m[1]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coerces a value to its correct database representation
|
* Coerces a value to its correct database representation
|
||||||
* based on the schema field definition.
|
* based on the schema field definition.
|
||||||
@@ -1640,6 +1966,163 @@ class AcaiAPI
|
|||||||
// ERROR HANDLING
|
// ERROR HANDLING
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ": <mensaje de MySQL>" del ultimo execute() fallido, o "" si no lo hay.
|
||||||
|
* Sin el, un alta que MySQL rechaza (p.ej. "Field 'x' doesn't have a
|
||||||
|
* default value") llegaba como "Error al insertar" a secas y el agente
|
||||||
|
* reintentaba a ciegas. isset(): una web con Db.php personalizado (sin
|
||||||
|
* lastError) sigue funcionando, solo que sin el detalle.
|
||||||
|
*/
|
||||||
|
/** Cache por peticion: tablas de la BD con columna `enlace` (null = sin consultar). */
|
||||||
|
private static $enlaceTablesCache = null;
|
||||||
|
|
||||||
|
/** Tablas de la BD actual con columna `enlace`, o null si no se pudo saber. */
|
||||||
|
private static function enlaceTables($db)
|
||||||
|
{
|
||||||
|
if (self::$enlaceTablesCache !== null) return self::$enlaceTablesCache ?: null;
|
||||||
|
try {
|
||||||
|
$rows = $db->executeS(
|
||||||
|
"SELECT TABLE_NAME FROM information_schema.COLUMNS"
|
||||||
|
. " WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'enlace'"
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::$enlaceTablesCache = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$tables = [];
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
if (is_array($row) && !empty($row['TABLE_NAME'])) $tables[] = $row['TABLE_NAME'];
|
||||||
|
}
|
||||||
|
self::$enlaceTablesCache = $tables;
|
||||||
|
return $tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quien usa ya `$enlace` en CUALQUIER tabla con columna enlace, sin contar
|
||||||
|
* `$fullTable` + `$excludeNums`. Devuelve [tabla, num] o null.
|
||||||
|
*/
|
||||||
|
private static function enlaceOwner($db, $enlace, $fullTable, array $excludeNums)
|
||||||
|
{
|
||||||
|
$tables = self::enlaceTables($db);
|
||||||
|
if (!$tables) return null;
|
||||||
|
$parts = [];
|
||||||
|
$params = [];
|
||||||
|
foreach ($tables as $t) {
|
||||||
|
$parts[] = "SELECT ? AS t, num FROM `$t` WHERE enlace = ?";
|
||||||
|
$params[] = $t;
|
||||||
|
$params[] = $enlace;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$rows = $db->executeS(implode(" UNION ALL ", $parts), $params);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
if ($row['t'] === $fullTable && in_array((string) $row['num'], $excludeNums, true)) continue;
|
||||||
|
return [$row['t'], $row['num']];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `enlace` UNICO entre todas las tablas: slug.php resuelve una URL
|
||||||
|
* buscandola en todas las tablas con columna enlace, asi que dos registros
|
||||||
|
* con el mismo enlace dejan uno inaccesible. Si choca:
|
||||||
|
* - options['enlaceOnConflict'] === 'error' -> error claro (enlace escrito
|
||||||
|
* a mano: no se le cambia al usuario una URL que ha elegido).
|
||||||
|
* - si no (por defecto) -> sufijo -2, -3... hasta que quede libre, para
|
||||||
|
* no romper importadores, hooks ni formularios.
|
||||||
|
* Se ignoran vacio, '#' y URLs externas. Devuelve el record (copia).
|
||||||
|
*/
|
||||||
|
private static function uniqueEnlace($db, $fullTable, array $record, array $excludeNums, array $options)
|
||||||
|
{
|
||||||
|
if (!isset($record['enlace']) || !is_string($record['enlace'])) return $record;
|
||||||
|
$enlace = trim($record['enlace']);
|
||||||
|
if ($enlace === '' || $enlace === '#' || preg_match('#^(https?:)?//#i', $enlace)) return $record;
|
||||||
|
$tables = self::enlaceTables($db);
|
||||||
|
if (!$tables || !in_array($fullTable, $tables, true)) return $record;
|
||||||
|
|
||||||
|
if (count($excludeNums) > 1) {
|
||||||
|
self::error(['error' => [
|
||||||
|
'message' => "No se puede poner el mismo enlace '$enlace' a varios registros a la vez",
|
||||||
|
'code' => 'ENLACE_DUPLICADO',
|
||||||
|
]]);
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = self::enlaceOwner($db, $enlace, $fullTable, $excludeNums);
|
||||||
|
if (!$owner) return $record;
|
||||||
|
|
||||||
|
$prefix = defined('TABLE_PREFIX') ? TABLE_PREFIX : (isset($GLOBALS['TABLE_PREFIX']) ? $GLOBALS['TABLE_PREFIX'] : 'cms_');
|
||||||
|
$ownerName = strpos($owner[0], $prefix) === 0 ? substr($owner[0], strlen($prefix)) : $owner[0];
|
||||||
|
// La home ('/') no admite sufijo: un segundo '/' es siempre un error.
|
||||||
|
if (rtrim($enlace, '/') === '' || (isset($options['enlaceOnConflict']) && $options['enlaceOnConflict'] === 'error')) {
|
||||||
|
self::error(['error' => [
|
||||||
|
'message' => "El enlace '$enlace' ya lo usa $ownerName #{$owner[1]}: elige otro",
|
||||||
|
'code' => 'ENLACE_DUPLICADO',
|
||||||
|
]]);
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slash = substr($enlace, -1) === '/' ? '/' : '';
|
||||||
|
$base = rtrim($enlace, '/');
|
||||||
|
for ($i = 2; $i <= 200; $i++) {
|
||||||
|
$candidate = $base . '-' . $i . $slash;
|
||||||
|
if (!self::enlaceOwner($db, $candidate, $fullTable, $excludeNums)) {
|
||||||
|
$record['enlace'] = $candidate;
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::error(['error' => [
|
||||||
|
'message' => "No se encontro un enlace libre para '$enlace'",
|
||||||
|
'code' => 'ENLACE_DUPLICADO',
|
||||||
|
]]);
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache por peticion: tabla completa => [columna => true]. */
|
||||||
|
private static $requiredColsCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columnas de `$fullTable` que un INSERT tiene que traer: NOT NULL, sin
|
||||||
|
* DEFAULT y no auto_increment. Devuelve [columna => [data_type, column_type]], o null si no se
|
||||||
|
* pudo consultar (el llamador no rellena nada en ese caso). Solo usa
|
||||||
|
* Db::executeS, que existe en todas las versiones de Db.php.
|
||||||
|
*/
|
||||||
|
private static function requiredColumns($db, $fullTable)
|
||||||
|
{
|
||||||
|
if (array_key_exists($fullTable, self::$requiredColsCache)) {
|
||||||
|
return self::$requiredColsCache[$fullTable];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$rows = $db->executeS(
|
||||||
|
"SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE FROM information_schema.COLUMNS"
|
||||||
|
. " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
|
||||||
|
. " AND IS_NULLABLE = 'NO' AND COLUMN_DEFAULT IS NULL"
|
||||||
|
. " AND EXTRA NOT LIKE '%auto_increment%'",
|
||||||
|
[$fullTable]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return self::$requiredColsCache[$fullTable] = null;
|
||||||
|
}
|
||||||
|
$cols = [];
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
if (!is_array($row) || empty($row['COLUMN_NAME'])) continue;
|
||||||
|
$cols[$row['COLUMN_NAME']] = [
|
||||||
|
'data_type' => strtolower((string) ($row['DATA_TYPE'] ?? '')),
|
||||||
|
'column_type' => (string) ($row['COLUMN_TYPE'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return self::$requiredColsCache[$fullTable] = $cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dbErrorSuffix($db)
|
||||||
|
{
|
||||||
|
return (is_object($db) && isset($db->lastError) && $db->lastError !== '')
|
||||||
|
? ': ' . $db->lastError
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
static function error(array $data = [])
|
static function error(array $data = [])
|
||||||
{
|
{
|
||||||
if (class_exists('API') && class_exists('ApiError')) {
|
if (class_exists('API') && class_exists('ApiError')) {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ class Db {
|
|||||||
|
|
||||||
private static $instance = false; // Db Class Instance
|
private static $instance = false; // Db Class Instance
|
||||||
|
|
||||||
|
/** Mensaje del ultimo execute() fallido (null si fue bien). AcaiAPI lo
|
||||||
|
* devuelve en su error para que el caller sepa POR QUE fallo. */
|
||||||
|
public $lastError = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param host: DB Host
|
* @param host: DB Host
|
||||||
* @param user: DB user
|
* @param user: DB user
|
||||||
@@ -69,12 +73,14 @@ class Db {
|
|||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function execute($sql, $params = null) {
|
public function execute($sql, $params = null) {
|
||||||
|
$this->lastError = null;
|
||||||
if ($params) {
|
if ($params) {
|
||||||
$stmt = $this->link->prepare($sql);
|
$stmt = $this->link->prepare($sql);
|
||||||
try {
|
try {
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
}
|
}
|
||||||
catch(Exception $e) {
|
catch(Exception $e) {
|
||||||
|
$this->lastError = $e->getMessage();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -7,8 +7,11 @@ $modulosCargados = array();
|
|||||||
$recursosCSS = array();
|
$recursosCSS = array();
|
||||||
// HASTA AUQI
|
// HASTA AUQI
|
||||||
|
|
||||||
if (!file_exists(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/')) {
|
// Sin carrera: varias peticiones a la vez (el prerender de Astro las lanza en
|
||||||
mkdir(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/');
|
// paralelo) veian la carpeta sin crear y la segunda imprimia "mkdir(): File
|
||||||
|
// exists" delante del JSON de action_ws, rompiendo el build de una web nueva.
|
||||||
|
if (!is_dir(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/')) {
|
||||||
|
@mkdir(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/', 0775, true);
|
||||||
}
|
}
|
||||||
global $configuracionTienda;
|
global $configuracionTienda;
|
||||||
$configuracionRecord = CocoDB::get("configuracion", "", null, 1);
|
$configuracionRecord = CocoDB::get("configuracion", "", null, 1);
|
||||||
@@ -259,6 +262,12 @@ function muestra_breadcrumb($record = array(), $previousLinks = array(),$class =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!@$breadcrumbField || !@$breadcrumbRecord["optionsTablename"]) break;
|
if (!@$breadcrumbField || !@$breadcrumbRecord["optionsTablename"]) break;
|
||||||
|
// Registro sin valor en su campo de migas (p.ej. producto sin
|
||||||
|
// categoria): la consulta quedaba "num=" -> error SQL -> excepcion que
|
||||||
|
// tumbaba el modulo entero y la ficha salia en blanco. Las migas se
|
||||||
|
// quedan en este nivel.
|
||||||
|
$valorMigas = @$breadcrumbRecord[$breadcrumbField];
|
||||||
|
if ($valorMigas === null || $valorMigas === '' || is_array($valorMigas)) break;
|
||||||
$breadcrumbRecord = dame_registros($breadcrumbRecord["optionsTablename"], $breadcrumbRecord["optionsValueField"]."=".mysql_real_escape_string($breadcrumbRecord[$breadcrumbField]));
|
$breadcrumbRecord = dame_registros($breadcrumbRecord["optionsTablename"], $breadcrumbRecord["optionsValueField"]."=".mysql_real_escape_string($breadcrumbRecord[$breadcrumbField]));
|
||||||
$breadcrumbRecord = @$breadcrumbRecord[0];
|
$breadcrumbRecord = @$breadcrumbRecord[0];
|
||||||
|
|
||||||
|
|||||||
@@ -300,6 +300,13 @@ if (!$routeResolved){
|
|||||||
|
|
||||||
$encontrado = mysql_fetch_assoc(mysql_query("SELECT * FROM ".$tableName." WHERE ".$columnName."='".$enlaceEscaped."' LIMIT 1"));
|
$encontrado = mysql_fetch_assoc(mysql_query("SELECT * FROM ".$tableName." WHERE ".$columnName."='".$enlaceEscaped."' LIMIT 1"));
|
||||||
|
|
||||||
|
// Enlace repetido entre tablas: se mantiene el comportamiento de
|
||||||
|
// siempre (gana la ULTIMA tabla recorrida; hay webs que dependen de
|
||||||
|
// ello, p.ej. apartados + servicios con el mismo enlace) pero ya no en
|
||||||
|
// silencio. Los nuevos duplicados los impide AcaiAPI::uniqueEnlace.
|
||||||
|
if (@$encontrado && @$tabla){
|
||||||
|
error_log("[slug] enlace duplicado '".$enlace."': ".$tabla."#".$registro." y ".$tableName."#".@$encontrado["num"]." (se sirve el segundo)");
|
||||||
|
}
|
||||||
if (@$encontrado){
|
if (@$encontrado){
|
||||||
$tabla = $tableName;
|
$tabla = $tableName;
|
||||||
$registro = $encontrado["num"];
|
$registro = $encontrado["num"];
|
||||||
|
|||||||
Reference in New Issue
Block a user