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
' + . "" + . "" + . '
' + . '' + . '
'; + } + 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 @@