diff --git a/cms/lib/classes/AcaiAPI.php b/cms/lib/classes/AcaiAPI.php index aa9e21e..be26f53 100644 --- a/cms/lib/classes/AcaiAPI.php +++ b/cms/lib/classes/AcaiAPI.php @@ -15,6 +15,7 @@ class AcaiAPI private static $schemaCache = []; private static $queryCaches = []; // list/query relation results private static $requestGetCache = []; // unconditional per-request deduplication + private static $uploadsColumnsCache = []; // prefix => [columna => true] de {prefix}uploads // ── Public caches (mirror CocoDB interface) ─────────────────────── public static $getCaches = []; // in-memory result cache for get() @@ -129,7 +130,7 @@ class AcaiAPI 'order' => time() + $i, 'width' => 640, 'height' => 480, - ], [], ['ignoreSchema' => true, 'prefix' => $prefix]); + ], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]); } } } @@ -154,6 +155,15 @@ class AcaiAPI 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; } @@ -198,6 +208,11 @@ class AcaiAPI 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; foreach ($records as $record) { @@ -273,7 +288,7 @@ class AcaiAPI 'order' => time() + $i, 'width' => 640, 'height' => 480, - ], [], ['ignoreSchema' => true, 'prefix' => $prefix]); + ], [], ['ignoreSchema' => true, 'prefix' => $prefix, 'skipTouchParent' => true]); } } } @@ -283,6 +298,10 @@ class AcaiAPI self::updateCategoryMetadata($table, $prefix); } + if ($touchParents && $count) { + self::touchUploadParents(array_merge($parents, self::uploadParents($where, $prefix)), $prefix); + } + return $count; } @@ -313,8 +332,14 @@ class AcaiAPI 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(); - 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; } @@ -831,7 +856,15 @@ class AcaiAPI $db = Db::getInstance(); $ph = implode(',', array_fill(0, count($nums), '?')); $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, ' + . ($extra ? '`' . implode('`, `', $extra) . '`, ' : '') . 'info1, info2, info3, info4, info5, alt'; $params = array_merge(array_values($nums), [$tableName], array_values($uploadFields)); @@ -852,10 +885,12 @@ class AcaiAPI // Re-index for O(1) lookup $index = []; foreach ($uploads as $upload) { - if ($options['useAbsoluteUrls'] && isset($upload['urlPath']) - && strpos($upload['urlPath'], '/cms') === 0 - ) { - $upload['urlPath'] = 'https://' . $_SERVER['HTTP_HOST'] . $upload['urlPath']; + if ($options['useAbsoluteUrls']) { + foreach (array_merge(['urlPath'], self::uploadThumbUrlColumns()) as $col) { + if (isset($upload[$col]) && strpos($upload[$col], '/cms') === 0) { + $upload[$col] = 'https://' . $_SERVER['HTTP_HOST'] . $upload[$col]; + } + } } $field = $upload['fieldName']; $recNum = $upload['recordNum']; @@ -864,6 +899,47 @@ class AcaiAPI 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. * Each upload field becomes an array of upload objects (same as CocoDB). @@ -1343,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) // ═══════════════════════════════════════════════════════════════════ diff --git a/funciones.php b/funciones.php index 7c3d12a..c7f6947 100755 --- a/funciones.php +++ b/funciones.php @@ -7,8 +7,11 @@ $modulosCargados = array(); $recursosCSS = array(); // HASTA AUQI -if (!file_exists(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/')) { - mkdir(__DIR__.'/'.CMS_FOLDER.'/uploads/webp/'); +// Sin carrera: varias peticiones a la vez (el prerender de Astro las lanza en +// 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; $configuracionRecord = CocoDB::get("configuracion", "", null, 1);