Files
wordpress-public-workspace/plugins/bible-reference-tooltips/bible-reference-tooltips.php
T

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[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();
}