Compare commits

..

5 Commits

Author SHA1 Message Date
rafa bc3af3cbb9 Verify complete audio assets before browser playback 2026-08-20 11:50:00 -04:00
rafa 68b95aae89 fix: extend accessible typography
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 10:50:56 -04:00
rafa 82c352aa15 fix: refine dark search states
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 01:01:26 -04:00
rafa 51ae723c5a fix: correct dark mode search and slider
Co-authored-by: rafa <rcalvotorrejon@gmail.com>
Signed-off-by: rafa <rcalvotorrejon@gmail.com>
2026-08-09 00:39:32 -04:00
rafa 7a623e8bb9 accesibilidad: Atkinson Hyperlegible + toggle claro/oscuro (#196)
Mu-plugin fea-accesibilidad.php:
- Atkinson Hyperlegible (OFL) self-hosted, subset latin regular+bold,
  sustituye la tipografia global del cuerpo sin selector.
- Toggle claro/oscuro persistente (localStorage), anti-flash en wp_head,
  boton inyectado junto al selector de idioma en el header.
- color-scheme: dark para controles nativos (audio, inputs) + overrides
  puntuales donde el sitio fija colores claros a mano (hero, tarjetas,
  selector de idioma, buscador movil, beta feedback, cookie consent).
- Acento mas claro (#e8899e) solo en modo oscuro: el carmesi de marca
  (#8b1a2e) no llega a 2:1 de contraste sobre el fondo casi negro.

QA local (docker wordpress-web, capturas Playwright en tools/e2e/out/):
portada, tarjetas y single verificados en claro y oscuro.
2026-08-09 00:15:45 -04:00
10 changed files with 369 additions and 216 deletions
+1
View File
@@ -17,6 +17,7 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
| `fea-homepage.php` | Tarjetas de portada |
| `fea-share.php` | Sección "Comparte FeAdulta" (Facebook, Instagram, Imprimir) |
| `fea-ui.php` | Estilos globales, menú, fondo cálido en artículos |
| `fea-accesibilidad.php` | Tipografía Atkinson Hyperlegible (self-hosted) + toggle claro/oscuro persistente |
| `fea-beta-feedback.php` | Barra de feedback beta + endpoint `/fea/v1/lang/{id}` |
| `fea-search.php` | Buscador móvil nativo |
| `fea-pensamientos.php` | Widget de pensamientos |
-9
View File
@@ -407,14 +407,5 @@ traducción inmediata, avisar a Rafa directamente además de pedírselo a Hermes
| Asignar `_carta_id` a un artículo | `POST /wp-json/fea/v1/carta-id/{id}` con body `{"carta_id": N}` |
| Borrar `_carta_id` de un artículo | `DELETE /wp-json/fea/v1/carta-id/{id}` |
## Regla para la sección Evangelio y comentarios al Evangelio
La sección empieza siempre con el enlace al post del evangelio de esa semana (por
ejemplo, `MATEO 14, 22-33`) y después van los comentarios. Si ese post no existe,
avisad antes de publicar la carta: no empecéis la sección por un comentario.
No hay que completar ningún campo nuevo. A partir de ese enlace, el sitio asigna la
cita bíblica automáticamente a los comentarios nuevos.
Campos útiles del post: `title`, `content` (HTML), `status` (`draft`/`publish`/`future`),
`date` (ISO 8601 para programar), `categories` (array de IDs), `featured_media` (ID de adjunto).
+276
View File
@@ -0,0 +1,276 @@
<?php
/**
* Plugin Name: Fe Adulta — Accesibilidad (tipografía + contraste)
* Description: Tipografía Atkinson Hyperlegible global (self-hosted) + toggle
* claro/oscuro persistente, pensado para el público mayor de
* Fe Adulta (issue rafa/feadulta#196, evidencia en comment #609).
* Version: 1.0
*
* Alcance decidido en el issue (2026-08-08): SOLO dos cosas, sin más superficie
* de UI que la necesaria —
* 1. Atkinson Hyperlegible sustituye la tipografía del cuerpo, sin selector.
* 2. Un único toggle claro/oscuro persistente (claro por defecto).
* Nada de panel de accesibilidad, A+/Aa ni cambio de tamaño de letra.
*/
if (!defined('ABSPATH')) exit;
if (!defined('FEA_A11Y_ACCENT_DARK')) {
// Variante clara del carmesí de marca (#8b1a2e) para que enlaces/foco
// cumplan contraste AA sobre fondo casi negro (el carmesí original da
// ~1.8:1 sobre #0d0d0d, muy por debajo del 4.5:1 que exige texto).
define('FEA_A11Y_ACCENT_DARK', '#e8899e');
}
/* ── 1) Anti-flash: fija el tema ANTES de que el navegador pinte nada ─────
* Se engancha con prioridad muy baja para salir de los primeros bytes de
* <head>, igual que el resto del sitio guarda preferencias en localStorage
* (ver fea-beta-feedback.php). Sin esto habría un parpadeo claro→oscuro. */
add_action('wp_head', function () {
if (is_admin()) return;
?>
<script>(function(){try{
var t = localStorage.getItem('fea_theme');
if (t === 'dark') document.documentElement.setAttribute('data-fea-theme', 'dark');
}catch(e){}})();</script>
<?php
}, 1);
/* ── 2) Fuente self-hosted + estilos de tema oscuro ───────────────────── */
add_action('wp_head', function () {
if (is_admin()) return;
$base = plugins_url('fea-accesibilidad/fonts', __FILE__);
$accent_dark = FEA_A11Y_ACCENT_DARK;
?>
<style id="fea-a11y">
/* ── Atkinson Hyperlegible (Braille Institute, OFL), subset latin ──── */
@font-face {
font-family: 'Atkinson Hyperlegible';
font-style: normal;
font-weight: 100 500;
font-display: swap;
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Atkinson Hyperlegible';
font-style: normal;
font-weight: 501 900;
font-display: swap;
src: url('<?php echo esc_url($base); ?>/atkinson-hyperlegible-bold.woff2') format('woff2');
}
/* Sustitución global del cuerpo de texto (no opcional, issue #196).
Especificidad baja a propósito: el menú (fea-ui.php, issue #95) usa
una sans de UI más específica y debe seguir ganando. */
body {
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
h1, h2, h3, h4, h5, h6,
.fea-hero-title,
.fea-section-title {
font-family: 'Atkinson Hyperlegible', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
input, textarea, select, button {
font-family: inherit;
}
/* ── Toggle claro/oscuro: botón inyectado junto al selector de idioma ── */
#fea-theme-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 50%;
border: 1px solid rgba(0,0,0,0.25); background: none; color: inherit;
cursor: pointer; font-size: 0.95rem; line-height: 1; padding: 0;
}
#fea-theme-btn:hover { background: rgba(0,0,0,0.06); }
/* ══════════════════════════════════════════════════════════════════
MODO OSCURO — activado por [data-fea-theme="dark"] en <html>
══════════════════════════════════════════════════════════════════ */
html[data-fea-theme="dark"] {
/* Deja que el navegador oscurezca controles nativos gratis:
audio/video, scrollbars, checkboxes, selects sin estilo propio. */
color-scheme: dark;
--wp--preset--color--base: #0d0d0d;
--wp--preset--color--contrast: #ececec;
}
html[data-fea-theme="dark"] body {
background-color: #0d0d0d !important;
color: #ececec !important;
}
html[data-fea-theme="dark"] a { color: <?php echo esc_html($accent_dark); ?>; }
html[data-fea-theme="dark"] a:focus-visible,
html[data-fea-theme="dark"] button:focus-visible {
outline-color: <?php echo esc_html($accent_dark); ?> !important;
}
/* Cabecera/pie y bloques de grupo sin fondo explícito heredan el body;
solo hay que corregir los pocos sitios con color fijo en claro. */
html[data-fea-theme="dark"] input,
html[data-fea-theme="dark"] textarea,
html[data-fea-theme="dark"] select {
background-color: #1c1c1c;
color: #ececec;
border-color: #3a3a3a;
}
/* Search block uses more-specific own color rules. */
html[data-fea-theme="dark"] .wp-block-search__inside-wrapper,
html[data-fea-theme="dark"] .wp-block-search__input {
background-color: #1c1c1c;
color: #ececec;
border-color: #3a3a3a;
}
html[data-fea-theme="dark"] .wp-block-search__button {
background-color: #ececec;
color: #0d0d0d;
border-color: #ececec;
}
html[data-fea-theme="dark"] .fea-adv-link-wrap { background: #161311; border-bottom-color: #2a2320; }
html[data-fea-theme="dark"] .fea-adv-link { color: <?php echo esc_html($accent_dark); ?>; }
/* Crop the white edge embedded in slide images in dark mode only. */
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image {
overflow: hidden;
}
html[data-fea-theme="dark"] .fea-hero-slider .n2-ss-slide-background-image img {
transform: scale(1.015);
}
/* Portada — franja hero (fea-homepage.php) */
html[data-fea-theme="dark"] .fea-hero-band {
background: linear-gradient(180deg, #1a1512, #0d0d0d);
border-bottom-color: #2a2320;
}
html[data-fea-theme="dark"] .fea-hero-title,
html[data-fea-theme="dark"] .fea-section-title {
color: #ececec;
}
html[data-fea-theme="dark"] .fea-section-label,
html[data-fea-theme="dark"] .fea-section-more,
html[data-fea-theme="dark"] .fea-card-author {
color: <?php echo esc_html($accent_dark); ?>;
}
html[data-fea-theme="dark"] .fea-hero-meta { color: #b8afa8; }
/* Tarjetas de autor/artículo (fea-homepage.php) */
html[data-fea-theme="dark"] .fea-card {
background: #161311;
border-color: #2a2320;
}
html[data-fea-theme="dark"] .fea-card-title a { color: #ececec; }
/* Selector de idioma (fea-homepage.php) */
html[data-fea-theme="dark"] #fea-lang-btn { border-color: rgba(255,255,255,0.3); }
html[data-fea-theme="dark"] #fea-lang-btn:hover,
html[data-fea-theme="dark"] #fea-theme-btn:hover { background: rgba(255,255,255,0.08); }
html[data-fea-theme="dark"] #fea-theme-btn { border-color: rgba(255,255,255,0.3); }
html[data-fea-theme="dark"] #fea-lang-dropdown {
background: #161311; border-color: #3a3a3a;
box-shadow: 0 6px 16px rgba(0,0,0,.5);
}
html[data-fea-theme="dark"] #fea-lang-dropdown a { color: #ececec; }
html[data-fea-theme="dark"] #fea-lang-dropdown a:hover { background: #241f1c; }
html[data-fea-theme="dark"] #fea-lang-dropdown a[aria-current] { background: #2a2320; color: #fff; }
/* Buscador móvil (fea-search.php) */
html[data-fea-theme="dark"] .fea-search-bar { background: #0d0d0d; border-bottom-color: #2a2320; }
html[data-fea-theme="dark"] .fea-search { background: #1c1c1c; border-color: #3a3a3a; }
html[data-fea-theme="dark"] .fea-search input[type=search] { color: #ececec; }
/* Reproductor de audio (fea-audio-player.php) — el <audio> nativo ya
cambia solo vía color-scheme; solo hace falta la etiqueta de texto. */
html[data-fea-theme="dark"] .fea-audio-label { color: #b8afa8; }
/* Barra y tarjeta de Beta Feedback (fea-beta-feedback.php) */
html[data-fea-theme="dark"] #fea-beta-bar {
background: #161311; border-top-color: #2a2320; color: #ececec;
}
html[data-fea-theme="dark"] #fea-beta-bar .fea-beta-dismiss { color: #b8afa8; }
html[data-fea-theme="dark"] #fea-fb .fea-fb-card {
background: #161311; border-color: #3a3a3a; color: #ececec;
}
html[data-fea-theme="dark"] #fea-fb button.fea-fb-vote {
background: #1c1c1c; border-color: #3a3a3a; color: #ececec;
}
html[data-fea-theme="dark"] #fea-fb button.fea-fb-vote.sel {
border-color: <?php echo esc_html($accent_dark); ?>; background: #2a1418;
}
html[data-fea-theme="dark"] #fea-fb textarea { background: #1c1c1c; border-color: #3a3a3a; color: #ececec; }
/* Barra de consentimiento de cookies (fea-cookie-consent.php) */
html[data-fea-theme="dark"] #fea-cc {
background: #161311 !important; color: #ececec !important; border-top-color: #2a2320 !important;
}
html[data-fea-theme="dark"] #fea-cc a { color: #b8afa8 !important; }
html[data-fea-theme="dark"] #fea-cc .fea-cc-reject { color: #ececec !important; border-color: #3a3a3a !important; }
</style>
<?php
}, 30);
/* ── 3) Botón de toggle, inyectado en el header junto al selector de idioma ──
* Mismo patrón que el selector de idioma (fea-homepage.php): se crea por JS
* y se inserta en el nav del header, para no tocar el template FSE. */
add_action('wp_footer', function () {
if (is_admin()) return;
?>
<script>
(function() {
var btn = document.createElement('button');
btn.id = 'fea-theme-btn';
btn.type = 'button';
btn.setAttribute('aria-pressed', document.documentElement.getAttribute('data-fea-theme') === 'dark' ? 'true' : 'false');
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
btn.textContent = document.documentElement.getAttribute('data-fea-theme') === 'dark' ? '☀️' : '🌙';
function setLabel(isDark) {
btn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
btn.setAttribute('aria-label', isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro');
btn.textContent = isDark ? '☀️' : '🌙';
}
btn.addEventListener('click', function() {
var isDark = document.documentElement.getAttribute('data-fea-theme') === 'dark';
if (isDark) {
document.documentElement.removeAttribute('data-fea-theme');
} else {
document.documentElement.setAttribute('data-fea-theme', 'dark');
}
setLabel(!isDark);
try { localStorage.setItem('fea_theme', !isDark ? 'dark' : 'light'); } catch (e) {}
});
function inject() {
var header = document.querySelector('header.wp-block-template-part') || document.querySelector('header');
if (!header) return;
var navGroup = header.querySelector('.wp-block-navigation__container') || header.querySelector('nav');
var li = document.createElement('li');
li.className = 'wp-block-navigation-item';
li.style.cssText = 'display:flex;align-items:center;margin-left:0.5rem;';
li.appendChild(btn);
if (!navGroup) {
header.style.position = 'relative';
li.style.cssText = 'position:absolute;top:50%;right:1.5rem;transform:translateY(-50%);';
header.appendChild(li);
return;
}
// Insertar después del selector de idioma si ya se inyectó, si no, al final del nav.
var langSwitcher = navGroup.querySelector('#fea-lang-switcher');
if (langSwitcher) {
var anchor = langSwitcher;
while (anchor.parentNode && anchor.parentNode !== navGroup) anchor = anchor.parentNode;
anchor.parentNode.insertBefore(li, anchor.nextSibling);
} else {
navGroup.appendChild(li);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', inject);
} else {
inject();
}
})();
</script>
<?php
}, 21);
+62 -12
View File
@@ -13,11 +13,23 @@ function fea_audio_player_html(): string {
$url = get_post_meta($id, 'fea_audio_url', true);
if (!$url) return '';
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
return '<div class="fea-audio">'
. '<span class="fea-audio-label">'
$sha256 = get_post_meta($id, 'fea_audio_sha256', true);
$label = '<span class="fea-audio-label">'
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
. '</svg> Escucha</span>'
. '</svg> Escucha</span>';
// Para un asset con hash registrado, descarga y verifica el fichero completo
// antes de delegar playback al navegador. Evita que un stream Range mezclado
// por una caché reproduzca fragmentos de otro MP3.
if (preg_match('/^[a-f0-9]{64}$/i', $sha256)) {
return '<div class="fea-audio">' . $label
. '<button type="button" class="fea-audio-verified-play" data-fea-audio-verified-play'
. ' data-src="' . esc_url($url) . '" data-sha256="' . esc_attr(strtolower($sha256)) . '"'
. ' data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '">Reproducir audio</button>'
. '<audio controls preload="none" hidden data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '<span class="fea-audio-status" role="status" aria-live="polite"></span></div>';
}
return '<div class="fea-audio">' . $label
. '<audio controls preload="none" src="' . esc_url($url) . '"'
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '</div>';
@@ -56,6 +68,9 @@ add_action('wp_head', function () {
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
.fea-audio audio{height:32px;width:230px;max-width:44vw}
.fea-audio-verified-play{border:1px solid #8b1a2e;border-radius:6px;background:#fff;color:#8b1a2e;padding:.35rem .6rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
.fea-audio-verified-play:disabled{opacity:.65;cursor:wait}
.fea-audio-status{font-size:.76rem;color:#6b5c60}
@media(max-width:600px){
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
@@ -73,17 +88,52 @@ add_action('wp_footer', function () {
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
?>
<script>
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
var fired = false;
audio.addEventListener('play', function () {
if (fired || typeof gtag !== 'function') return;
fired = true;
gtag('event', 'audio_play', {
post_id: audio.dataset.postId,
voice: audio.dataset.voice
(function () {
function hex(buffer) {
return Array.from(new Uint8Array(buffer)).map(function (n) {
return n.toString(16).padStart(2, '0');
}).join('');
}
document.querySelectorAll('[data-fea-audio-verified-play]').forEach(function (button) {
button.addEventListener('click', async function () {
var wrap = button.closest('.fea-audio');
var audio = wrap.querySelector('audio[data-fea-audio-track]');
var status = wrap.querySelector('.fea-audio-status');
button.disabled = true;
status.textContent = 'Verificando audio…';
try {
// Se descarga el objeto entero una vez y se valida su hash. Sólo
// entonces se crea un blob local: el <audio> no hará Range contra
// CDN y jamás podrá mezclar fragmentos de otro MP3.
var response = await fetch(button.dataset.src, {cache: 'no-store'});
if (!response.ok) throw new Error('HTTP ' + response.status);
var bytes = await response.arrayBuffer();
var digest = hex(await crypto.subtle.digest('SHA-256', bytes));
if (digest !== button.dataset.sha256) throw new Error('La copia recibida no coincide con el audio verificado');
audio.src = URL.createObjectURL(new Blob([bytes], {type: 'audio/mpeg'}));
audio.hidden = false;
button.hidden = true;
status.textContent = '';
await audio.play();
} catch (error) {
status.textContent = 'No se pudo verificar el audio. Inténtalo de nuevo.';
button.disabled = false;
console.error('Fe Adulta audio verification failed:', error);
}
});
});
});
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
var fired = false;
audio.addEventListener('play', function () {
if (fired || typeof gtag !== 'function') return;
fired = true;
gtag('event', 'audio_play', {
post_id: audio.dataset.postId,
voice: audio.dataset.voice
});
});
});
}());
</script>
<?php
});
-159
View File
@@ -1,159 +0,0 @@
<?php
/**
* Plugin Name: Fe Adulta — Evangelio en comentarios
* Description: Deriva la cita de la carta semanal y muestra la lectura en los comentarios al evangelio.
* Version: 1.0
*/
function fea_evangelio_category($es_id) {
return function_exists('fea_cat') ? (int) fea_cat($es_id) : (int) $es_id;
}
function fea_evangelio_is_comment($post_id) {
return has_category(fea_evangelio_category(1647), $post_id);
}
/** Lee el meta del comentario; las traducciones heredan el dato editorial del original ES. */
function fea_evangelio_comment_meta($post_id, $key) {
$value = get_post_meta($post_id, $key, true);
if ($value !== '' && $value !== null) return $value;
if (!function_exists('pll_get_post')) return $value;
$source_id = (int) pll_get_post((int) $post_id, 'es');
return $source_id && $source_id !== (int) $post_id
? get_post_meta($source_id, $key, true)
: $value;
}
function fea_evangelio_reading_title($cita) {
$cita = trim(wp_strip_all_tags((string) $cita));
if (!preg_match('/^(Mt|Mc|Lc|Jn)\s+(.+)$/u', $cita, $m)) return '';
$books = ['Mt' => 'MATEO', 'Mc' => 'MARCOS', 'Lc' => 'LUCAS', 'Jn' => 'JUAN'];
return $books[$m[1]] . ' ' . preg_replace('/\s+/u', ' ', trim($m[2]));
}
/** Localiza la lectura española, sin depender del slug (que puede llevar sufijos). */
function fea_evangelio_reading_id($cita) {
global $wpdb;
static $cache = [];
$title = fea_evangelio_reading_title($cita);
if ($title === '') return 0;
if (isset($cache[$title])) return $cache[$title];
// La coincidencia se hace siempre contra la lectura original en español; después
// Polylang la sustituye por la traducción del comentario cuando exista.
$cat = 1645;
$sql = $wpdb->prepare(
"SELECT p.ID FROM {$wpdb->posts} p
INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
WHERE p.post_type = 'post' AND p.post_status = 'publish' AND p.post_title = %s
AND tt.taxonomy = 'category' AND tt.term_id = %d
ORDER BY p.post_date DESC LIMIT 1",
$title, $cat
);
return $cache[$title] = (int) $wpdb->get_var($sql);
}
/** Devuelve los evangelios enlazados en la sección pertinente de una carta. */
function fea_evangelio_from_carta($carta_id) {
if (!function_exists('fea_parse_carta_sections')) return [];
if (function_exists('pll_get_post')) $carta_id = (int) (pll_get_post((int) $carta_id, 'es') ?: $carta_id);
$links = fea_parse_carta_sections((int) $carta_id)['evangelio'] ?? [];
$result = [];
foreach ($links as $id) {
$post = get_post($id);
if (!$post) continue;
$cita = get_post_meta($id, '* Cita biblica', true);
if (!$cita && preg_match('/^(MATEO|MARCOS|LUCAS|JUAN)\s+(.+)$/u', trim($post->post_title), $m)) {
$abbr = ['MATEO' => 'Mt', 'MARCOS' => 'Mc', 'LUCAS' => 'Lc', 'JUAN' => 'Jn'][$m[1]];
$cita = $abbr . ' ' . $m[2];
}
if (fea_evangelio_reading_title($cita)) $result[(string) $id] = trim($cita);
}
return $result;
}
/** Busca una carta en las tres categorías históricas, dentro de cuatro días. */
function fea_evangelio_nearest_carta_id(WP_Post $post) {
$date = mysql2date('Y-m-d', $post->post_date, false);
$cartas = get_posts([
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 10,
'category__in' => array_map('fea_evangelio_category', [6, 21, 22]),
'date_query' => [[
'after' => date('Y-m-d', strtotime($date . ' -4 days')),
'before' => date('Y-m-d', strtotime($date . ' +4 days')), 'inclusive' => true,
]],
'orderby' => 'date', 'order' => 'DESC',
]);
if (!$cartas) return 0;
usort($cartas, function($a, $b) use ($post) {
return abs(strtotime($a->post_date) - strtotime($post->post_date)) <=> abs(strtotime($b->post_date) - strtotime($post->post_date));
});
return (int) $cartas[0]->ID;
}
/** Escribe solo metadatos ausentes cuando la carta identifica un único evangelio. */
function fea_evangelio_capture_meta($post_id) {
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post' || $post->post_status !== 'publish' || !fea_evangelio_is_comment($post_id)) return false;
if (get_post_meta($post_id, '_cita_evangelio', true)) return false;
$carta_id = (int) get_post_meta($post_id, '_carta_id', true);
if (!$carta_id) $carta_id = fea_evangelio_nearest_carta_id($post);
if (!$carta_id) return false;
$evangelios = fea_evangelio_from_carta($carta_id);
if (count($evangelios) !== 1) {
error_log(sprintf('Fe Adulta: comentario %d sin cita; carta %d tiene %d evangelios.', $post_id, $carta_id, count($evangelios)));
return false;
}
update_post_meta($post_id, '_cita_evangelio', reset($evangelios));
if (!get_post_meta($post_id, '_carta_id', true)) update_post_meta($post_id, '_carta_id', $carta_id);
return true;
}
function fea_evangelio_summary_label($lang) {
$labels = [
'es' => 'Leer el evangelio', 'en' => 'Read the Gospel', 'fr' => 'Lire l’Évangile',
'it' => 'Leggi il Vangelo', 'pt' => 'Ler o Evangelho',
];
return $labels[$lang] ?? $labels['es'];
}
add_action('transition_post_status', function($new_status, $old_status, $post) {
if ($new_status === 'publish' && $old_status !== 'publish') fea_evangelio_capture_meta($post->ID);
}, 20, 3);
/** Añade referencia y texto plegado, cerrado por defecto, antes del comentario. */
add_filter('the_content', function($content) {
if (!is_single() || is_admin()) return $content;
global $post;
if (!$post || !fea_evangelio_is_comment($post->ID)) return $content;
$cita = trim((string) fea_evangelio_comment_meta($post->ID, '_cita_evangelio'));
if (!$cita) return $content;
$reading_id = fea_evangelio_reading_id($cita);
$lang = function_exists('pll_current_language') ? pll_current_language() : 'es';
if ($reading_id && $lang && $lang !== 'es' && function_exists('pll_get_post')) $reading_id = (int) (pll_get_post($reading_id, $lang) ?: $reading_id);
$reading = $reading_id ? get_post($reading_id) : null;
$starts_with_cita = preg_match('/^\s*' . preg_quote($cita, '/') . '(?:[\s\.,:;\-–—]|$)/iu', wp_strip_all_tags($content));
$html = '<section class="fea-com-evangelio" aria-label="' . esc_attr(fea_evangelio_summary_label($lang)) . '">';
if (!$starts_with_cita) $html .= '<p class="fea-com-evangelio-cita">' . esc_html($cita) . '</p>';
if ($reading && trim(wp_strip_all_tags($reading->post_content)) !== '') {
$html .= '<details class="fea-com-evangelio-details"><summary>' . esc_html(fea_evangelio_summary_label($lang)) . '</summary>'
. wpautop(do_shortcode($reading->post_content)) . '</details>';
}
return $html . '</section>' . $content;
}, 8);
add_action('wp_head', function() {
if (!is_single()) return;
global $post;
if (!$post || !fea_evangelio_is_comment($post->ID)) return;
?>
<style>
.fea-com-evangelio { margin: 0 0 1.75rem; padding: 1rem 1.15rem; background: #f7f4ed; border-left: 4px solid #a77b2d; }
.fea-com-evangelio-cita { margin: 0 0 .65rem; font-weight: 700; }
.fea-com-evangelio-details summary { cursor: pointer; font-weight: 700; color: #51401e; }
.fea-com-evangelio-details > :last-child { margin-bottom: 0; }
.fea-com-evangelio-details[open] summary { margin-bottom: .85rem; }
</style>
<?php
});
+19 -6
View File
@@ -2044,12 +2044,8 @@ add_filter('the_content', function($content) {
global $post;
if (!in_category('comentarios-al-evangelio', $post)) return $content;
$carta_id = function_exists('fea_evangelio_comment_meta')
? fea_evangelio_comment_meta($post->ID, '_carta_id')
: get_post_meta($post->ID, '_carta_id', true);
$cita = function_exists('fea_evangelio_comment_meta')
? fea_evangelio_comment_meta($post->ID, '_cita_evangelio')
: get_post_meta($post->ID, '_cita_evangelio', true);
$carta_id = get_post_meta($post->ID, '_carta_id', true);
$cita = get_post_meta($post->ID, '_cita_evangelio', true);
if (!$carta_id && !$cita) return $content;
@@ -2070,6 +2066,23 @@ add_filter('the_content', function($content) {
}
}
// --- Cita del evangelio — link directo al anchor en el índice ---
if ($cita) {
$book_post_id = ['Mt' => 43908, 'Mc' => 43909, 'Lc' => 43907, 'Jn' => 43906];
$abbr = substr($cita, 0, 2);
$anchor = fea_cita_to_anchor($cita);
$index_url = isset($book_post_id[$abbr])
? esc_url(get_permalink($book_post_id[$abbr]) . '#' . $anchor)
: '';
$html .= '<div class="fea-com-row fea-com-cita">'
. '<span class="fea-com-label">Evangelio</span>'
. ($index_url
? '<a href="' . $index_url . '">' . esc_html($cita) . '</a>'
: '<span>' . esc_html($cita) . '</span>')
. '</div>';
}
// --- Otros comentarios de esta misma cita ---
if ($cita) {
$others = get_posts([
-26
View File
@@ -1,26 +0,0 @@
<?php
/** Propuesta/aplicación segura del backfill de _cita_evangelio. */
/** Uso: wp eval-file scripts/backfill_evangelio_meta.php [--apply] --path=/var/www/html */
$apply = in_array('--apply', $_SERVER['argv'] ?? [], true);
$posts = get_posts([
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => -1,
'category__in' => [fea_evangelio_category(1647)],
'meta_query' => [['key' => '_cita_evangelio', 'compare' => 'NOT EXISTS']],
'orderby' => 'date', 'order' => 'ASC',
]);
$out = fopen('php://output', 'w');
fputcsv($out, ['post_id', 'fecha', 'carta_id', 'cita_propuesta', 'resultado']);
foreach ($posts as $post) {
$before_carta = (int) get_post_meta($post->ID, '_carta_id', true);
// Las cartas deducidas solo se proponen: la cola histórica requiere revisión humana.
$carta_id = $before_carta ?: fea_evangelio_nearest_carta_id($post);
$evangelios = $carta_id ? fea_evangelio_from_carta($carta_id) : [];
$cita = $before_carta && count($evangelios) === 1 ? reset($evangelios) : '';
$result = $cita ? ($apply ? 'aplicado' : 'propuesta') : (!$before_carta ? 'revision_manual_carta_por_fecha' : (count($evangelios) > 1 ? 'revision_manual_multi_evangelio' : 'sin_evangelio'));
if ($apply && $cita) {
update_post_meta($post->ID, '_cita_evangelio', $cita);
if (!$before_carta) update_post_meta($post->ID, '_carta_id', $carta_id);
}
fputcsv($out, [$post->ID, $post->post_date, $carta_id ?: '', $cita, $result]);
}
+11 -4
View File
@@ -19,6 +19,7 @@ Rollback (despublica en prod lo que este script publicó):
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
@@ -144,16 +145,21 @@ def prod_helper(subcmd: str, *args: str) -> str:
return _ssh_text(_remote_wrap(inner), timeout=60)
def prod_upload_mp3(post_id: int) -> None:
def prod_upload_mp3(post_id: int) -> str:
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
data = src.read_bytes()
local_sha256 = hashlib.sha256(data).hexdigest()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
_ssh_upload_bytes(data, remote_path)
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
# Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset
# sea el mismo. El hash se persiste como meta y permite playback verificado.
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
remote_sha256 = _ssh_text(_remote_wrap(f"sha256sum {remote_path} | cut -d' ' -f1")).strip()
if remote_sha256 != local_sha256:
raise RuntimeError(f"SHA-256 no coincide tras subir #{post_id}: local={local_sha256} remoto={remote_sha256}")
return local_sha256
def prod_remove_mp3(post_id: int) -> None:
@@ -185,8 +191,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
return "PLAN: subiría mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id)
sha256 = prod_upload_mp3(post_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
prod_helper("setflag", str(post_id), "fea_audio_sha256", sha256)
if post_id not in state["synced"]:
state["synced"].append(post_id)
save_state(state)