// 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 // LA PALABRA v1.0.0 — Web que muestra un versículo al azar de la Reina-Valera 1909 con cuatro reflexiones generadas por OpenAI y guardadas para no pagar dos veces. Shortcode: [la_palabra] // 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 /* **************************************************************************** * 010 · CONSTANTES, LIBROS Y VALORES POR DEFECTO * ---------------------------------------------------------------------------- * Todo lo que es fijo en la aplicación vive aquí. Lo que se puede cambiar sin * tocar código (clave, modelo, prompt, color...) está en la pantalla «Ajustes». * ************************************************************************** */ // Versión de la APLICACIÓN. Súbela (1.0.1, 1.1.0...) cada vez que cambies este // código: los móviles con la versión antigua verán el aviso «Actualizar ahora». // OJO: es distinta de la «versión del prompt», que se cambia en Ajustes. define( 'CITA_VERSION_APP', '1.0.0' ); // Versión de la estructura de tablas. Solo se sube si cambian las tablas. define( 'CITA_VERSION_BD', '1' ); // Identificador de la Biblia. Forma parte de la clave única de cada reflexión, // de modo que si algún día se añade otra Biblia no se mezclan los textos. define( 'CITA_VERSION_BIBLICA', 'RV1909' ); // Nombre del fichero de datos con los versículos (se sube desde Ajustes). define( 'CITA_FICHERO', 'cita-rv1909.tsv.gz' ); // Cifras de la edición, comprobadas contra dos fuentes digitales distintas. define( 'CITA_LIBROS', 66 ); define( 'CITA_CAPITULOS', 1189 ); define( 'CITA_VERSICULOS_NUMERADOS', 31102 ); /** * Nombres de los 66 libros, en orden bíblico. * La clave es el número de libro (1 = Génesis ... 66 = Apocalipsis), que es * el mismo número que se guarda en la columna «libro» de las tablas. * * @return array */ function cita_libros() { $nombres = array( 'Génesis', 'Éxodo', 'Levítico', 'Números', 'Deuteronomio', 'Josué', 'Jueces', 'Rut', '1 Samuel', '2 Samuel', '1 Reyes', '2 Reyes', '1 Crónicas', '2 Crónicas', 'Esdras', 'Nehemías', 'Ester', 'Job', 'Salmos', 'Proverbios', 'Eclesiastés', 'Cantares', 'Isaías', 'Jeremías', 'Lamentaciones', 'Ezequiel', 'Daniel', 'Oseas', 'Joel', 'Amós', 'Abdías', 'Jonás', 'Miqueas', 'Nahum', 'Habacuc', 'Sofonías', 'Hageo', 'Zacarías', 'Malaquías', 'Mateo', 'Marcos', 'Lucas', 'Juan', 'Hechos', 'Romanos', '1 Corintios', '2 Corintios', 'Gálatas', 'Efesios', 'Filipenses', 'Colosenses', '1 Tesalonicenses', '2 Tesalonicenses', '1 Timoteo', '2 Timoteo', 'Tito', 'Filemón', 'Hebreos', 'Santiago', '1 Pedro', '2 Pedro', '1 Juan', '2 Juan', '3 Juan', 'Judas', 'Apocalipsis', ); // Se renumera para que empiece en 1 en lugar de en 0. return array_combine( range( 1, count( $nombres ) ), $nombres ); } /** * Prompt que se usa si en Ajustes no se ha escrito otro. * Es el mensaje de «sistema» que recibe OpenAI. Las extensiones en palabras * NO van aquí: las añade el código en cada petición a partir de los márgenes * de Ajustes, para que lo que se pide y lo que se valida sea siempre lo mismo. * * @return string */ function cita_prompt_por_defecto() { return "Eres un comentarista bíblico evangélico, sobrio y cercano, que escribe para gente sencilla.\n" . "Trabajas únicamente con el versículo que se te da, tomado de la Biblia Reina-Valera 1909.\n\n" . "Normas:\n" . "- Respeta el contexto y el género literario del pasaje (ley, genealogía, relato, poesía, profecía, carta). Si es una lista o una genealogía, no fuerces un sentido que no tiene.\n" . "- No le atribuyas al pasaje ideas que no contiene. No inventes citas ni referencias y no cites otros versículos.\n" . "- Escribe en español actual, claro y sin tecnicismos, tratando de tú al lector.\n" . "- No menciones noticias, fechas ni hechos recientes concretos, y no presentes nada como si fuera actualidad.\n" . "- No afirmes que Dios ha elegido este versículo para el lector.\n\n" . "Devuelve únicamente un objeto JSON con estas cuatro claves, todas de texto:\n" . "- interpretacion: qué expresa el pasaje y cómo puede aplicarse a la vida personal.\n" . "- pregunta: una pregunta que el lector se hace a sí mismo, en primera persona, seguida de una explicación que la desarrolla.\n" . "- mundo: relación del pasaje con realidades humanas o sociales amplias y permanentes.\n" . "- gesto: una acción concreta, sencilla y realista que el lector podría hacer hoy, relacionada con el pasaje."; } /** * Devuelve todos los ajustes: lo guardado en la base de datos, completado con * los valores por defecto para lo que aún no se haya configurado. * * @return array */ function cita_ajustes() { $defecto = array( 'clave' => '', // Clave de la API de OpenAI. 'modelo' => 'gpt-4o-mini', // Modelo de OpenAI. 'prompt' => cita_prompt_por_defecto(), 'version_prompt' => '1', // Se guarda junto a cada reflexión. 'max_tokens' => 900, // Tope de tamaño de la respuesta. 'limite_diario' => 500, // Máximo de llamadas a OpenAI por día. 'color_menu' => '#8B1A1A', // Color de nuestros ítems de menú. // Márgenes de extensión, en palabras, de cada uno de los cuatro textos. 'interpretacion_min' => 48, 'interpretacion_max' => 52, 'pregunta_min' => 40, 'pregunta_max' => 60, 'mundo_min' => 45, 'mundo_max' => 55, 'gesto_min' => 35, 'gesto_max' => 50, ); $guardado = get_option( 'cita_ajustes', array() ); return array_merge( $defecto, is_array( $guardado ) ? $guardado : array() ); } /** * Los cuatro textos que se generan, con su nombre legible. * El orden de este array es el orden en que se muestran en pantalla. * * @return array */ function cita_campos() { return array( 'interpretacion' => 'Interpretación y aplicación', 'pregunta' => 'La pregunta que has de hacerte', 'mundo' => 'Lo que pasa en el mundo', 'gesto' => 'Un gesto que podrías hacer hoy', ); } /* **************************************************************************** * 020 · TABLAS DE LA BASE DE DATOS * ---------------------------------------------------------------------------- * Dos tablas propias dentro de la base de datos de WordPress: * - cita_versiculos: el texto de la Biblia. Se llena una vez al importar. * - cita_reflexiones: los cuatro textos de OpenAI. Empieza vacía y crece * solo cuando sale un versículo que aún no tiene textos. * Actualizar la aplicación NO borra ninguna de las dos. * ************************************************************************** */ /** * Crea (o pone al día) las dos tablas. dbDelta solo añade lo que falta: * nunca borra datos existentes. * * @return void */ function cita_crear_tablas() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $juego = $wpdb->get_charset_collate(); // «orden» es el número correlativo (0, 1, 2...) que usa el sorteo. // La clave única impide que un mismo versículo se importe dos veces. dbDelta( "CREATE TABLE {$wpdb->prefix}cita_versiculos ( orden int(10) unsigned NOT NULL, libro tinyint(3) unsigned NOT NULL, capitulo smallint(5) unsigned NOT NULL, versiculo smallint(5) unsigned NOT NULL, texto text NOT NULL, PRIMARY KEY (orden), UNIQUE KEY referencia (libro,capitulo,versiculo) ) $juego;" ); // La clave única (versión bíblica + libro + capítulo + versículo) es la // que garantiza que NUNCA haya dos reflexiones del mismo versículo. dbDelta( "CREATE TABLE {$wpdb->prefix}cita_reflexiones ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, version_biblica varchar(20) NOT NULL, libro tinyint(3) unsigned NOT NULL, capitulo smallint(5) unsigned NOT NULL, versiculo smallint(5) unsigned NOT NULL, interpretacion text NOT NULL, pregunta text NOT NULL, mundo text NOT NULL, gesto text NOT NULL, modelo varchar(80) NOT NULL DEFAULT '', version_prompt varchar(20) NOT NULL DEFAULT '', creado datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY versiculo_unico (version_biblica,libro,capitulo,versiculo) ) $juego;" ); update_option( 'cita_version_bd', CITA_VERSION_BD ); } // Se comprueba en cada carga, pero solo se ejecuta la creación la primera vez // (o cuando se suba CITA_VERSION_BD). Es una lectura de opción, no cuesta nada. add_action( 'init', function () { if ( get_option( 'cita_version_bd' ) !== CITA_VERSION_BD ) { cita_crear_tablas(); } } ); /* **************************************************************************** * 030 · IMPORTADOR DE LA BIBLIA * ---------------------------------------------------------------------------- * El fichero cita-rv1909.tsv.gz trae una línea por versículo con cinco campos * separados por tabulador: orden · libro · capítulo · versículo · texto * Se sube desde Ajustes y se carga en la tabla con el botón «Importar». * ************************************************************************** */ /** * Carpeta propia dentro de «uploads» donde se guarda el fichero de la Biblia. * * @return string Ruta absoluta, sin barra final. */ function cita_carpeta_datos() { $subidas = wp_upload_dir(); return $subidas['basedir'] . '/la-palabra'; } /** * Busca el fichero de la Biblia: primero en uploads/la-palabra (subido desde * Ajustes) y, si no está, en la carpeta del tema (subido por FTP). * * @return string Ruta del fichero, o cadena vacía si no existe. */ function cita_ruta_fichero() { $candidatos = array( cita_carpeta_datos() . '/' . CITA_FICHERO, get_stylesheet_directory() . '/' . CITA_FICHERO, ); foreach ( $candidatos as $ruta ) { if ( is_readable( $ruta ) ) { return $ruta; } } return ''; } /** * Importa el fichero a la tabla de versículos. * Lee línea a línea (no carga el fichero entero en memoria) e inserta en * tandas de 500. Usa INSERT IGNORE: si se corta a medias, basta con volver a * pulsar «Importar» y continúa sin duplicar nada. * * @return array|WP_Error array( 'leidas' => int, 'nuevas' => int ) o error. */ function cita_importar_biblia() { global $wpdb; $ruta = cita_ruta_fichero(); if ( '' === $ruta ) { return new WP_Error( 'cita_sin_fichero', 'No encuentro el fichero ' . CITA_FICHERO . '. Súbelo primero.' ); } if ( ! function_exists( 'gzopen' ) ) { return new WP_Error( 'cita_sin_zlib', 'Este servidor no tiene la extensión zlib de PHP, necesaria para leer el fichero.' ); } if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 300 ); // Margen amplio por si el servidor es lento. } $fichero = gzopen( $ruta, 'rb' ); if ( ! $fichero ) { return new WP_Error( 'cita_no_abre', 'No he podido abrir el fichero de la Biblia.' ); } $tabla = $wpdb->prefix . 'cita_versiculos'; $tanda = array(); // Trozos «(%d,%d,%d,%d,%s)» ya preparados. $leidas = 0; $nuevas = 0; // Vuelca la tanda acumulada en una sola consulta. $volcar = function () use ( &$tanda, &$nuevas, $wpdb, $tabla ) { if ( $tanda ) { $nuevas += (int) $wpdb->query( "INSERT IGNORE INTO $tabla (orden,libro,capitulo,versiculo,texto) VALUES " . implode( ',', $tanda ) ); $tanda = array(); } }; while ( false !== ( $linea = gzgets( $fichero ) ) ) { $campos = explode( "\t", rtrim( $linea, "\r\n" ) ); // Se descarta cualquier línea que no tenga exactamente la forma esperada. if ( 5 !== count( $campos ) || ! ctype_digit( $campos[0] ) || ! ctype_digit( $campos[1] ) || ! ctype_digit( $campos[2] ) || ! ctype_digit( $campos[3] ) || '' === $campos[4] ) { continue; } $tanda[] = $wpdb->prepare( '(%d,%d,%d,%d,%s)', $campos[0], $campos[1], $campos[2], $campos[3], $campos[4] ); $leidas++; if ( count( $tanda ) >= 500 ) { $volcar(); } } $volcar(); gzclose( $fichero ); cita_recontar(); return array( 'leidas' => $leidas, 'nuevas' => $nuevas ); } /** * Cuenta lo que hay realmente en la tabla y lo guarda en una opción, para no * tener que contar en cada visita. También comprueba que los números de orden * son correlativos y sin huecos, que es lo que necesita el sorteo. * * @return array */ function cita_recontar() { global $wpdb; $tabla = $wpdb->prefix . 'cita_versiculos'; $fila = $wpdb->get_row( "SELECT COUNT(*) AS versiculos, COUNT(DISTINCT libro) AS libros, COUNT(DISTINCT libro, capitulo) AS capitulos, MIN(orden) AS minimo, MAX(orden) AS maximo FROM $tabla", ARRAY_A ); $versiculos = (int) $fila['versiculos']; $recuento = array( 'versiculos' => $versiculos, 'libros' => (int) $fila['libros'], 'capitulos' => (int) $fila['capitulos'], // Correlativo = empieza en 0 y el último es (total - 1). 'correlativo' => $versiculos > 0 && 0 === (int) $fila['minimo'] && (int) $fila['maximo'] === $versiculos - 1, ); update_option( 'cita_recuento', $recuento, false ); return $recuento; } /** * Número de versículos entre los que se sortea (0 si aún no se ha importado * o si la numeración no es correlativa, en cuyo caso no se debe sortear). * * @return int */ function cita_total_versiculos() { $recuento = get_option( 'cita_recuento' ); if ( ! is_array( $recuento ) ) { $recuento = cita_recontar(); } return empty( $recuento['correlativo'] ) ? 0 : (int) $recuento['versiculos']; } /* **************************************************************************** * 040 · PANTALLA «AJUSTES» DEL BACKEND Y COLOR DE NUESTROS MENÚS * ---------------------------------------------------------------------------- * Un ítem propio en el menú de administración con: clave de OpenAI (visible), * modelo, prompt, versión del prompt, márgenes de palabras, límite diario, * color de los ítems de menú creados por nosotros, subida e importación de la * Biblia y borrado manual de una reflexión para regenerarla. * ************************************************************************** */ add_action( 'admin_menu', function () { add_menu_page( 'La Palabra · Ajustes', // Título de la página. 'La Palabra · Ajustes', // Texto del ítem de menú. 'manage_options', // Solo administradores. 'cita-ajustes', // Identificador. Todos los nuestros empiezan por «cita-». 'cita_pagina_ajustes', // Función que pinta la página. 'dashicons-book-alt', 3 ); } ); /** * Pinta con el color elegido en Ajustes TODOS los ítems de menú creados por * nosotros: cualquiera cuyo identificador empiece por «cita-». Si en el futuro * se añaden más pantallas con ese prefijo, heredan el color sin tocar nada. */ add_action( 'admin_head', function () { $ajustes = cita_ajustes(); $color = sanitize_hex_color( $ajustes['color_menu'] ); if ( ! $color ) { return; } echo ''; } ); // Los formularios se procesan en «admin_init», ANTES de que se pinte nada. // Así, al guardar un color nuevo, el menú ya sale con él en esa misma carga. add_action( 'admin_init', function () { if ( isset( $_GET['page'] ) && 'cita-ajustes' === $_GET['page'] ) { $GLOBALS['cita_mensaje'] = cita_procesar_ajustes(); } } ); /** * Procesa los formularios de la pantalla de Ajustes. * Cada botón envía un campo «cita_accion» distinto. Todo va protegido con * nonce y con la comprobación de que quien lo envía es administrador. * * @return string Mensaje para mostrar arriba de la página (o cadena vacía). */ function cita_procesar_ajustes() { global $wpdb; if ( empty( $_POST['cita_accion'] ) ) { return ''; } if ( ! current_user_can( 'manage_options' ) ) { return 'No tienes permiso para cambiar estos ajustes.'; } check_admin_referer( 'cita_admin' ); $accion = sanitize_key( wp_unslash( $_POST['cita_accion'] ) ); // ----- Guardar ajustes ----- if ( 'guardar' === $accion ) { $dato = function ( $nombre ) { return isset( $_POST[ $nombre ] ) ? wp_unslash( $_POST[ $nombre ] ) : ''; }; $nuevo = array( 'clave' => sanitize_text_field( $dato( 'clave' ) ), 'modelo' => sanitize_text_field( $dato( 'modelo' ) ), 'prompt' => sanitize_textarea_field( $dato( 'prompt' ) ), 'version_prompt' => sanitize_text_field( $dato( 'version_prompt' ) ), 'max_tokens' => max( 200, absint( $dato( 'max_tokens' ) ) ), 'limite_diario' => absint( $dato( 'limite_diario' ) ), 'color_menu' => sanitize_hex_color( $dato( 'color_menu' ) ) ?: '#8B1A1A', ); if ( '' === trim( $nuevo['prompt'] ) ) { $nuevo['prompt'] = cita_prompt_por_defecto(); } // Márgenes de palabras: mínimo 1 y el máximo nunca por debajo del mínimo. foreach ( array_keys( cita_campos() ) as $campo ) { $minimo = max( 1, absint( $dato( $campo . '_min' ) ) ); $nuevo[ $campo . '_min' ] = $minimo; $nuevo[ $campo . '_max' ] = max( $minimo, absint( $dato( $campo . '_max' ) ) ); } update_option( 'cita_ajustes', $nuevo, false ); return 'Ajustes guardados.'; } // ----- Subir el fichero de la Biblia ----- if ( 'subir' === $accion ) { if ( empty( $_FILES['cita_fichero']['tmp_name'] ) || ! is_uploaded_file( $_FILES['cita_fichero']['tmp_name'] ) ) { return 'No ha llegado ningún fichero. Si pesa más de lo que admite tu servidor, súbelo por FTP a la carpeta del tema.'; } $carpeta = cita_carpeta_datos(); wp_mkdir_p( $carpeta ); // Se guarda siempre con nuestro nombre fijo, sea cual sea el original. if ( ! move_uploaded_file( $_FILES['cita_fichero']['tmp_name'], $carpeta . '/' . CITA_FICHERO ) ) { return 'No he podido guardar el fichero en ' . $carpeta . '.'; } return 'Fichero subido. Ahora pulsa «Importar la Biblia».'; } // ----- Importar ----- if ( 'importar' === $accion ) { $resultado = cita_importar_biblia(); if ( is_wp_error( $resultado ) ) { return $resultado->get_error_message(); } return sprintf( 'Importación terminada: %s líneas leídas, %s versículos nuevos.', number_format_i18n( $resultado['leidas'] ), number_format_i18n( $resultado['nuevas'] ) ); } // ----- Borrar la reflexión de un versículo para que se regenere ----- if ( 'borrar' === $accion ) { $borradas = $wpdb->delete( $wpdb->prefix . 'cita_reflexiones', array( 'version_biblica' => CITA_VERSION_BIBLICA, 'libro' => absint( $_POST['libro'] ?? 0 ), 'capitulo' => absint( $_POST['capitulo'] ?? 0 ), 'versiculo' => absint( $_POST['versiculo'] ?? 0 ), ) ); return $borradas ? 'Reflexión borrada. Se generará de nuevo la próxima vez que salga ese versículo.' : 'Ese versículo no tenía reflexión guardada.'; } return ''; } /** * Pinta la pantalla de Ajustes. * * @return void */ function cita_pagina_ajustes() { global $wpdb; if ( ! current_user_can( 'manage_options' ) ) { return; } $mensaje = isset( $GLOBALS['cita_mensaje'] ) ? (string) $GLOBALS['cita_mensaje'] : ''; // Resultado de la última acción. $a = cita_ajustes(); $recuento = cita_recontar(); $guardadas = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}cita_reflexiones WHERE version_biblica = %s", CITA_VERSION_BIBLICA ) ); $contador = cita_contador_diario(); $fichero = cita_ruta_fichero(); $nonce = wp_nonce_field( 'cita_admin', '_wpnonce', true, false ); // --- Aviso superior con el resultado de la última acción --- $aviso = $mensaje ? '

' . esc_html( $mensaje ) . '

' : ''; // --- Estado de la Biblia importada --- $completa = CITA_LIBROS === $recuento['libros'] && CITA_CAPITULOS === $recuento['capitulos'] && $recuento['correlativo']; $estado = $recuento['versiculos'] ? sprintf( '%s libros · %s capítulos · %s versículos con texto · numeración %s', number_format_i18n( $recuento['libros'] ), number_format_i18n( $recuento['capitulos'] ), number_format_i18n( $recuento['versiculos'] ), $recuento['correlativo'] ? 'correlativa' : 'CON HUECOS (vuelve a importar)' ) : 'Todavía no hay versículos importados.'; $estado .= $recuento['versiculos'] ? ( $completa ? ' — CORRECTO' : ' — INCOMPLETO' ) : ''; // --- Valores ya escapados para meterlos en el HTML --- $e = array(); foreach ( $a as $nombre => $valor ) { $e[ $nombre ] = 'prompt' === $nombre ? esc_textarea( $valor ) : esc_attr( $valor ); } $estado_e = esc_html( $estado ); $fichero_e = esc_html( $fichero ? $fichero : 'no subido todavía' ); $guardadas_e = esc_html( number_format_i18n( $guardadas ) ); $hoy_e = esc_html( number_format_i18n( $contador['llamadas'] ) ); $version_e = esc_html( CITA_VERSION_APP ); $nombre_f = esc_html( CITA_FICHERO ); // --- Filas de los márgenes de palabras --- $filas_margenes = ''; foreach ( cita_campos() as $campo => $titulo ) { $filas_margenes .= '' . esc_html( $titulo ) . '' . 'de ' . 'a palabras'; } // --- Desplegable de libros para el borrado manual --- $opciones_libros = ''; foreach ( cita_libros() as $numero => $nombre ) { $opciones_libros .= ''; } echo <<

La Palabra · Ajustes

{$aviso}

Versión de la aplicación: {$version_e}. Para mostrar la web, crea una página y escribe en ella el shortcode [la_palabra].

1. Biblia (Reina-Valera 1909)

Estado: {$estado_e}

Fichero: {$fichero_e}

{$nonce}
{$nonce} Se puede pulsar más de una vez: no duplica nada.

2. OpenAI y textos

Reflexiones guardadas: {$guardadas_e}. Llamadas a OpenAI hoy: {$hoy_e}.

{$nonce} {$filas_margenes}

3. Regenerar un versículo a mano

Borra los textos guardados de un versículo. La próxima vez que salga en el sorteo se generarán de nuevo con el prompt actual.

{$nonce}
HTML; } /* **************************************************************************** * 050 · SORTEO Y LECTURA DE VERSÍCULOS Y REFLEXIONES * ---------------------------------------------------------------------------- * El sorteo elige un número de orden al azar y con él localiza el versículo. * Así todos tienen exactamente la misma probabilidad, sin importar si su libro * es largo o corto. * ************************************************************************** */ /** * Sortea un versículo. * random_int usa el generador criptográfico del sistema: no favorece a ningún * número y no es predecible. * * @return array|null Fila con orden, libro, capitulo, versiculo y texto. */ function cita_sortear() { global $wpdb; $total = cita_total_versiculos(); if ( $total < 1 ) { return null; } $orden = random_int( 0, $total - 1 ); return $wpdb->get_row( $wpdb->prepare( "SELECT orden, libro, capitulo, versiculo, texto FROM {$wpdb->prefix}cita_versiculos WHERE orden = %d", $orden ), ARRAY_A ); } /** * Referencia legible de un versículo: «Filipenses 4:13». * * @param array $versiculo Fila de la tabla de versículos. * @return string */ function cita_referencia( $versiculo ) { $libros = cita_libros(); $libro = isset( $libros[ (int) $versiculo['libro'] ] ) ? $libros[ (int) $versiculo['libro'] ] : ''; return $libro . ' ' . (int) $versiculo['capitulo'] . ':' . (int) $versiculo['versiculo']; } /** * Lee de la caché la reflexión de un versículo. * * @param array $versiculo Fila de la tabla de versículos. * @return array|null Los cuatro textos, o null si aún no existen. */ function cita_leer_reflexion( $versiculo ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT interpretacion, pregunta, mundo, gesto FROM {$wpdb->prefix}cita_reflexiones WHERE version_biblica = %s AND libro = %d AND capitulo = %d AND versiculo = %d", CITA_VERSION_BIBLICA, $versiculo['libro'], $versiculo['capitulo'], $versiculo['versiculo'] ), ARRAY_A ); } /** * Guarda una reflexión recién generada. * INSERT IGNORE + clave única: si otra petición la guardó un instante antes, * esta inserción no hace nada y no se crea ningún duplicado. * * @param array $versiculo Fila de la tabla de versículos. * @param array $textos Los cuatro textos validados. * @param array $ajustes Ajustes (para anotar modelo y versión del prompt). * @return void */ function cita_guardar_reflexion( $versiculo, $textos, $ajustes ) { global $wpdb; $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->prefix}cita_reflexiones (version_biblica, libro, capitulo, versiculo, interpretacion, pregunta, mundo, gesto, modelo, version_prompt, creado) VALUES (%s, %d, %d, %d, %s, %s, %s, %s, %s, %s, %s)", CITA_VERSION_BIBLICA, $versiculo['libro'], $versiculo['capitulo'], $versiculo['versiculo'], $textos['interpretacion'], $textos['pregunta'], $textos['mundo'], $textos['gesto'], $ajustes['modelo'], $ajustes['version_prompt'], current_time( 'mysql', true ) // Fecha de generación, en UTC. ) ); } /** * Versículo «de reserva»: uno al azar entre los que YA tienen reflexión. * Solo se usa cuando no se puede generar (límite diario alcanzado o falta la * clave), para que el visitante no se quede sin palabra. No gasta nada. * * @return array|null array( 'versiculo' => fila, 'reflexion' => textos ). */ function cita_reserva() { global $wpdb; $fila = $wpdb->get_row( $wpdb->prepare( "SELECT v.orden, v.libro, v.capitulo, v.versiculo, v.texto, r.interpretacion, r.pregunta, r.mundo, r.gesto FROM {$wpdb->prefix}cita_reflexiones r JOIN {$wpdb->prefix}cita_versiculos v ON v.libro = r.libro AND v.capitulo = r.capitulo AND v.versiculo = r.versiculo WHERE r.version_biblica = %s ORDER BY RAND() LIMIT 1", CITA_VERSION_BIBLICA ), ARRAY_A ); if ( ! $fila ) { return null; } return array( 'versiculo' => array_intersect_key( $fila, array_flip( array( 'orden', 'libro', 'capitulo', 'versiculo', 'texto' ) ) ), 'reflexion' => array_intersect_key( $fila, cita_campos() ), ); } /* **************************************************************************** * 060 · GENERACIÓN CON OPENAI * ---------------------------------------------------------------------------- * Una sola llamada pide los cuatro textos en JSON. Se valida que estén todos y * que cumplan la extensión. Si algo falla se hace UN único intento de * reparación; si vuelve a fallar no se guarda nada. Nunca hay más de dos * llamadas por versículo. La clave no sale jamás del servidor. * ************************************************************************** */ /** * Cuenta palabras como las contaría una persona: grupos de letras o números, * admitiendo guiones y apóstrofos internos. Los signos sueltos no cuentan. * * @param string $texto * @return int */ function cita_contar_palabras( $texto ) { return (int) preg_match_all( '/[\p{L}\p{N}]+(?:[\'’\-][\p{L}\p{N}]+)*/u', (string) $texto ); } /** * Contador de llamadas a OpenAI del día (se reinicia solo al cambiar la fecha). * * @return array array( 'fecha' => 'AAAA-MM-DD', 'llamadas' => int ) */ function cita_contador_diario() { $hoy = wp_date( 'Y-m-d' ); $contador = get_option( 'cita_contador' ); if ( ! is_array( $contador ) || ( $contador['fecha'] ?? '' ) !== $hoy ) { $contador = array( 'fecha' => $hoy, 'llamadas' => 0 ); } return $contador; } /** * ¿Queda margen hoy para llamar a OpenAI? * * @param array $ajustes * @return bool */ function cita_queda_cupo( $ajustes ) { $limite = (int) $ajustes['limite_diario']; if ( $limite < 1 ) { return true; // 0 = sin límite. } $contador = cita_contador_diario(); return $contador['llamadas'] < $limite; } /** * Comprueba la respuesta de OpenAI. * Devuelve los cuatro textos limpios y la lista de fallos encontrados (vacía * si todo está bien). Los fallos se redactan de forma que sirvan tal cual para * pedirle a OpenAI que los corrija. * * @param mixed $datos Lo que ha devuelto OpenAI ya decodificado. * @param array $ajustes * @return array array( 'textos' => array, 'fallos' => string[] ) */ function cita_validar( $datos, $ajustes ) { $textos = array(); $fallos = array(); foreach ( array_keys( cita_campos() ) as $campo ) { $valor = is_array( $datos ) && isset( $datos[ $campo ] ) && is_string( $datos[ $campo ] ) ? $datos[ $campo ] : ''; // Se quita cualquier etiqueta y se dejan los espacios en uno solo. $valor = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $valor ) ) ); $textos[ $campo ] = $valor; if ( '' === $valor ) { $fallos[] = "falta el campo «{$campo}»"; continue; } $palabras = cita_contar_palabras( $valor ); $minimo = (int) $ajustes[ $campo . '_min' ]; $maximo = (int) $ajustes[ $campo . '_max' ]; if ( $palabras < $minimo || $palabras > $maximo ) { $fallos[] = "«{$campo}» tiene {$palabras} palabras y debe tener entre {$minimo} y {$maximo}"; } } return array( 'textos' => $textos, 'fallos' => $fallos ); } /** * Hace UNA llamada a la API de OpenAI y devuelve el texto de la respuesta. * Los mensajes de error son genéricos a propósito: nunca incluyen la clave ni * la respuesta en bruto del servicio. * * @param array $mensajes Conversación en el formato de OpenAI. * @param array $ajustes * @return string|WP_Error Contenido de la respuesta (un JSON en texto). */ function cita_llamar_openai( $mensajes, $ajustes ) { // Se anota la llamada ANTES de hacerla: aunque falle, cuenta para el límite. $contador = cita_contador_diario(); $contador['llamadas']++; update_option( 'cita_contador', $contador, false ); $respuesta = wp_remote_post( 'https://api.openai.com/v1/chat/completions', array( 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . $ajustes['clave'], 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'model' => $ajustes['modelo'], 'messages' => $mensajes, 'response_format' => array( 'type' => 'json_object' ), // Respuesta estructurada. 'max_completion_tokens' => (int) $ajustes['max_tokens'], // Tope de tamaño. ) ), ) ); if ( is_wp_error( $respuesta ) ) { return new WP_Error( 'cita_openai_red', 'No he podido conectar con el servicio que redacta las reflexiones.' ); } $codigo = (int) wp_remote_retrieve_response_code( $respuesta ); if ( 200 !== $codigo ) { return new WP_Error( 'cita_openai_http', 'El servicio que redacta las reflexiones ha respondido con el error ' . $codigo . '.' ); } $cuerpo = json_decode( wp_remote_retrieve_body( $respuesta ), true ); $contenido = $cuerpo['choices'][0]['message']['content'] ?? ''; if ( ! is_string( $contenido ) || '' === $contenido ) { return new WP_Error( 'cita_openai_vacio', 'El servicio que redacta las reflexiones ha devuelto una respuesta vacía.' ); } return $contenido; } /** * Genera los cuatro textos de un versículo. * Envía a OpenAI solo lo imprescindible: el prompt, la referencia, el texto * del versículo y las extensiones pedidas. * * @param array $versiculo Fila de la tabla de versículos. * @param array $ajustes * @return array|WP_Error Los cuatro textos validados, o un error. */ function cita_generar( $versiculo, $ajustes ) { // --- Petición: referencia, texto y extensiones (las mismas que se validan) --- $peticion = 'Versículo: ' . cita_referencia( $versiculo ) . " (Reina-Valera 1909)\n" . 'Texto: «' . $versiculo['texto'] . "»\n\n" . "Extensión obligatoria de cada campo del JSON, en número de palabras:\n"; foreach ( array_keys( cita_campos() ) as $campo ) { $peticion .= "- {$campo}: entre " . (int) $ajustes[ $campo . '_min' ] . ' y ' . (int) $ajustes[ $campo . '_max' ] . " palabras\n"; } $mensajes = array( array( 'role' => 'system', 'content' => $ajustes['prompt'] ), array( 'role' => 'user', 'content' => $peticion ), ); // --- Llamada única --- $contenido = cita_llamar_openai( $mensajes, $ajustes ); if ( is_wp_error( $contenido ) ) { return $contenido; } $revision = cita_validar( json_decode( $contenido, true ), $ajustes ); if ( ! $revision['fallos'] ) { return $revision['textos']; } // --- Un único intento de reparación (y solo si queda cupo) --- if ( ! cita_queda_cupo( $ajustes ) ) { return new WP_Error( 'cita_validacion', 'La reflexión generada no cumplía los requisitos y hoy no se puede reintentar.' ); } $mensajes[] = array( 'role' => 'assistant', 'content' => $contenido ); $mensajes[] = array( 'role' => 'user', 'content' => 'Tu respuesta no cumple los requisitos: ' . implode( '; ', $revision['fallos'] ) . '. ' . 'Devuelve de nuevo el objeto JSON completo, con las cuatro claves, corrigiendo solo eso. Cuenta las palabras antes de responder.', ); $contenido = cita_llamar_openai( $mensajes, $ajustes ); if ( is_wp_error( $contenido ) ) { return $contenido; } $revision = cita_validar( json_decode( $contenido, true ), $ajustes ); if ( $revision['fallos'] ) { // No se guarda nada que no cumpla. No hay más reintentos. return new WP_Error( 'cita_validacion', 'No he podido preparar bien la reflexión de este versículo. Pide otra palabra.' ); } return $revision['textos']; } /* **************************************************************************** * 070 · RUTAS REST: «DAME OTRA PALABRA» Y RECORDATORIO DE CALENDARIO * ---------------------------------------------------------------------------- * El botón de la web hace UNA petición a /wp-json/cita/v1/palabra con la * versión que tiene instalada. El servidor sigue este orden estricto: * 1º compara versiones (si no coinciden, se para aquí: ni sortea, ni lee la * caché, ni gasta tokens) * 2º sortea el versículo * 3º mira si ya tiene reflexión guardada * 4º solo si no la tiene, llama a OpenAI, valida, guarda y responde * ************************************************************************** */ add_action( 'rest_api_init', function () { register_rest_route( 'cita/v1', '/palabra', array( 'methods' => 'POST', 'callback' => 'cita_rest_palabra', 'permission_callback' => '__return_true', // Web pública: no hace falta sesión. ) ); register_rest_route( 'cita/v1', '/aviso', array( 'methods' => 'GET', 'callback' => 'cita_rest_aviso', 'permission_callback' => '__return_true', ) ); } ); /** * Envuelve los datos en una respuesta REST que ningún navegador ni caché * intermedia puede guardar: cada pulsación llega siempre al servidor. * * @param array $datos * @return WP_REST_Response */ function cita_responder( $datos ) { $datos['version'] = CITA_VERSION_APP; $respuesta = new WP_REST_Response( $datos, 200 ); $respuesta->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' ); $respuesta->header( 'Pragma', 'no-cache' ); $respuesta->header( 'Expires', '0' ); return $respuesta; } /** * Da forma a la respuesta con un versículo y sus cuatro textos. * * @param array $versiculo * @param array $reflexion * @param bool $generada true si se acaba de generar con OpenAI. * @return WP_REST_Response */ function cita_responder_palabra( $versiculo, $reflexion, $generada ) { return cita_responder( array( 'estado' => 'ok', 'generada' => (bool) $generada, 'cita' => array( 'orden' => (int) $versiculo['orden'], 'referencia' => cita_referencia( $versiculo ), 'texto' => $versiculo['texto'], // Texto literal, sin retocar. ), 'reflexion' => array( 'interpretacion' => $reflexion['interpretacion'], 'pregunta' => $reflexion['pregunta'], 'mundo' => $reflexion['mundo'], 'gesto' => $reflexion['gesto'], ), ) ); } /** * Atiende el botón «Dame otra palabra». * * @param WP_REST_Request $peticion * @return WP_REST_Response */ function cita_rest_palabra( $peticion ) { global $wpdb; // ---------- 1º VERSIÓN ---------- // Si el móvil trae una versión distinta de la del servidor se corta aquí. if ( (string) $peticion->get_param( 'version' ) !== CITA_VERSION_APP ) { return cita_responder( array( 'estado' => 'actualiza' ) ); } // ---------- 2º SORTEO ---------- $versiculo = cita_sortear(); if ( ! $versiculo ) { return cita_responder( array( 'estado' => 'error', 'mensaje' => 'La Biblia todavía no está cargada en esta web.' ) ); } // ---------- 3º CACHÉ ---------- $reflexion = cita_leer_reflexion( $versiculo ); if ( $reflexion ) { return cita_responder_palabra( $versiculo, $reflexion, false ); // Sin llamar a OpenAI. } // ---------- 4º GENERACIÓN ---------- $ajustes = cita_ajustes(); // Sin clave o sin cupo no se puede generar: se sirve uno ya guardado. if ( '' === $ajustes['clave'] || ! cita_queda_cupo( $ajustes ) ) { $reserva = cita_reserva(); if ( $reserva ) { return cita_responder_palabra( $reserva['versiculo'], $reserva['reflexion'], false ); } return cita_responder( array( 'estado' => 'error', 'mensaje' => 'Ahora mismo no puedo preparar una palabra nueva. Vuelve a intentarlo más tarde.' ) ); } // Cerrojo por versículo: si dos personas sacan el mismo versículo nuevo a // la vez, la segunda espera a que termine la primera y reutiliza su texto. // Así nunca se pagan dos llamadas por el mismo versículo. $cerrojo = 'cita_' . $wpdb->prefix . (int) $versiculo['orden']; $obtenido = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $cerrojo, 45 ) ); if ( 1 !== $obtenido ) { // No se consiguió el cerrojo a tiempo: quizá la otra petición ya acabó. $reflexion = cita_leer_reflexion( $versiculo ); if ( $reflexion ) { return cita_responder_palabra( $versiculo, $reflexion, false ); } return cita_responder( array( 'estado' => 'error', 'mensaje' => 'Hay mucha gente pidiendo palabra a la vez. Prueba otra vez en un momento.' ) ); } // Con el cerrojo ya cogido se vuelve a mirar la caché: puede que otra // petición la haya rellenado mientras esperábamos. $reflexion = cita_leer_reflexion( $versiculo ); $generada = false; $error = null; if ( ! $reflexion ) { $textos = cita_generar( $versiculo, $ajustes ); if ( is_wp_error( $textos ) ) { $error = $textos; } else { cita_guardar_reflexion( $versiculo, $textos, $ajustes ); $reflexion = cita_leer_reflexion( $versiculo ); // Se devuelve lo que quedó guardado. $generada = true; } } $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $cerrojo ) ); if ( $error || ! $reflexion ) { return cita_responder( array( 'estado' => 'error', 'mensaje' => $error ? $error->get_error_message() : 'No he podido guardar la reflexión.' ) ); } return cita_responder_palabra( $versiculo, $reflexion, $generada ); } /** * Devuelve un evento de calendario (.ics) que se repite cada día a la hora * elegida, con alarma y el enlace a la web. El móvil lo abre con su propia * aplicación de calendario; el servidor no guarda nada de quien lo pide. * * @param WP_REST_Request $peticion Parámetro «hora» con formato HH:MM. * @return void Escribe el fichero y termina. */ function cita_rest_aviso( $peticion ) { $hora = (string) $peticion->get_param( 'hora' ); if ( ! preg_match( '/^([01]\d|2[0-3]):([0-5]\d)$/', $hora, $partes ) ) { $partes = array( '', '08', '00' ); // Hora por defecto si llega algo raro. } $web = home_url( '/' ); $dominio = wp_parse_url( $web, PHP_URL_HOST ); // Hora «flotante» (sin zona): suena a esa hora en el reloj del propio móvil. $lineas = array( 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//La Palabra//Recordatorio diario//ES', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', 'UID:la-palabra-diaria@' . $dominio, 'DTSTAMP:' . gmdate( 'Ymd\THis\Z' ), 'DTSTART:' . wp_date( 'Ymd' ) . 'T' . $partes[1] . $partes[2] . '00', 'DURATION:PT5M', 'RRULE:FREQ=DAILY', 'SUMMARY:Tu palabra de hoy', 'DESCRIPTION:Abre La Palabra y lee tu versículo de hoy: ' . $web, 'URL:' . $web, 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Tu palabra de hoy', 'TRIGGER:PT0M', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ); nocache_headers(); header( 'Content-Type: text/calendar; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="la-palabra.ics"' ); echo implode( "\r\n", $lineas ) . "\r\n"; // El formato exige saltos CRLF. exit; } /* **************************************************************************** * 080 · PANTALLA PÚBLICA: SHORTCODE [la_palabra] * ---------------------------------------------------------------------------- * Pensada para móvil. En ordenador es la misma columna estrecha, centrada. * Todo el HTML, el CSS y el JavaScript salen de aquí; no hay ficheros aparte. * En el navegador NO hay ninguna clave: solo la dirección de la ruta REST. * ************************************************************************** */ add_shortcode( 'la_palabra', 'cita_shortcode' ); /** * Devuelve el HTML completo de la aplicación. * * @return string */ function cita_shortcode() { // Pide a los plugins de caché que no guarden esta página, para que la // versión incrustada en ella sea siempre la actual. if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); } $total = cita_total_versiculos(); // Datos que necesita el JavaScript. Ninguno es secreto. $configuracion = wp_json_encode( array( 'url' => esc_url_raw( rest_url( 'cita/v1/palabra' ) ), 'aviso' => esc_url_raw( rest_url( 'cita/v1/aviso' ) ), 'version' => CITA_VERSION_APP, 'web' => home_url( '/' ), 'espera' => 75000, // Milisegundos antes de dar la petición por perdida. ) ); // Cifras del pie, ya formateadas y escapadas. $libros = esc_html( number_format_i18n( CITA_LIBROS ) ); $capitulos = esc_html( number_format_i18n( CITA_CAPITULOS ) ); $numerados = esc_html( number_format_i18n( CITA_VERSICULOS_NUMERADOS ) ); $sorteables = esc_html( number_format_i18n( $total ) ); $sin_texto = esc_html( number_format_i18n( max( 0, CITA_VERSICULOS_NUMERADOS - $total ) ) ); $css = cita_css(); $js = cita_js(); return <<{$css}
La Palabra Reina-Valera 1909

Recibe tu palabra cada mañana

Elige la hora y se añadirá a tu calendario un recordatorio diario con el enlace a esta web.

Qué Biblia estás leyendo

Versión: Santa Biblia, Reina-Valera, revisión de 1909.

Traducción: Casiodoro de Reina (1569), revisada por Cipriano de Valera (1602).

Contenido: Antiguo y Nuevo Testamento. {$libros} libros, {$capitulos} capítulos y {$numerados} versículos numerados.

Tradición: es la Biblia protestante clásica en español, la de uso común en las iglesias evangélicas.

Texto: de dominio público. Se muestra sin cambios, con la ortografía de 1909.

Versículo: se elige al azar, con la misma probabilidad, entre los {$sorteables} que tienen texto propio. En esta edición hay {$sin_texto} números de versículo cuyo texto va unido al versículo contiguo.

Comentarios: no forman parte de la Biblia. Los redacta una inteligencia artificial y pueden contener errores.

HTML; } /** * Estilos de la pantalla pública. Todas las reglas van bajo «.cita-app» para * no afectar al resto del tema ni dejarse afectar por él más de lo necesario. * * @return string */ function cita_css() { return <<<'CSS' /* --- Contenedor: columna estrecha y centrada, también en ordenador --- */ .cita-app{--cita-tam:20px;--cita-rojo:#B3261E;--cita-verde:#1B6E2D;--cita-tinta:#111111;--cita-gris:#555555; box-sizing:border-box;max-width:520px;margin:0 auto;padding:16px 20px 40px;background:#FFFFFF;color:var(--cita-tinta); font-family:Verdana,"Segoe UI",system-ui,-apple-system,sans-serif;font-size:var(--cita-tam);line-height:1.55;text-align:left} .cita-app *,.cita-app *::before,.cita-app *::after{box-sizing:border-box} .cita-app [hidden]{display:none !important} .cita-app p{margin:0} /* --- Cabecera --- */ .cita-app .cita-cabecera{display:flex;justify-content:space-between;align-items:baseline;gap:12px;border-bottom:1px solid #CFCFCF;padding-bottom:10px;margin-bottom:24px} .cita-app .cita-marca{font-size:16px;font-weight:700;letter-spacing:.04em;text-transform:uppercase} .cita-app .cita-edicion{font-size:14px;color:var(--cita-gris)} .cita-app .cita-estado{font-size:17px;color:var(--cita-gris);min-height:1.5em;margin-bottom:8px} /* --- Versículo: el elemento principal, en negrita y claramente mayor --- */ .cita-app .cita-versiculo{margin-bottom:32px} .cita-app .cita-hoy{font-size:15px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--cita-rojo);margin-bottom:10px} .cita-app .cita-texto{font-size:1.6em;line-height:1.25;font-weight:700;overflow-wrap:break-word} .cita-app .cita-referencia{font-size:.9em;color:var(--cita-gris);margin-top:10px} /* --- Bloques de texto --- */ .cita-app .cita-bloque{margin-bottom:28px} .cita-app .cita-titular{margin:0 0 6px;padding:0;font-family:inherit;font-size:.82em;line-height:1.3;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--cita-tinta)} .cita-app .cita-rojo,.cita-app .cita-rojo .cita-titular{color:var(--cita-rojo)} .cita-app .cita-verde,.cita-app .cita-verde .cita-titular{color:var(--cita-verde)} /* --- Botones: grandes, fáciles de pulsar con el dedo --- */ .cita-app .cita-boton{display:block;width:100%;min-height:56px;margin:0;padding:10px 12px;border:2px solid var(--cita-tinta);border-radius:8px; background:#FFFFFF;color:var(--cita-tinta);font-family:inherit;font-size:16px;line-height:1.2;font-weight:700;text-align:center;text-transform:none;letter-spacing:0;cursor:pointer;box-shadow:none} .cita-app .cita-principal{background:var(--cita-tinta);color:#FFFFFF;font-size:20px;min-height:64px} .cita-app .cita-enlace{border-color:transparent;text-decoration:underline;min-height:48px;margin-top:8px} .cita-app .cita-boton:disabled{opacity:.55;cursor:wait} .cita-app .cita-boton:focus-visible{outline:3px solid #1A5FB4;outline-offset:2px} .cita-app .cita-dos{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px} /* --- Recordatorio diario --- */ .cita-app .cita-aviso{background:#F1F1F1;border-radius:8px;padding:18px;margin-top:28px;font-size:16px;line-height:1.45} .cita-app .cita-aviso-titulo{margin:0 0 6px;font-family:inherit;font-size:18px;font-weight:700;color:var(--cita-tinta);text-transform:none;letter-spacing:0} .cita-app .cita-aviso-fila{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end;margin-top:14px} .cita-app .cita-campo{display:flex;flex-direction:column;gap:4px} .cita-app .cita-campo label{font-size:14px;font-weight:700} .cita-app .cita-campo input{height:56px;padding:0 12px;border:2px solid var(--cita-tinta);border-radius:8px;background:#FFFFFF;color:var(--cita-tinta);font-family:inherit;font-size:20px} .cita-app .cita-aviso-fila .cita-principal{flex:1 1 170px;width:auto;min-height:56px;font-size:17px} /* --- Pie: qué Biblia se está leyendo --- */ .cita-app .cita-pie{border-top:1px solid #CFCFCF;margin-top:32px;padding-top:18px;font-size:15px;line-height:1.5;color:#3A3A3A} .cita-app .cita-pie p{margin-bottom:8px} .cita-app .cita-pie-titulo{margin:0 0 10px;font-family:inherit;font-size:15px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--cita-tinta)} /* --- Aviso emergente (pop-up) --- */ .cita-app .cita-velo{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(0,0,0,.6)} .cita-app .cita-dialogo{width:100%;max-width:420px;background:#FFFFFF;border-radius:10px;padding:24px;font-size:20px;line-height:1.4;font-weight:700;color:var(--cita-tinta)} .cita-app .cita-dialogo-botones{display:flex;flex-direction:column;gap:10px;margin-top:20px} CSS; } /** * JavaScript de la pantalla pública (sin librerías). * * @return string */ function cita_js() { return <<<'JS' (function () { 'use strict'; /* ------------------------------------------------------------------ * 1. Preparación * ------------------------------------------------------------------ */ var CFG = window.CITA_CFG; var app = document.getElementById('cita-app'); if (!CFG || !app) { return; } // Tamaños de letra disponibles, de menor a mayor (en píxeles). var TAMANOS = [16, 18, 20, 22, 25, 28, 32]; // Nombres de lo que se guarda en este móvil. Nada de esto sale del aparato. var CLAVE_ACTUAL = 'cita_actual'; // Última palabra mostrada. var CLAVE_HOY = 'cita_hoy'; // La primera palabra del día. var CLAVE_TAMANO = 'cita_tamano'; // Tamaño de letra elegido. var CLAVE_PENDIENTE = 'cita_pendiente'; // Acción a repetir tras actualizar. var ocupado = false; // true mientras hay una petición en curso. var mostrada = null; // Datos de la palabra que está en pantalla. var vieneDeActualizar = false; // true si esta carga es la posterior a «Actualizar ahora». var leyendo = false; // true mientras suena la lectura en voz alta. function el(id) { return document.getElementById(id); } /** Lee un dato guardado en el móvil. Devuelve null si no hay o falla. */ function leer(clave, almacen) { try { return JSON.parse((almacen || window.localStorage).getItem(clave)); } catch (e) { return null; } } /** Guarda un dato en el móvil. Si el navegador no deja, no pasa nada. */ function guardar(clave, valor, almacen) { try { (almacen || window.localStorage).setItem(clave, JSON.stringify(valor)); } catch (e) { /* sin almacenamiento */ } } function borrar(clave, almacen) { try { (almacen || window.localStorage).removeItem(clave); } catch (e) { /* sin almacenamiento */ } } function dos(n) { return (n < 10 ? '0' : '') + n; } /** Fecha de hoy en el reloj del móvil, como AAAA-MM-DD. */ function fechaDeHoy() { var d = new Date(); return d.getFullYear() + '-' + dos(d.getMonth() + 1) + '-' + dos(d.getDate()); } /** ¿Tienen la forma correcta los datos de una palabra? */ function esValida(d) { return !!(d && d.cita && d.cita.texto && d.cita.referencia && d.reflexion && d.reflexion.interpretacion); } /** La palabra de hoy guardada en este móvil, o null si aún no hay. */ function palabraDeHoy() { var h = leer(CLAVE_HOY); return (h && h.fecha === fechaDeHoy() && esValida(h.datos)) ? h.datos : null; } /* ------------------------------------------------------------------ * 2. Pintar una palabra en pantalla * ------------------------------------------------------------------ */ function pintar(d) { var hoy = palabraDeHoy(); var esLaDeHoy = !!(hoy && hoy.cita.orden === d.cita.orden); pararLectura(); mostrada = d; // textContent escribe texto plano: nunca se interpreta como HTML. el('cita-texto').textContent = '“' + d.cita.texto + '”'; // Entre comillas. el('cita-referencia').textContent = d.cita.referencia; el('cita-interpretacion').textContent = d.reflexion.interpretacion; el('cita-pregunta').textContent = d.reflexion.pregunta; el('cita-mundo').textContent = d.reflexion.mundo; el('cita-gesto').textContent = d.reflexion.gesto; el('cita-fecha').textContent = new Date().toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' }); el('cita-hoy').hidden = !esLaDeHoy; el('cita-volver').hidden = esLaDeHoy || !hoy; el('cita-contenido').hidden = false; } /* ------------------------------------------------------------------ * 3. Avisos emergentes (pop-ups) * ------------------------------------------------------------------ */ /** * Muestra un aviso. Si se pasa textoAccion aparece un botón principal que * ejecuta alAceptar. El botón «Cerrar» está siempre. */ function avisar(texto, textoAccion, alAceptar) { var accion = el('cita-dialogo-accion'); el('cita-dialogo-texto').textContent = texto; accion.hidden = !textoAccion; accion.textContent = textoAccion || ''; accion.onclick = function () { cerrarAviso(); if (alAceptar) { alAceptar(); } }; el('cita-velo').hidden = false; (textoAccion ? accion : el('cita-dialogo-cerrar')).focus(); } function cerrarAviso() { el('cita-velo').hidden = true; el('cita-otra').focus(); } el('cita-dialogo-cerrar').addEventListener('click', cerrarAviso); el('cita-velo').addEventListener('keydown', function (e) { if (e.key === 'Escape') { cerrarAviso(); } }); /** Aviso de falta de conexión, con la frase exacta acordada. */ function sinConexion() { avisar('De momento no tengo conexión; no podemos continuar.'); } /* ------------------------------------------------------------------ * 4. Pedir una palabra al servidor * ------------------------------------------------------------------ */ function ponerOcupado(si) { ocupado = si; el('cita-otra').disabled = si; // Evita dobles pulsaciones. el('cita-estado').textContent = si ? 'Buscando tu palabra…' : ''; } function pedirPalabra() { if (ocupado) { return; } // Aviso rápido: si el propio navegador ya sabe que no hay red, no se intenta. if (navigator.onLine === false) { sinConexion(); return; } ponerOcupado(true); // Si el servidor no contesta en el tiempo fijado, se corta la petición. var control = new AbortController(); var reloj = setTimeout(function () { control.abort(); }, CFG.espera); fetch(CFG.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: CFG.version }), // La versión instalada viaja en la misma petición. cache: 'no-store', credentials: 'omit', signal: control.signal }).then(function (r) { if (!r.ok) { throw new Error('http'); } return r.json(); }).then(function (d) { clearTimeout(reloj); ponerOcupado(false); // El servidor dice que esta versión está anticuada: no ha sorteado nada. if (d && d.estado === 'actualiza') { hayVersionNueva(); return; } vieneDeActualizar = false; // El servidor ha contestado, pero no ha podido preparar la palabra. if (!d || d.estado !== 'ok' || !esValida(d)) { avisar((d && d.mensaje) || 'No he podido preparar tu palabra. Inténtalo de nuevo.'); return; } guardar(CLAVE_ACTUAL, d); if (!palabraDeHoy()) { guardar(CLAVE_HOY, { fecha: fechaDeHoy(), datos: d }); } // La primera del día queda fijada. pintar(d); window.scrollTo(0, 0); }).catch(function () { // Sin red, servidor caído, respuesta ilegible o tiempo agotado: // la palabra que había en pantalla NO se toca. clearTimeout(reloj); ponerOcupado(false); sinConexion(); }); } /* ------------------------------------------------------------------ * 5. Actualización obligatoria * ------------------------------------------------------------------ */ function hayVersionNueva() { // Si acabamos de actualizar y el servidor sigue pidiéndolo, la // actualización no ha funcionado: se informa y NO se continúa. if (vieneDeActualizar) { vieneDeActualizar = false; avisar('No se ha podido completar la actualización. Cierra la web, vuelve a abrirla y prueba de nuevo. Con la versión antigua no podemos continuar.'); return; } avisar('Hay una versión nueva de La Palabra. Es necesario actualizar antes de seguir.', 'Actualizar ahora', actualizar); } function actualizar() { el('cita-estado').textContent = 'Actualizando…'; // Se anota la acción pendiente para repetirla UNA sola vez tras recargar. guardar(CLAVE_PENDIENTE, { accion: 'palabra' }, window.sessionStorage); // Se descarga la página saltándose la copia guardada y luego se recarga. fetch(window.location.href, { cache: 'reload', credentials: 'same-origin' }).then(function (r) { if (!r.ok) { throw new Error('http'); } window.location.reload(); }).catch(function () { borrar(CLAVE_PENDIENTE, window.sessionStorage); el('cita-estado').textContent = ''; avisar('No se ha podido descargar la versión nueva. Comprueba tu conexión; con la versión antigua no podemos continuar.'); }); } /* ------------------------------------------------------------------ * 6. Escuchar, WhatsApp, tamaño de letra y recordatorio * ------------------------------------------------------------------ */ function pararLectura() { if (window.speechSynthesis) { window.speechSynthesis.cancel(); } leyendo = false; el('cita-escuchar').textContent = 'Escuchar'; } function escuchar() { if (leyendo) { pararLectura(); return; } if (!mostrada) { return; } var r = mostrada.reflexion; var texto = [ mostrada.cita.texto, mostrada.cita.referencia + '.', 'Interpretación y aplicación.', r.interpretacion, 'La pregunta que has de hacerte.', r.pregunta, 'Lo que pasa en el mundo.', r.mundo, 'Un gesto que podrías hacer hoy.', r.gesto ].join(' '); var voz = new SpeechSynthesisUtterance(texto); voz.lang = 'es-ES'; voz.rate = 0.95; voz.onend = voz.onerror = function () { leyendo = false; el('cita-escuchar').textContent = 'Escuchar'; }; window.speechSynthesis.cancel(); window.speechSynthesis.speak(voz); leyendo = true; el('cita-escuchar').textContent = 'Parar'; } // Si el navegador no sabe leer en voz alta, el botón no se ofrece. if (!window.speechSynthesis || !window.SpeechSynthesisUtterance) { el('cita-escuchar').hidden = true; } function enviarWhatsApp() { if (!mostrada) { return; } var texto = '“' + mostrada.cita.texto + '”\n' + mostrada.cita.referencia + ' (Reina-Valera 1909)\n\n' + CFG.web; window.open('https://wa.me/?text=' + encodeURIComponent(texto), '_blank', 'noopener'); } function aplicarTamano(indice) { indice = Math.max(0, Math.min(TAMANOS.length - 1, indice)); app.style.setProperty('--cita-tam', TAMANOS[indice] + 'px'); guardar(CLAVE_TAMANO, indice); // Se recuerda en este móvil. return indice; } var guardado = leer(CLAVE_TAMANO); var tamano = aplicarTamano(typeof guardado === 'number' ? guardado : 2); function pedirRecordatorio() { var hora = el('cita-hora').value || '08:00'; // El servidor devuelve un evento de calendario; lo abre el propio móvil. window.location.href = CFG.aviso + (CFG.aviso.indexOf('?') === -1 ? '?' : '&') + 'hora=' + encodeURIComponent(hora); } /* ------------------------------------------------------------------ * 7. Botones * ------------------------------------------------------------------ */ el('cita-otra').addEventListener('click', pedirPalabra); el('cita-volver').addEventListener('click', function () { var h = palabraDeHoy(); if (h) { pintar(h); window.scrollTo(0, 0); } }); el('cita-escuchar').addEventListener('click', escuchar); el('cita-whatsapp').addEventListener('click', enviarWhatsApp); el('cita-mas').addEventListener('click', function () { tamano = aplicarTamano(tamano + 1); }); el('cita-menos').addEventListener('click', function () { tamano = aplicarTamano(tamano - 1); }); el('cita-avisar').addEventListener('click', pedirRecordatorio); window.addEventListener('pagehide', pararLectura); /* ------------------------------------------------------------------ * 8. Arranque * ------------------------------------------------------------------ */ var pendiente = leer(CLAVE_PENDIENTE, window.sessionStorage); borrar(CLAVE_PENDIENTE, window.sessionStorage); // Se borra ya: solo se repite una vez. var deHoy = palabraDeHoy(); var ultima = leer(CLAVE_ACTUAL); if (pendiente) { // Venimos de «Actualizar ahora»: se repite la acción que quedó a medias. if (esValida(ultima)) { pintar(ultima); } vieneDeActualizar = true; pedirPalabra(); } else if (deHoy) { // Ya hay palabra de hoy en este móvil: se muestra sin pedir nada. pintar(deHoy); } else { // Primera visita del día: se enseña la última conocida (si la hay) // mientras se pide la de hoy. Si no hay conexión, se queda la anterior. if (esValida(ultima)) { pintar(ultima); } pedirPalabra(); } })(); JS; } // 🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥 // FIN DE LA PALABRA v1.0.0 — Web que muestra un versículo al azar de la Reina-Valera 1909 con cuatro reflexiones generadas por OpenAI y guardadas para no pagar dos veces. Shortcode: [la_palabra] // 🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥🟥