diff --git a/cms/lib/classes/AcaiAPI.php b/cms/lib/classes/AcaiAPI.php
new file mode 100644
index 0000000..5251654
--- /dev/null
+++ b/cms/lib/classes/AcaiAPI.php
@@ -0,0 +1,1688 @@
+ callable] applied before coercion
+ * @param array $options See option list below
+ * @return int Number of inserted records, or last inserted ID if return_last_id
+ *
+ * Options:
+ * prefix string Table prefix override (default: $TABLE_PREFIX)
+ * ignoreSchema bool Skip schema validation — trust all fields as-is
+ * forceNum bool Allow caller to set the num (PK) manually
+ * preSaveTempId string Link pre-uploaded files/translations to the new record
+ * generate_category_metadata bool Rebuild category tree metadata after insert
+ * return_last_id bool Return the last auto-increment ID instead of row count
+ * dieBeforeQuery bool Dump SQL + params and die (debug)
+ */
+ static function insert($table, $records, $functions = [], $options = [])
+ {
+ self::$requestGetCache = [];
+ global $TABLE_PREFIX;
+
+ if (!isset($records[0])) $records = [$records];
+
+ $prefix = isset($options['prefix']) ? $options['prefix'] : $TABLE_PREFIX;
+ $ignoreSchema = !empty($options['ignoreSchema']);
+ $forceNum = !empty($options['forceNum']);
+
+ $schema = [];
+ if (!$ignoreSchema) {
+ $schema = self::schema($table);
+ if (!$schema) self::error(['error' => "Tabla no encontrada: $table"]);
+ }
+
+ $db = Db::getInstance();
+ $lastId = 0;
+ $count = 0;
+
+ foreach ($records as $record) {
+ list($setParts, $params, $uploadFields) = self::buildSetClause(
+ $record, $schema, $functions, $ignoreSchema, true, $forceNum
+ );
+
+ if (empty($setParts)) continue;
+
+ $sql = "INSERT INTO `{$prefix}{$table}` SET " . implode(', ', $setParts);
+
+ if (!empty($options['dieBeforeQuery'])) {
+ self::error(['info' => $sql, 'params' => $params]);
+ }
+
+ $result = $db->execute($sql, $params ? $params : null);
+ if ($result === false) {
+ self::error(['error' => "Error al insertar en `$table`"]);
+ continue;
+ }
+
+ $count++;
+ $row = $db->fetch('SELECT LAST_INSERT_ID() AS id');
+ $lastId = ($row && isset($row['id'])) ? (int) $row['id'] : 0;
+
+ // Register each upload in the uploads table
+ foreach ($uploadFields as $fieldName => $urlPaths) {
+ foreach ($urlPaths as $i => $urlPath) {
+ if (!$urlPath) continue;
+ self::insert('uploads', [
+ 'urlPath' => $urlPath,
+ 'filePath' => realpath(__DIR__ . '/../../../' . $urlPath),
+ 'fieldName' => $fieldName,
+ 'recordNum' => $lastId,
+ 'tableName' => $table,
+ 'createdTime' => date('Y-m-d H:i:s'),
+ 'order' => time() + $i,
+ 'width' => 640,
+ 'height' => 480,
+ ], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
+ }
+ }
+ }
+
+ // Link uploads/traducciones that were pre-uploaded before the record existed
+ if (!empty($options['preSaveTempId'])) {
+ $db->execute(
+ "UPDATE `{$prefix}uploads`
+ SET recordNum = ?, preSaveTempId = ''
+ WHERE tableName = ? AND preSaveTempId = ?",
+ [$lastId, $table, $options['preSaveTempId']]
+ );
+ $db->execute(
+ "UPDATE `{$prefix}traducciones`
+ SET recordNum = ?, preSaveTempId = ''
+ WHERE tableName = ? AND preSaveTempId = ?",
+ [$lastId, $table, $options['preSaveTempId']]
+ );
+ }
+
+ if (!empty($options['generate_category_metadata'])) {
+ self::updateCategoryMetadata($table, $prefix);
+ }
+
+ return !empty($options['return_last_id']) ? $lastId : $count;
+ }
+
+ /**
+ * Update records in a table.
+ *
+ * @param string $table Table name (without prefix)
+ * @param array $records Single record or list of records
+ * @param string|array $where WHERE condition (same formats as buildWhere)
+ * @param array $functions Associative [fieldName => callable]
+ * @param array $options See option list below
+ * @return int Number of updated records
+ *
+ * Options (in addition to insert options):
+ * delete_old_uploads bool Delete existing uploads for each field before inserting new ones
+ * insert_new_uploads bool Insert new upload records after update
+ * delete_upload_nums int|int[] Delete specific upload records by their num
+ */
+ static function update($table, $records, $where, $functions = [], $options = [])
+ {
+ self::$requestGetCache = [];
+ global $TABLE_PREFIX;
+
+ if (!isset($records[0])) $records = [$records];
+
+ $prefix = isset($options['prefix']) ? $options['prefix'] : $TABLE_PREFIX;
+ $ignoreSchema = !empty($options['ignoreSchema']);
+
+ $schema = [];
+ if (!$ignoreSchema) {
+ $schema = self::schema($table);
+ if (!$schema) self::error(['error' => "Tabla no encontrada: $table"]);
+ }
+
+ $db = Db::getInstance();
+ $whereParams = [];
+ $whereSql = self::buildWhere($where, $whereParams);
+
+ if ($whereSql === null || $whereSql === '') return 0;
+
+ $count = 0;
+
+ foreach ($records as $record) {
+ $lastNum = isset($record['num']) ? $record['num'] : null;
+
+ list($setParts, $setParams, $uploadFields) = self::buildSetClause(
+ $record, $schema, $functions, $ignoreSchema, false, false
+ );
+
+ if (empty($setParts)) continue;
+
+ $sql = "UPDATE `{$prefix}{$table}` SET " . implode(', ', $setParts)
+ . " WHERE " . $whereSql;
+ $allParams = array_merge($setParams, $whereParams);
+
+ if (!empty($options['dieBeforeQuery'])) {
+ self::error(['info' => $sql, 'params' => $allParams]);
+ }
+
+ $result = $db->execute($sql, $allParams ? $allParams : null);
+ if ($result === false) {
+ self::error(['error' => "Error al actualizar `$table`"]);
+ continue;
+ }
+ $count++;
+
+ // Delete uploads by specific num list
+ if ($lastNum && !empty($options['delete_upload_nums'])) {
+ $numsToDelete = (array) $options['delete_upload_nums'];
+ $ph = implode(',', array_fill(0, count($numsToDelete), '?'));
+ $db->execute(
+ "DELETE FROM `{$prefix}uploads` WHERE num IN ($ph)",
+ $numsToDelete
+ );
+ }
+
+ foreach ($uploadFields as $fieldName => $urlPaths) {
+ // Remove all existing uploads for this field+record before inserting
+ if ($lastNum && !empty($options['delete_old_uploads'])) {
+ $db->execute(
+ "DELETE FROM `{$prefix}uploads`
+ WHERE fieldName = ? AND recordNum = ? AND tableName = ?",
+ [$fieldName, $lastNum, $table]
+ );
+ }
+
+ if ($lastNum && !empty($options['insert_new_uploads'])) {
+ foreach ($urlPaths as $i => $urlPath) {
+ if (!$urlPath) continue;
+ self::insert('uploads', [
+ 'urlPath' => $urlPath,
+ 'filePath' => realpath(__DIR__ . '/../../../' . $urlPath),
+ 'fieldName' => $fieldName,
+ 'recordNum' => $lastNum,
+ 'tableName' => $table,
+ 'createdTime' => date('Y-m-d H:i:s'),
+ 'order' => time() + $i,
+ 'width' => 640,
+ 'height' => 480,
+ ], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
+ }
+ }
+ }
+ }
+
+ if (!empty($options['generate_category_metadata'])) {
+ self::updateCategoryMetadata($table, $prefix);
+ }
+
+ return $count;
+ }
+
+ /**
+ * Delete records from a table.
+ *
+ * @param string $table Table name (without prefix)
+ * @param string|array $where WHERE condition
+ * @param array $options prefix, dieBeforeQuery
+ * @return bool
+ */
+ static function delete($table, $where, $options = [])
+ {
+ self::$requestGetCache = [];
+ global $TABLE_PREFIX;
+
+ $prefix = isset($options['prefix']) ? $options['prefix'] : $TABLE_PREFIX;
+ $params = [];
+ $whereSql = self::buildWhere($where, $params);
+
+ if (!$whereSql) return false;
+
+ $sql = "DELETE FROM `{$prefix}{$table}` WHERE {$whereSql}";
+
+ if (!empty($options['dieBeforeQuery'])) {
+ self::error(['info' => $sql, 'params' => $params]);
+ }
+
+ $db = Db::getInstance();
+ return $db->execute($sql, $params ? $params : null) !== false;
+ }
+
+
+ // ═══════════════════════════════════════════════════════════════════
+ // PUBLIC READ API
+ // ═══════════════════════════════════════════════════════════════════
+
+ /**
+ * Fetch records from one or more tables.
+ *
+ * @param string $table Table name, or comma-separated list for multi-table
+ * @param string|array $where WHERE condition — string (raw SQL) or array (structured)
+ * @param string|null $order ORDER BY clause
+ * @param mixed $limit int | 'offset,limit' | ['limit','offset'|'page'|'perPage']
+ * @param array $options See defaults below
+ * @return array Records array, or [$metaArray, $records] if withMetas=true
+ *
+ * Options:
+ * debug bool Render HTML debug panel after query
+ * translates string Language code for automatic translation
+ * uploads bool Load uploads for each record (default true)
+ * useAbsoluteUrls bool Prefix https://host to urlPath in uploads
+ * groupBy string GROUP BY clause
+ * ignoreSchema bool Skip schema/relation/upload enrichment
+ * aggregates array Extra SELECT expressions e.g. ['COUNT(*) AS n']
+ * relations bool|array true=all, ['field1']= only those fields
+ * relationsDepth int Recursive relation depth (default 2; 0=none)
+ * redis bool Cache this query in Redis
+ * redis_expire int Redis TTL in seconds (default 60)
+ * onlyFields array Restrict SELECT to these columns
+ * withMetas bool Return [$meta, $records] with pagination info
+ * returnDataByKey string Index result by this field instead of numeric keys
+ * dieBeforeQuery bool Dump SQL and die (debug)
+ * prefix string Table prefix override
+ */
+ static function get($table, $where = null, $order = null, $limit = null, $options = [])
+ {
+ global $TABLE_PREFIX;
+
+ // ── Option defaults ──────────────────────────────────────────────
+ $defaults = [
+ 'debug' => false,
+ 'translates' => isset($_REQUEST['idioma']) ? $_REQUEST['idioma'] : null,
+ 'uploads' => true,
+ 'useAbsoluteUrls' => false,
+ 'groupBy' => null,
+ 'ignoreSchema' => false,
+ 'aggregates' => [],
+ 'relations' => true,
+ 'redis' => null,
+ 'onlyFields' => null,
+ 'redis_expire' => 60,
+ 'relationsDepth' => self::$defaultRelationsDepth,
+ 'dieBeforeQuery' => false,
+ 'prefix' => $TABLE_PREFIX,
+ 'withMetas' => false,
+ 'returnDataByKey' => false,
+ ];
+ foreach ($defaults as $k => $v) {
+ if (!isset($options[$k])) $options[$k] = $v;
+ }
+
+ // CocoDB compat alias
+ if (!empty($options['ignoreSchemas'])) $options['ignoreSchema'] = true;
+
+ // Recursive calls bottom out at depth -1
+ if ((int) $options['relationsDepth'] < 0) return [];
+
+ // Redis force flags
+ if (self::$force_redis) {
+ if ($options['redis'] === null) $options['redis'] = true;
+ if (self::$redis_expireTime) $options['redis_expire'] = self::$redis_expireTime;
+ }
+ if ($options['redis']) self::initCache();
+
+ $prefix = $options['prefix'];
+
+ // ── Parse tables ─────────────────────────────────────────────────
+ $tables = array_values(array_map('trim', array_filter(explode(',', $table))));
+ $schemas = []; // field-only schema (no separators)
+ $fullSchemas = []; // raw schema including meta-keys
+
+ if (!$options['ignoreSchema']) {
+ foreach ($tables as $i => $tbl) {
+ $tblName = trim(explode(' ', $tbl)[0]);
+ $sc = self::schema($tblName);
+ if (empty($sc)) { unset($tables[$i]); continue; }
+
+ if (!$order && isset($sc['listPageOrder'])) $order = $sc['listPageOrder'];
+
+ $fullSchemas[$tblName] = $sc;
+ $schemas[$tblName] = array_filter($sc, function ($f) use ($options) {
+ if (!is_array($f) || !isset($f['type'])) return false;
+ if ($f['type'] === 'separator') return false;
+ if (!$options['uploads'] && $f['type'] === 'upload') return false;
+ return true;
+ });
+ }
+ $tables = array_values($tables);
+ }
+
+ if (empty($tables)) return [];
+
+ // ── Build SELECT ──────────────────────────────────────────────────
+ $select = $options['onlyFields'] ? (array) $options['onlyFields'] : [];
+
+ if (empty($select)) {
+ if (count($tables) === 1) {
+ $select = ['*'];
+ } else {
+ foreach ($tables as $i => $tbl) {
+ $parts = explode(' ', trim($tbl));
+ $tblName = $parts[0];
+ $ref = isset($parts[1]) ? $parts[1] : $prefix . $tblName;
+ if ($i === 0) {
+ $select[] = $ref . '.*';
+ } else {
+ if (!isset($schemas[$tblName])) continue;
+ $cols = array_keys($schemas[$tblName]);
+ $colExprs = array_map(function ($col) use ($ref) {
+ return $ref . '.' . $col . " AS '" . $ref . '.' . $col . "'";
+ }, $cols);
+ $select[] = implode(', ', $colExprs);
+ }
+ }
+ }
+ }
+
+ // Aggregates appended to SELECT
+ if (!empty($options['aggregates'])) {
+ foreach ((array) $options['aggregates'] as $agg) $select[] = $agg;
+ }
+
+ // ── Build FROM ────────────────────────────────────────────────────
+ $fromParts = array_map(function ($tbl) use ($prefix) {
+ $parts = explode(' ', trim($tbl));
+ $tblName = $parts[0];
+ $alias = isset($parts[1]) ? $parts[1] : null;
+ return $alias
+ ? '`' . $prefix . $tblName . '` ' . $alias
+ : '`' . $prefix . $tblName . '`';
+ }, $tables);
+ $from = implode(', ', $fromParts);
+
+ // ── Build WHERE ───────────────────────────────────────────────────
+ $whereParams = [];
+ $whereSql = self::buildWhere($where, $whereParams);
+
+ // ── Build LIMIT ───────────────────────────────────────────────────
+ $metaLimit = 1000000;
+ $limitSql = '';
+ if ($limit !== null && $limit !== '') {
+ if (is_array($limit)) {
+ if (isset($limit['perPage'])) $limit['limit'] = $limit['perPage'];
+ if (!isset($limit['limit'])) self::error(['error' => 'Limit array requires a "limit" key']);
+ if (isset($limit['page']) && !isset($limit['offset'])) {
+ $limit['offset'] = (max(1, (int) $limit['page']) - 1) * (int) $limit['limit'];
+ }
+ $metaLimit = (int) $limit['limit'];
+ $limitSql = isset($limit['offset'])
+ ? ((int) $limit['offset']) . ',' . ((int) $limit['limit'])
+ : (string) (int) $limit['limit'];
+ } else {
+ $limitSql = (string) $limit;
+ $metaLimit = (strpos($limit, ',') !== false)
+ ? (int) explode(',', $limit)[1]
+ : (int) $limit;
+ }
+ }
+
+ // ── Assemble SQL ──────────────────────────────────────────────────
+ $sql = 'SELECT ' . implode(', ', $select) . ' FROM ' . $from;
+ if ($whereSql) $sql .= ' WHERE ' . $whereSql;
+ if ($options['groupBy']) $sql .= ' GROUP BY ' . $options['groupBy'];
+ if ($order) $sql .= ' ORDER BY ' . $order;
+ $sqlCount = $sql; // used for COUNT(*) — before LIMIT
+ if ($limitSql) $sql .= ' LIMIT ' . $limitSql;
+
+ if ($options['dieBeforeQuery']) self::error(['info' => $sql, 'params' => $whereParams]);
+
+ // ── Cache read ────────────────────────────────────────────────────
+ $hashSql = self::cacheHash(md5($sql . json_encode($whereParams) . json_encode($options)));
+ if (array_key_exists($hashSql, self::$requestGetCache)) {
+ return self::$requestGetCache[$hashSql];
+ }
+ if (self::shouldCache($table)) {
+ if ($options['redis'] && ($cached = self::cacheGet($hashSql))) {
+ return json_decode($cached, true);
+ }
+ if (self::$force_load_cache && isset(self::$getCaches[$hashSql])) {
+ return json_decode(self::$getCaches[$hashSql], true);
+ }
+ }
+
+ // ── Execute ───────────────────────────────────────────────────────
+ $db = Db::getInstance();
+ $pdoParams = $whereParams ? $whereParams : null;
+ $microtime = microtime(true);
+
+ // COUNT only when caller explicitly requests pagination metadata
+ $totalRecords = 0;
+ if ($options['withMetas']) {
+ $countSql = 'SELECT COUNT(*) AS n FROM ' . $from
+ . ($whereSql ? ' WHERE ' . $whereSql : '')
+ . ($options['groupBy'] ? ' GROUP BY ' . $options['groupBy'] : '');
+ $countRow = $db->fetch($countSql, $pdoParams);
+ $totalRecords = $countRow ? (int) $countRow['n'] : 0;
+ }
+
+ $rows = $db->executeS($sql, $pdoParams);
+ if (!is_array($rows)) $rows = [];
+
+ // ── Hydrate records ───────────────────────────────────────────────
+ $firstTableName = trim(explode(' ', $tables[0])[0]);
+ $firstSchema = isset($fullSchemas[$firstTableName]) ? $fullSchemas[$firstTableName] : [];
+
+ // Phase 3: batch-load uploads once for the entire result set
+ $uploadsIndex = [];
+ if ($options['uploads'] && !empty($firstSchema) && !empty($rows)) {
+ $uploadsIndex = self::batchLoadUploads($rows, $firstTableName, $firstSchema, $options, $prefix);
+ }
+
+ $records = [];
+ foreach ($rows as $row) {
+ // Inject uploads from pre-built index (Phase 4); pass even when empty so
+ // upload fields are initialized to [] for records with no uploads (matches CocoDB).
+ if ($options['uploads'] && !empty($firstSchema)) {
+ self::injectUploads($row, $firstTableName, $firstSchema, $uploadsIndex);
+ }
+
+ // Resolve text/query relations, decode multitext, set post-process fields (Phases 3 & 5)
+ if (!$options['ignoreSchema'] && !empty($firstSchema)) {
+ self::hydrateRecord($row, $firstTableName, $firstSchema, $options, $prefix);
+ }
+
+ if ($options['returnDataByKey']) {
+ $records[$row[$options['returnDataByKey']]] = $row;
+ } else {
+ $records[] = $row;
+ }
+ }
+
+ // Batch-resolve list/table relations for the full result set — one IN query per field (Phase 3)
+ if (!$options['ignoreSchema'] && !empty($firstSchema) && !empty($records)
+ && $options['relations'] !== false
+ ) {
+ self::batchLoadRelations($records, $firstSchema, $options, $prefix);
+ }
+
+ // Apply translations only after uploads and recursive relations have been hydrated.
+ // This allows AcaiAPI to resolve all required values in a few batched queries.
+ if (!$options['ignoreSchema'] && !empty($records) && !empty($options['translates'])) {
+ self::applyTranslations($records, $options['translates']);
+ }
+
+ // ── Debug ─────────────────────────────────────────────────────────
+ if (self::$storeDebugData) {
+ $entry = [
+ 'hora' => date('Y-m-d H:i:s') . ' ' . microtime(),
+ 'query' => $sql,
+ 'records' => $records,
+ 'time' => microtime(true) - $microtime,
+ ];
+ if (self::$backTracePoint) $entry['backTracePoint'] = self::$backTracePoint;
+ self::$debugData[] = $entry;
+ }
+
+ // ── Cache write ───────────────────────────────────────────────────
+ if (self::shouldCache($table) && $hashSql) {
+ $payload = json_encode($options['withMetas']
+ ? [self::buildMeta($totalRecords, $metaLimit), $records]
+ : $records
+ );
+ if ($options['redis']) {
+ self::cacheSet($hashSql, $payload, $options['redis_expire']);
+ } elseif (self::$force_load_cache) {
+ self::$getCaches[$hashSql] = $payload;
+ }
+ }
+
+ $result = $options['withMetas']
+ ? [self::buildMeta($totalRecords, $metaLimit), $records]
+ : $records;
+ self::$requestGetCache[$hashSql] = $result;
+ return $result;
+ }
+
+ /** Activate in-memory per-request result cache. */
+ static function localCache()
+ {
+ self::$force_load_cache = true;
+ }
+
+ /** Activate Redis cache for all subsequent get() calls. */
+ static function fullCache($expireTime = 60)
+ {
+ self::$force_redis = true;
+ self::$redis_expireTime = $expireTime;
+ self::initCache();
+ }
+
+ /** Connect to Redis (idempotent). */
+ static function initCache()
+ {
+ $redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
+ $redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
+
+ if (!self::$redis) {
+ self::$redis = new Redis();
+ self::$redis->connect($redisHost, $redisPort);
+ } elseif (!self::$redis->isConnected()) {
+ self::$redis->connect($redisHost, $redisPort);
+ }
+ }
+
+ /** Check if cache should be used for a given table name. */
+ private static function shouldCache($table)
+ {
+ global $TABLE_PREFIX;
+ if (!self::$noCacheTABLES) return true;
+ $full = $TABLE_PREFIX . str_replace($TABLE_PREFIX, '', $table);
+ return !in_array($full, self::$noCacheTABLES, true);
+ }
+
+ /** Namespaced hash key: host + hash + expireTime. */
+ static function cacheHash($string)
+ {
+ return $_SERVER['HTTP_HOST'] . '_' . $string . '_' . self::$redis_expireTime;
+ }
+
+ static function cacheGet($hash)
+ {
+ if (!self::$redis || !self::$redis->isConnected()) return null;
+ return self::$redis->exists($hash) ? self::$redis->get($hash) : null;
+ }
+
+ static function cacheSet($hash, $data, $expireTime = null)
+ {
+ if (!self::$redis || !self::$redis->isConnected()) return;
+ if (!$expireTime) $expireTime = self::$redis_expireTime ?: 60;
+ self::$redis->set($hash, $data);
+ self::$redis->expire($hash, $expireTime);
+ }
+
+ /**
+ * Returns true if the current URL matches any pattern in $noCacheURIS.
+ */
+ static function bloquedCacheByURL($url)
+ {
+ if (!self::$noCacheURIS) return false;
+ foreach (self::$noCacheURIS as $pattern) {
+ if ($url === $pattern) return true;
+ if (@preg_match('/' . $pattern . '/i', $url)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Replaces the security token in cached HTML with the current session token.
+ */
+ static function replaceHooksToken($html)
+ {
+ session_start();
+ return preg_replace_callback(
+ "/var hooksToken(\s)?=(\s)?[\'\"]([a-zA-Z0-9]+)[\'\"]\;/i",
+ function ($m) {
+ $token = sha1(session_id() . $_SERVER['HTTP_HOST']);
+ return "var hooksToken = '$token'; console.log('⚡️ Render cached HTML ⚡️');";
+ },
+ $html
+ );
+ }
+
+ // ── Debug / tracking ─────────────────────────────────────────────────
+
+ /** Alias de cacheHash() para compatibilidad con CocoDB::cacheGenerateHash(). */
+ static function cacheGenerateHash($string) {
+ return self::cacheHash($string);
+ }
+
+ /** Renderiza el panel de debug (misma firma que CocoDB::showDebug()). */
+ static function showDebug($formated = false, $index = -1) {
+ if (!$formated) return json_encode(self::$debugData, JSON_PRETTY_PRINT);
+ if (!self::$debugData) return '';
+ $result = ''
+ . '
';
+ foreach (self::$debugData as $cont => $q) {
+ if ($index > -1 && $cont != $index) continue;
+ $result .= '
'
+ . '
'
+ . $q['hora'] . '
';
+ if (self::$backTracePoint) $result .= self::$backTracePoint . '
';
+ $result .= $q['query'] . '
' . $q['time'] . ' microsegundos
'
+ . ""
+ . ""
+ . '
'
+ . '
'
+ . str_replace(' ', ' ', nl2br(json_encode($q['records'], JSON_PRETTY_PRINT)))
+ . ''
+ . '
'
+ . str_replace(' ', ' ', nl2br(json_encode(debug_backtrace(), JSON_PRETTY_PRINT)))
+ . '';
+ }
+ return $result . '
';
+ }
+
+ static function setBacktracePoint($string) {
+ self::$backTracePoint = $string;
+ }
+
+ static function getTrackData() {
+ return self::$trackData;
+ }
+
+ static function setTrackData($init = false, $type = null, $id = null, $data = []) {
+ if ($init) {
+ $pushedData = [
+ 'ip' => $_SERVER['REMOTE_ADDR'],
+ 'timestamp' => round(floatval(microtime(true) * 1000), 4),
+ 'totalTime' => 0,
+ 'host' => $_SERVER['HTTP_HOST'],
+ 'url' => $_SERVER['REQUEST_URI'],
+ 'trackData' => [],
+ ];
+ self::$trackData[] = $pushedData;
+ } else {
+ $last = count(self::$trackData) - 1;
+ $prevTimestamp = self::$trackData[$last]['timestamp'];
+ $pushedData = [
+ 'timestamp' => round((floatval(microtime(true) * 1000) - $prevTimestamp), 4),
+ 'type' => $type,
+ 'id' => $id,
+ 'transferKeys' => !isset($data[0])
+ ? array_keys($data)
+ : (is_array($data[0]) ? array_keys($data[0]) : ['undefined' => $data[0]]),
+ ];
+ self::$trackData[$last]['trackData'][] = $pushedData;
+ self::$trackData[$last]['totalTime'] = $pushedData['timestamp'];
+ $prevPercent = 0;
+ foreach (self::$trackData[$last]['trackData'] as $c => $td) {
+ $pct = ($td['timestamp'] * 100) / self::$trackData[$last]['totalTime'];
+ self::$trackData[$last]['trackData'][$c]['percent'] = round($pct, 2);
+ self::$trackData[$last]['trackData'][$c]['initPercent'] = $prevPercent;
+ self::$trackData[$last]['trackData'][$c]['widthPercent'] = $pct - $prevPercent;
+ $prevPercent = $pct;
+ }
+ }
+ return $pushedData;
+ }
+
+ /**
+ * Carga y cachea la configuración de plugins desde aux_plg_config.
+ * Misma firma que CocoDB::getPluginsConfig().
+ */
+ static function getPluginsConfig($table, $where) {
+ global $TABLE_PREFIX;
+ if (!self::$pluginsConfig) {
+ $db = Db::getInstance();
+ $configTable = preg_replace("/[^a-zA-Z0-9_]/", "", (string) $table) ?: "aux_plg_config";
+ $rows = $db->executeS("SELECT * FROM `".$configTable."` ORDER BY num DESC") ?: [];
+ self::$pluginsConfig = [];
+ foreach ($rows as $row) {
+ if (!isset(self::$pluginsConfig[$row['plugin']])) {
+ self::$pluginsConfig[$row['plugin']] = $row;
+ }
+ }
+ }
+ $key = str_replace([' ', "'", '"', 'plugin='], '', strtolower(trim($where)));
+ return isset(self::$pluginsConfig[$key]) ? self::$pluginsConfig[$key] : [];
+ }
+
+ /**
+ * Aplica traducciones a un registro completo.
+ * Alias público de applyTranslations() para compatibilidad con
+ * CocoDB::t_recursivo().
+ */
+ static function t_recursivo($record, $idx = null) {
+ if (!is_array($record)) return $record;
+ self::applyTranslations($record);
+ return $record;
+ }
+
+ // ── Internal read helpers (stubs — expanded in Phases 3-5) ──────────
+
+ // ═══════════════════════════════════════════════════════════════════
+ // PHASE 4 — BATCH UPLOAD LOADING
+ // ═══════════════════════════════════════════════════════════════════
+
+ /**
+ * Loads all uploads for a result set in ONE query per upload field
+ * (vs CocoDB's one query per record).
+ *
+ * Returns an index structured as:
+ * $index[$fieldName][$recordNum] = [upload_row, upload_row, ...]
+ */
+ private static function batchLoadUploads(array $rows, $tableName, array $schema, array $options, $prefix)
+ {
+ // Collect upload field names declared in schema
+ $uploadFields = [];
+ foreach ($schema as $fieldName => $field) {
+ if (is_array($field) && isset($field['type']) && $field['type'] === 'upload') {
+ $uploadFields[] = $fieldName;
+ }
+ }
+ if (empty($uploadFields)) return [];
+
+ // Collect all record nums from the result
+ $nums = array_filter(array_column($rows, 'num'));
+ if (empty($nums)) return [];
+
+ $db = Db::getInstance();
+ $ph = implode(',', array_fill(0, count($nums), '?'));
+ $fieldPh = implode(',', array_fill(0, count($uploadFields), '?'));
+ $selectCols = 'num, `order`, tableName, fieldName, recordNum, filePath, urlPath, '
+ . 'info1, info2, info3, info4, info5, alt';
+
+ $params = array_merge(array_values($nums), [$tableName], array_values($uploadFields));
+
+ $uploads = $db->executeS(
+ "SELECT $selectCols
+ FROM `{$prefix}uploads`
+ WHERE recordNum IN ($ph)
+ AND tableName = ?
+ AND fieldName IN ($fieldPh)
+ ORDER BY `order` ASC",
+ $params
+ );
+
+ if (empty($uploads)) return [];
+
+ // 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'];
+ }
+ $field = $upload['fieldName'];
+ $recNum = $upload['recordNum'];
+ $index[$field][$recNum][] = $upload;
+ }
+ return $index;
+ }
+
+ /**
+ * 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).
+ */
+ private static function injectUploads(array &$row, $tableName, array $schema, array $uploadsIndex)
+ {
+ if (!isset($row['num'])) return;
+ foreach ($schema as $fieldName => $field) {
+ if (!is_array($field) || !isset($field['type']) || $field['type'] !== 'upload') continue;
+ $row[$fieldName] = isset($uploadsIndex[$fieldName][$row['num']])
+ ? $uploadsIndex[$fieldName][$row['num']]
+ : [];
+ }
+ }
+
+
+ // ═══════════════════════════════════════════════════════════════════
+ // PHASE 3 — RELATION LOADING
+ // ═══════════════════════════════════════════════════════════════════
+
+ /**
+ * Resolves list relations, decodes multitext, applies translations,
+ * and sets breadcrumb helpers on a single record.
+ *
+ * list/optionsType=text → pure PHP, no DB
+ * list/optionsType=query → getEvalOutput() with per-query static cache
+ * list/optionsType=table → batch-loaded after the main loop (see batchLoadRelations)
+ * multitext → json_decode → _bd
+ */
+ private static function hydrateRecord(array &$row, $tableName, array $schema, array $options, $prefix)
+ {
+ global $TABLE_PREFIX;
+
+ $row['tableName'] = $tableName;
+
+ $relationsFilter = $options['relations'];
+ $relationsDepth = (int) $options['relationsDepth'];
+
+ foreach ($schema as $fieldName => $field) {
+ if (!is_array($field) || !isset($field['type'])) continue;
+
+ // Honour the `relations` option — skip fields not in the whitelist
+ if (is_array($relationsFilter) && !in_array($fieldName, $relationsFilter, true)) continue;
+
+ $rawValue = isset($row[$fieldName]) ? $row[$fieldName] : null;
+
+ switch ($field['type']) {
+
+ case 'list':
+ $optionsType = isset($field['optionsType']) ? $field['optionsType'] : '';
+
+ switch ($optionsType) {
+
+ case 'text':
+ // Parse inline option list: "value|Label\nvalue2|Label2"
+ $lines = array_filter(explode("\n", isset($field['optionsText']) ? $field['optionsText'] : ''));
+ $map = [];
+ foreach ($lines as $line) {
+ $parts = explode('|', $line);
+ $map[trim($parts[0])] = isset($parts[1]) ? trim($parts[1]) : trim($parts[0]);
+ }
+ $resultDatas = ($rawValue !== null && $rawValue !== '')
+ ? explode("\t", $rawValue)
+ : [];
+ $row[$fieldName . '_bd'] = [];
+ foreach ($resultDatas as $rval) {
+ $rval = trim($rval);
+ if ($rval === '') continue;
+ $row[$fieldName . '_bd'][] = [
+ 'key' => $rval,
+ 'value' => isset($map[$rval])
+ ? t_var($map[$rval])
+ : t_var($rval),
+ ];
+ }
+ break;
+
+ case 'query':
+ // Evaluates PHP/SQL expression from schema; result is cached statically
+ $query = function_exists('getEvalOutput')
+ ? getEvalOutput(isset($field['optionsQuery']) ? $field['optionsQuery'] : '')
+ : (isset($field['optionsQuery']) ? $field['optionsQuery'] : '');
+ $cacheKey = md5($query);
+ if (!isset(self::$queryCaches[$cacheKey])) {
+ $db = Db::getInstance();
+ self::$queryCaches[$cacheKey] = $db->executeS($query) ?: [];
+ }
+ // Extract table name from the query for tableName field
+ $tableQuery = null;
+ if (preg_match('/FROM\s+(\S+)/i', $query, $m)) {
+ $tableQuery = str_replace($TABLE_PREFIX, '', trim($m[1]));
+ }
+ $result = array_values(array_filter(
+ self::$queryCaches[$cacheKey],
+ function ($rec) use ($rawValue) {
+ $firstVal = reset($rec);
+ return $rawValue == $firstVal;
+ }
+ ));
+ $row[$fieldName . '_bd'] = array_map(function ($rec) use ($tableQuery) {
+ if ($tableQuery) $rec['tableName'] = $tableQuery;
+ return $rec;
+ }, $result);
+ break;
+
+ case 'table':
+ // Resolved in batchLoadRelations() after the main loop.
+ // Only pre-set the key when relations will actually be loaded;
+ // with depth=0 CocoDB leaves these keys absent entirely.
+ if ($relationsDepth > 0 && !isset($row[$fieldName . '_bd'])) {
+ $row[$fieldName . '_bd'] = [];
+ }
+ break;
+ }
+ break;
+
+ case 'multitext':
+ $row[$fieldName . '_bd'] = $rawValue !== null ? json_decode($rawValue, true) : null;
+ break;
+ }
+ }
+
+ // ── Post-processing (Phase 5) ─────────────────────────────────────
+ self::applyPostProcessing($row, $schema, $options);
+ }
+
+ /**
+ * Batch-resolves all list/table relations for an entire result set.
+ * Called from get() after the per-record loop so we can collect all
+ * needed IDs first and issue one IN query per relation field.
+ *
+ * Mutates $records in place, populating fieldName_bd arrays.
+ */
+ private static function batchLoadRelations(
+ array &$records,
+ array $schema,
+ array $options,
+ $prefix
+ ) {
+ if (empty($records) || (int) $options['relationsDepth'] <= 0) return;
+
+ $relationsFilter = $options['relations'];
+ $subDepth = (int) $options['relationsDepth'] - 1;
+ $definitions = [];
+ $pools = [];
+
+ // Group fields sharing the same related table and key in one IN query.
+ foreach ($schema as $fieldName => $field) {
+ if (!is_array($field)) continue;
+ if (($field['type'] ?? null) !== 'list' || ($field['optionsType'] ?? '') !== 'table') continue;
+ if (is_array($relationsFilter) && !in_array($fieldName, $relationsFilter, true)) continue;
+
+ $relTable = $field['optionsTablename'] ?? null;
+ $relValueField = $field['optionsValueField'] ?? 'num';
+ $listType = $field['listType'] ?? 'pulldown';
+ if (!$relTable) continue;
+
+ $poolKey = $relTable . '|' . $relValueField;
+ $definitions[$fieldName] = ['poolKey' => $poolKey, 'listType' => $listType];
+ if (!isset($pools[$poolKey])) {
+ $pools[$poolKey] = ['table' => $relTable, 'valueField' => $relValueField, 'ids' => [], 'byId' => []];
+ }
+
+ foreach ($records as $record) {
+ $raw = $record[$fieldName] ?? '';
+ $ids = $listType === 'pulldownMulti'
+ ? array_filter(explode("\t", (string) $raw))
+ : (($raw !== null && $raw !== '') ? [$raw] : []);
+ foreach ($ids as $id) $pools[$poolKey]['ids'][(string) $id] = true;
+ }
+ }
+
+ foreach ($pools as &$pool) {
+ $ids = array_keys($pool['ids']);
+ if (!$ids) continue;
+ $related = self::get($pool['table'], $pool['valueField'] . ' IN (' . implode(',', $ids) . ')', null, null, [
+ 'relationsDepth' => $subDepth,
+ 'translates' => $options['translates'],
+ 'uploads' => $options['uploads'],
+ 'prefix' => $prefix,
+ ]);
+ foreach ($related as $relatedRecord) {
+ if (array_key_exists($pool['valueField'], $relatedRecord)) {
+ $pool['byId'][(string) $relatedRecord[$pool['valueField']]] = $relatedRecord;
+ }
+ }
+ }
+ unset($pool);
+
+ foreach ($definitions as $fieldName => $definition) {
+ $byId = $pools[$definition['poolKey']]['byId'];
+ foreach ($records as &$record) {
+ $raw = $record[$fieldName] ?? '';
+ if ($definition['listType'] === 'pulldownMulti') {
+ $ids = array_filter(explode("\t", (string) $raw));
+ if (!$ids) {
+ unset($record[$fieldName . '_bd']);
+ continue;
+ }
+ $record[$fieldName . '_bd'] = array_values(array_filter(array_map(
+ function ($id) use ($byId) { return $byId[(string) $id] ?? null; }, $ids
+ )));
+ } else {
+ if ($raw === null || $raw === '') {
+ unset($record[$fieldName . '_bd']);
+ continue;
+ }
+ $record[$fieldName . '_bd'] = isset($byId[(string) $raw]) ? [$byId[(string) $raw]] : [];
+ }
+ }
+ unset($record);
+ }
+ }
+
+
+ // PHASE 5 — POST-PROCESSING (per record)
+ // ═══════════════════════════════════════════════════════════════════
+
+ /**
+ * Sets breadcrumb helper fields and applies translations.
+ * Runs after relation/upload injection so all data is present.
+ */
+ private static function applyPostProcessing(array &$row, array $schema, array $options)
+ {
+ // Breadcrumb metadata
+ $breadcrumbField = isset($schema['breadcrumbField']) ? $schema['breadcrumbField'] : '';
+ $row['breadcrumbField'] = $breadcrumbField;
+
+ if ($breadcrumbField === 'parentNum') {
+ $row['optionsTablename'] = $row['tableName'];
+ $row['optionsValueField'] = 'num';
+ } elseif ($breadcrumbField && isset($schema[$breadcrumbField])) {
+ $row['optionsTablename'] = isset($schema[$breadcrumbField]['optionsTablename'])
+ ? $schema[$breadcrumbField]['optionsTablename'] : null;
+ $row['optionsValueField'] = isset($schema[$breadcrumbField]['optionsValueField'])
+ ? $schema[$breadcrumbField]['optionsValueField'] : null;
+ }
+
+ // Main display field for link/breadcrumb generation
+ $row['mainFieldBreadcrumb'] = null;
+ foreach (['name', 'title', 'titulo', 'nombre'] as $candidate) {
+ if (!empty($row[$candidate])) {
+ $row['mainFieldBreadcrumb'] = $row[$candidate];
+ break;
+ }
+ }
+ if ($row['mainFieldBreadcrumb'] === null) {
+ // Fall back to first textfield in schema order
+ foreach ($schema as $key => $field) {
+ if (!is_array($field)) continue;
+ if (isset($field['type']) && $field['type'] === 'textfield' && $key !== 'enlace') {
+ $row['mainFieldBreadcrumb'] = isset($row[$key]) ? $row[$key] : null;
+ break;
+ }
+ }
+ }
+
+ }
+
+ /**
+ * Applies translations to a fully hydrated record tree.
+ *
+ * Required keys are collected first and fetched in batches grouped by
+ * language and table. The request-local maps also prevent repeated work
+ * when nested get() calls return records later embedded in a parent query.
+ */
+ private static function applyTranslations(array &$record, $language = null)
+ {
+ global $TABLE_PREFIX;
+
+ $language = $language ?: (isset($_REQUEST['idioma']) ? $_REQUEST['idioma'] : null);
+ if (!$language) return;
+
+ if (self::$allowedTranslateFields === null) {
+ $db = Db::getInstance();
+ $fields = $db->executeS("SELECT DISTINCT fieldName FROM `{$TABLE_PREFIX}traducciones`");
+ self::$allowedTranslateFields = array_flip(array_column($fields ?: [], 'fieldName'));
+ }
+
+ // Spanish is the source content: parse it directly and never query
+ // cms_traducciones, which only stores en/de/fr in this project.
+ if ($language === 'es') {
+ self::applyTranslationValues($record, $language, []);
+ return;
+ }
+
+ static $knownKeys = [];
+ static $translatedValues = [];
+
+ $requests = [];
+ self::collectTranslationRequests($record, $language, $requests);
+
+ $missing = [];
+ foreach ($requests as $key => $meta) {
+ if (!isset($knownKeys[$key])) {
+ $knownKeys[$key] = true;
+ $missing[$key] = $meta;
+ }
+ }
+
+ if ($missing) {
+ $groups = [];
+ foreach ($missing as $meta) {
+ $groupKey = $meta['tableName'];
+ $groups[$groupKey]['recordNums'][$meta['recordNum']] = true;
+ $groups[$groupKey]['fieldNames'][$meta['fieldName']] = true;
+ }
+
+ $db = Db::getInstance();
+ foreach ($groups as $tableName => $group) {
+ $fieldNames = array_keys($group['fieldNames']);
+ if (!$fieldNames) continue;
+
+ foreach (array_chunk(array_keys($group['recordNums']), 400) as $recordNums) {
+ $recordPh = implode(',', array_fill(0, count($recordNums), '?'));
+ $fieldPh = implode(',', array_fill(0, count($fieldNames), '?'));
+ $params = array_merge([$language, $tableName], $recordNums, $fieldNames);
+
+ $rows = $db->executeS(
+ "SELECT num, tableName, recordNum, fieldName, uploadNum, fieldValue
+ FROM `{$TABLE_PREFIX}traducciones`
+ WHERE prefix = ?
+ AND tableName = ?
+ AND recordNum IN ($recordPh)
+ AND fieldName IN ($fieldPh)
+ ORDER BY num ASC",
+ $params
+ ) ?: [];
+
+ foreach ($rows as $row) {
+ $uploadNum = $row['tableName'] === 'uploads'
+ ? (int) $row['uploadNum']
+ : null;
+ $key = self::translationCacheKey(
+ $language,
+ $row['tableName'],
+ (int) $row['recordNum'],
+ $row['fieldName'],
+ $uploadNum
+ );
+ // Preserve the previous LIMIT 1 behaviour when duplicates exist.
+ if (!array_key_exists($key, $translatedValues)) {
+ $translatedValues[$key] = $row['fieldValue'];
+ }
+ }
+ }
+ }
+ }
+
+ self::applyTranslationValues($record, $language, $translatedValues);
+ }
+
+ /** Collects unique translation lookup keys from a nested record tree. */
+ private static function collectTranslationRequests(array $node, $language, array &$requests)
+ {
+ foreach ($node as $fieldName => $value) {
+ if (is_array($value)) {
+ self::collectTranslationRequests($value, $language, $requests);
+ continue;
+ }
+ if (!isset(self::$allowedTranslateFields[$fieldName])) continue;
+
+ $meta = self::translationMeta($node, $fieldName);
+ if (!$meta) continue;
+
+ $key = self::translationCacheKey(
+ $language,
+ $meta['tableName'],
+ $meta['recordNum'],
+ $meta['fieldName'],
+ $meta['uploadNum']
+ );
+ $requests[$key] = $meta;
+ }
+ }
+
+ /** Builds the legacy translation identity for regular and upload records. */
+ private static function translationMeta(array $record, $fieldName)
+ {
+ if (empty($record['num']) || empty($record['tableName'])) return null;
+
+ if (!empty($record['urlPath']) || !empty($record['info1'])) {
+ return [
+ 'tableName' => 'uploads',
+ 'recordNum' => (int) ($record['recordNum'] ?? 0),
+ 'fieldName' => $fieldName,
+ 'uploadNum' => (int) $record['num'],
+ ];
+ }
+
+ return [
+ 'tableName' => $record['tableName'],
+ 'recordNum' => (int) $record['num'],
+ 'fieldName' => $fieldName,
+ 'uploadNum' => null,
+ ];
+ }
+
+ /** Creates an unambiguous key for the request-local translation map. */
+ private static function translationCacheKey($language, $tableName, $recordNum, $fieldName, $uploadNum)
+ {
+ return json_encode([$language, $tableName, (int) $recordNum, $fieldName, $uploadNum]);
+ }
+
+ /** Applies source or translated values recursively without further SQL. */
+ private static function applyTranslationValues(array &$node, $language, array $translatedValues)
+ {
+ global $hashTraducciones;
+
+ foreach ($node as $fieldName => &$value) {
+ if (is_array($value)) {
+ self::applyTranslationValues($value, $language, $translatedValues);
+ continue;
+ }
+ if (!isset(self::$allowedTranslateFields[$fieldName])) continue;
+
+ $translated = null;
+ $hasTranslation = false;
+ $meta = self::translationMeta($node, $fieldName);
+
+ if ($language !== 'es' && $meta) {
+ $key = self::translationCacheKey(
+ $language,
+ $meta['tableName'],
+ $meta['recordNum'],
+ $meta['fieldName'],
+ $meta['uploadNum']
+ );
+ if (array_key_exists($key, $translatedValues)) {
+ $translated = $translatedValues[$key];
+ $hasTranslation = true;
+ }
+ }
+
+ $rawValue = $hasTranslation ? $translated : $value;
+ if ($hasTranslation && function_exists('parsea_texto2')) {
+ $rawValue = parsea_texto2($rawValue);
+ }
+ $value = class_exists('CocoParser')
+ ? CocoParser::parsea_codigos_en_linea($rawValue)
+ : $rawValue;
+
+ // Keep the legacy per-request t() cache coherent for later direct calls.
+ if ($meta) {
+ $legacyHash = md5(
+ $meta['uploadNum'] !== null
+ ? $meta['uploadNum'] . $fieldName . 'uploads' . $language . json_encode($_REQUEST)
+ : $meta['recordNum'] . $fieldName . $meta['tableName'] . $language . json_encode($_REQUEST)
+ );
+ $hashTraducciones[$legacyHash] = $value;
+ }
+
+ if (isset($node[$fieldName . '_bd'])
+ && is_string($value)
+ && substr($value, 0, 1) === '['
+ && substr($value, -1) === ']'
+ ) {
+ $node[$fieldName . '_bd'] = json_decode($value, true);
+ }
+ }
+ unset($value);
+ }
+
+ /** Assembles the meta array returned with withMetas=true. */
+ private static function buildMeta($totalRecords, $perPage)
+ {
+ return [
+ 'totalRecords' => $totalRecords,
+ 'perPage' => $perPage,
+ 'totalPages' => ceil($totalRecords / max(1, $perPage)),
+ ];
+ }
+
+
+ // ═══════════════════════════════════════════════════════════════════
+ // WRITE HELPERS (private)
+ // ═══════════════════════════════════════════════════════════════════
+
+ /**
+ * Builds the SET clause for an INSERT or UPDATE using positional PDO params.
+ * Upload fields (type=upload) have no column in the main table and are
+ * returned separately for post-processing.
+ *
+ * @return array [$setParts, $params, $uploadFields]
+ * $setParts string[] e.g. ['`title` = ?', '`num` = NULL', ...]
+ * $params mixed[] positional values matching each `?`
+ * $uploadFields array [fieldName => [urlPath, ...]]
+ */
+ private static function buildSetClause(
+ array $record,
+ array $schema,
+ array $functions,
+ $ignoreSchema,
+ $isInsert,
+ $forceNum
+ ) {
+ $now = date('Y-m-d H:i:s');
+ $user = 1;
+ if (class_exists('API') && isset(API::$user['num'])) {
+ $user = (int) API::$user['num'] ?: 1;
+ }
+
+ $menuType = isset($schema['menuType']) ? $schema['menuType'] : '';
+
+ $setParts = [];
+ $params = [];
+ $uploadFields = [];
+
+ // ── Audit fields — always written, not overridable by the caller ──
+ $setParts[] = '`updatedDate` = ?'; $params[] = $now;
+ $setParts[] = '`updatedByUserNum` = ?'; $params[] = $user;
+
+ // ── INSERT-only defaults ──────────────────────────────────────────
+ if ($isInsert) {
+ // Auto-increment num — use literal NULL; forceNum overrides
+ if ($forceNum && isset($record['num'])) {
+ $setParts[] = '`num` = ?';
+ $params[] = (int) $record['num'];
+ } else {
+ $setParts[] = '`num` = NULL';
+ }
+
+ if (!isset($record['createdDate'])) {
+ $setParts[] = '`createdDate` = ?';
+ $params[] = $now;
+ }
+ if (!isset($record['createdByUserNum'])) {
+ $setParts[] = '`createdByUserNum` = ?';
+ $params[] = $user;
+ }
+
+ // Category tree initialisation
+ if ($menuType === 'category') {
+ $catDefaults = [
+ 'globalOrder' => 0,
+ 'siblingOrder' => 0,
+ 'depth' => 0,
+ 'parentNum' => 0,
+ 'lineage' => '',
+ 'breadcrumb' => '',
+ ];
+ foreach ($catDefaults as $field => $default) {
+ if (!isset($record[$field])) {
+ $setParts[] = "`$field` = ?";
+ $params[] = $default;
+ }
+ }
+ }
+
+ // Multi table drag-sort initialisation
+ if ($menuType === 'multi' && !isset($record['dragSortOrder'])) {
+ $setParts[] = '`dragSortOrder` = ?';
+ $params[] = time();
+ }
+ }
+
+ // ── User-supplied fields ─────────────────────────────────────────
+ // num is always protected unless forceNum is set
+ $skip = $forceNum ? [] : ['num'];
+
+ foreach ($record as $key => $value) {
+ if (in_array($key, $skip, true)) continue;
+
+ $schemaField = isset($schema[$key]) ? $schema[$key] : null;
+
+ // When a schema is available, silently drop fields not declared in it
+ if (!$ignoreSchema && $schema && $schemaField === null) continue;
+
+ // Upload fields: no column in the main table — collected for later
+ if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'upload') {
+ $vals = is_array($value) ? $value : array_filter([$value]);
+ if ($vals) $uploadFields[$key] = array_values($vals);
+ continue;
+ }
+
+ // Apply custom per-field callback, or schema-driven type coercion
+ if (isset($functions[$key]) && is_callable($functions[$key])) {
+ $value = $functions[$key]($value);
+ } elseif (!$ignoreSchema && $schemaField) {
+ $value = self::coerceValue($value, $schemaField);
+ }
+
+ if ($value === null) continue;
+ if (is_array($value)) $value = json_encode($value);
+
+ $setParts[] = "`$key` = ?";
+ $params[] = $value;
+ }
+
+ return [$setParts, $params, $uploadFields];
+ }
+
+ /**
+ * Coerces a value to its correct database representation
+ * based on the schema field definition.
+ */
+ private static function coerceValue($value, array $field)
+ {
+ $type = isset($field['type']) ? $field['type'] : '';
+
+ switch ($type) {
+ case 'checkbox':
+ // Stored as tinyint 0/1
+ return $value ? 1 : 0;
+
+ case 'list':
+ // Multi-select lists store values tab-delimited: \t1\t3\t7\t
+ if (isset($field['listType']) && $field['listType'] === 'pulldownMulti'
+ && is_array($value)
+ ) {
+ return "\t" . implode("\t", $value) . "\t";
+ }
+ break;
+
+ case 'multitext':
+ // Structured repeater stored as JSON
+ if (is_array($value)) return json_encode($value);
+ break;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Builds a parameterised WHERE clause compatible with CocoDB's parse_where().
+ * Populates $params with positional PDO values.
+ *
+ * Input formats:
+ * null / '' → '' (no WHERE)
+ * 'num=1 AND visible=1' → passed through as raw SQL
+ * ['field' => 'value', ...] → simple equality conditions (AND)
+ * [['column','value','operator', → structured conditions
+ * 'or','not','raw_key'], ...]
+ *
+ * Operators: = != < > <= >= LIKE IN IS NULL
+ * Flags: or → join to previous condition with OR (wraps in parentheses)
+ * not → NOT prefix
+ * raw_key → skip backtick quoting on the column name
+ *
+ * @param mixed $where
+ * @param array $params Populated by reference
+ * @return string|null SQL fragment (no WHERE keyword), or null on invalid input
+ */
+ private static function buildWhere($where, array &$params = [])
+ {
+ if (!$where && $where !== '0') return '';
+ if (is_string($where)) return $where;
+ if (!is_array($where)) return '';
+
+ $parts = [];
+ $links = [];
+ $addParenthesis = false;
+
+ foreach ($where as $key => $w) {
+ if (is_array($w)) {
+ if (!isset($w['column']) || !array_key_exists('value', $w)) return null;
+
+ $operator = strtoupper(isset($w['operator']) ? $w['operator'] : '=');
+ $not = !empty($w['not']) ? 'NOT ' : '';
+ $link = !empty($w['or']) ? 'OR' : 'AND';
+ if (!empty($w['or'])) $addParenthesis = true;
+
+ $col = empty($w['raw_key']) ? $w['column'] : '`' . $w['column'] . '`';
+
+ switch ($operator) {
+ case 'LIKE':
+ $parts[] = "$col {$not}LIKE ?";
+ $params[] = $w['value'];
+ break;
+
+ case 'IS NULL':
+ // IS NULL / IS NOT NULL take no parameter
+ $parts[] = $not ? "$col IS NOT NULL" : "$col IS NULL";
+ break;
+
+ case 'IN':
+ $vals = (array) $w['value'];
+ $ph = implode(',', array_fill(0, count($vals), '?'));
+ $parts[] = "$col {$not}IN ($ph)";
+ foreach ($vals as $v) $params[] = $v;
+ break;
+
+ default:
+ if (!in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) {
+ $operator = '=';
+ }
+ // "col NOT = ?" is invalid SQL; wrap as NOT (col op ?) instead
+ if ($not && in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) {
+ $parts[] = "NOT ($col $operator ?)";
+ } else {
+ $parts[] = "$col $not$operator ?";
+ }
+ $params[] = $w['value'];
+ }
+
+ $links[] = $link;
+
+ } else {
+ // Simple associative key => value
+ $parts[] = "`$key` = ?";
+ $params[] = $w;
+ $links[] = 'AND';
+ }
+ }
+
+ if (empty($parts)) return '';
+
+ // Join conditions: links[$i] is the separator BEFORE parts[$i]
+ $sql = $parts[0];
+ for ($i = 1, $n = count($parts); $i < $n; $i++) {
+ $sql .= ' ' . $links[$i] . ' ' . $parts[$i];
+ }
+
+ return $addParenthesis ? "($sql)" : $sql;
+ }
+
+
+ // ═══════════════════════════════════════════════════════════════════
+ // SCHEMA (private)
+ // ═══════════════════════════════════════════════════════════════════
+
+ /**
+ * Loads and caches the schema for a table.
+ * loadSchema() strips TABLE_PREFIX internally, so both 'blog' and
+ * 'cms_blog' resolve to the same file.
+ */
+ static function schema($table)
+ {
+ if (!isset(self::$schemaCache[$table])) {
+ self::$schemaCache[$table] = loadSchema($table) ?: [];
+ }
+ return self::$schemaCache[$table];
+ }
+
+
+ // ═══════════════════════════════════════════════════════════════════
+ // ERROR HANDLING
+ // ═══════════════════════════════════════════════════════════════════
+
+ static function error(array $data = [])
+ {
+ if (class_exists('API') && class_exists('ApiError')) {
+ API::$die = true;
+ API::error(new ApiError(json_encode($data)));
+ } else {
+ die(json_encode($data));
+ }
+ }
+
+
+ // ═══════════════════════════════════════════════════════════════════
+ // CATEGORY METADATA (adapted from CocoDB, uses PDO)
+ // ═══════════════════════════════════════════════════════════════════
+
+ /**
+ * Rebuilds globalOrder / siblingOrder / depth / lineage / breadcrumb
+ * for all records in a category-type table.
+ */
+ static function updateCategoryMetadata($table, $prefix = '')
+ {
+ global $TABLE_PREFIX;
+ if (!$prefix) $prefix = $TABLE_PREFIX;
+
+ $fullTable = $prefix . str_replace($prefix, '', $table);
+ $schema = self::schema($fullTable);
+
+ if (!isset($schema['menuType']) || $schema['menuType'] !== 'category') return;
+
+ $db = Db::getInstance();
+ $rows = $db->executeS("SELECT * FROM `$fullTable` ORDER BY globalOrder ASC");
+
+ if (empty($rows)) return;
+
+ $byNum = [];
+ $childNums = [];
+ foreach ($rows as $row) {
+ $byNum[$row['num']] = $row;
+ $childNums[(int) $row['parentNum']][] = $row['num'];
+ }
+
+ self::_rebuildCategoryBranch([
+ 'branchParent' => 0,
+ 'records' => &$byNum,
+ 'childNodes' => $childNums,
+ ]);
+
+ foreach ($byNum as $num => $cat) {
+ $db->execute(
+ "UPDATE `$fullTable`
+ SET globalOrder = ?,
+ siblingOrder = ?,
+ depth = ?,
+ lineage = ?,
+ breadcrumb = ?
+ WHERE num = ?",
+ [
+ $cat['globalOrder'],
+ $cat['siblingOrder'],
+ $cat['depth'],
+ $cat['lineage'],
+ $cat['breadcrumb'],
+ $num,
+ ]
+ );
+ }
+ }
+
+ /**
+ * Recursive depth-first traversal that assigns order/depth/lineage/breadcrumb.
+ */
+ private static function _rebuildCategoryBranch(array $args)
+ {
+ if (!isset($args['globalOrder'])) $args['globalOrder'] = 0;
+ if (!isset($args['depth'])) $args['depth'] = 0;
+ if (!isset($args['lineage'])) $args['lineage'] = ':';
+
+ $children = isset($args['childNodes'][$args['branchParent']])
+ ? $args['childNodes'][$args['branchParent']]
+ : [];
+
+ if (empty($children)) return;
+
+ // Sort by current siblingOrder ascending
+ $sortable = [];
+ foreach ($children as $childNum) {
+ $sortable[$childNum] = isset($args['records'][$childNum]['siblingOrder'])
+ ? (int) $args['records'][$childNum]['siblingOrder']
+ : 0;
+ }
+ asort($sortable);
+
+ $siblingOrder = 0;
+ foreach (array_keys($sortable) as $childNum) {
+ $r = &$args['records'][$childNum];
+
+ $r['globalOrder'] = ++$args['globalOrder'];
+ $r['siblingOrder'] = ++$siblingOrder;
+ $r['depth'] = $args['depth'];
+ $r['lineage'] = $args['lineage'] . $childNum . ':';
+ $r['breadcrumb'] = isset($args['breadcrumb'])
+ ? $args['breadcrumb'] . ' : ' . (isset($r['name']) ? $r['name'] : '')
+ : (isset($r['name']) ? $r['name'] : '');
+
+ if (!empty($args['childNodes'][$childNum])) {
+ self::_rebuildCategoryBranch([
+ 'branchParent' => $childNum,
+ 'globalOrder' => &$args['globalOrder'],
+ 'records' => &$args['records'],
+ 'childNodes' => $args['childNodes'],
+ 'depth' => $args['depth'] + 1,
+ 'lineage' => $r['lineage'],
+ 'breadcrumb' => $r['breadcrumb'],
+ ]);
+ }
+ }
+ }
+}
diff --git a/cms/lib/classes/CocoDB.php b/cms/lib/classes/CocoDB.php
index d6e577b..551132d 100755
--- a/cms/lib/classes/CocoDB.php
+++ b/cms/lib/classes/CocoDB.php
@@ -1,5 +1,6 @@