Compare commits
18 Commits
5cf588ffe3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1333a2bb64 | |||
| 654c3d3c35 | |||
| 7af2173014 | |||
| 483c68bf5f | |||
| 2260343ec4 | |||
| bc3af3cbb9 | |||
| bbae0571ae | |||
| a806cf9cae | |||
| 696934016f | |||
| 06d7057e76 | |||
| ef2978fc21 | |||
| 587b10c2b3 | |||
| 6b966a3b5f | |||
| 754915b91f | |||
| 75c0f07eab | |||
| f0946be1fc | |||
| d836297c89 | |||
| d9ff5e891c |
@@ -32,8 +32,40 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi
|
|||||||
| `translate_haiku.py` | Traducción automática vía Claude Haiku |
|
| `translate_haiku.py` | Traducción automática vía Claude Haiku |
|
||||||
| `tts_produce.py` | Generación de audio TTS (MiniMax) |
|
| `tts_produce.py` | Generación de audio TTS (MiniMax) |
|
||||||
| `sync_translations_to_prod.py` | Sincronizar traducciones local → producción |
|
| `sync_translations_to_prod.py` | Sincronizar traducciones local → producción |
|
||||||
|
| `sync_audio_to_prod.py` | Sincronizar UN post/carta de audio TTS local → producción (sube el mp3, verifica SHA-256 remoto y persiste `fea_audio_sha256`) |
|
||||||
|
| `sync_audio_bulk.sh` | Envoltorio de `sync_audio_to_prod.py` para lotes: para en el primer fallo, sin reintentos ciegos, resumen ok/skip |
|
||||||
| `detect_untranslated.php` | Detectar posts sin traducir |
|
| `detect_untranslated.php` | Detectar posts sin traducir |
|
||||||
|
|
||||||
|
## Sincronizar audio TTS a producción (local → prod)
|
||||||
|
|
||||||
|
`sync_audio_to_prod.py` sube el mp3 de UN post/carta y, desde el #213, calcula y verifica el
|
||||||
|
SHA-256 en cada subida (reproducción verificada automática, sin backfill manual — ver #212).
|
||||||
|
|
||||||
|
Para lotes de varios posts, usar `sync_audio_bulk.sh`: es el envoltorio que añade "parar en el
|
||||||
|
primer fallo" (el script Python no lo hace por sí solo, recorre toda la lista y acumula errores).
|
||||||
|
Carga los IDs con `mapfile`/mediante array y redirige el stdin de cada invocación a `/dev/null` a
|
||||||
|
propósito — un `while read id; do ...ssh...; done < fichero` sin esa precaución se rompe en
|
||||||
|
silencio porque `ssh` se come el resto del fichero de IDs (ver
|
||||||
|
`feedback-bash-while-read-ssh-stdin` en la memoria del proyecto).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
|
||||||
|
export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
|
||||||
|
|
||||||
|
# Dry-run primero, siempre
|
||||||
|
scripts/sync_audio_bulk.sh --ids 55519,55520,822 --dry-run
|
||||||
|
|
||||||
|
# Aplicar de verdad
|
||||||
|
scripts/sync_audio_bulk.sh --ids 55519,55520,822
|
||||||
|
|
||||||
|
# Desde un fichero (un ID por línea) o toda la cola de una carta
|
||||||
|
scripts/sync_audio_bulk.sh --file /tmp/ids_pendientes.txt
|
||||||
|
scripts/sync_audio_bulk.sh --carta 54254
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback de un post concreto: `python3 scripts/sync_audio_to_prod.py --rollback --ids <id>`
|
||||||
|
(borra el meta en prod, no toca el mp3 local).
|
||||||
|
|
||||||
## Servidor local de desarrollo
|
## Servidor local de desarrollo
|
||||||
|
|
||||||
WordPress corre en Docker local, accesible vía Tailscale:
|
WordPress corre en Docker local, accesible vía Tailscale:
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ add_action('wp_head', function () {
|
|||||||
--wp--preset--color--base: #0d0d0d;
|
--wp--preset--color--base: #0d0d0d;
|
||||||
--wp--preset--color--contrast: #ececec;
|
--wp--preset--color--contrast: #ececec;
|
||||||
}
|
}
|
||||||
html[data-fea-theme="dark"] a { color: <?php echo esc_html($accent_dark); ?>; }
|
html[data-fea-theme="dark"] a,
|
||||||
|
html[data-fea-theme="dark"] a:visited { color: <?php echo esc_html($accent_dark); ?>; }
|
||||||
html[data-fea-theme="dark"] a:focus-visible,
|
html[data-fea-theme="dark"] a:focus-visible,
|
||||||
html[data-fea-theme="dark"] button:focus-visible {
|
html[data-fea-theme="dark"] button:focus-visible {
|
||||||
outline-color: <?php echo esc_html($accent_dark); ?> !important;
|
outline-color: <?php echo esc_html($accent_dark); ?> !important;
|
||||||
@@ -184,6 +185,117 @@ add_action('wp_head', function () {
|
|||||||
color: #b8afa8;
|
color: #b8afa8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Comentarios al evangelio: el panel usa colores editoriales claros fijos. */
|
||||||
|
html[data-fea-theme="dark"] .fea-com-evangelio {
|
||||||
|
background: #1a1711;
|
||||||
|
border-left-color: #c9973e;
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-com-evangelio-details summary {
|
||||||
|
color: #f0c674;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elementos editoriales presentes en artículos y resultados. */
|
||||||
|
html[data-fea-theme="dark"] .fea-audio {
|
||||||
|
background: #161311;
|
||||||
|
border-color: #3a302b;
|
||||||
|
border-left-color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-byline-name { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .fea-byline-cat,
|
||||||
|
html[data-fea-theme="dark"] .fea-eed-link .fecha { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-rule { border-top-color: #3a302b; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-row { color: #d5cbc3; }
|
||||||
|
html[data-fea-theme="dark"] .fea-share-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-share-sep { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Formulario y filtros de búsqueda avanzada. */
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-details {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-summary {
|
||||||
|
background: #24181c; border-bottom-color: #3a302b;
|
||||||
|
color: <?php echo esc_html($accent_dark); ?>;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-chips-label,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-meta,
|
||||||
|
html[data-fea-theme="dark"] .fea-archive-card .fea-adv-byline { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-chip {
|
||||||
|
background: #2a1b20; border-color: #56343d; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-input,
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-select {
|
||||||
|
background: #1c1c1c; border-color: #3a3a3a; color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-adv-count { color: #b8afa8; }
|
||||||
|
|
||||||
|
/* Recopilatorios y galería multimedia. */
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-year { border-bottom-color: #3a302b; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-list li { border-bottom-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-list a,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-title { color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .fea-recop-date,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-date,
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-excerpt { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-card { background: #161311; border-color: #2a2320; }
|
||||||
|
html[data-fea-theme="dark"] .fea-mm-thumb { background: #241f1c; }
|
||||||
|
|
||||||
|
/* Galerías de pensamientos y navegación del tema base. */
|
||||||
|
html[data-fea-theme="dark"] .fea-gallery-item {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-lightbox-frame img { background: #161311; }
|
||||||
|
html[data-fea-theme="dark"] .fea-lightbox button { background: #2a2320; color: #ececec; }
|
||||||
|
html[data-fea-theme="dark"] .wp-block-navigation .wp-block-navigation-item__content {
|
||||||
|
color: #ececec;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .wp-block-navigation__submenu-container {
|
||||||
|
background: #161311; border-color: #3a302b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Página de apoyo: plantilla propia con colores claros explícitos. */
|
||||||
|
html[data-fea-theme="dark"] .fea-support-page {
|
||||||
|
--fea-support-ink: #ececec;
|
||||||
|
--fea-support-warm: #161311;
|
||||||
|
--fea-support-line: #3a302b;
|
||||||
|
--fea-support-card: #161311;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-hero,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-banner {
|
||||||
|
background: #1a1711 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-card,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-button.is-secondary {
|
||||||
|
background: #161311 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .fea-support-card__title,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__numbers span,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__note,
|
||||||
|
html[data-fea-theme="dark"] .fea-support-sidebar__small { color: #b8afa8; }
|
||||||
|
html[data-fea-theme="dark"] .fea-support-progress__track { background: #3a302b; }
|
||||||
|
|
||||||
|
/* Colaboradores: stylesheet propio del tema con tarjetas claras y !important. */
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper {
|
||||||
|
background: #161311 !important;
|
||||||
|
border-color: #3a302b !important;
|
||||||
|
color: #ececec !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,.45) !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper strong {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a,
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a:visited {
|
||||||
|
color: #ececec !important;
|
||||||
|
}
|
||||||
|
html[data-fea-theme="dark"] .entry-content .wrapper a:hover {
|
||||||
|
color: <?php echo esc_html($accent_dark); ?> !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Selector de idioma (fea-homepage.php) */
|
/* 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 { border-color: rgba(255,255,255,0.3); }
|
||||||
html[data-fea-theme="dark"] #fea-lang-btn:hover,
|
html[data-fea-theme="dark"] #fea-lang-btn:hover,
|
||||||
@@ -240,6 +352,10 @@ add_action('wp_footer', function () {
|
|||||||
?>
|
?>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
// El footer puede renderizarse más de una vez en algunos templates FSE.
|
||||||
|
// Evitamos crear un segundo toggle en ese caso.
|
||||||
|
if (document.getElementById('fea-theme-btn')) return;
|
||||||
|
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.id = 'fea-theme-btn';
|
btn.id = 'fea-theme-btn';
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
|
|||||||
@@ -13,11 +13,23 @@ function fea_audio_player_html(): string {
|
|||||||
$url = get_post_meta($id, 'fea_audio_url', true);
|
$url = get_post_meta($id, 'fea_audio_url', true);
|
||||||
if (!$url) return '';
|
if (!$url) return '';
|
||||||
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
|
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
|
||||||
return '<div class="fea-audio">'
|
$sha256 = get_post_meta($id, 'fea_audio_sha256', true);
|
||||||
. '<span class="fea-audio-label">'
|
$label = '<span class="fea-audio-label">'
|
||||||
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
|
. '<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"/>'
|
. '<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) . '"'
|
. '<audio controls preload="none" src="' . esc_url($url) . '"'
|
||||||
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
@@ -56,6 +68,9 @@ add_action('wp_head', function () {
|
|||||||
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
|
.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}
|
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
|
||||||
.fea-audio audio{height:32px;width:230px;max-width:44vw}
|
.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){
|
@media(max-width:600px){
|
||||||
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
|
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
|
||||||
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
|
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
|
||||||
@@ -73,6 +88,40 @@ add_action('wp_footer', function () {
|
|||||||
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
|
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
|
||||||
?>
|
?>
|
||||||
<script>
|
<script>
|
||||||
|
(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) {
|
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
|
||||||
var fired = false;
|
var fired = false;
|
||||||
audio.addEventListener('play', function () {
|
audio.addEventListener('play', function () {
|
||||||
@@ -84,6 +133,7 @@ add_action('wp_footer', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}());
|
||||||
</script>
|
</script>
|
||||||
<?php
|
<?php
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ function fea_evangelio_category($es_id) {
|
|||||||
return function_exists('fea_cat') ? (int) fea_cat($es_id) : (int) $es_id;
|
return function_exists('fea_cat') ? (int) fea_cat($es_id) : (int) $es_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fea_evangelio_comment_categories() {
|
||||||
|
return array_map('fea_evangelio_category', [1647, 1646]);
|
||||||
|
}
|
||||||
|
|
||||||
function fea_evangelio_is_comment($post_id) {
|
function fea_evangelio_is_comment($post_id) {
|
||||||
return has_category(fea_evangelio_category(1647), $post_id);
|
return has_category(fea_evangelio_comment_categories(), $post_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lee el meta del comentario; las traducciones heredan el dato editorial del original ES. */
|
/** Lee el meta del comentario; las traducciones heredan el dato editorial del original ES. */
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
/** Propuesta/aplicación segura del backfill de _cita_evangelio. */
|
/** Propuesta/aplicación segura del backfill de _cita_evangelio. */
|
||||||
/** Uso: wp eval-file scripts/backfill_evangelio_meta.php [--apply] --path=/var/www/html */
|
/** Uso: wp eval-file scripts/backfill_evangelio_meta.php [--apply] --path=/var/www/html */
|
||||||
|
|
||||||
$apply = in_array('--apply', $_SERVER['argv'] ?? [], true);
|
// WP-CLI no reenvía de forma consistente argumentos desconocidos a eval-file.
|
||||||
|
// El modo escritura exige por tanto una intención explícita y auditable:
|
||||||
|
// FEA_EVANGELIO_APPLY=1 wp eval-file scripts/backfill_evangelio_meta.php
|
||||||
|
$apply = getenv('FEA_EVANGELIO_APPLY') === '1'
|
||||||
|
|| in_array('--apply', $_SERVER['argv'] ?? [], true);
|
||||||
$posts = get_posts([
|
$posts = get_posts([
|
||||||
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => -1,
|
'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => -1,
|
||||||
'category__in' => [fea_evangelio_category(1647)],
|
'category__in' => fea_evangelio_comment_categories(),
|
||||||
'meta_query' => [['key' => '_cita_evangelio', 'compare' => 'NOT EXISTS']],
|
'meta_query' => [['key' => '_cita_evangelio', 'compare' => 'NOT EXISTS']],
|
||||||
'orderby' => 'date', 'order' => 'ASC',
|
'orderby' => 'date', 'order' => 'ASC',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""doc_convert.py — convierte un .doc viejo a .docx usando Microsoft Word (COM).
|
||||||
|
|
||||||
|
Necesario para Marcos (marroro@telefonica.net), que envía .doc (Word antiguo) y
|
||||||
|
python-docx no sabe leer .doc. El contenido ya viene en EML (lo etiqueta Inma al
|
||||||
|
revisar); aquí solo cambiamos el FORMATO de archivo.
|
||||||
|
|
||||||
|
Requiere Microsoft Word instalado + pywin32 (igual que marcos_review).
|
||||||
|
API: doc_to_docx(path, force=False) -> Path | None
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WD_FORMAT_DOCX = 16 # wdFormatDocumentDefault (.docx)
|
||||||
|
|
||||||
|
|
||||||
|
def doc_to_docx(path, force: bool = False):
|
||||||
|
"""Convierte un .doc a .docx con Word COM. Idempotente: si el .docx ya existe y
|
||||||
|
no force, lo devuelve sin reconvertir. Devuelve la ruta .docx, o None si falla."""
|
||||||
|
path = Path(path)
|
||||||
|
if path.suffix.lower() != ".doc":
|
||||||
|
return None
|
||||||
|
out = path.with_suffix(".docx")
|
||||||
|
if out.exists() and not force:
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
import win32com.client # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
word = win32com.client.Dispatch("Word.Application")
|
||||||
|
word.Visible = False
|
||||||
|
try:
|
||||||
|
d = word.Documents.Open(str(path))
|
||||||
|
d.SaveAs(str(out), FileFormat=WD_FORMAT_DOCX)
|
||||||
|
d.Close(SaveChanges=False)
|
||||||
|
finally:
|
||||||
|
word.Quit()
|
||||||
|
if out.exists():
|
||||||
|
quitar_smarttags(out)
|
||||||
|
return out if out.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def quitar_smarttags(docx_path) -> int:
|
||||||
|
"""Desenvuelve los <w:smartTag> del .docx. Devuelve cuántos ha quitado.
|
||||||
|
|
||||||
|
⚠️ CRÍTICO, no es cosmético. Los .doc de Fray Marcos traen `smartTag` (el viejo
|
||||||
|
reconocimiento de nombres de Word) envolviendo expresiones como "la Iglesia" o
|
||||||
|
"la Cananea". **python-docx solo lee los <w:r> que cuelgan directamente del párrafo,
|
||||||
|
así que el texto de dentro del smartTag DESAPARECE sin dar error**: se publicaron
|
||||||
|
frases mutiladas en las cartas 736, 737 y 739 ("...no va dirigida a los apóstoles,
|
||||||
|
sino a dureza de la respuesta..."). Detectado el 12-ago-2026.
|
||||||
|
"""
|
||||||
|
import re, shutil, zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
docx_path = Path(docx_path)
|
||||||
|
with zipfile.ZipFile(docx_path) as z:
|
||||||
|
piezas = {n: z.read(n) for n in z.namelist()}
|
||||||
|
xml = piezas.get("word/document.xml", b"").decode("utf-8", "replace")
|
||||||
|
nuevo, n = re.subn(r"</?w:smartTag(?:Pr)?\b[^>]*>|<w:attr\b[^>]*/>", "", xml)
|
||||||
|
if not n:
|
||||||
|
return 0
|
||||||
|
piezas["word/document.xml"] = nuevo.encode("utf-8")
|
||||||
|
tmp = docx_path.with_suffix(".docx.tmp")
|
||||||
|
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for nombre, datos in piezas.items():
|
||||||
|
z.writestr(nombre, datos)
|
||||||
|
shutil.move(str(tmp), str(docx_path))
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("uso: python doc_convert.py path/al/archivo.doc", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
res = doc_to_docx(Path(sys.argv[1]), force="--force" in sys.argv)
|
||||||
|
print(f"OK: {res}" if res else "FALLO (¿Word/pywin32?)")
|
||||||
|
sys.exit(0 if res else 1)
|
||||||
Executable
+115
@@ -0,0 +1,115 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# sync_audio_bulk.sh — Sincroniza a producción, uno por uno, un lote de posts con
|
||||||
|
# audio TTS ya generado en local. Envoltorio de sync_audio_to_prod.py que añade
|
||||||
|
# "parar en el primer fallo" (el script Python no lo hace: recorre toda la
|
||||||
|
# lista y acumula errores, ver #212/#213).
|
||||||
|
#
|
||||||
|
# Motivo del wrapper en bash con mapfile: un `while read id; do ...; done <
|
||||||
|
# fichero` normal comparte el file descriptor de stdin con cualquier `ssh`
|
||||||
|
# lanzado dentro del bucle. sync_audio_to_prod.py llama a ssh internamente; si
|
||||||
|
# el bucle no está protegido, ssh se come el resto del fichero de IDs y el
|
||||||
|
# bucle termina "sin error" tras procesar solo el primero. Detectado y
|
||||||
|
# documentado en memoria (feedback-bash-while-read-ssh-stdin) el 2026-08-24
|
||||||
|
# tras un lote real que solo sincronizó 1 de 184 posts sin ningún error visible.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# scripts/sync_audio_bulk.sh --ids 55519,55520,822
|
||||||
|
# scripts/sync_audio_bulk.sh --file /tmp/ids.txt # un ID por línea
|
||||||
|
# scripts/sync_audio_bulk.sh --carta 54254 # cola completa de la carta
|
||||||
|
# scripts/sync_audio_bulk.sh --ids 55519,55520 --dry-run # solo plan, no toca prod
|
||||||
|
#
|
||||||
|
# Requiere las mismas variables de entorno que sync_audio_to_prod.py para
|
||||||
|
# apuntar a producción (ver ese script): FEA_PROD_SSH_HOST,
|
||||||
|
# FEA_PROD_DOCKER_CONTAINER. Ejemplo:
|
||||||
|
# export FEA_PROD_SSH_HOST="rafa@188.40.120.157"
|
||||||
|
# export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa"
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SYNC_PY="$SCRIPT_DIR/sync_audio_to_prod.py"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Uso: $0 (--ids id1,id2,... | --file ruta | --carta post_id) [--dry-run]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
ids_csv=""
|
||||||
|
ids_file=""
|
||||||
|
carta=""
|
||||||
|
dry_run=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ids) ids_csv="$2"; shift 2 ;;
|
||||||
|
--file) ids_file="$2"; shift 2 ;;
|
||||||
|
--carta) carta="$2"; shift 2 ;;
|
||||||
|
--dry-run) dry_run="--dry-run"; shift ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Carga explícita de IDs en un array ANTES de cualquier llamada a ssh — es la
|
||||||
|
# parte que evita el bug de stdin. No usar `while read ... < fichero`.
|
||||||
|
ids=()
|
||||||
|
if [ -n "$ids_csv" ]; then
|
||||||
|
IFS=',' read -r -a ids <<< "$ids_csv"
|
||||||
|
elif [ -n "$ids_file" ]; then
|
||||||
|
mapfile -t ids < "$ids_file"
|
||||||
|
elif [ -n "$carta" ]; then
|
||||||
|
# No usar `mapfile < <(cmd)`: la sustitución de procesos no deja comprobar
|
||||||
|
# de forma fiable el exit code de `cmd` (mapfile ve su propio $?, no el de
|
||||||
|
# la tubería). Si la consulta falla, un array vacío se confundiría con
|
||||||
|
# "la carta no tiene artículos" y el lote saldría "éxito" sin sincronizar
|
||||||
|
# nada. Se resuelve a fichero temporal y se comprueba el rc de python
|
||||||
|
# explícitamente antes de poblar el array.
|
||||||
|
carta_tmpfile="$(mktemp)"
|
||||||
|
python3 - "$carta" "$SCRIPT_DIR" > "$carta_tmpfile" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, sys.argv[2])
|
||||||
|
from sync_audio_to_prod import carta_article_ids
|
||||||
|
for pid in carta_article_ids(int(sys.argv[1])):
|
||||||
|
print(pid)
|
||||||
|
PYEOF
|
||||||
|
py_rc=$?
|
||||||
|
if [ $py_rc -ne 0 ]; then
|
||||||
|
echo "ERROR: fallo consultando la cola de la carta $carta (exit $py_rc), no se sincroniza nada." >&2
|
||||||
|
rm -f "$carta_tmpfile"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mapfile -t ids < "$carta_tmpfile"
|
||||||
|
rm -f "$carta_tmpfile"
|
||||||
|
else
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
total=${#ids[@]}
|
||||||
|
if [ "$total" -eq 0 ]; then
|
||||||
|
echo "Nada que sincronizar (lista de IDs vacía)." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok=0
|
||||||
|
skip=0
|
||||||
|
n=0
|
||||||
|
for id in "${ids[@]}"; do
|
||||||
|
id="$(echo "$id" | tr -d '[:space:]')"
|
||||||
|
[ -z "$id" ] && continue
|
||||||
|
n=$((n + 1))
|
||||||
|
# stdin del hijo explícitamente en /dev/null: cinturón y tirantes además del
|
||||||
|
# array en memoria, por si algún día se cambia el bucle.
|
||||||
|
out="$(python3 "$SYNC_PY" --ids "$id" $dry_run < /dev/null)"
|
||||||
|
rc=$?
|
||||||
|
echo "$out"
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "=== ABORT: fallo en #$id ($n/$total procesados, $ok ok). Deteniendo lote, sin reintentar. ==="
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if echo "$out" | grep -q "PLAN: subiría\|#$id: ok"; then
|
||||||
|
ok=$((ok + 1))
|
||||||
|
else
|
||||||
|
skip=$((skip + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== FIN LOTE. ok=$ok skip=$skip de $total candidatos. ==="
|
||||||
@@ -19,6 +19,7 @@ Rollback (despublica en prod lo que este script publicó):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -74,6 +75,8 @@ def local_meta(post_id: int, key: str) -> str:
|
|||||||
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
|
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
|
||||||
capture_output=True, text=True, timeout=60,
|
capture_output=True, text=True, timeout=60,
|
||||||
)
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"docker exec {WP_CONTAINER} falló leyendo meta '{key}' de #{post_id} (rc={r.returncode}): {r.stderr.strip()[:300]}")
|
||||||
return r.stdout.strip()
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +88,8 @@ def carta_article_ids(carta_id: int) -> list[int]:
|
|||||||
DB_NAME, "-N", "-e", q],
|
DB_NAME, "-N", "-e", q],
|
||||||
capture_output=True, text=True, timeout=60,
|
capture_output=True, text=True, timeout=60,
|
||||||
)
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"consulta de _carta_id={carta_id} falló (rc={r.returncode}): {r.stderr.strip()[:300]}")
|
||||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||||
|
|
||||||
|
|
||||||
@@ -144,16 +149,21 @@ def prod_helper(subcmd: str, *args: str) -> str:
|
|||||||
return _ssh_text(_remote_wrap(inner), timeout=60)
|
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"
|
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
||||||
data = src.read_bytes()
|
data = src.read_bytes()
|
||||||
|
local_sha256 = hashlib.sha256(data).hexdigest()
|
||||||
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
||||||
_ssh_upload_bytes(data, remote_path)
|
_ssh_upload_bytes(data, remote_path)
|
||||||
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
|
# Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset
|
||||||
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
|
# 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())
|
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
|
||||||
if remote_size != len(data):
|
if remote_size != len(data):
|
||||||
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
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:
|
def prod_remove_mp3(post_id: int) -> None:
|
||||||
@@ -185,8 +195,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
|||||||
return "PLAN: subiría mp3 + setaudio"
|
return "PLAN: subiría mp3 + setaudio"
|
||||||
|
|
||||||
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
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("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"]:
|
if post_id not in state["synced"]:
|
||||||
state["synced"].append(post_id)
|
state["synced"].append(post_id)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+69
@@ -0,0 +1,69 @@
|
|||||||
|
# `.doc` originales de Fray Marcos
|
||||||
|
|
||||||
|
Los 30 ficheros `.doc` (Word binario antiguo) tal y como los envía Fray Marcos por correo a
|
||||||
|
`ediciones@feadulta.com`, sin tocar. Subidos a petición de Rafa en el **issue #209**, para poder
|
||||||
|
reproducir y diffear desde este lado el problema de los `<w:smartTag>`.
|
||||||
|
|
||||||
|
## Por qué están aquí
|
||||||
|
|
||||||
|
`python-docx` **no lee el texto que va dentro de un `<w:smartTag>`**: lo salta sin dar error. Los `.doc`
|
||||||
|
de Fray Marcos arrastran esas etiquetas heredadas de versiones viejas de Word envolviendo expresiones
|
||||||
|
como «la Iglesia» o «la Cananea», así que al convertirlos se publicaban frases con palabras borradas en
|
||||||
|
silencio. Está corregido en `scripts/doc_convert.py` con `quitar_smarttags()`.
|
||||||
|
|
||||||
|
Ojo: la cadena `smartTag` **no aparece en el binario `.doc`**; surge al convertirlo a `.docx`, que es
|
||||||
|
formato OOXML. Para verlas hay que convertir primero.
|
||||||
|
|
||||||
|
## Cómo reproducir
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/doc_convert.py "tests/fixtures/fray-marcos/739_8r_Marcos_d 20 la Cananea.doc"
|
||||||
|
```
|
||||||
|
|
||||||
|
Requiere Word instalado (la conversión va por COM). `doc_convert.py` desenvuelve los smartTag y hace el
|
||||||
|
reemplazo con reintentos, porque Dropbox bloquea el fichero unos segundos justo después de que Word lo
|
||||||
|
cree (WinError 1224).
|
||||||
|
|
||||||
|
## Alcance del problema (barrido cerrado en el #209)
|
||||||
|
|
||||||
|
De los 30 `.doc` revisados, 20 traían smartTag y sumaban 46 fragmentos envueltos, pero solo **3
|
||||||
|
artículos publicados quedaron dañados** (posts `53981`, `55078` y `55659`), ya reparados en producción.
|
||||||
|
Los comentarios de febrero a junio están intactos porque se publicaron por la vía de la migración de
|
||||||
|
Joomla, no desde el `.docx`; solo quedó expuesto lo publicado por Mixbot desde julio.
|
||||||
|
|
||||||
|
## Inventario
|
||||||
|
|
||||||
|
El prefijo numérico es el número de carta; el resto es el nombre original del fichero.
|
||||||
|
|
||||||
|
| carta | semana | fichero original |
|
||||||
|
|---:|---|---|
|
||||||
|
| 713 | semana_VI_TO | `713_8r_Marcos_d 06 plenitud de ley.doc` |
|
||||||
|
| 714 | semana_I_Cuaresma | `714_8r_Marcos_Cuar 1 tentaciones.doc` |
|
||||||
|
| 715 | semana_II_Cuaresma | `715_8r_Marcos_Cuar 2 Transfigurac.doc` |
|
||||||
|
| 716 | semana_III_Cuaresma | `716_8r_Marcos_Cuar 3 Samaritana.doc` |
|
||||||
|
| 717 | semana_IV_Cuaresma | `717_8r_Marcos_Cuar 4 ciego de naci.doc` |
|
||||||
|
| 718 | semana_V_Cuaresma | `718_8r_Marcos_Cuar 5 Lázaro.doc` |
|
||||||
|
| 719 | semana_Domingo_Ramos | `719_8r_Marcos_Cuar 6 Ramos.doc` |
|
||||||
|
| 720 | semana_Santa | `720_8r_Marcos_JUEVES SANTO.doc` |
|
||||||
|
| 720 | semana_Santa | `720_8r_Marcos_Pasc 1 resucitó.doc` |
|
||||||
|
| 720 | semana_Santa | `720_8r_Marcos_VIERNES SANTO.doc` |
|
||||||
|
| 721 | semana_II_Pascua | `721_8r_Marcos_Pasc 2 Tomás.doc` |
|
||||||
|
| 722 | semana_III_Pascua | `722_8r_Marcos_Pasc 3 Emaús.doc` |
|
||||||
|
| 723 | semana_IV_Pascua | `723_8r_Marcos_Pasc 4 pastor modelo.doc` |
|
||||||
|
| 724 | semana_V_Pascua | `724_8r_Marcos_Pasc 5 camino,verdad,vida.doc` |
|
||||||
|
| 725 | semana_VI_Pascua | `725_8r_Marcos_Pasc 6 El Paráclito.doc` |
|
||||||
|
| 726 | semana_VII_Pascua | `726_8r_Marcos_Pasc 7 ascensión.doc` |
|
||||||
|
| 727 | semana_Pentecostes | `727_8r_Marcos_PENTECOSTÉS.doc` |
|
||||||
|
| 728 | semana_Trinidad | `728_8r_Marcos_TRINIDAD.doc` |
|
||||||
|
| 729 | semana_Corpus | `729_8r_Marcos_CORPUS.doc` |
|
||||||
|
| 730 | semana_11_TO | `730_8r_Marcos_d 11 misión de los 12.doc` |
|
||||||
|
| 731 | semana_12_TO | `731_8r_Marcos_d 12 no tengas miedo.doc` |
|
||||||
|
| 732 | semana_13_TO | `732_8r_Marcos_d 13 amor a Dios o família.doc` |
|
||||||
|
| 733 | semana_14_TO | `733_8r_Marcos_d 14 sencillez.doc` |
|
||||||
|
| 734 | semana_15_TO | `734_8r_Marcos_d 15 sembrador.doc` |
|
||||||
|
| 735 | semana_16_TO | `735_8r_Marcos_d 16 cizaña.doc` |
|
||||||
|
| 736 | semana_17_TO | `736_8r_Marcos_d 17 tesoro y perla.doc` |
|
||||||
|
| 737 | semana_18_TO | `737_8r_Marcos_d 18 panes y peces.doc` |
|
||||||
|
| 738 | semana_19_TO | `738_8r_Marcos_d 19 Qué poca fe.doc` |
|
||||||
|
| 739 | semana_20_TO | `739_8r_Marcos_d 20 la Cananea.doc` |
|
||||||
|
| 740 | semana_21_TO | `740_8r_Marcos_d 21 quién es Jesús.doc` |
|
||||||
Reference in New Issue
Block a user