Merge pull request 'feat: preparar migración del evangelio diario (issue #20)' from feat/issue-20-deploy into main

Aprobado por Rafa. Revisión (Claudix, 2026-08-30): importador idempotente con guardas de match dudoso/colisión antes de escribir; despliegue del JSON del calendario cableado en el propio import; fallback civil mientras no está desplegado. Dry-run contra producción limpio tras despejar 5 casos de contenido duplicado real (497/500 en cobertura, 304 existentes + 196 nuevos = 500, 0 ambiguos/duplicados/sin-coincidencia).
This commit is contained in:
2026-08-30 09:12:14 -04:00
2 changed files with 141 additions and 9 deletions
+48 -9
View File
@@ -1204,8 +1204,9 @@ add_shortcode('fea_evangelio', function($atts) {
// "El Evangelio de cada día": dos devocionales diarios indexados por día del año.
// · A la fuente cada día (texto, Fray Marcos) → categoría term_id 14
// · Otro evangelio es posible (vídeo YouTube) → categoría term_id 15
// Los posts están titulados "D mes" (ej. "21 junio"). Se muestra el de HOY
// (zona horaria del sitio) o el día indicado por ?fed=M-D, con pestañas para
// El texto de Fray Marcos se resuelve contra el calendario litúrgico versionado
// (fecha ISO -> book_index), no por el título civil heredado de Joomla. Se muestra
// el de HOY (zona horaria del sitio) o el día indicado por ?fed=M-D, con pestañas para
// que el usuario elija formato y navegación día anterior / siguiente.
// Contenido solo en ES (devocional sin traducción) → categorías 14/15 fijas.
add_shortcode('fea_evangelio_diario', function($atts) {
@@ -1220,23 +1221,61 @@ add_shortcode('fea_evangelio_diario', function($atts) {
$gm = (int) $mm[1]; $gd = (int) $mm[2];
if ($gm >= 1 && $gm <= 12 && $gd >= 1 && $gd <= 31) { $m = $gm; $d = $gd; }
}
$titulo_dia = $d . ' ' . $MESES[$m]; // "21 junio"
$date = $now->setDate((int) $now->format('Y'), $m, $d);
$calendar_path = defined('FEA_EVANGELIO_CALENDAR_PATH')
? FEA_EVANGELIO_CALENDAR_PATH
: WP_CONTENT_DIR . '/fea-data/calendario-liturgico-2026-2040.json';
$calendar = is_readable($calendar_path)
? json_decode((string) file_get_contents($calendar_path), true)
: null;
$book_index = (int) ($calendar['dates'][$date->format('Y-m-d')]['book_index'] ?? 0);
$find = function($cat) use ($titulo_dia) {
$find_text = function($index) {
global $wpdb;
if ($index < 1) return null;
$id = $wpdb->get_var($wpdb->prepare(
"SELECT p.ID FROM {$wpdb->posts} p
JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
WHERE pm.meta_key = '_fea_book_index' AND pm.meta_value = %d
AND p.post_type = 'post' AND p.post_status = 'publish'
ORDER BY p.ID ASC LIMIT 1",
$index));
return $id ? get_post((int) $id) : null;
};
$texto = $find_text($book_index);
// El vídeo queda deliberadamente con el índice civil heredado: su catálogo
// no forma parte de la migración litúrgica de #20.
$titulo_dia = $d . ' ' . $MESES[$m];
$find_video = function() use ($titulo_dia) {
global $wpdb;
$id = $wpdb->get_var($wpdb->prepare(
"SELECT p.ID FROM {$wpdb->posts} p
JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
WHERE tt.taxonomy = 'category' AND tt.term_id = %d
WHERE tt.taxonomy = 'category' AND tt.term_id = 15
AND p.post_type = 'post' AND p.post_status = 'publish'
AND LOWER(TRIM(p.post_title)) = %s
ORDER BY p.ID ASC LIMIT 1",
$cat, $titulo_dia));
ORDER BY p.ID ASC LIMIT 1", $titulo_dia));
return $id ? get_post((int) $id) : null;
};
$texto = $find(14);
$video = $find(15);
$video = $find_video();
// Hasta que el importador instale el JSON y reindexe, conservar el contenido
// civil heredado evita dejar la pestaña de texto en blanco durante el despliegue.
if (!$texto && !$book_index) {
$legacy_text = function() use ($titulo_dia) {
global $wpdb;
$id = $wpdb->get_var($wpdb->prepare(
"SELECT p.ID FROM {$wpdb->posts} p
JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
WHERE tt.taxonomy = 'category' AND tt.term_id = 14
AND p.post_type = 'post' AND p.post_status = 'publish'
AND LOWER(TRIM(p.post_title)) = %s
ORDER BY p.ID ASC LIMIT 1", $titulo_dia));
return $id ? get_post((int) $id) : null;
};
$texto = $legacy_text();
}
// navegación por día del calendario (año irrelevante; usamos un año bisiesto fijo)
$base = get_permalink();
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* Migra A la fuente cada día al índice estable del libro.
*
* Uso (por defecto solo informa):
* wp eval-file scripts/import_evangelio_diario.php --path=/var/www/html
* Escritura explícita: FEA_EVANGELIO_APPLY=1 wp eval-file ...
*
* Requiere que entradas-libro.json esté junto a este script durante la ejecución.
* Nunca elimina posts: actualiza únicamente los ya identificados de categoría 14 y
* crea los índices ausentes. Es idempotente por _fea_book_index.
*/
$apply = getenv('FEA_EVANGELIO_APPLY') === '1';
$book_file = __DIR__ . '/../data/evangelio-diario/entradas-libro.json';
$calendar_file = __DIR__ . '/../data/evangelio-diario/calendario-liturgico-2026-2040.json';
if (!is_readable($book_file)) { fwrite(STDERR, "No se lee $book_file\n"); exit(2); }
if (!is_readable($calendar_file)) { fwrite(STDERR, "No se lee $calendar_file\n"); exit(2); }
$book = json_decode(file_get_contents($book_file), true, 512, JSON_THROW_ON_ERROR);
if (count($book) !== 500) { fwrite(STDERR, "El libro debe tener 500 entradas\n"); exit(2); }
$norm = static function ($text) {
$text = html_entity_decode(wp_strip_all_tags((string) $text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return preg_replace('/\s+/u', ' ', trim(mb_strtolower($text, 'UTF-8')));
};
$by_motto = [];
foreach ($book as $i => $entry) {
$key = $norm($entry['motto']);
if ($key !== '') $by_motto[$key][] = $i + 1;
}
$existing = get_posts([
'post_type' => 'post', 'post_status' => 'any', 'posts_per_page' => -1,
'category' => 14, 'orderby' => 'ID', 'order' => 'ASC',
]);
$used = [];
$updated = $ambiguous = $unmatched = $duplicate = 0;
$to_update = [];
foreach ($existing as $post) {
$matches = [];
foreach ($by_motto as $motto => $indices) {
if (str_contains($norm($post->post_content), $motto)) $matches = array_merge($matches, $indices);
}
$matches = array_values(array_unique($matches));
if (count($matches) !== 1) {
$ambiguous += count($matches) > 1; $unmatched += count($matches) === 0;
continue;
}
if (isset($used[$matches[0]])) { $duplicate++; continue; }
$idx = $matches[0]; $used[$idx] = true;
$entry = $book[$idx - 1];
$to_update[] = [$post->ID, $idx, $entry];
$updated++;
}
$planned_new = count($book) - count($used);
if ($ambiguous || $unmatched || $duplicate) {
printf("DRY-RUN: existentes indexados=%d; nuevos=%d; ambiguos=%d; sin coincidencia=%d; duplicados=%d; total=%d\n",
$updated, $planned_new, $ambiguous, $unmatched, $duplicate, count($book));
fwrite(STDERR, "ABORTAR APLICACIÓN: hay posts existentes sin correspondencia única.\n");
exit(3);
}
// El JSON se instala antes de tocar contenido: el shortcode lo consume desde
// WP_CONTENT_DIR/fea-data y así código y calendario llegan como una unidad.
if ($apply) {
$calendar_target = WP_CONTENT_DIR . '/fea-data/calendario-liturgico-2026-2040.json';
if (!wp_mkdir_p(dirname($calendar_target)) || !copy($calendar_file, $calendar_target)) {
fwrite(STDERR, "No se pudo instalar el calendario en $calendar_target\n"); exit(1);
}
}
if ($apply) foreach ($to_update as [$post_id, $idx, $entry]) {
update_post_meta($post_id, '_fea_book_index', (string) $idx);
update_post_meta($post_id, '_fea_cita', $entry['citation']);
update_post_meta($post_id, '_fea_fuente', 'A la fuente cada día — Fray Marcos, entrada #' . $idx);
}
$created = 0;
foreach ($book as $i => $entry) {
$idx = $i + 1;
if (isset($used[$idx])) continue;
$body = '<h1>' . esc_html($entry['title']) . '</h1>'
. '<p><strong>' . esc_html($entry['citation']) . '</strong></p>'
. '<p>' . esc_html($entry['gospel']) . '</p>'
. '<p><em>' . esc_html($entry['motto']) . '</em></p>';
foreach ($entry['paragraphs'] as $paragraph) $body .= '<p>' . esc_html($paragraph) . '</p>';
if ($apply) {
$id = wp_insert_post(['post_title' => $entry['title'], 'post_content' => $body,
'post_status' => 'publish', 'post_category' => [14]], true);
if (is_wp_error($id)) { fwrite(STDERR, $id->get_error_message() . "\n"); exit(1); }
update_post_meta($id, '_fea_book_index', (string) $idx);
update_post_meta($id, '_fea_cita', $entry['citation']);
update_post_meta($id, '_fea_fuente', 'A la fuente cada día — Fray Marcos, entrada #' . $idx);
}
$created++;
}
printf("%s: existentes indexados=%d; nuevos=%d; ambiguos=%d; sin coincidencia=%d; duplicados=%d; total=%d\n",
$apply ? 'APLICADO' : 'DRY-RUN', $updated, $created, $ambiguous, $unmatched, $duplicate, count($book));