1401 lines
80 KiB
PHP
1401 lines
80 KiB
PHP
<?php
|
|
/**
|
|
* Plugin Name: Bible Reference Tooltips
|
|
* Plugin URI: https://christit.com/bible-reference-tooltips
|
|
* Description: Local, open Bible reference tooltips for WordPress content. Detects verse references on page load and serves hover cards from a local verse table.
|
|
* Version: 1.1.0
|
|
* Stage: Stable
|
|
* Release Channel: Public
|
|
* Public Release: Yes
|
|
* Public Candidate: Yes
|
|
* WordPress.org Slug: bible-reference-tooltips
|
|
* Tested up to: 7.0.1
|
|
* Category: Content
|
|
* Settings Slug: bible-reference-tooltips
|
|
* Author: Jason Eugene Garrison
|
|
* Author URI: https://christit.com
|
|
* License: GPLv2 or later
|
|
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
|
* Text Domain: bible-reference-tooltips
|
|
* Requires at least: 6.0
|
|
* Requires PHP: 7.4
|
|
* Icon: 📖
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) exit;
|
|
|
|
if (!class_exists('Bible_Reference_Tooltips')) {
|
|
class Bible_Reference_Tooltips {
|
|
const VERSION = '1.1.0';
|
|
const SLUG = 'bible-reference-tooltips';
|
|
const NONCE = 'brt_nonce';
|
|
const OPTION_KEY = 'brt_settings';
|
|
const TABLE_VERSION = '1';
|
|
|
|
private $book_map = null;
|
|
|
|
public function __construct() {
|
|
add_action('init', [$this, 'ensure_defaults']);
|
|
add_action('init', [$this, 'ensure_runtime_repairs'], 20);
|
|
add_action('admin_init', [$this, 'ensure_schema']);
|
|
add_action('admin_menu', [$this, 'admin_menu'], 110);
|
|
add_action('wp_enqueue_scripts', [$this, 'frontend_assets']);
|
|
add_action('rest_api_init', [$this, 'rest_routes']);
|
|
add_filter('the_content', [$this, 'mark_content_scope'], 1);
|
|
add_filter('comment_text', [$this, 'mark_comment_scope'], 1);
|
|
add_action('admin_post_brt_save_settings', [$this, 'handle_save_settings']);
|
|
add_action('admin_post_brt_import_web', [$this, 'handle_import_web']);
|
|
add_action('admin_post_brt_clear', [$this, 'handle_clear']);
|
|
}
|
|
|
|
public static function table() {
|
|
global $wpdb;
|
|
return $wpdb->prefix . 'brt_verses';
|
|
}
|
|
|
|
public static function activate() {
|
|
self::install_schema();
|
|
if (!get_option(self::OPTION_KEY)) update_option(self::OPTION_KEY, self::defaults(), false);
|
|
self::log_event('activated', ['version' => self::VERSION], 'Bible Reference Tooltips activated.');
|
|
}
|
|
|
|
public static function deactivate() {
|
|
self::log_event('deactivated', [], 'Bible Reference Tooltips deactivated.');
|
|
}
|
|
|
|
private static function log_event($event, $payload = [], $message = '', $severity = 'INFO') {
|
|
$payload = is_array($payload) ? $payload : [];
|
|
$payload['event'] = $event;
|
|
$payload['severity'] = $payload['severity'] ?? $severity;
|
|
$payload['component'] = $payload['component'] ?? self::SLUG;
|
|
do_action('bible_reference_tooltips_event', $event, $payload, $message, $severity);
|
|
}
|
|
|
|
public function ensure_defaults() {
|
|
if (!get_option(self::OPTION_KEY)) update_option(self::OPTION_KEY, self::defaults(), false);
|
|
}
|
|
|
|
public function ensure_schema() {
|
|
if (get_option('brt_table_version') !== self::TABLE_VERSION) self::install_schema();
|
|
$this->repair_book_aliases();
|
|
}
|
|
|
|
public function ensure_runtime_repairs() {
|
|
$this->repair_book_aliases();
|
|
$this->repair_missing_james_verses();
|
|
}
|
|
|
|
public static function defaults() {
|
|
return [
|
|
'enabled' => '1',
|
|
'translation' => 'WEB',
|
|
'scope' => 'body',
|
|
'theme' => 'auto',
|
|
'underline' => '1',
|
|
'show_translation' => '1',
|
|
'show_tooltips' => '1',
|
|
'link_references' => '1',
|
|
'link_target' => 'same',
|
|
'bible_version' => '',
|
|
'logos_insert' => '0',
|
|
'logos_existing' => '0',
|
|
'logos_translation' => '',
|
|
'logos_icon' => 'dark',
|
|
'add_tooltips_to_links' => '0',
|
|
'case_sensitive' => '0',
|
|
'tag_chapters' => '0',
|
|
'scan_comments' => '0',
|
|
'skip_tags' => ['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'SELECT', 'BUTTON', 'CODE', 'PRE', 'H1', 'H2', 'H3'],
|
|
'max_range' => '12',
|
|
];
|
|
}
|
|
|
|
private function settings() {
|
|
$settings = get_option(self::OPTION_KEY, []);
|
|
$settings = array_merge(self::defaults(), is_array($settings) ? $settings : []);
|
|
$settings['skip_tags'] = $this->normalize_skip_tags($settings['skip_tags'] ?? []);
|
|
return $settings;
|
|
}
|
|
|
|
private static function install_schema() {
|
|
global $wpdb;
|
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
$table = self::table();
|
|
$charset = $wpdb->get_charset_collate();
|
|
$sql = "CREATE TABLE {$table} (
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
translation varchar(20) NOT NULL DEFAULT 'WEB',
|
|
book varchar(16) NOT NULL,
|
|
book_name varchar(64) NOT NULL,
|
|
chapter smallint unsigned NOT NULL,
|
|
verse smallint unsigned NOT NULL,
|
|
verse_text longtext NOT NULL,
|
|
source varchar(120) NOT NULL DEFAULT '',
|
|
license varchar(120) NOT NULL DEFAULT '',
|
|
created_at datetime NOT NULL,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY verse_key (translation, book, chapter, verse),
|
|
KEY translation (translation),
|
|
KEY lookup (translation, book, chapter)
|
|
) {$charset};";
|
|
dbDelta($sql);
|
|
update_option('brt_table_version', self::TABLE_VERSION, false);
|
|
}
|
|
|
|
public function admin_menu() {
|
|
$title = 'Bible Reference Tooltips';
|
|
add_options_page($title, $title, 'manage_options', self::SLUG, [$this, 'admin_page']);
|
|
}
|
|
|
|
public function frontend_assets() {
|
|
$settings = $this->settings();
|
|
if (empty($settings['enabled']) || is_admin() || is_feed() || wp_doing_ajax()) return;
|
|
wp_register_script('brt', '', [], self::VERSION, true);
|
|
wp_enqueue_script('brt');
|
|
wp_register_style('brt', false, [], self::VERSION);
|
|
wp_enqueue_style('brt');
|
|
wp_add_inline_style('brt', $this->frontend_css());
|
|
$installed_translations = $this->installed_translations();
|
|
$default_translation = $this->default_translation($settings['translation'], $installed_translations);
|
|
wp_localize_script('brt', 'BibleReferenceTooltips', [
|
|
'rest' => esc_url_raw(rest_url('bible-reference-tooltips/v1/lookup')),
|
|
'nonce' => wp_create_nonce('wp_rest'),
|
|
'scope' => (string)$settings['scope'],
|
|
'translation' => $default_translation,
|
|
'translations' => $installed_translations,
|
|
'theme' => (string)$settings['theme'],
|
|
'underline' => !empty($settings['underline']),
|
|
'showTranslation' => !empty($settings['show_translation']),
|
|
'showTooltips' => !empty($settings['show_tooltips']),
|
|
'linkReferences' => !empty($settings['link_references']),
|
|
'linkTarget' => (string)$settings['link_target'],
|
|
'bibleVersion' => (string)($settings['bible_version'] ?: $default_translation),
|
|
'logosInsert' => !empty($settings['logos_insert']),
|
|
'logosExisting' => !empty($settings['logos_existing']),
|
|
'logosTranslation' => (string)($settings['logos_translation'] ?: $default_translation),
|
|
'logosIcon' => (string)$settings['logos_icon'],
|
|
'addTooltipsToLinks' => !empty($settings['add_tooltips_to_links']),
|
|
'caseSensitive' => !empty($settings['case_sensitive']),
|
|
'tagChapters' => !empty($settings['tag_chapters']),
|
|
'scanComments' => !empty($settings['scan_comments']),
|
|
'skipTags' => array_values((array)$settings['skip_tags']),
|
|
]);
|
|
wp_add_inline_script('brt', $this->frontend_js());
|
|
}
|
|
|
|
public function mark_content_scope($content) {
|
|
if (is_admin() || is_feed()) return $content;
|
|
return '<div class="brt-scan-scope">' . $content . '</div>';
|
|
}
|
|
|
|
public function mark_comment_scope($content) {
|
|
$settings = $this->settings();
|
|
if (is_admin() || is_feed() || empty($settings['scan_comments'])) return $content;
|
|
return '<div class="brt-comment-scope">' . $content . '</div>';
|
|
}
|
|
|
|
public function rest_routes() {
|
|
register_rest_route('bible-reference-tooltips/v1', '/lookup', [
|
|
'methods' => 'GET',
|
|
'callback' => [$this, 'rest_lookup'],
|
|
'permission_callback' => '__return_true',
|
|
'args' => [
|
|
'ref' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
|
'translation' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function rest_lookup($request) {
|
|
$settings = $this->settings();
|
|
$installed_translations = $this->installed_translations();
|
|
$translation = strtoupper(preg_replace('/[^A-Z0-9_-]/i', '', $request->get_param('translation') ?: $settings['translation']));
|
|
if (!$translation) $translation = $this->default_translation($settings['translation'], $installed_translations);
|
|
$parsed = $this->parse_reference($request->get_param('ref'));
|
|
if (!$parsed) {
|
|
self::log_event('lookup_failed', [
|
|
'reason' => 'unrecognized_reference',
|
|
'ref' => (string)$request->get_param('ref'),
|
|
'translation' => $translation,
|
|
], 'Bible reference lookup failed: reference not recognized.', 'WARNING');
|
|
return new WP_REST_Response([
|
|
'ok' => false,
|
|
'message' => 'This does not look like a supported Bible reference.',
|
|
'reason' => 'unrecognized_reference',
|
|
'translation' => $translation,
|
|
'translations' => $installed_translations,
|
|
], 400);
|
|
}
|
|
|
|
$max_range = max(1, min(50, absint($settings['max_range'])));
|
|
$lookup_translation = $translation;
|
|
$verses = $this->lookup_verses($lookup_translation, $parsed, $max_range);
|
|
if (!$verses && strtoupper($parsed['book'] ?? '') === 'JAS') {
|
|
$this->import_vpl_book($lookup_translation, 'JAS');
|
|
$verses = $this->lookup_verses($lookup_translation, $parsed, $max_range);
|
|
}
|
|
if (!$verses) {
|
|
$fallback = $this->fallback_installed_translation($lookup_translation);
|
|
if ($fallback) {
|
|
$lookup_translation = $fallback;
|
|
$verses = $this->lookup_verses($lookup_translation, $parsed, $max_range);
|
|
if (!$verses && strtoupper($parsed['book'] ?? '') === 'JAS') {
|
|
$this->import_vpl_book($lookup_translation, 'JAS');
|
|
$verses = $this->lookup_verses($lookup_translation, $parsed, $max_range);
|
|
}
|
|
}
|
|
}
|
|
if (!$verses) {
|
|
$installed_codes = array_map(function($row) { return strtoupper((string)($row['code'] ?? '')); }, $installed_translations);
|
|
$translation_installed = in_array(strtoupper($lookup_translation), $installed_codes, true);
|
|
$book_candidates = $this->book_lookup_candidates($parsed);
|
|
$book_name_candidates = $this->book_name_lookup_candidates($parsed);
|
|
$message = $translation_installed
|
|
? $this->translation_label($lookup_translation) . ' is installed, but no local rows were found for ' . $this->format_reference($parsed) . '.'
|
|
: $this->translation_label($lookup_translation) . ' is not installed locally.';
|
|
self::log_event('lookup_failed', [
|
|
'reason' => $translation_installed ? 'missing_verse_rows' : 'translation_not_installed',
|
|
'reference' => $this->format_reference($parsed),
|
|
'translation' => $lookup_translation,
|
|
'book' => $parsed['book'],
|
|
'chapter' => (int)$parsed['chapter'],
|
|
'verse_start' => (int)$parsed['verse_start'],
|
|
'book_candidates' => $book_candidates,
|
|
'book_name_candidates' => $book_name_candidates,
|
|
], 'Bible reference lookup failed: ' . $this->format_reference($parsed), 'WARNING');
|
|
return new WP_REST_Response([
|
|
'ok' => false,
|
|
'reference' => $this->format_reference($parsed),
|
|
'translation' => $lookup_translation,
|
|
'translation_label' => $this->translation_label($lookup_translation),
|
|
'translations' => $installed_translations,
|
|
'message' => $message,
|
|
'reason' => $translation_installed ? 'missing_verse_rows' : 'translation_not_installed',
|
|
'diagnostic' => [
|
|
'book' => $parsed['book'],
|
|
'book_name' => $parsed['book_name'],
|
|
'chapter' => (int)$parsed['chapter'],
|
|
'verse_start' => (int)$parsed['verse_start'],
|
|
'book_candidates' => $book_candidates,
|
|
'book_name_candidates' => $book_name_candidates,
|
|
],
|
|
], 404);
|
|
}
|
|
|
|
self::log_event('lookup_success', [
|
|
'reference' => $this->format_reference($parsed),
|
|
'translation' => $lookup_translation,
|
|
'book' => $parsed['book'],
|
|
'chapter' => (int)$parsed['chapter'],
|
|
'verse_start' => (int)$parsed['verse_start'],
|
|
'verse_count' => count($verses),
|
|
], 'Bible reference looked up: ' . $this->format_reference($parsed), 'INFO');
|
|
|
|
return [
|
|
'ok' => true,
|
|
'reference' => $this->format_reference($parsed),
|
|
'translation' => $lookup_translation,
|
|
'translation_label' => $this->translation_label($lookup_translation),
|
|
'translations' => $installed_translations,
|
|
'verses' => $verses,
|
|
'diagnostic' => [
|
|
'book' => $parsed['book'],
|
|
'book_name' => $parsed['book_name'],
|
|
'chapter' => (int)$parsed['chapter'],
|
|
'verse_start' => (int)$parsed['verse_start'],
|
|
'verse_count' => count($verses),
|
|
],
|
|
'text' => implode(' ', array_map(function($row) {
|
|
return $row['verse'] . '. ' . $row['text'];
|
|
}, $verses)),
|
|
];
|
|
}
|
|
|
|
private function lookup_verses($translation, $parsed, $max_range) {
|
|
global $wpdb;
|
|
$chapter_only = empty($parsed['verse_start']);
|
|
$start = $chapter_only ? 1 : (int)$parsed['verse_start'];
|
|
$end = $chapter_only ? $start + $max_range - 1 : (int)($parsed['verse_end'] ?: $start);
|
|
if ($end < $start) $end = $start;
|
|
if (($end - $start + 1) > $max_range) $end = $start + $max_range - 1;
|
|
$book_candidates = $this->book_lookup_candidates($parsed);
|
|
$book_name_candidates = $this->book_name_lookup_candidates($parsed);
|
|
$book_placeholders = implode(',', array_fill(0, count($book_candidates), '%s'));
|
|
$book_name_placeholders = implode(',', array_fill(0, count($book_name_candidates), '%s'));
|
|
$params = array_merge(
|
|
[$translation],
|
|
$book_candidates,
|
|
$book_name_candidates,
|
|
[$parsed['chapter'], $start, $end]
|
|
);
|
|
$rows = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT chapter, verse, verse_text FROM " . self::table() . " WHERE translation = %s AND (book IN ({$book_placeholders}) OR book_name IN ({$book_name_placeholders})) AND chapter = %d AND verse BETWEEN %d AND %d ORDER BY verse ASC",
|
|
$params
|
|
), ARRAY_A);
|
|
return array_map(function($row) {
|
|
return [
|
|
'chapter' => (int)$row['chapter'],
|
|
'verse' => (int)$row['verse'],
|
|
'text' => wp_kses_post($row['verse_text']),
|
|
];
|
|
}, $rows ?: []);
|
|
}
|
|
|
|
private function fallback_installed_translation($requested_translation = '') {
|
|
global $wpdb;
|
|
$rows = $wpdb->get_col("SELECT DISTINCT translation FROM " . self::table() . " ORDER BY translation ASC");
|
|
$rows = array_values(array_filter(array_map('strval', $rows ?: [])));
|
|
if (count($rows) === 1 && strtoupper($rows[0]) !== strtoupper((string)$requested_translation)) return $rows[0];
|
|
if ($rows && !in_array(strtoupper((string)$requested_translation), array_map('strtoupper', $rows), true)) return $rows[0];
|
|
return '';
|
|
}
|
|
|
|
private function installed_translations() {
|
|
global $wpdb;
|
|
$rows = $wpdb->get_results("SELECT translation, COUNT(*) AS verses, MAX(source) AS source FROM " . self::table() . " GROUP BY translation ORDER BY translation ASC", ARRAY_A);
|
|
$available = $this->available_translations();
|
|
return array_map(function($row) use ($available) {
|
|
$code = strtoupper((string)$row['translation']);
|
|
return [
|
|
'code' => $code,
|
|
'name' => $available[$code]['name'] ?? $code,
|
|
'verses' => (int)$row['verses'],
|
|
'source' => (string)$row['source'],
|
|
];
|
|
}, $rows ?: []);
|
|
}
|
|
|
|
private function default_translation($preferred = '', $installed = null) {
|
|
$preferred = strtoupper(preg_replace('/[^A-Z0-9_-]/i', '', (string)$preferred));
|
|
if ($installed === null) $installed = $this->installed_translations();
|
|
$codes = array_map(function($row) { return strtoupper((string)($row['code'] ?? '')); }, is_array($installed) ? $installed : []);
|
|
$codes = array_values(array_filter($codes));
|
|
if ($preferred && in_array($preferred, $codes, true)) return $preferred;
|
|
if ($codes) return $codes[0];
|
|
return $preferred ?: 'WEB';
|
|
}
|
|
|
|
private function translation_label($translation) {
|
|
$translation = strtoupper((string)$translation);
|
|
$available = $this->available_translations();
|
|
return $available[$translation]['name'] ?? $translation;
|
|
}
|
|
|
|
private function normalize_skip_tags($tags) {
|
|
$allowed = ['B', 'I', 'U', 'OL', 'UL', 'SPAN', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'SELECT', 'BUTTON', 'CODE', 'PRE'];
|
|
$required = ['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'SELECT', 'BUTTON', 'CODE', 'PRE'];
|
|
$tags = is_array($tags) ? $tags : explode(',', (string)$tags);
|
|
$tags = array_map(function($tag) {
|
|
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)$tag));
|
|
}, $tags);
|
|
$tags = array_values(array_intersect(array_unique(array_filter($tags)), $allowed));
|
|
return array_values(array_unique(array_merge($required, $tags)));
|
|
}
|
|
|
|
private function display_skip_tags() {
|
|
return [
|
|
'B' => 'Bold',
|
|
'I' => 'Italic',
|
|
'U' => 'Underline',
|
|
'OL' => 'Ordered list',
|
|
'UL' => 'Unordered list',
|
|
'SPAN' => 'Span',
|
|
'H1' => 'Header 1',
|
|
'H2' => 'Header 2',
|
|
'H3' => 'Header 3',
|
|
'H4' => 'Header 4',
|
|
'H5' => 'Header 5',
|
|
'H6' => 'Header 6',
|
|
];
|
|
}
|
|
|
|
private function bible_link_versions() {
|
|
return [
|
|
'WEB' => 'WEB',
|
|
'KJV' => 'KJV',
|
|
'ASV' => 'ASV',
|
|
'YLT' => 'YLT',
|
|
'ESV' => 'ESV',
|
|
'NIV' => 'NIV',
|
|
'NKJV' => 'NKJV',
|
|
'NLT' => 'NLT',
|
|
'NASB' => 'NASB',
|
|
'CSB' => 'CSB',
|
|
'NET' => 'NET',
|
|
'RSV' => 'RSV',
|
|
'NRSV' => 'NRSV',
|
|
'LEB' => 'LEB',
|
|
'MSG' => 'The Message',
|
|
];
|
|
}
|
|
|
|
private function book_lookup_candidates($parsed) {
|
|
$abbr = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $parsed['book'] ?? ''));
|
|
$name = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $parsed['book_name'] ?? ''));
|
|
$variants = [
|
|
'JHN' => ['JHN', 'JOH', 'JOHN'],
|
|
'PSA' => ['PSA', 'PS', 'PSM', 'PSALM', 'PSALMS'],
|
|
'SNG' => ['SNG', 'SON', 'SOS', 'SONG'],
|
|
'JAS' => ['JAS', 'JA', 'JAM', 'JMS', 'JM', 'JAC', 'JACOB', 'EPISTLEOFJAMES', 'THEEPISTLEOFJAMES'],
|
|
'JOL' => ['JOL', 'JOE'],
|
|
'NAM' => ['NAM', 'NAH'],
|
|
'PHM' => ['PHM', 'PHLM', 'PHILEM'],
|
|
'REV' => ['REV', 'RE', 'APO'],
|
|
];
|
|
$candidates = array_merge([$abbr, $name], $variants[$abbr] ?? []);
|
|
foreach ($this->book_map() as $alias => $entry) {
|
|
if (strtoupper($entry['abbr']) !== $abbr) continue;
|
|
$candidates[] = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $alias));
|
|
$candidates[] = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $entry['name']));
|
|
}
|
|
if ($name && strlen($name) >= 3) $candidates[] = substr($name, 0, 3);
|
|
return array_values(array_unique(array_filter($candidates)));
|
|
}
|
|
|
|
private function book_name_lookup_candidates($parsed) {
|
|
$abbr = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $parsed['book'] ?? ''));
|
|
$names = [$parsed['book_name'] ?? '', $abbr];
|
|
foreach ($this->book_map() as $alias => $entry) {
|
|
if (strtoupper($entry['abbr']) !== $abbr) continue;
|
|
$names[] = $entry['name'];
|
|
$names[] = $alias;
|
|
$names[] = strtoupper($alias);
|
|
}
|
|
if ($abbr === 'JAS') {
|
|
$names = array_merge($names, ['James', 'Jas', 'JAS', 'Ja', 'JA', 'Jam', 'JAM', 'Jms', 'JMS', 'Jm', 'JM', 'Jac', 'JAC', 'Jacob', 'Epistle of James', 'The Epistle of James']);
|
|
}
|
|
return array_values(array_unique(array_filter(array_map('strval', $names))));
|
|
}
|
|
|
|
private function parse_reference($reference) {
|
|
$reference = trim(preg_replace('/\s+/', ' ', (string)$reference));
|
|
if (!preg_match('/^((?:[1-3]\s*)?[A-Za-z][A-Za-z .]+?)\s+(\d{1,3})(?:\s*:\s*(\d{1,3})(?:\s*[-\x{2013}\x{2014}]\s*(\d{1,3}))?)?$/u', $reference, $m)) return null;
|
|
$book = $this->normalize_book($m[1]);
|
|
if (!$book) return null;
|
|
return [
|
|
'book' => $book['abbr'],
|
|
'book_name' => $book['name'],
|
|
'chapter' => (int)$m[2],
|
|
'verse_start' => isset($m[3]) && $m[3] !== '' ? (int)$m[3] : 0,
|
|
'verse_end' => isset($m[4]) && $m[4] !== '' ? (int)$m[4] : 0,
|
|
];
|
|
}
|
|
|
|
private function format_reference($parsed) {
|
|
if (empty($parsed['verse_start'])) return $parsed['book_name'] . ' ' . (int)$parsed['chapter'];
|
|
$end = !empty($parsed['verse_end']) && (int)$parsed['verse_end'] !== (int)$parsed['verse_start'] ? '-' . (int)$parsed['verse_end'] : '';
|
|
return $parsed['book_name'] . ' ' . (int)$parsed['chapter'] . ':' . (int)$parsed['verse_start'] . $end;
|
|
}
|
|
|
|
private function normalize_book($book) {
|
|
$key = strtolower(preg_replace('/[^a-z0-9]/i', '', $book));
|
|
$map = $this->book_map();
|
|
if (isset($map[$key])) return $map[$key];
|
|
if (strlen($key) >= 3) {
|
|
$best = null;
|
|
$best_distance = 99;
|
|
foreach ($map as $alias => $entry) {
|
|
if (abs(strlen($alias) - strlen($key)) > 1) continue;
|
|
$distance = levenshtein($key, $alias);
|
|
if ($distance < $best_distance) {
|
|
$best = $entry;
|
|
$best_distance = $distance;
|
|
}
|
|
}
|
|
if ($best && $best_distance <= 1) return $best;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function book_map() {
|
|
if ($this->book_map !== null) return $this->book_map;
|
|
$books = [
|
|
['GEN','Genesis',['gen','ge','gn']],
|
|
['EXO','Exodus',['exo','ex','exod']],
|
|
['LEV','Leviticus',['lev','le','lv']],
|
|
['NUM','Numbers',['num','nu','nm','nb']],
|
|
['DEU','Deuteronomy',['deut','deu','dt']],
|
|
['JOS','Joshua',['josh','jos']],
|
|
['JDG','Judges',['judg','jdg','jg']],
|
|
['RUT','Ruth',['ruth','rut']],
|
|
['1SA','1 Samuel',['1samuel','1sam','isamuel','isam']],
|
|
['2SA','2 Samuel',['2samuel','2sam','iisamuel','iisam']],
|
|
['1KI','1 Kings',['1kings','1kgs','1ki','ikings']],
|
|
['2KI','2 Kings',['2kings','2kgs','2ki','iikings']],
|
|
['1CH','1 Chronicles',['1chronicles','1chron','1chr','ichronicles']],
|
|
['2CH','2 Chronicles',['2chronicles','2chron','2chr','iichronicles']],
|
|
['EZR','Ezra',['ezra','ezr']],
|
|
['NEH','Nehemiah',['neh','ne']],
|
|
['EST','Esther',['esther','est']],
|
|
['JOB','Job',['job']],
|
|
['PSA','Psalms',['psalms','psalm','psa','ps']],
|
|
['PRO','Proverbs',['proverbs','prov','pro','pr']],
|
|
['ECC','Ecclesiastes',['ecclesiastes','eccl','ecc','qoheleth']],
|
|
['SNG','Song of Solomon',['songofsolomon','songofsongs','song','sos','sng']],
|
|
['ISA','Isaiah',['isaiah','isa']],
|
|
['JER','Jeremiah',['jeremiah','jer']],
|
|
['LAM','Lamentations',['lamentations','lam']],
|
|
['EZK','Ezekiel',['ezekiel','ezek','ezk']],
|
|
['DAN','Daniel',['daniel','dan','dn']],
|
|
['HOS','Hosea',['hosea','hos']],
|
|
['JOL','Joel',['joel','jol','joe']],
|
|
['AMO','Amos',['amos','amo']],
|
|
['OBA','Obadiah',['obadiah','oba','obad']],
|
|
['JON','Jonah',['jonah','jon']],
|
|
['MIC','Micah',['micah','mic']],
|
|
['NAM','Nahum',['nahum','nah','nam']],
|
|
['HAB','Habakkuk',['habakkuk','hab']],
|
|
['ZEP','Zephaniah',['zephaniah','zeph','zep']],
|
|
['HAG','Haggai',['haggai','hag']],
|
|
['ZEC','Zechariah',['zechariah','zech','zec']],
|
|
['MAL','Malachi',['malachi','mal']],
|
|
['MAT','Matthew',['matthew','matt','mat','mt']],
|
|
['MRK','Mark',['mark','mrk','mk']],
|
|
['LUK','Luke',['luke','luk','lk']],
|
|
['JHN','John',['john','jhn','jn','joh']],
|
|
['ACT','Acts',['acts','act']],
|
|
['ROM','Romans',['romans','rom','ro']],
|
|
['1CO','1 Corinthians',['1corinthians','1cor','1co','icorinthians']],
|
|
['2CO','2 Corinthians',['2corinthians','2cor','2co','iicorinthians']],
|
|
['GAL','Galatians',['galatians','gal']],
|
|
['EPH','Ephesians',['ephesians','eph']],
|
|
['PHP','Philippians',['philippians','phil','php','phi']],
|
|
['COL','Colossians',['colossians','col']],
|
|
['1TH','1 Thessalonians',['1thessalonians','1thess','1th','ithessalonians']],
|
|
['2TH','2 Thessalonians',['2thessalonians','2thess','2th','iithessalonians']],
|
|
['1TI','1 Timothy',['1timothy','1tim','1ti','itimothy']],
|
|
['2TI','2 Timothy',['2timothy','2tim','2ti','iitimothy']],
|
|
['TIT','Titus',['titus','tit']],
|
|
['PHM','Philemon',['philemon','phm']],
|
|
['HEB','Hebrews',['hebrews','heb']],
|
|
['JAS','James',['james','jas','ja','jam','jms','jm','jac','jacob','epistleofjames','theepistleofjames']],
|
|
['1PE','1 Peter',['1peter','1pet','1pe','ipeter']],
|
|
['2PE','2 Peter',['2peter','2pet','2pe','iipeter']],
|
|
['1JN','1 John',['1john','1jn','1jhn','ijohn']],
|
|
['2JN','2 John',['2john','2jn','2jhn','iijohn']],
|
|
['3JN','3 John',['3john','3jn','3jhn','iiijohn']],
|
|
['JUD','Jude',['jude','jud']],
|
|
['REV','Revelation',['revelation','rev','re']],
|
|
];
|
|
$map = [];
|
|
foreach ($books as $book) {
|
|
$name_key = strtolower(preg_replace('/[^a-z0-9]/i', '', $book[1]));
|
|
$map[$name_key] = ['abbr' => $book[0], 'name' => $book[1]];
|
|
foreach ($book[2] as $alias) $map[$alias] = ['abbr' => $book[0], 'name' => $book[1]];
|
|
}
|
|
return $this->book_map = $map;
|
|
}
|
|
|
|
public function admin_page() {
|
|
if (!current_user_can('manage_options')) return;
|
|
$settings = $this->settings();
|
|
$stats = $this->translation_stats();
|
|
$translations = $this->available_translations();
|
|
$installed = $this->installed_translations();
|
|
$installed_by_code = [];
|
|
foreach ($installed as $row) $installed_by_code[$row['code']] = $row;
|
|
$msg = sanitize_text_field($_GET['msg'] ?? '');
|
|
?>
|
|
<div class="wrap brt-admin">
|
|
<h1>📖 Bible Reference Tooltips</h1>
|
|
<p class="brt-lede">Open Bible hover cards powered by your own WordPress database. No remote lookup is needed during page views.</p>
|
|
<?php if ($msg): ?><div class="notice notice-success is-dismissible"><p><?php echo esc_html($msg); ?></p></div><?php endif; ?>
|
|
<style><?php echo $this->admin_css(); ?></style>
|
|
<script>
|
|
document.addEventListener('change', function(e){
|
|
if (!e.target.classList.contains('brt-check-all')) return;
|
|
e.target.closest('table').querySelectorAll('tbody input[type="checkbox"]').forEach(function(box){ box.checked = e.target.checked; });
|
|
});
|
|
</script>
|
|
<div class="brt-grid">
|
|
<section class="brt-card">
|
|
<h2>Installed Texts</h2>
|
|
<p>Install any combination of local Bible texts. Installed translations are automatically available in frontend tooltip tabs; the default translation only controls which one opens first.</p>
|
|
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="brt-library">
|
|
<?php wp_nonce_field(self::NONCE); ?>
|
|
<input type="hidden" name="action" value="brt_import_web">
|
|
<div class="brt-bulkbar">
|
|
<select name="bulk_action">
|
|
<option value="install_selected">Install / Update selected</option>
|
|
<option value="install_all">Install / Update all</option>
|
|
<option value="set_default">Make selected default</option>
|
|
<option value="remove_selected">Remove selected</option>
|
|
<option value="clear_all">Remove all installed texts</option>
|
|
</select>
|
|
<button class="button button-primary">Apply</button>
|
|
</div>
|
|
<table class="widefat striped brt-table">
|
|
<thead><tr><th class="check-column"><input type="checkbox" class="brt-check-all"></th><th>Translation</th><th>Status</th><th>Source</th><th>Default</th></tr></thead>
|
|
<tbody>
|
|
<?php foreach ($translations as $code => $info):
|
|
$row = $installed_by_code[$code] ?? null;
|
|
$is_default = strtoupper($settings['translation']) === $code;
|
|
?>
|
|
<tr>
|
|
<th class="check-column"><input type="checkbox" name="translations[]" value="<?php echo esc_attr($code); ?>"></th>
|
|
<td><strong><?php echo esc_html($code); ?></strong><br><span><?php echo esc_html($info['name']); ?></span></td>
|
|
<td><?php echo $row ? esc_html(number_format_i18n((int)$row['verses']) . ' verses installed') : '<span class="brt-muted">Not installed</span>'; ?></td>
|
|
<td><?php echo esc_html($info['source']); ?><br><span><?php echo esc_html($info['license']); ?></span></td>
|
|
<td><?php echo $is_default ? '<span class="brt-pill">Default</span>' : ''; ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<p class="description">If a translation looks installed but returns blank cards, run “Install / Update selected” to rebuild those rows with the current parser.</p>
|
|
</form>
|
|
</section>
|
|
<section class="brt-card">
|
|
<h2>Settings</h2>
|
|
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
|
|
<?php wp_nonce_field(self::NONCE); ?>
|
|
<input type="hidden" name="action" value="brt_save_settings">
|
|
<label class="brt-check"><input type="checkbox" name="enabled" value="1" <?php checked($settings['enabled'], '1'); ?>> Enable frontend scanning</label>
|
|
<label>Default translation
|
|
<select name="translation">
|
|
<?php
|
|
$active_options = $installed ?: array_map(function($info, $code) {
|
|
return ['code' => $code, 'name' => $info['name'], 'verses' => 0];
|
|
}, $translations, array_keys($translations));
|
|
foreach ($active_options as $row):
|
|
$code = $row['code'];
|
|
$label = $code . ' - ' . $row['name'] . (!empty($row['verses']) ? ' (' . number_format_i18n((int)$row['verses']) . ' verses)' : '');
|
|
?>
|
|
<option value="<?php echo esc_attr($code); ?>" <?php selected($settings['translation'], $code); ?>><?php echo esc_html($label); ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
<span>The tooltip opens with this translation first. Other installed translations appear as quick switches inside the card.</span>
|
|
</label>
|
|
<label>Scan selectors <textarea name="scope" rows="3"><?php echo esc_textarea($settings['scope']); ?></textarea><span>Comma-separated CSS selectors. Use body for the whole public page, or narrower selectors for faster scanning.</span></label>
|
|
<div class="brt-row">
|
|
<label>Card theme <select name="theme"><option value="auto" <?php selected($settings['theme'], 'auto'); ?>>Match site</option><option value="dark" <?php selected($settings['theme'], 'dark'); ?>>Dark</option><option value="light" <?php selected($settings['theme'], 'light'); ?>>Light</option></select></label>
|
|
<label>Range limit <input type="number" min="1" max="50" name="max_range" value="<?php echo esc_attr($settings['max_range']); ?>"><span>Maximum verses shown for references like John 3:16-20.</span></label>
|
|
</div>
|
|
<label class="brt-check"><input type="checkbox" name="underline" value="1" <?php checked($settings['underline'], '1'); ?>> Underline detected references</label>
|
|
<label class="brt-check"><input type="checkbox" name="show_translation" value="1" <?php checked($settings['show_translation'], '1'); ?>> Show translation in card header</label>
|
|
<label class="brt-check"><input type="checkbox" name="show_tooltips" value="1" <?php checked($settings['show_tooltips'], '1'); ?>> Show hover/focus tooltip cards</label>
|
|
<label class="brt-check"><input type="checkbox" name="link_references" value="1" <?php checked($settings['link_references'], '1'); ?>> Link detected Bible references to Biblia.com</label>
|
|
<div class="brt-row">
|
|
<label>Links open in <select name="link_target"><option value="same" <?php selected($settings['link_target'], 'same'); ?>>Existing window</option><option value="new" <?php selected($settings['link_target'], 'new'); ?>>New window</option></select></label>
|
|
<label>Bible link version <select name="bible_version">
|
|
<?php foreach ($this->bible_link_versions() as $code => $label): ?>
|
|
<option value="<?php echo esc_attr($code); ?>" <?php selected($settings['bible_version'] ?: $settings['translation'], $code); ?>><?php echo esc_html($label); ?></option>
|
|
<?php endforeach; ?>
|
|
</select></label>
|
|
</div>
|
|
<h3>Logos Links</h3>
|
|
<label class="brt-check"><input type="checkbox" name="logos_insert" value="1" <?php checked($settings['logos_insert'], '1'); ?>> Insert a small Logos Bible Software link icon beside detected references</label>
|
|
<label class="brt-check"><input type="checkbox" name="logos_existing" value="1" <?php checked($settings['logos_existing'], '1'); ?>> Add a Logos icon to existing Logos Bible Software links</label>
|
|
<div class="brt-row">
|
|
<label>Logos Bible version <select name="logos_translation">
|
|
<?php foreach ($this->bible_link_versions() as $code => $label): ?>
|
|
<option value="<?php echo esc_attr($code); ?>" <?php selected($settings['logos_translation'] ?: $settings['translation'], $code); ?>><?php echo esc_html($label); ?></option>
|
|
<?php endforeach; ?>
|
|
</select></label>
|
|
<label>Logos icon <select name="logos_icon"><option value="dark" <?php selected($settings['logos_icon'], 'dark'); ?>>Dark icon for light backgrounds</option><option value="light" <?php selected($settings['logos_icon'], 'light'); ?>>Light icon for dark backgrounds</option></select></label>
|
|
</div>
|
|
<h3>Matching Rules</h3>
|
|
<label class="brt-check"><input type="checkbox" name="add_tooltips_to_links" value="1" <?php checked($settings['add_tooltips_to_links'], '1'); ?>> Add tooltips to existing Biblia.com and Ref.ly links</label>
|
|
<label class="brt-check"><input type="checkbox" name="case_sensitive" value="1" <?php checked($settings['case_sensitive'], '1'); ?>> Tag Bible references with improper casing, such as jn 3:16 or JOHN 3:16</label>
|
|
<label class="brt-check"><input type="checkbox" name="tag_chapters" value="1" <?php checked($settings['tag_chapters'], '1'); ?>> Tag chapter references, such as Genesis 1</label>
|
|
<label class="brt-check"><input type="checkbox" name="scan_comments" value="1" <?php checked($settings['scan_comments'], '1'); ?>> Search for Bible references in user comments</label>
|
|
<label>Do not search these HTML tags
|
|
<span>Script, style, form fields, code, and preformatted blocks are always skipped.</span>
|
|
</label>
|
|
<div class="brt-skip-grid">
|
|
<?php foreach ($this->display_skip_tags() as $tag => $label): ?>
|
|
<label class="brt-check"><input type="checkbox" name="skip_tags[]" value="<?php echo esc_attr($tag); ?>" <?php checked(in_array($tag, $settings['skip_tags'], true)); ?>> <?php echo esc_html($label); ?></label>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<button class="button button-primary">Save Settings</button>
|
|
</form>
|
|
</section>
|
|
<section class="brt-card brt-wide">
|
|
<h2>Open Text Policy</h2>
|
|
<p>This plugin stores only open/public texts in your local WordPress database. WEB, KJV, ASV, and YLT are available from eBible VPL packages. NIV, NASB, and most modern commercial translations should remain external/licensed integrations unless you explicitly secure rights to store and redistribute them.</p>
|
|
</section>
|
|
</div>
|
|
<p class="brt-footer">Bible Reference Tooltips <?php echo esc_html(self::VERSION); ?>. Local open Bible text lookup for WordPress.</p>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
private function translation_stats() {
|
|
global $wpdb;
|
|
return $wpdb->get_results("SELECT translation, COUNT(*) AS verses, MAX(source) AS source FROM " . self::table() . " GROUP BY translation ORDER BY translation ASC");
|
|
}
|
|
|
|
private function available_translations() {
|
|
return [
|
|
'WEB' => [
|
|
'name' => 'World English Bible',
|
|
'url' => 'https://ebible.org/Scriptures/eng-web_vpl.zip',
|
|
'file' => 'eng-web_vpl.txt',
|
|
'source' => 'eBible WEB VPL',
|
|
'license' => 'World English Bible public domain',
|
|
],
|
|
'KJV' => [
|
|
'name' => 'King James Version',
|
|
'url' => 'https://ebible.org/Scriptures/eng-kjv_vpl.zip',
|
|
'file' => 'eng-kjv_vpl.txt',
|
|
'source' => 'eBible KJV VPL',
|
|
'license' => 'King James Version public domain in the United States',
|
|
],
|
|
'ASV' => [
|
|
'name' => 'American Standard Version',
|
|
'url' => 'https://ebible.org/Scriptures/eng-asv_vpl.zip',
|
|
'file' => 'eng-asv_vpl.txt',
|
|
'source' => 'eBible ASV VPL',
|
|
'license' => 'American Standard Version 1901 public domain',
|
|
],
|
|
'YLT' => [
|
|
'name' => "Young's Literal Translation",
|
|
'url' => 'https://ebible.org/Scriptures/engylt_vpl.zip',
|
|
'file' => 'engylt_vpl.txt',
|
|
'source' => 'eBible YLT VPL',
|
|
'license' => "Young's Literal Translation public domain",
|
|
],
|
|
];
|
|
}
|
|
|
|
public function handle_save_settings() {
|
|
$this->require_admin();
|
|
$translation = strtoupper(sanitize_text_field($_POST['translation'] ?? $this->settings()['translation'] ?? 'WEB'));
|
|
$known = array_keys($this->available_translations());
|
|
$installed = array_map(function($row) { return $row['code']; }, $this->installed_translations());
|
|
if (!in_array($translation, array_unique(array_merge($known, $installed)), true)) $translation = 'WEB';
|
|
$theme = sanitize_key($_POST['theme'] ?? 'auto');
|
|
if (!in_array($theme, ['auto', 'dark', 'light'], true)) $theme = 'auto';
|
|
$link_target = sanitize_key($_POST['link_target'] ?? 'same');
|
|
if (!in_array($link_target, ['same', 'new'], true)) $link_target = 'same';
|
|
$logos_icon = sanitize_key($_POST['logos_icon'] ?? 'dark');
|
|
if (!in_array($logos_icon, ['dark', 'light'], true)) $logos_icon = 'dark';
|
|
$link_versions = array_keys($this->bible_link_versions());
|
|
$bible_version = strtoupper(sanitize_text_field($_POST['bible_version'] ?? $translation));
|
|
if (!in_array($bible_version, $link_versions, true)) $bible_version = $translation;
|
|
$logos_translation = strtoupper(sanitize_text_field($_POST['logos_translation'] ?? $translation));
|
|
if (!in_array($logos_translation, $link_versions, true)) $logos_translation = $translation;
|
|
$settings = [
|
|
'enabled' => !empty($_POST['enabled']) ? '1' : '0',
|
|
'translation' => $translation,
|
|
'scope' => sanitize_textarea_field($_POST['scope'] ?? self::defaults()['scope']),
|
|
'theme' => $theme,
|
|
'underline' => !empty($_POST['underline']) ? '1' : '0',
|
|
'show_translation' => !empty($_POST['show_translation']) ? '1' : '0',
|
|
'show_tooltips' => !empty($_POST['show_tooltips']) ? '1' : '0',
|
|
'link_references' => !empty($_POST['link_references']) ? '1' : '0',
|
|
'link_target' => $link_target,
|
|
'bible_version' => $bible_version,
|
|
'logos_insert' => !empty($_POST['logos_insert']) ? '1' : '0',
|
|
'logos_existing' => !empty($_POST['logos_existing']) ? '1' : '0',
|
|
'logos_translation' => $logos_translation,
|
|
'logos_icon' => $logos_icon,
|
|
'add_tooltips_to_links' => !empty($_POST['add_tooltips_to_links']) ? '1' : '0',
|
|
'case_sensitive' => !empty($_POST['case_sensitive']) ? '1' : '0',
|
|
'tag_chapters' => !empty($_POST['tag_chapters']) ? '1' : '0',
|
|
'scan_comments' => !empty($_POST['scan_comments']) ? '1' : '0',
|
|
'skip_tags' => $this->normalize_skip_tags($_POST['skip_tags'] ?? []),
|
|
'max_range' => (string)max(1, min(50, absint($_POST['max_range'] ?? 12))),
|
|
];
|
|
update_option(self::OPTION_KEY, $settings, false);
|
|
$this->redirect('Settings saved.');
|
|
}
|
|
|
|
public function handle_clear() {
|
|
$this->require_admin();
|
|
global $wpdb;
|
|
$wpdb->query("TRUNCATE TABLE " . self::table());
|
|
self::log_event('texts_cleared', [], 'Bible verse table cleared.');
|
|
$this->redirect('Verse table cleared.');
|
|
}
|
|
|
|
public function handle_import_web() {
|
|
$this->require_admin();
|
|
$available = $this->available_translations();
|
|
$bulk_action = sanitize_key($_POST['bulk_action'] ?? 'install_selected');
|
|
$selected = array_map(function($code) {
|
|
return strtoupper(preg_replace('/[^A-Z0-9_-]/i', '', sanitize_text_field($code)));
|
|
}, (array)($_POST['translations'] ?? []));
|
|
if (!$selected && !empty($_POST['translation'])) {
|
|
$selected[] = strtoupper(preg_replace('/[^A-Z0-9_-]/i', '', sanitize_text_field($_POST['translation'])));
|
|
}
|
|
$selected = array_values(array_filter(array_unique($selected), function($code) use ($available) {
|
|
return isset($available[$code]);
|
|
}));
|
|
|
|
if ($bulk_action === 'install_all') $selected = array_keys($available);
|
|
if ($bulk_action === 'clear_all') {
|
|
global $wpdb;
|
|
$wpdb->query("TRUNCATE TABLE " . self::table());
|
|
self::log_event('texts_removed_all', [], 'All installed Bible texts removed.');
|
|
$this->redirect('All installed Bible texts removed.');
|
|
}
|
|
if (!$selected) $this->redirect('No Bible translations selected.');
|
|
|
|
if ($bulk_action === 'remove_selected') {
|
|
$removed = $this->remove_translations($selected);
|
|
$remaining = $this->installed_translations();
|
|
$this->set_active_translation($this->default_translation($this->settings()['translation'], $remaining));
|
|
$this->redirect('Removed ' . number_format_i18n($removed) . ' verse rows from selected translation(s).');
|
|
}
|
|
|
|
if ($bulk_action === 'set_default') {
|
|
$this->set_active_translation($selected[0]);
|
|
$this->redirect($selected[0] . ' is now the default tooltip translation.');
|
|
}
|
|
|
|
$messages = [];
|
|
foreach ($selected as $translation) $messages[] = $this->import_vpl_translation($translation, false);
|
|
$this->set_active_translation($selected[0]);
|
|
$this->redirect(implode(' ', $messages));
|
|
}
|
|
|
|
private function remove_translations($translations) {
|
|
global $wpdb;
|
|
$count = 0;
|
|
foreach ((array)$translations as $translation) {
|
|
$count += (int)$wpdb->delete(self::table(), ['translation' => strtoupper((string)$translation)], ['%s']);
|
|
}
|
|
self::log_event('texts_removed', ['translations' => array_values((array)$translations), 'rows' => $count], 'Removed Bible text translations.');
|
|
return $count;
|
|
}
|
|
|
|
private function require_admin() {
|
|
if (!current_user_can('manage_options')) wp_die('Insufficient permissions.');
|
|
check_admin_referer(self::NONCE);
|
|
}
|
|
|
|
private function redirect($msg) {
|
|
wp_safe_redirect(add_query_arg('msg', rawurlencode($msg), admin_url('admin.php?page=' . self::SLUG)));
|
|
exit;
|
|
}
|
|
|
|
private function set_active_translation($translation) {
|
|
$settings = $this->settings();
|
|
$settings['translation'] = $translation;
|
|
update_option(self::OPTION_KEY, $settings, false);
|
|
}
|
|
|
|
private function import_vpl_translation($translation, $make_default = true) {
|
|
$translations = $this->available_translations();
|
|
if (empty($translations[$translation])) return 'Unknown translation requested.';
|
|
$info = $translations[$translation];
|
|
if (!class_exists('ZipArchive')) return 'ZipArchive is not available on this server.';
|
|
require_once ABSPATH . 'wp-admin/includes/file.php';
|
|
$tmp = download_url($info['url'], 120);
|
|
if (is_wp_error($tmp)) return 'Download failed: ' . $tmp->get_error_message();
|
|
$dir = wp_tempnam('brt-vpl');
|
|
if (file_exists($dir)) @unlink($dir);
|
|
wp_mkdir_p($dir);
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($tmp) !== true) {
|
|
@unlink($tmp);
|
|
return 'Could not open downloaded ' . $translation . ' archive.';
|
|
}
|
|
$zip->extractTo($dir);
|
|
$zip->close();
|
|
@unlink($tmp);
|
|
$file = trailingslashit($dir) . $info['file'];
|
|
if (!file_exists($file)) return $translation . ' VPL file was not found in archive.';
|
|
$handle = fopen($file, 'r');
|
|
if (!$handle) return 'Could not read ' . $translation . ' VPL file.';
|
|
global $wpdb;
|
|
$wpdb->delete(self::table(), ['translation' => $translation], ['%s']);
|
|
$batch = [];
|
|
$count = 0;
|
|
while (($line = fgets($handle)) !== false) {
|
|
$parsed = $this->parse_vpl_line($line);
|
|
if (!$parsed) continue;
|
|
$batch[] = [$translation, $parsed['book']['abbr'], $parsed['book']['name'], $parsed['chapter'], $parsed['verse'], $parsed['text']];
|
|
if (count($batch) >= 400) {
|
|
$count += $this->insert_rows($batch, $info['source'], $info['license']);
|
|
$batch = [];
|
|
}
|
|
}
|
|
fclose($handle);
|
|
if ($batch) $count += $this->insert_rows($batch, $info['source'], $info['license']);
|
|
if ($make_default) $this->set_active_translation($translation);
|
|
self::log_event('text_imported', ['translation' => $translation, 'verses' => $count], 'Imported Bible text.');
|
|
return $translation . ' import complete: ' . number_format_i18n($count) . ' verses installed.';
|
|
}
|
|
|
|
private function import_vpl_book($translation, $target_abbr) {
|
|
$translations = $this->available_translations();
|
|
if (empty($translations[$translation])) return 0;
|
|
$info = $translations[$translation];
|
|
if (!class_exists('ZipArchive')) return 0;
|
|
require_once ABSPATH . 'wp-admin/includes/file.php';
|
|
$tmp = download_url($info['url'], 120);
|
|
if (is_wp_error($tmp)) return 0;
|
|
$dir = wp_tempnam('brt-vpl-book');
|
|
if (file_exists($dir)) @unlink($dir);
|
|
wp_mkdir_p($dir);
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($tmp) !== true) {
|
|
@unlink($tmp);
|
|
return 0;
|
|
}
|
|
$zip->extractTo($dir);
|
|
$zip->close();
|
|
@unlink($tmp);
|
|
$file = trailingslashit($dir) . $info['file'];
|
|
if (!file_exists($file)) {
|
|
$this->delete_temp_dir($dir);
|
|
return 0;
|
|
}
|
|
$handle = fopen($file, 'r');
|
|
if (!$handle) {
|
|
$this->delete_temp_dir($dir);
|
|
return 0;
|
|
}
|
|
$batch = [];
|
|
$count = 0;
|
|
while (($line = fgets($handle)) !== false) {
|
|
$parsed = $this->parse_vpl_line($line);
|
|
if (!$parsed || strtoupper($parsed['book']['abbr']) !== strtoupper($target_abbr)) continue;
|
|
$batch[] = [$translation, $parsed['book']['abbr'], $parsed['book']['name'], $parsed['chapter'], $parsed['verse'], $parsed['text']];
|
|
if (count($batch) >= 200) {
|
|
$count += $this->insert_rows($batch, $info['source'], $info['license']);
|
|
$batch = [];
|
|
}
|
|
}
|
|
fclose($handle);
|
|
if ($batch) $count += $this->insert_rows($batch, $info['source'], $info['license']);
|
|
$this->delete_temp_dir($dir);
|
|
return $count;
|
|
}
|
|
|
|
private function delete_temp_dir($dir) {
|
|
if (!is_dir($dir)) return;
|
|
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
|
foreach ($it as $file) {
|
|
$file->isDir() ? @rmdir($file->getRealPath()) : @unlink($file->getRealPath());
|
|
}
|
|
@rmdir($dir);
|
|
}
|
|
|
|
private function parse_vpl_line($line) {
|
|
$line = trim((string)$line);
|
|
if ($line === '') return null;
|
|
if (!preg_match('/^((?:[1-3]\s*)?[A-Za-z][A-Za-z0-9 .\'-]{1,40}?)\s+(\d{1,3}):(\d{1,3})\s+(.+)$/u', $line, $m)) return null;
|
|
$book = $this->normalize_book($m[1]) ?: ['abbr' => strtoupper(preg_replace('/[^A-Z0-9]/i', '', $m[1])), 'name' => $this->book_name_from_abbr($m[1])];
|
|
$text = trim(wp_specialchars_decode($m[4], ENT_QUOTES));
|
|
if ($text === '') return null;
|
|
return [
|
|
'book' => $book,
|
|
'chapter' => (int)$m[2],
|
|
'verse' => (int)$m[3],
|
|
'text' => $text,
|
|
];
|
|
}
|
|
|
|
private function insert_rows($rows, $source, $license) {
|
|
global $wpdb;
|
|
$count = 0;
|
|
foreach ($rows as $row) {
|
|
$result = $wpdb->replace(self::table(), [
|
|
'translation' => $row[0],
|
|
'book' => $row[1],
|
|
'book_name' => $row[2],
|
|
'chapter' => (int)$row[3],
|
|
'verse' => (int)$row[4],
|
|
'verse_text' => $row[5],
|
|
'source' => $source,
|
|
'license' => $license,
|
|
'created_at' => current_time('mysql'),
|
|
], ['%s','%s','%s','%d','%d','%s','%s','%s','%s']);
|
|
if ($result !== false) $count++;
|
|
}
|
|
return $count;
|
|
}
|
|
|
|
private function repair_book_aliases() {
|
|
if (get_option('brt_alias_repair_version') === self::VERSION) return;
|
|
global $wpdb;
|
|
$repairs = [
|
|
'JAM' => ['JAS', 'James'],
|
|
'JMS' => ['JAS', 'James'],
|
|
'JM' => ['JAS', 'James'],
|
|
'JA' => ['JAS', 'James'],
|
|
'JAC' => ['JAS', 'James'],
|
|
'JACOB' => ['JAS', 'James'],
|
|
'EPISTLEOFJAMES' => ['JAS', 'James'],
|
|
'THEEPISTLEOFJAMES' => ['JAS', 'James'],
|
|
'JOH' => ['JHN', 'John'],
|
|
'JOE' => ['JOL', 'Joel'],
|
|
'NAH' => ['NAM', 'Nahum'],
|
|
'PHI' => ['PHP', 'Philippians'],
|
|
'PHL' => ['PHM', 'Philemon'],
|
|
];
|
|
foreach ($repairs as $old => $new) {
|
|
$wpdb->query($wpdb->prepare(
|
|
"UPDATE IGNORE " . self::table() . " SET book = %s, book_name = %s WHERE book = %s",
|
|
$new[0],
|
|
$new[1],
|
|
$old
|
|
));
|
|
$wpdb->query($wpdb->prepare(
|
|
"UPDATE " . self::table() . " SET book_name = %s WHERE book = %s",
|
|
$new[1],
|
|
$new[0]
|
|
));
|
|
}
|
|
$name_repairs = [
|
|
'jam' => ['JAS', 'James'],
|
|
'jms' => ['JAS', 'James'],
|
|
'jm' => ['JAS', 'James'],
|
|
'ja' => ['JAS', 'James'],
|
|
'jas' => ['JAS', 'James'],
|
|
'jac' => ['JAS', 'James'],
|
|
'jacob' => ['JAS', 'James'],
|
|
'epistleofjames' => ['JAS', 'James'],
|
|
'theepistleofjames' => ['JAS', 'James'],
|
|
'joh' => ['JHN', 'John'],
|
|
'joe' => ['JOL', 'Joel'],
|
|
'nah' => ['NAM', 'Nahum'],
|
|
'phi' => ['PHP', 'Philippians'],
|
|
'phl' => ['PHM', 'Philemon'],
|
|
];
|
|
foreach ($name_repairs as $old_name => $new) {
|
|
$wpdb->query($wpdb->prepare(
|
|
"UPDATE IGNORE " . self::table() . " SET book = %s, book_name = %s WHERE LOWER(REPLACE(REPLACE(REPLACE(book_name, ' ', ''), '.', ''), '-', '')) = %s",
|
|
$new[0],
|
|
$new[1],
|
|
$old_name
|
|
));
|
|
}
|
|
update_option('brt_alias_repair_version', self::VERSION, false);
|
|
}
|
|
|
|
private function repair_missing_james_verses() {
|
|
if (get_option('brt_james_backfill_version') === self::VERSION) return;
|
|
global $wpdb;
|
|
$installed = $wpdb->get_col("SELECT DISTINCT translation FROM " . self::table() . " ORDER BY translation ASC");
|
|
$available = $this->available_translations();
|
|
$repaired = [];
|
|
foreach ((array)$installed as $translation) {
|
|
$translation = strtoupper((string)$translation);
|
|
if (empty($available[$translation])) continue;
|
|
$count = (int)$wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM " . self::table() . " WHERE translation = %s AND (book IN ('JAS','JAM','JMS','JM','JA','JAC') OR book_name IN ('James','JAS','JAM','Jms','JMS','Jacob'))",
|
|
$translation
|
|
));
|
|
if ($count > 0) continue;
|
|
$inserted = $this->import_vpl_book($translation, 'JAS');
|
|
if ($inserted > 0) $repaired[$translation] = $inserted;
|
|
}
|
|
update_option('brt_james_backfill_version', self::VERSION, false);
|
|
if ($repaired) {
|
|
self::log_event('james_backfilled', ['translations' => $repaired], 'Backfilled missing James verses for installed Bible translations.');
|
|
}
|
|
}
|
|
|
|
private function book_name_from_abbr($abbr) {
|
|
$abbr = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)$abbr));
|
|
foreach ($this->book_map() as $book) {
|
|
if ($book['abbr'] === $abbr) return $book['name'];
|
|
}
|
|
return $abbr;
|
|
}
|
|
|
|
private function admin_css() {
|
|
return '.brt-admin{max-width:1500px}.brt-lede{font-size:15px;color:#56657a}.brt-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.brt-card{background:#f8fafc;border:1px solid #cbd5e1;border-radius:8px;padding:20px;box-shadow:0 12px 28px rgba(15,23,42,.08)}.brt-card h2{margin-top:0}.brt-wide{grid-column:1/-1}.brt-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}.brt-card label{display:block;margin:0 0 12px;font-weight:700}.brt-card input:not([type=checkbox]),.brt-card textarea,.brt-card select{width:100%;max-width:100%;border-radius:6px;min-height:38px}.brt-card label span,.brt-table span{display:block;margin-top:5px;color:#64748b;font-size:12px;font-weight:500}.brt-current{background:#e8eef8;border:1px solid #cbd5e1;border-radius:6px;margin:0 0 14px;padding:10px 12px}.brt-check{display:flex!important;gap:8px;align-items:center}.brt-row{display:grid;grid-template-columns:1fr 220px;gap:12px}.brt-skip-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 14px;margin:8px 0 16px}.brt-bulkbar{display:flex;gap:10px;align-items:center;margin:14px 0}.brt-bulkbar select{max-width:250px}.brt-table td,.brt-table th{vertical-align:middle}.brt-muted{color:#94a3b8!important}.brt-pill{display:inline-flex!important;margin:0!important;padding:4px 8px;border-radius:999px;background:#dbeafe;color:#1d4ed8!important;font-weight:800!important;font-size:11px!important}.brt-library .description{margin-top:10px}.brt-footer{color:#64748b;font-size:12px;margin:18px 0 0}@media(max-width:900px){.brt-grid,.brt-row,.brt-skip-grid{grid-template-columns:1fr}.brt-bulkbar{align-items:stretch;flex-direction:column}.brt-bulkbar select{max-width:none}}';
|
|
}
|
|
|
|
private function frontend_css() {
|
|
return '.brt-ref{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px;color:inherit;font-weight:700}.brt-ref:focus{outline:2px solid rgba(47,128,237,.45);outline-offset:2px}.brt-ref.no-underline{text-decoration:none}.brt-logos-link{display:inline-flex;align-items:center;justify-content:center;width:1.15em;height:1.15em;margin-left:.2em;border:1px solid currentColor;border-radius:.25em;font:800 .68em/1 system-ui,-apple-system,Segoe UI,sans-serif;text-decoration:none;vertical-align:.08em;opacity:.78}.brt-logos-link:hover,.brt-logos-link:focus{opacity:1;text-decoration:none}.brt-logos-dark{color:#162033;background:#fff}.brt-logos-light{color:#fff;background:#162033}.brt-pop{position:fixed;z-index:999999;max-width:min(520px,calc(100vw - 28px));padding:16px;border-radius:12px;border:1px solid rgba(148,163,184,.45);background:#fff;color:#0f172a;box-shadow:0 20px 50px rgba(15,23,42,.18);font:14px/1.55 system-ui,-apple-system,Segoe UI,sans-serif;pointer-events:auto;opacity:0;transform:translateY(5px);transition:opacity .22s ease,transform .22s ease}.brt-pop.ready{opacity:1;transform:translateY(0)}.brt-pop.dark{background:#080b10;color:#eef4ff;box-shadow:0 20px 60px rgba(0,0,0,.42)}.brt-pop.light{background:#fff;color:#0f172a;box-shadow:0 20px 50px rgba(15,23,42,.18)}.brt-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 10px}.brt-title{font-weight:800;font-size:15px;color:inherit;white-space:nowrap}.brt-tabs{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.brt-tab{border:1px solid rgba(148,163,184,.45);border-radius:999px;background:rgba(148,163,184,.12);color:inherit;cursor:pointer;font:700 11px/1 system-ui,-apple-system,Segoe UI,sans-serif;padding:5px 8px}.brt-tab.active{background:#2f80ed;border-color:#2f80ed;color:#fff}.brt-pop.dark .brt-tab.active{background:#60a5fa;border-color:#60a5fa;color:#06101f}.brt-verse{margin:0 0 9px}.brt-verse:last-child{margin-bottom:0}.brt-verse-num{font-weight:900;margin-right:5px}.brt-foot{display:block;margin-top:11px;color:#64748b;font-size:12px}.brt-pop.dark .brt-foot{color:#93a4bb}.brt-pop .err{color:#b91c1c}.brt-pop.dark .err{color:#fca5a5}.brt-loading{display:flex;align-items:center;gap:10px;margin:3px 0 1px;color:#64748b}.brt-pop.dark .brt-loading{color:#93a4bb}.brt-spinner{width:14px;height:14px;border:2px solid rgba(148,163,184,.35);border-top-color:#2f80ed;border-radius:999px;animation:brtSpin .8s linear infinite}@keyframes brtSpin{to{transform:rotate(360deg)}}@media(max-width:520px){.brt-head{align-items:flex-start;flex-direction:column}.brt-tabs{justify-content:flex-start}}';
|
|
}
|
|
|
|
private function frontend_js() {
|
|
return <<<'JS'
|
|
(function(){
|
|
if (!window.BibleReferenceTooltips || !BibleReferenceTooltips.rest) return;
|
|
const bookAliases = [
|
|
'Genesis','Gen','Ge','Gn','Exodus','Exod','Exo','Ex','Leviticus','Lev','Le','Lv','Numbers','Num','Nu','Nm','Nb','Deuteronomy','Deut','Deu','Dt',
|
|
'Joshua','Josh','Jos','Judges','Judg','Jdg','Jg','Ruth','Rut','Ru','1\\s*Samuel','1\\s*Sam','1\\s*Sa','2\\s*Samuel','2\\s*Sam','2\\s*Sa',
|
|
'1\\s*Kings','1\\s*Kgs','1\\s*Ki','2\\s*Kings','2\\s*Kgs','2\\s*Ki','1\\s*Chronicles','1\\s*Chron','1\\s*Chr','2\\s*Chronicles','2\\s*Chron','2\\s*Chr',
|
|
'Ezra','Ezr','Nehemiah','Neh','Ne','Esther','Est','Job','Psalms','Psalm','Psa','Ps','Proverbs','Prov','Pro','Pr','Ecclesiastes','Eccl','Ecc',
|
|
'Song\\s*of\\s*Solomon','Song\\s*of\\s*Songs','Song','Sos','Sng','Isaiah','Isa','Jeremiah','Jer','Lamentations','Lam','Ezekiel','Ezek','Ezk',
|
|
'Daniel','Dan','Dn','Hosea','Hos','Joel','Jol','Joe','Jl','Amos','Amo','Obadiah','Obad','Oba','Jonah','Jon','Micah','Mic','Nahum','Nah','Nam','Habakkuk','Hab','Zephaniah','Zeph','Zep',
|
|
'Haggai','Hag','Zechariah','Zech','Zec','Malachi','Mal','Matthew','Matt','Mat','Mt','Mark','Mrk','Mk','Luke','Luk','Lk','John','Jhn','Jn',
|
|
'Acts','Act','Romans','Rom','Ro','1\\s*Corinthians','1\\s*Cor','1\\s*Co','2\\s*Corinthians','2\\s*Cor','2\\s*Co','Galatians','Gal','Ephesians','Eph',
|
|
'Philippians','Phil','Php','Colossians','Col','1\\s*Thessalonians','1\\s*Thess','1\\s*Th','2\\s*Thessalonians','2\\s*Thess','2\\s*Th',
|
|
'1\\s*Timothy','1\\s*Tim','1\\s*Ti','2\\s*Timothy','2\\s*Tim','2\\s*Ti','Titus','Tit','Philemon','Phlm','Phm','Hebrews','Heb',
|
|
'James\\.?','Jas\\.?','Ja\\.?','Jam\\.?','Jms\\.?','Jm\\.?','Jac\\.?','Jacob','Epistle\\s*of\\s*James','1\\s*Peter','1\\s*Pet','1\\s*Pe','2\\s*Peter','2\\s*Pet','2\\s*Pe','1\\s*John','1\\s*Jn','1\\s*Jhn',
|
|
'2\\s*John','2\\s*Jn','2\\s*Jhn','3\\s*John','3\\s*Jn','3\\s*Jhn','Jude','Jud','Revelation','Rev','Re'
|
|
];
|
|
const book = '(?:' + bookAliases.sort((a,b) => b.length - a.length).join('|') + ')';
|
|
const chapterPattern = BibleReferenceTooltips.tagChapters ? '(?:\\s*:\\s*\\d{1,3}(?:\\s*[-\\u2013\\u2014]\\s*\\d{1,3})?)?' : '\\s*:\\s*\\d{1,3}(?:\\s*[-\\u2013\\u2014]\\s*\\d{1,3})?';
|
|
const refPattern = '\\b(' + book + ')\\s+\\d{1,3}' + chapterPattern + '\\b';
|
|
const refFlags = BibleReferenceTooltips.caseSensitive ? 'g' : 'gi';
|
|
const refRe = new RegExp(refPattern, refFlags);
|
|
const refTestRe = new RegExp(refPattern, BibleReferenceTooltips.caseSensitive ? '' : 'i');
|
|
const configuredSkip = Array.isArray(BibleReferenceTooltips.skipTags) ? BibleReferenceTooltips.skipTags : [];
|
|
const skip = new Set(['A','SCRIPT','STYLE','TEXTAREA','INPUT','SELECT','BUTTON','CODE','PRE','NOSCRIPT','IFRAME','OBJECT','EMBED','SVG','MATH'].concat(configuredSkip.map(tag => String(tag || '').toUpperCase())));
|
|
const preferenceKey = 'brtTranslation';
|
|
const cache = new Map();
|
|
let pop = null;
|
|
let activeRef = '';
|
|
let activeTrigger = null;
|
|
let hideTimer = null;
|
|
let loadingTimer = null;
|
|
let requestToken = 0;
|
|
function getStoredPreference(){
|
|
try { return window.localStorage ? (localStorage.getItem(preferenceKey) || '') : ''; }
|
|
catch(e) { return ''; }
|
|
}
|
|
function setStoredPreference(code){
|
|
try { if (window.localStorage) localStorage.setItem(preferenceKey, code); }
|
|
catch(e) {}
|
|
}
|
|
function preferredTranslation(){
|
|
const available = Array.isArray(BibleReferenceTooltips.translations) ? BibleReferenceTooltips.translations.map(row => String(row.code || '').toUpperCase()).filter(Boolean) : [];
|
|
const stored = String(getStoredPreference()).toUpperCase();
|
|
if (stored && (!available.length || available.includes(stored))) return stored;
|
|
return String(BibleReferenceTooltips.translation || 'WEB').toUpperCase();
|
|
}
|
|
function rememberTranslation(code){
|
|
code = String(code || '').toUpperCase();
|
|
if (!code) return;
|
|
setStoredPreference(code);
|
|
}
|
|
function scan(root){
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
acceptNode(node){
|
|
if (!node.nodeValue || !refTestRe.test(node.nodeValue)) return NodeFilter.FILTER_REJECT;
|
|
refRe.lastIndex = 0;
|
|
let el = node.parentElement;
|
|
while (el) {
|
|
if (skip.has(el.tagName) || el.classList.contains('brt-ref') || el.classList.contains('brt-pop') || el.classList.contains('brt-logos-link')) return NodeFilter.FILTER_REJECT;
|
|
el = el.parentElement;
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
});
|
|
const nodes = [];
|
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
nodes.forEach(wrapNode);
|
|
}
|
|
function wrapNode(node){
|
|
const text = node.nodeValue;
|
|
const frag = document.createDocumentFragment();
|
|
let last = 0;
|
|
refRe.lastIndex = 0;
|
|
text.replace(refRe, function(match){
|
|
const offset = arguments[arguments.length - 2];
|
|
if (offset > last) frag.appendChild(document.createTextNode(text.slice(last, offset)));
|
|
const ref = makeReferenceNode(match);
|
|
frag.appendChild(ref);
|
|
if (BibleReferenceTooltips.logosInsert) frag.appendChild(makeLogosLink(match));
|
|
last = offset + match.length;
|
|
});
|
|
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
|
|
node.parentNode.replaceChild(frag, node);
|
|
}
|
|
function makeReferenceNode(ref){
|
|
const el = BibleReferenceTooltips.linkReferences ? document.createElement('a') : document.createElement('span');
|
|
el.className = 'brt-ref';
|
|
if (!BibleReferenceTooltips.underline) el.className += ' no-underline';
|
|
el.dataset.ref = ref;
|
|
el.textContent = ref;
|
|
if (BibleReferenceTooltips.linkReferences) {
|
|
el.href = bibleUrl(ref, BibleReferenceTooltips.bibleVersion || BibleReferenceTooltips.translation || 'WEB');
|
|
if (BibleReferenceTooltips.linkTarget === 'new') {
|
|
el.target = '_blank';
|
|
el.rel = 'noopener noreferrer';
|
|
}
|
|
} else {
|
|
el.tabIndex = 0;
|
|
}
|
|
return el;
|
|
}
|
|
function enhanceExistingLinks(root){
|
|
if (!BibleReferenceTooltips.addTooltipsToLinks && !BibleReferenceTooltips.logosExisting) return;
|
|
const links = root.querySelectorAll ? root.querySelectorAll('a[href]') : [];
|
|
links.forEach(link => {
|
|
if (link.classList.contains('brt-logos-link')) return;
|
|
const href = String(link.getAttribute('href') || '').toLowerCase();
|
|
const isBibleLink = href.includes('biblia.com') || href.includes('ref.ly') || href.includes('logos.com') || href.includes('logosres:');
|
|
if (!isBibleLink) return;
|
|
const ref = extractRef(link.textContent || link.getAttribute('title') || link.getAttribute('aria-label') || '');
|
|
if (ref && BibleReferenceTooltips.addTooltipsToLinks && !link.classList.contains('brt-ref')) {
|
|
link.classList.add('brt-ref');
|
|
if (!BibleReferenceTooltips.underline) link.classList.add('no-underline');
|
|
link.dataset.ref = ref;
|
|
}
|
|
if (BibleReferenceTooltips.logosExisting && href.includes('logos') && !link.nextElementSibling?.classList.contains('brt-logos-link')) {
|
|
link.insertAdjacentElement('afterend', makeLogosLink(ref || link.textContent || ''));
|
|
}
|
|
});
|
|
}
|
|
function extractRef(text){
|
|
refRe.lastIndex = 0;
|
|
const match = refRe.exec(String(text || ''));
|
|
refRe.lastIndex = 0;
|
|
return match ? match[0] : '';
|
|
}
|
|
function bibleUrl(ref, version){
|
|
const code = String(version || 'WEB').trim() || 'WEB';
|
|
return 'https://biblia.com/bible/' + encodeURIComponent(code) + '/' + encodeURIComponent(String(ref || '').replace(/\s+/g, ' ').trim());
|
|
}
|
|
function logosUrl(ref, version){
|
|
const compact = String(ref || '').replace(/\s+/g, '');
|
|
const code = String(version || 'WEB').trim() || 'WEB';
|
|
return 'https://ref.ly/logosref/Bible.' + encodeURIComponent(compact) + '?ver=' + encodeURIComponent(code);
|
|
}
|
|
function makeLogosLink(ref){
|
|
const link = document.createElement('a');
|
|
const icon = BibleReferenceTooltips.logosIcon === 'light' ? 'light' : 'dark';
|
|
link.className = 'brt-logos-link brt-logos-' + icon;
|
|
link.href = logosUrl(ref, BibleReferenceTooltips.logosTranslation || BibleReferenceTooltips.translation || 'WEB');
|
|
link.textContent = 'L';
|
|
link.setAttribute('aria-label', 'Open ' + String(ref || 'Bible reference') + ' in Logos Bible Software');
|
|
link.title = 'Open in Logos Bible Software';
|
|
if (BibleReferenceTooltips.linkTarget === 'new') {
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
}
|
|
return link;
|
|
}
|
|
async function show(e){
|
|
if (!BibleReferenceTooltips.showTooltips) return;
|
|
const target = e.target.closest('.brt-ref');
|
|
if (!target) return;
|
|
window.clearTimeout(hideTimer);
|
|
window.clearTimeout(loadingTimer);
|
|
const token = ++requestToken;
|
|
activeRef = target.dataset.ref;
|
|
activeTrigger = target;
|
|
loadingTimer = window.setTimeout(() => {
|
|
if (token !== requestToken || activeTrigger !== target) return;
|
|
ensurePop();
|
|
pop.className = 'brt-pop ' + resolvedTheme();
|
|
pop.innerHTML = '<div class="brt-head"><span class="brt-title">' + esc(activeRef) + '</span></div><div class="brt-loading"><span class="brt-spinner" aria-hidden="true"></span><span>Loading local Bible text...</span></div>';
|
|
place(target);
|
|
revealPop();
|
|
}, 180);
|
|
try {
|
|
const data = await lookup(activeRef, preferredTranslation());
|
|
if (token !== requestToken || activeTrigger !== target) return;
|
|
window.clearTimeout(loadingTimer);
|
|
ensurePop();
|
|
pop.className = 'brt-pop ' + resolvedTheme();
|
|
render(data);
|
|
place(target);
|
|
revealPop();
|
|
} catch(err) {
|
|
if (token !== requestToken || activeTrigger !== target) return;
|
|
window.clearTimeout(loadingTimer);
|
|
ensurePop();
|
|
pop.className = 'brt-pop ' + resolvedTheme();
|
|
pop.innerHTML = '<div class="brt-head"><span class="brt-title">' + esc(activeRef) + '</span></div><p class="err">' + esc(err.message || 'Verse not available locally.') + '</p>';
|
|
place(target);
|
|
revealPop();
|
|
}
|
|
}
|
|
async function lookup(ref, translation){
|
|
const code = translation || 'WEB';
|
|
const key = code + ':' + ref.toLowerCase();
|
|
if (cache.has(key)) return cache.get(key);
|
|
const url = BibleReferenceTooltips.rest + '?ref=' + encodeURIComponent(ref) + '&translation=' + encodeURIComponent(code);
|
|
const data = await fetch(url, {headers:{'X-WP-Nonce': BibleReferenceTooltips.nonce || ''}}).then(r => r.json());
|
|
cache.set(key, data);
|
|
return data;
|
|
}
|
|
function render(data){
|
|
if (!pop) return;
|
|
const translations = Array.isArray(data.translations) && data.translations.length ? data.translations : (Array.isArray(BibleReferenceTooltips.translations) ? BibleReferenceTooltips.translations : []);
|
|
const tabs = translations.map(row => {
|
|
const code = String(row.code || '').toUpperCase();
|
|
if (!code) return '';
|
|
const active = code === String(data.translation || '').toUpperCase() ? ' active' : '';
|
|
const title = row.name ? ' title="' + esc(row.name) + '"' : '';
|
|
return '<button type="button" class="brt-tab' + active + '" data-translation="' + esc(code) + '"' + title + '>' + esc(code) + '</button>';
|
|
}).join('');
|
|
const label = BibleReferenceTooltips.showTranslation && data.translation_label ? ' <span class="brt-label">(' + esc(data.translation_label) + ')</span>' : '';
|
|
const title = data.reference || activeRef || '';
|
|
const errorReason = data.reason ? '<small class="brt-foot">' + esc(String(data.reason).replace(/_/g, ' ')) + '</small>' : '';
|
|
const verses = data.ok === false ? '<p class="err">' + esc(data.message || 'Verse text is not available locally for this reference.') + '</p>' + errorReason : (Array.isArray(data.verses) && data.verses.length ? data.verses.map(row => {
|
|
return '<p class="brt-verse"><span class="brt-verse-num">' + esc(row.verse) + '.</span>' + esc(row.text) + '</p>';
|
|
}).join('') : '<p>' + esc(data.text || '') + '</p>');
|
|
pop.innerHTML = '<div class="brt-head"><span class="brt-title">' + esc(title) + label + '</span><span class="brt-tabs">' + tabs + '</span></div><div class="brt-body">' + verses + '</div><small class="brt-foot">Local Bible lookup</small>';
|
|
}
|
|
function ensurePop(){
|
|
if (pop) return;
|
|
pop = document.createElement('div');
|
|
pop.addEventListener('mouseenter', () => window.clearTimeout(hideTimer));
|
|
pop.addEventListener('mouseleave', scheduleClear);
|
|
pop.addEventListener('click', async e => {
|
|
const button = e.target.closest('.brt-tab');
|
|
if (!button || !activeRef) return;
|
|
e.preventDefault();
|
|
window.clearTimeout(hideTimer);
|
|
try {
|
|
rememberTranslation(button.dataset.translation);
|
|
const data = await lookup(activeRef, button.dataset.translation);
|
|
render(data);
|
|
if (activeTrigger) place(activeTrigger);
|
|
} catch(err) {
|
|
pop.innerHTML = '<div class="brt-head"><span class="brt-title">' + esc(activeRef) + '</span></div><p class="err">' + esc(err.message || 'Verse text is not available locally for this reference.') + '</p>';
|
|
}
|
|
});
|
|
document.body.appendChild(pop);
|
|
}
|
|
function revealPop(){
|
|
if (!pop) return;
|
|
pop.classList.remove('ready');
|
|
window.requestAnimationFrame(() => {
|
|
if (pop) pop.classList.add('ready');
|
|
});
|
|
}
|
|
function place(target){
|
|
if (!pop) return;
|
|
const pad = 14;
|
|
const rect = target.getBoundingClientRect();
|
|
let x = Math.min(rect.left, window.innerWidth - pop.offsetWidth - pad);
|
|
let y = rect.bottom + 10;
|
|
if (y + pop.offsetHeight > window.innerHeight - pad) y = Math.max(pad, rect.top - pop.offsetHeight - 10);
|
|
pop.style.left = Math.max(pad, x) + 'px';
|
|
pop.style.top = Math.max(pad, y) + 'px';
|
|
}
|
|
function scheduleClear(){ hideTimer = window.setTimeout(clear, 140); }
|
|
function clear(){ window.clearTimeout(loadingTimer); requestToken++; if (pop) { pop.remove(); pop = null; } activeRef = ''; activeTrigger = null; }
|
|
function resolvedTheme(){
|
|
if (BibleReferenceTooltips.theme === 'light') return 'light';
|
|
if (BibleReferenceTooltips.theme === 'dark') return 'dark';
|
|
const doc = document.documentElement;
|
|
const body = document.body;
|
|
const darkClass = doc.classList.contains('dark') || body.classList.contains('dark') || body.classList.contains('dark-mode') || doc.dataset.theme === 'dark' || body.dataset.theme === 'dark';
|
|
if (darkClass) return 'dark';
|
|
return 'light';
|
|
}
|
|
function esc(s){ return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
if (BibleReferenceTooltips.showTooltips) {
|
|
document.addEventListener('mouseover', show);
|
|
document.addEventListener('focusin', show);
|
|
document.addEventListener('mouseout', e => {
|
|
const ref = e.target.closest('.brt-ref');
|
|
if (ref && (!e.relatedTarget || (!ref.contains(e.relatedTarget) && (!pop || !pop.contains(e.relatedTarget))))) scheduleClear();
|
|
});
|
|
document.addEventListener('focusout', e => { if (e.target.closest('.brt-ref')) scheduleClear(); });
|
|
}
|
|
function initScan(){
|
|
const selectors = (BibleReferenceTooltips.scope || 'body').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (BibleReferenceTooltips.scanComments) selectors.push('.brt-comment-scope');
|
|
const roots = selectors.flatMap(s => Array.from(document.querySelectorAll(s)));
|
|
Array.from(new Set(roots.length ? roots : [document.body])).forEach(root => {
|
|
enhanceExistingLinks(root);
|
|
scan(root);
|
|
});
|
|
}
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initScan, {once:true});
|
|
else initScan();
|
|
})();
|
|
JS;
|
|
}
|
|
}
|
|
|
|
register_activation_hook(__FILE__, ['Bible_Reference_Tooltips', 'activate']);
|
|
register_deactivation_hook(__FILE__, ['Bible_Reference_Tooltips', 'deactivate']);
|
|
new Bible_Reference_Tooltips();
|
|
}
|