* : Nombre del componente a sincronizar (sin extensión .json) * * ## EJEMPLOS * * # Sincronizar top-notification-bar * wp roi-theme sync-component top-notification-bar * * # Sincronizar navbar * wp roi-theme sync-component navbar * * @param array $args Argumentos posicionales * @param array $assoc_args Argumentos asociativos (--flags) * @return void */ public function sync_component(array $args, array $assoc_args): void { if (empty($args[0])) { \WP_CLI::error('Debes especificar el nombre del componente. Ejemplo: wp roi-theme sync-component top-notification-bar'); return; } $component_name = $args[0]; $schemas_path = get_template_directory() . '/Schemas'; $schema_file = $schemas_path . '/' . $component_name . '.json'; if (!file_exists($schema_file)) { \WP_CLI::error("Schema no encontrado: {$schema_file}"); return; } \WP_CLI::line(''); \WP_CLI::line("🔄 Sincronizando componente: {$component_name}"); \WP_CLI::line(''); $result = $this->syncSchemaToDatabase($schema_file, $component_name); \WP_CLI::line(''); if ($result['success']) { \WP_CLI::success($result['message']); \WP_CLI::line(''); \WP_CLI::line('📊 Estadísticas:'); \WP_CLI::line(' ├─ Campos insertados: ' . $result['stats']['inserted']); \WP_CLI::line(' ├─ Campos actualizados: ' . $result['stats']['updated']); \WP_CLI::line(' ├─ Campos eliminados (obsoletos): ' . $result['stats']['deleted']); \WP_CLI::line(' └─ Total en schema: ' . $result['stats']['total']); \WP_CLI::line(''); } else { \WP_CLI::error($result['message']); } } /** * Sincronizar todos los componentes desde schemas/ a la BD * * ## EJEMPLOS * * wp roi-theme sync-all-components * * @param array $args Argumentos posicionales * @param array $assoc_args Argumentos asociativos * @return void */ public function sync_all_components(array $args, array $assoc_args): void { $schemas_path = get_template_directory() . '/Schemas'; if (!is_dir($schemas_path)) { \WP_CLI::error("Directorio de schemas no encontrado: {$schemas_path}"); return; } $schema_files = glob($schemas_path . '/*.json'); if (empty($schema_files)) { \WP_CLI::warning('No se encontraron schemas JSON en ' . $schemas_path); return; } \WP_CLI::line(''); \WP_CLI::line('🔄 Sincronizando todos los componentes...'); \WP_CLI::line(''); $total_stats = [ 'components' => 0, 'inserted' => 0, 'updated' => 0, 'total' => 0, 'errors' => 0 ]; foreach ($schema_files as $schema_file) { $component_name = basename($schema_file, '.json'); \WP_CLI::line(" → Procesando: {$component_name}"); $result = $this->syncSchemaToDatabase($schema_file, $component_name); if ($result['success']) { $total_stats['components']++; $total_stats['inserted'] += $result['stats']['inserted']; $total_stats['updated'] += $result['stats']['updated']; $total_stats['total'] += $result['stats']['total']; \WP_CLI::line(" ✓ {$result['message']}"); } else { $total_stats['errors']++; \WP_CLI::line(" ✗ Error: {$result['message']}"); } } \WP_CLI::line(''); \WP_CLI::success('Sincronización completada'); \WP_CLI::line(''); \WP_CLI::line('📊 Estadísticas totales:'); \WP_CLI::line(' ├─ Componentes sincronizados: ' . $total_stats['components']); \WP_CLI::line(' ├─ Campos insertados: ' . $total_stats['inserted']); \WP_CLI::line(' ├─ Campos actualizados: ' . $total_stats['updated']); \WP_CLI::line(' ├─ Total procesado: ' . $total_stats['total']); \WP_CLI::line(' └─ Errores: ' . $total_stats['errors']); \WP_CLI::line(''); } /** * Sincronizar un schema JSON a la base de datos * * @param string $schema_file Ruta completa al archivo JSON * @param string $component_name Nombre del componente * @return array{success: bool, message: string, stats: array{inserted: int, updated: int, deleted: int, total: int}} */ private function syncSchemaToDatabase(string $schema_file, string $component_name): array { global $wpdb; // Leer y decodificar JSON $json_content = file_get_contents($schema_file); if ($json_content === false) { return [ 'success' => false, 'message' => 'Error al leer archivo JSON', 'stats' => ['inserted' => 0, 'updated' => 0, 'deleted' => 0, 'total' => 0] ]; } $schema = json_decode($json_content, true); if ($schema === null) { return [ 'success' => false, 'message' => 'Error al decodificar JSON: ' . json_last_error_msg(), 'stats' => ['inserted' => 0, 'updated' => 0, 'deleted' => 0, 'total' => 0] ]; } $table = $wpdb->prefix . 'roi_theme_component_settings'; $stats = ['inserted' => 0, 'updated' => 0, 'deleted' => 0, 'total' => 0]; // Iterar grupos y campos if (!isset($schema['groups']) || !is_array($schema['groups'])) { return [ 'success' => false, 'message' => 'Schema inválido: falta clave "groups"', 'stats' => $stats ]; } // Construir lista de campos válidos del schema $validFields = []; foreach ($schema['groups'] as $group_name => $group_data) { if (!isset($group_data['fields']) || !is_array($group_data['fields'])) { continue; } foreach ($group_data['fields'] as $attribute_name => $field_config) { $validFields[] = $group_name . '::' . $attribute_name; } } // PASO 1: Eliminar registros obsoletos (que no están en el schema) $existing_records = $wpdb->get_results($wpdb->prepare( "SELECT id, group_name, attribute_name FROM {$table} WHERE component_name = %s", $component_name )); foreach ($existing_records as $record) { $key = $record->group_name . '::' . $record->attribute_name; if (!in_array($key, $validFields, true)) { $wpdb->delete($table, ['id' => $record->id], ['%d']); $stats['deleted']++; } } // PASO 2: Insertar/Actualizar campos del schema foreach ($schema['groups'] as $group_name => $group_data) { if (!isset($group_data['fields']) || !is_array($group_data['fields'])) { continue; } foreach ($group_data['fields'] as $attribute_name => $field_config) { $stats['total']++; // Verificar si el campo ya existe $existing = $wpdb->get_row($wpdb->prepare( "SELECT id, attribute_value FROM {$table} WHERE component_name = %s AND group_name = %s AND attribute_name = %s", $component_name, $group_name, $attribute_name )); $is_editable = isset($field_config['editable']) ? (bool)$field_config['editable'] : false; $default_value = isset($field_config['default']) ? $field_config['default'] : ''; // Convertir valor a string para almacenamiento if (is_array($default_value) || is_object($default_value)) { $default_value = json_encode($default_value); } elseif (is_bool($default_value)) { $default_value = $default_value ? '1' : '0'; } else { $default_value = (string)$default_value; } if ($existing === null) { // INSERT: Campo nuevo, usar default del JSON $result = $wpdb->insert( $table, [ 'component_name' => $component_name, 'group_name' => $group_name, 'attribute_name' => $attribute_name, 'attribute_value' => $default_value, 'is_editable' => $is_editable ? 1 : 0 ], ['%s', '%s', '%s', '%s', '%d'] ); if ($result !== false) { $stats['inserted']++; } } else { // UPDATE: Campo existente, preservar valor del usuario, solo actualizar is_editable $result = $wpdb->update( $table, ['is_editable' => $is_editable ? 1 : 0], [ 'component_name' => $component_name, 'group_name' => $group_name, 'attribute_name' => $attribute_name ], ['%d'], ['%s', '%s', '%s'] ); if ($result !== false) { $stats['updated']++; } } } } return [ 'success' => true, 'message' => "Componente '{$component_name}' sincronizado correctamente", 'stats' => $stats ]; } /** * Migra configuración de visibilidad para todos los componentes * * ## EXAMPLES * * wp roi-theme migrate-visibility * * @when after_wp_load */ public function migrate_visibility(): void { $container = DIContainer::getInstance(); $service = $container->getMigratePageVisibilityService(); $result = $service->migrate(); \WP_CLI::success(sprintf( 'Migración completada: %d creados, %d omitidos', $result['created'], $result['skipped'] )); } /** * Shortcodes que DEBEN ser preservados */ private const PROTECTED_SHORTCODES = ['[roi_apu_search', '[roi_']; /** * Máximo porcentaje de contenido que puede eliminarse */ private const MAX_CONTENT_LOSS_PERCENT = 50; /** * Limpia contenido Thrive congelado de páginas (H2 y paginación) * * LIMPIEZA QUIRÚRGICA CON VALIDACIONES DE SEGURIDAD: * - Elimina H2 con data-shortcode="tcb_post_title" * - Elimina paginación rota ([tcb_pagination_current_page], [tcb_pagination_total_pages]) * - PRESERVA todo el demás contenido incluyendo shortcodes [roi_apu_search] * - Verifica que shortcodes importantes NO sean eliminados * - Aborta si se detecta pérdida excesiva de contenido (>50%) * * ## OPTIONS * * [--dry-run] * : Mostrar qué se limpiaría sin modificar nada (OBLIGATORIO primero) * * [--force] * : Ejecutar la limpieza real después de verificar dry-run * * [--include-others] * : Incluir otras páginas afectadas (Blog, Curso) * * ## EXAMPLES * * # Ver qué se limpiaría (modo seguro) - SIEMPRE PRIMERO * wp roi-theme clean_thrive --dry-run * * # Ejecutar limpieza real (requiere --force) * wp roi-theme clean_thrive --force * * @when after_wp_load */ public function clean_thrive(array $args, array $assoc_args): void { $affectedPageIds = [ 107264, 107312, 107340, 107345, 107351, 107357, 107362, 107369, 107374, 107379, 107384, 107389, 107395, 107399, 107403, 107407, 107411, 107416, 107421, 107425, 185752 ]; $otherAffectedIds = [252030, 290709]; $dryRun = isset($assoc_args['dry-run']); $includeOthers = isset($assoc_args['include-others']); $force = isset($assoc_args['force']); $pageIds = $affectedPageIds; if ($includeOthers) { $pageIds = array_merge($pageIds, $otherAffectedIds); } \WP_CLI::line(''); \WP_CLI::line('╔══════════════════════════════════════════════════════════════════╗'); \WP_CLI::line('║ LIMPIEZA QUIRÚRGICA DE CONTENIDO THRIVE CONGELADO (v2.0) ║'); \WP_CLI::line('║ Con validaciones de seguridad para proteger shortcodes ║'); \WP_CLI::line('╚══════════════════════════════════════════════════════════════════╝'); \WP_CLI::line(''); if ($dryRun) { \WP_CLI::warning('MODO DRY-RUN: No se modificará ningún contenido'); } else { \WP_CLI::error('MODO REAL DESHABILITADO: Ejecuta primero con --dry-run', false); \WP_CLI::line(''); \WP_CLI::line('Para ejecutar la limpieza real, primero revisa el dry-run:'); \WP_CLI::line(' wp roi-theme clean_thrive --dry-run'); \WP_CLI::line(''); \WP_CLI::line('Si el dry-run es correcto y deseas ejecutar:'); \WP_CLI::line(' wp roi-theme clean_thrive --force'); if (!$force) { return; } \WP_CLI::warning('MODO REAL CON --force: Se modificará el contenido'); } \WP_CLI::line(''); \WP_CLI::line('Páginas a procesar: ' . count($pageIds)); \WP_CLI::line('Shortcodes protegidos: ' . implode(', ', self::PROTECTED_SHORTCODES)); \WP_CLI::line('Máxima pérdida permitida: ' . self::MAX_CONTENT_LOSS_PERCENT . '%'); \WP_CLI::line(''); $totalH2Removed = 0; $totalPaginationRemoved = 0; $totalBytesFreed = 0; $pagesModified = 0; $pagesSkipped = 0; $errors = []; foreach ($pageIds as $id) { $page = get_post($id); if (!$page) { \WP_CLI::warning("Página {$id} no encontrada, saltando..."); continue; } $originalContent = $page->post_content; $originalSize = strlen($originalContent); $hasThrive = strpos($originalContent, 'tcb_post_title') !== false || strpos($originalContent, 'tcb_pagination') !== false; if (!$hasThrive) { \WP_CLI::line(sprintf("[SIN THRIVE] ID %d: %s", $id, mb_substr($page->post_title, 0, 50))); continue; } $h2Count = preg_match_all('/]*>\s*]*data-shortcode="tcb_post_title"[^>]*>.*?<\/span>\s*<\/h2>/s', $originalContent); $protectedBefore = $this->countProtectedShortcodes($originalContent); $cleanResult = $this->cleanThriveContentSafely($originalContent); if ($cleanResult['error']) { $errors[] = "ID {$id}: {$cleanResult['error']}"; \WP_CLI::error(sprintf("[ERROR] ID %d: %s - %s", $id, mb_substr($page->post_title, 0, 40), $cleanResult['error']), false); $pagesSkipped++; continue; } $cleanedContent = $cleanResult['content']; $newSize = strlen($cleanedContent); $protectedAfter = $this->countProtectedShortcodes($cleanedContent); if ($protectedAfter < $protectedBefore) { $errors[] = "ID {$id}: Se perderían shortcodes protegidos ({$protectedBefore} → {$protectedAfter})"; \WP_CLI::error(sprintf("[ABORTADO] ID %d: Se perderían shortcodes protegidos (%d → %d)", $id, $protectedBefore, $protectedAfter), false); $pagesSkipped++; continue; } $lossPercent = $originalSize > 0 ? (($originalSize - $newSize) / $originalSize) * 100 : 0; if ($lossPercent > self::MAX_CONTENT_LOSS_PERCENT) { $errors[] = "ID {$id}: Pérdida excesiva de contenido ({$lossPercent}%)"; \WP_CLI::error(sprintf("[ABORTADO] ID %d: Pérdida excesiva %.1f%% (máx %d%%)", $id, $lossPercent, self::MAX_CONTENT_LOSS_PERCENT), false); $pagesSkipped++; continue; } $hasChanges = $originalContent !== $cleanedContent; $bytesSaved = $originalSize - $newSize; $paginationRemoved = (strpos($originalContent, 'tcb_pagination_current_page') !== false && strpos($cleanedContent, 'tcb_pagination_current_page') === false) ? 1 : 0; if ($hasChanges) { $pagesModified++; $totalH2Removed += $h2Count; $totalPaginationRemoved += $paginationRemoved; $totalBytesFreed += $bytesSaved; $status = $dryRun ? '[DRY-RUN]' : '[LIMPIADO]'; \WP_CLI::line(sprintf("%s ID %d: %s", $status, $id, mb_substr($page->post_title, 0, 50) . (mb_strlen($page->post_title) > 50 ? '...' : ''))); \WP_CLI::line(sprintf(" → H2 eliminados: %d | Paginación: %s | Pérdida: %.1f%%", $h2Count, $paginationRemoved ? 'Sí' : 'No', $lossPercent)); \WP_CLI::line(sprintf(" → Shortcodes [roi_*] preservados: %d | Bytes liberados: %s", $protectedAfter, $this->formatBytes($bytesSaved))); if (!$dryRun && $force) { wp_update_post(['ID' => $id, 'post_content' => $cleanedContent]); } } else { \WP_CLI::line(sprintf("[SIN CAMBIOS] ID %d: %s", $id, mb_substr($page->post_title, 0, 50))); } } \WP_CLI::line(''); \WP_CLI::line('════════════════════════════════════════════════════════════════════'); \WP_CLI::line('RESUMEN:'); \WP_CLI::line(sprintf(' Páginas modificadas: %d', $pagesModified)); \WP_CLI::line(sprintf(' Páginas omitidas: %d', $pagesSkipped)); \WP_CLI::line(sprintf(' Total H2 eliminados: %d', $totalH2Removed)); \WP_CLI::line(sprintf(' Paginaciones removidas: %d', $totalPaginationRemoved)); \WP_CLI::line(sprintf(' Espacio liberado: %s', $this->formatBytes($totalBytesFreed))); \WP_CLI::line('════════════════════════════════════════════════════════════════════'); if (count($errors) > 0) { \WP_CLI::line(''); \WP_CLI::warning('ERRORES ENCONTRADOS:'); foreach ($errors as $error) { \WP_CLI::line(" - {$error}"); } } if ($dryRun && $pagesModified > 0 && count($errors) === 0) { \WP_CLI::line(''); \WP_CLI::success('Dry-run completado SIN errores.'); \WP_CLI::line(''); \WP_CLI::warning('Para ejecutar la limpieza real:'); \WP_CLI::line(' wp roi-theme clean_thrive --force'); } elseif (!$dryRun && $force && $pagesModified > 0) { \WP_CLI::line(''); \WP_CLI::success('Limpieza completada exitosamente.'); \WP_CLI::line(''); \WP_CLI::warning('IMPORTANTE: Purga el caché del sitio para ver los cambios.'); } } /** * Limpia el contenido con validaciones de seguridad * @return array{content: string, error: string|null} */ private function cleanThriveContentSafely(string $content): array { $originalContent = $content; // Patrón específico: H2 que contiene span con data-shortcode="tcb_post_title" // Estructura:

...

$result = preg_replace('/]*>\s*]*data-shortcode="tcb_post_title"[^>]*>.*?<\/span>\s*<\/h2>/s', '', $content); if ($result === null) { return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón H2']; } $content = $result; $result = preg_replace('/]*>.*?\[tcb_pagination_current_page\].*?\[tcb_pagination_total_pages\].*?<\/p>/s', '', $content); if ($result === null) { return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón paginación']; } $content = $result; $result = preg_replace('/]*data-button_layout="[^"]*"[^>]*data-page="[^"]*"[^>]*>.*?<\/p>/s', '', $content); if ($result === null) { return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón botones']; } $content = $result; $content = str_replace('[tcb_pagination_current_page]', '', $content); $content = str_replace('[tcb_pagination_total_pages]', '', $content); $result = preg_replace('/(\r?\n){3,}/', "\n\n", $content); if ($result === null) { return ['content' => $originalContent, 'error' => 'preg_replace falló en limpieza líneas']; } $content = trim($result); if (empty($content) && !empty($originalContent)) { return ['content' => $originalContent, 'error' => 'El contenido quedó vacío']; } return ['content' => $content, 'error' => null]; } /** * Cuenta shortcodes protegidos en el contenido */ private function countProtectedShortcodes(string $content): int { $count = 0; foreach (self::PROTECTED_SHORTCODES as $shortcode) { $count += substr_count($content, $shortcode); } return $count; } /** * Formatea bytes a formato legible */ private function formatBytes(int $bytes): string { if ($bytes < 1024) { return $bytes . ' B'; } elseif ($bytes < 1048576) { return round($bytes / 1024, 1) . ' KB'; } else { return round($bytes / 1048576, 2) . ' MB'; } } } // Registrar comando WP-CLI if (defined('WP_CLI') && WP_CLI) { \WP_CLI::add_command('roi-theme', MigrationCommand::class); }