Files
roi-theme/Shared/Infrastructure/Api/WordPress/MigrationCommand.php
FrankZamora 8735962f52 feat(visibility): sistema de visibilidad por tipo de página
- Añadir PageVisibility use case y repositorio
- Implementar PageTypeDetector para detectar home/single/page/archive
- Actualizar FieldMappers con soporte show_on_[page_type]
- Extender FormBuilders con UI de visibilidad por página
- Refactorizar Renderers para evaluar visibilidad dinámica
- Limpiar schemas removiendo campos de visibilidad legacy
- Añadir MigrationCommand para migrar configuraciones existentes
- Implementar adsense-loader.js para carga lazy de ads
- Actualizar front-page.php con nueva estructura
- Extender DIContainer con nuevos servicios

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 09:16:34 -06:00

600 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Api\WordPress;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
/**
* WP-CLI Command para Sincronización de Schemas
*
* Responsabilidad: Sincronizar schemas JSON a base de datos
*
* COMANDOS DISPONIBLES:
* - wp roi-theme sync-component [nombre] : Sincronizar un componente específico
* - wp roi-theme sync-all-components : Sincronizar todos los componentes
*
* USO:
* ```bash
* # Sincronizar un componente específico
* wp roi-theme sync-component top-notification-bar
*
* # Sincronizar todos los componentes
* wp roi-theme sync-all-components
* ```
*
* IMPORTANTE:
* - Si un campo NO existe en BD → INSERT con valor default del JSON
* - Si un campo YA existe → PRESERVA el valor del usuario, solo actualiza is_editable
*/
final class MigrationCommand
{
/**
* Sincronizar un componente específico desde su schema JSON a la BD
*
* ## OPCIONES
*
* <component_name>
* : 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('/<h2[^>]*>\s*<span[^>]*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: <h2><span data-shortcode="tcb_post_title"...>...</span></h2>
$result = preg_replace('/<h2[^>]*>\s*<span[^>]*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('/<p[^>]*>.*?\[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('/<p[^>]*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);
}