1402 lines
83 KiB
PHP
1402 lines
83 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.6
|
|
* 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.6';
|
|
const SLUG = 'bible-reference-tooltips';
|
|
const NONCE = 'brt_nonce';
|
|
const OPTION_KEY = 'brt_settings';
|
|
const SETTINGS_VERSION = '1.1.6';
|
|
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('admin_enqueue_scripts', [$this, 'admin_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() {
|
|
$settings = get_option(self::OPTION_KEY);
|
|
if (!is_array($settings)) {
|
|
update_option(self::OPTION_KEY, self::defaults(), false);
|
|
update_option('brt_settings_version', self::SETTINGS_VERSION, false);
|
|
return;
|
|
}
|
|
if (get_option('brt_settings_version') !== self::SETTINGS_VERSION) {
|
|
update_option(self::OPTION_KEY, $this->normalize_settings($settings), false);
|
|
update_option('brt_settings_version', self::SETTINGS_VERSION, 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',
|
|
'admin_enabled' => '0',
|
|
'translation' => 'WEB',
|
|
'scope' => '.brt-scan-scope, main, .entry-content, .wp-block-post-content',
|
|
'admin_scope' => '#wpbody-content',
|
|
'theme' => 'auto',
|
|
'underline' => '1',
|
|
'show_translation' => '1',
|
|
'show_tooltips' => '1',
|
|
'case_insensitive' => '1',
|
|
'tag_chapters' => '1',
|
|
'scan_comments' => '1',
|
|
'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, []);
|
|
return $this->normalize_settings(is_array($settings) ? $settings : []);
|
|
}
|
|
|
|
private function normalize_settings($settings) {
|
|
$settings = array_merge(self::defaults(), is_array($settings) ? $settings : []);
|
|
if (array_key_exists('case_sensitive', $settings) && !array_key_exists('case_insensitive', $settings)) {
|
|
$settings['case_insensitive'] = empty($settings['case_sensitive']) ? '1' : '0';
|
|
}
|
|
$settings['skip_tags'] = $this->normalize_skip_tags($settings['skip_tags'] ?? []);
|
|
$clean = [];
|
|
foreach (array_keys(self::defaults()) as $key) $clean[$key] = $settings[$key];
|
|
return $clean;
|
|
}
|
|
|
|
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;
|
|
$this->enqueue_tooltip_assets($settings, (string)$settings['scope']);
|
|
}
|
|
|
|
public function admin_assets($hook_suffix = '') {
|
|
$settings = $this->settings();
|
|
if (empty($settings['admin_enabled']) || wp_doing_ajax()) return;
|
|
$this->enqueue_tooltip_assets($settings, (string)$settings['admin_scope']);
|
|
}
|
|
|
|
private function enqueue_tooltip_assets($settings, $scope) {
|
|
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' => $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']),
|
|
'bookAliases' => $this->frontend_book_alias_patterns(),
|
|
'caseSensitive' => empty($settings['case_insensitive']),
|
|
'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 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'] ?? ''));
|
|
$candidates = array_merge([$abbr, $name], $this->book_registry_aliases($abbr), $this->source_book_lookup_candidates($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];
|
|
$book = $this->book_registry_entry($abbr);
|
|
if ($book) {
|
|
$names[] = $book['name'];
|
|
foreach ($book['aliases'] as $alias) {
|
|
$names[] = $alias;
|
|
$names[] = strtoupper($alias);
|
|
}
|
|
}
|
|
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]|I{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}))?)?$/iu', $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 normalize_source_book($book, $source = '') {
|
|
$key = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)$book));
|
|
if ($source === 'ebible_vpl') {
|
|
$map = $this->ebible_vpl_book_map();
|
|
if (isset($map[$key])) return $map[$key];
|
|
return ['abbr' => $key, 'name' => $this->book_name_from_abbr($key)];
|
|
}
|
|
$human = $this->normalize_book($book);
|
|
if ($human) return $human;
|
|
return ['abbr' => $key, 'name' => $this->book_name_from_abbr($key)];
|
|
}
|
|
|
|
private function source_book_lookup_candidates($canonical_abbr) {
|
|
$canonical_abbr = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)$canonical_abbr));
|
|
$candidates = [];
|
|
foreach ($this->ebible_vpl_book_map() as $source_abbr => $book) {
|
|
if (strtoupper($book['abbr']) === $canonical_abbr) $candidates[] = $source_abbr;
|
|
}
|
|
return $candidates;
|
|
}
|
|
|
|
private function ebible_vpl_book_map() {
|
|
$map = [];
|
|
foreach ($this->book_registry() as $book) {
|
|
foreach ($book['source_aliases']['ebible_vpl'] as $source_abbr) {
|
|
$map[$source_abbr] = ['abbr' => $book['abbr'], 'name' => $book['name']];
|
|
}
|
|
}
|
|
return $map;
|
|
}
|
|
|
|
private function book_map() {
|
|
if ($this->book_map !== null) return $this->book_map;
|
|
$map = [];
|
|
foreach ($this->book_registry() as $book) {
|
|
$map[$this->alias_key($book['name'])] = ['abbr' => $book['abbr'], 'name' => $book['name']];
|
|
foreach ($book['aliases'] as $alias) $map[$this->alias_key($alias)] = ['abbr' => $book['abbr'], 'name' => $book['name']];
|
|
}
|
|
return $this->book_map = $map;
|
|
}
|
|
|
|
private function book_registry_entry($abbr) {
|
|
$abbr = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)$abbr));
|
|
foreach ($this->book_registry() as $book) {
|
|
if ($book['abbr'] === $abbr) return $book;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function book_registry_aliases($abbr) {
|
|
$book = $this->book_registry_entry($abbr);
|
|
if (!$book) return [];
|
|
$aliases = [$book['abbr'], $book['name']];
|
|
$aliases = array_merge($aliases, $book['aliases'], $book['source_aliases']['ebible_vpl']);
|
|
return array_map(function($alias) {
|
|
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)$alias));
|
|
}, $aliases);
|
|
}
|
|
|
|
private function alias_key($alias) {
|
|
return strtolower(preg_replace('/[^a-z0-9]/i', '', (string)$alias));
|
|
}
|
|
|
|
private function frontend_book_alias_patterns() {
|
|
$patterns = [];
|
|
foreach ($this->book_registry() as $book) {
|
|
foreach (array_merge([$book['name']], $book['aliases']) as $alias) {
|
|
$patterns[] = $this->frontend_book_alias_pattern($alias);
|
|
}
|
|
}
|
|
usort($patterns, function($a, $b) { return strlen($b) - strlen($a); });
|
|
return array_values(array_unique(array_filter($patterns)));
|
|
}
|
|
|
|
private function frontend_book_alias_pattern($alias) {
|
|
$alias = trim((string)$alias);
|
|
if ($alias === '') return '';
|
|
$pattern = preg_quote($alias, '/');
|
|
$pattern = preg_replace('/\\\\\s+/', '\\\\s*', $pattern);
|
|
$pattern = preg_replace('/^([1-3])([A-Za-z])/', '$1\\\\s*$2', $pattern);
|
|
return str_replace('\\ ', '\\s*', $pattern);
|
|
}
|
|
|
|
private function book_registry() {
|
|
return [
|
|
['abbr'=>'GEN','name'=>'Genesis','aliases'=>['gen','ge','gn'],'source_aliases'=>['ebible_vpl'=>['GEN']]],
|
|
['abbr'=>'EXO','name'=>'Exodus','aliases'=>['exo','ex','exod'],'source_aliases'=>['ebible_vpl'=>['EXO']]],
|
|
['abbr'=>'LEV','name'=>'Leviticus','aliases'=>['lev','le','lv'],'source_aliases'=>['ebible_vpl'=>['LEV']]],
|
|
['abbr'=>'NUM','name'=>'Numbers','aliases'=>['num','nu','nm','nb'],'source_aliases'=>['ebible_vpl'=>['NUM']]],
|
|
['abbr'=>'DEU','name'=>'Deuteronomy','aliases'=>['deut','deu','dt'],'source_aliases'=>['ebible_vpl'=>['DEU']]],
|
|
['abbr'=>'JOS','name'=>'Joshua','aliases'=>['josh','jos'],'source_aliases'=>['ebible_vpl'=>['JOS']]],
|
|
['abbr'=>'JDG','name'=>'Judges','aliases'=>['judg','jdg','jg'],'source_aliases'=>['ebible_vpl'=>['JDG']]],
|
|
['abbr'=>'RUT','name'=>'Ruth','aliases'=>['ruth','rut'],'source_aliases'=>['ebible_vpl'=>['RUT']]],
|
|
['abbr'=>'1SA','name'=>'1 Samuel','aliases'=>['1samuel','1sam','i samuel','isamuel','isam'],'source_aliases'=>['ebible_vpl'=>['1SA']]],
|
|
['abbr'=>'2SA','name'=>'2 Samuel','aliases'=>['2samuel','2sam','ii samuel','iisamuel','iisam'],'source_aliases'=>['ebible_vpl'=>['2SA']]],
|
|
['abbr'=>'1KI','name'=>'1 Kings','aliases'=>['1kings','1kgs','1ki','i kings','ikings'],'source_aliases'=>['ebible_vpl'=>['1KI']]],
|
|
['abbr'=>'2KI','name'=>'2 Kings','aliases'=>['2kings','2kgs','2ki','ii kings','iikings'],'source_aliases'=>['ebible_vpl'=>['2KI']]],
|
|
['abbr'=>'1CH','name'=>'1 Chronicles','aliases'=>['1chronicles','1chron','1chr','i chronicles','ichronicles'],'source_aliases'=>['ebible_vpl'=>['1CH']]],
|
|
['abbr'=>'2CH','name'=>'2 Chronicles','aliases'=>['2chronicles','2chron','2chr','ii chronicles','iichronicles'],'source_aliases'=>['ebible_vpl'=>['2CH']]],
|
|
['abbr'=>'EZR','name'=>'Ezra','aliases'=>['ezra','ezr'],'source_aliases'=>['ebible_vpl'=>['EZR']]],
|
|
['abbr'=>'NEH','name'=>'Nehemiah','aliases'=>['neh','ne'],'source_aliases'=>['ebible_vpl'=>['NEH']]],
|
|
['abbr'=>'EST','name'=>'Esther','aliases'=>['esther','est'],'source_aliases'=>['ebible_vpl'=>['EST']]],
|
|
['abbr'=>'JOB','name'=>'Job','aliases'=>['job'],'source_aliases'=>['ebible_vpl'=>['JOB']]],
|
|
['abbr'=>'PSA','name'=>'Psalms','aliases'=>['psalms','psalm','psa','ps','psm'],'source_aliases'=>['ebible_vpl'=>['PSA']]],
|
|
['abbr'=>'PRO','name'=>'Proverbs','aliases'=>['proverbs','prov','pro','pr'],'source_aliases'=>['ebible_vpl'=>['PRO']]],
|
|
['abbr'=>'ECC','name'=>'Ecclesiastes','aliases'=>['ecclesiastes','eccl','ecc','qoheleth'],'source_aliases'=>['ebible_vpl'=>['ECC']]],
|
|
['abbr'=>'SNG','name'=>'Song of Solomon','aliases'=>['songofsolomon','song of songs','songofsongs','song','sos','sng','sol'],'source_aliases'=>['ebible_vpl'=>['SNG','SOL']]],
|
|
['abbr'=>'ISA','name'=>'Isaiah','aliases'=>['isaiah','isa'],'source_aliases'=>['ebible_vpl'=>['ISA']]],
|
|
['abbr'=>'JER','name'=>'Jeremiah','aliases'=>['jeremiah','jer'],'source_aliases'=>['ebible_vpl'=>['JER']]],
|
|
['abbr'=>'LAM','name'=>'Lamentations','aliases'=>['lamentations','lam'],'source_aliases'=>['ebible_vpl'=>['LAM']]],
|
|
['abbr'=>'EZK','name'=>'Ezekiel','aliases'=>['ezekiel','ezek','ezk','eze'],'source_aliases'=>['ebible_vpl'=>['EZK','EZE']]],
|
|
['abbr'=>'DAN','name'=>'Daniel','aliases'=>['daniel','dan','dn'],'source_aliases'=>['ebible_vpl'=>['DAN']]],
|
|
['abbr'=>'HOS','name'=>'Hosea','aliases'=>['hosea','hos'],'source_aliases'=>['ebible_vpl'=>['HOS']]],
|
|
['abbr'=>'JOL','name'=>'Joel','aliases'=>['joel','jol','joe'],'source_aliases'=>['ebible_vpl'=>['JOL','JOE']]],
|
|
['abbr'=>'AMO','name'=>'Amos','aliases'=>['amos','amo'],'source_aliases'=>['ebible_vpl'=>['AMO']]],
|
|
['abbr'=>'OBA','name'=>'Obadiah','aliases'=>['obadiah','oba','obad'],'source_aliases'=>['ebible_vpl'=>['OBA']]],
|
|
['abbr'=>'JON','name'=>'Jonah','aliases'=>['jonah','jon'],'source_aliases'=>['ebible_vpl'=>['JON']]],
|
|
['abbr'=>'MIC','name'=>'Micah','aliases'=>['micah','mic'],'source_aliases'=>['ebible_vpl'=>['MIC']]],
|
|
['abbr'=>'NAM','name'=>'Nahum','aliases'=>['nahum','nah','nam'],'source_aliases'=>['ebible_vpl'=>['NAM','NAH']]],
|
|
['abbr'=>'HAB','name'=>'Habakkuk','aliases'=>['habakkuk','hab'],'source_aliases'=>['ebible_vpl'=>['HAB']]],
|
|
['abbr'=>'ZEP','name'=>'Zephaniah','aliases'=>['zephaniah','zeph','zep'],'source_aliases'=>['ebible_vpl'=>['ZEP']]],
|
|
['abbr'=>'HAG','name'=>'Haggai','aliases'=>['haggai','hag'],'source_aliases'=>['ebible_vpl'=>['HAG']]],
|
|
['abbr'=>'ZEC','name'=>'Zechariah','aliases'=>['zechariah','zech','zec'],'source_aliases'=>['ebible_vpl'=>['ZEC']]],
|
|
['abbr'=>'MAL','name'=>'Malachi','aliases'=>['malachi','mal'],'source_aliases'=>['ebible_vpl'=>['MAL']]],
|
|
['abbr'=>'MAT','name'=>'Matthew','aliases'=>['matthew','matt','mat','mt'],'source_aliases'=>['ebible_vpl'=>['MAT']]],
|
|
['abbr'=>'MRK','name'=>'Mark','aliases'=>['mark','mrk','mk','mar'],'source_aliases'=>['ebible_vpl'=>['MRK','MAR']]],
|
|
['abbr'=>'LUK','name'=>'Luke','aliases'=>['luke','luk','lk'],'source_aliases'=>['ebible_vpl'=>['LUK']]],
|
|
['abbr'=>'JHN','name'=>'John','aliases'=>['john','jhn','jn','joh'],'source_aliases'=>['ebible_vpl'=>['JHN','JOH']]],
|
|
['abbr'=>'ACT','name'=>'Acts','aliases'=>['acts','act'],'source_aliases'=>['ebible_vpl'=>['ACT']]],
|
|
['abbr'=>'ROM','name'=>'Romans','aliases'=>['romans','rom','ro'],'source_aliases'=>['ebible_vpl'=>['ROM']]],
|
|
['abbr'=>'1CO','name'=>'1 Corinthians','aliases'=>['1corinthians','1cor','1co','i corinthians','icorinthians'],'source_aliases'=>['ebible_vpl'=>['1CO']]],
|
|
['abbr'=>'2CO','name'=>'2 Corinthians','aliases'=>['2corinthians','2cor','2co','ii corinthians','iicorinthians'],'source_aliases'=>['ebible_vpl'=>['2CO']]],
|
|
['abbr'=>'GAL','name'=>'Galatians','aliases'=>['galatians','gal'],'source_aliases'=>['ebible_vpl'=>['GAL']]],
|
|
['abbr'=>'EPH','name'=>'Ephesians','aliases'=>['ephesians','eph'],'source_aliases'=>['ebible_vpl'=>['EPH']]],
|
|
['abbr'=>'PHP','name'=>'Philippians','aliases'=>['philippians','phil','php','phi'],'source_aliases'=>['ebible_vpl'=>['PHP','PHI']]],
|
|
['abbr'=>'COL','name'=>'Colossians','aliases'=>['colossians','col'],'source_aliases'=>['ebible_vpl'=>['COL']]],
|
|
['abbr'=>'1TH','name'=>'1 Thessalonians','aliases'=>['1thessalonians','1thess','1th','i thessalonians','ithessalonians'],'source_aliases'=>['ebible_vpl'=>['1TH']]],
|
|
['abbr'=>'2TH','name'=>'2 Thessalonians','aliases'=>['2thessalonians','2thess','2th','ii thessalonians','iithessalonians'],'source_aliases'=>['ebible_vpl'=>['2TH']]],
|
|
['abbr'=>'1TI','name'=>'1 Timothy','aliases'=>['1timothy','1tim','1ti','i timothy','itimothy'],'source_aliases'=>['ebible_vpl'=>['1TI']]],
|
|
['abbr'=>'2TI','name'=>'2 Timothy','aliases'=>['2timothy','2tim','2ti','ii timothy','iitimothy'],'source_aliases'=>['ebible_vpl'=>['2TI']]],
|
|
['abbr'=>'TIT','name'=>'Titus','aliases'=>['titus','tit'],'source_aliases'=>['ebible_vpl'=>['TIT']]],
|
|
['abbr'=>'PHM','name'=>'Philemon','aliases'=>['philemon','phm','phlm','philemeon'],'source_aliases'=>['ebible_vpl'=>['PHM']]],
|
|
['abbr'=>'HEB','name'=>'Hebrews','aliases'=>['hebrews','heb'],'source_aliases'=>['ebible_vpl'=>['HEB']]],
|
|
['abbr'=>'JAS','name'=>'James','aliases'=>['james','jas','ja','jam','jms','jm','jac','jacob','epistleofjames','the epistle of james','theepistleofjames'],'source_aliases'=>['ebible_vpl'=>['JAS','JAM']]],
|
|
['abbr'=>'1PE','name'=>'1 Peter','aliases'=>['1peter','1pet','1pe','i peter','ipeter'],'source_aliases'=>['ebible_vpl'=>['1PE']]],
|
|
['abbr'=>'2PE','name'=>'2 Peter','aliases'=>['2peter','2pet','2pe','ii peter','iipeter'],'source_aliases'=>['ebible_vpl'=>['2PE']]],
|
|
['abbr'=>'1JN','name'=>'1 John','aliases'=>['1john','1jn','1jhn','1jo','i john','ijohn','ijn','ijhn','ijo'],'source_aliases'=>['ebible_vpl'=>['1JN','1JO']]],
|
|
['abbr'=>'2JN','name'=>'2 John','aliases'=>['2john','2jn','2jhn','2jo','ii john','iijohn','iijn','iijhn','iijo'],'source_aliases'=>['ebible_vpl'=>['2JN','2JO']]],
|
|
['abbr'=>'3JN','name'=>'3 John','aliases'=>['3john','3jn','3jhn','3jo','iii john','iiijohn','iiijn','iiijhn','iiijo'],'source_aliases'=>['ebible_vpl'=>['3JN','3JO']]],
|
|
['abbr'=>'JUD','name'=>'Jude','aliases'=>['jude','jud'],'source_aliases'=>['ebible_vpl'=>['JUD']]],
|
|
['abbr'=>'REV','name'=>'Revelation','aliases'=>['revelation','rev','re','apo'],'source_aliases'=>['ebible_vpl'=>['REV']]],
|
|
];
|
|
}
|
|
|
|
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();
|
|
$integrity = $this->text_integrity_summary();
|
|
$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>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 class="brt-check"><input type="checkbox" name="enabled" value="1" <?php checked($settings['enabled'], '1'); ?>> Enable public-site scanning</label>
|
|
<label class="brt-check"><input type="checkbox" name="admin_enabled" value="1" <?php checked($settings['admin_enabled'], '1'); ?>> Enable WordPress admin scanning</label>
|
|
<label>Public scan selectors <textarea name="scope" rows="3"><?php echo esc_textarea($settings['scope']); ?></textarea><span>Comma-separated CSS selectors for public pages. Examples: body scans the full page; main scans the main layout; .entry-content scans classic post content; .wp-block-post-content scans block-theme content; .entry-content, .wp-block-post-content scans either area.</span></label>
|
|
<label>Admin scan selectors <textarea name="admin_scope" rows="2"><?php echo esc_textarea($settings['admin_scope']); ?></textarea><span>Comma-separated CSS selectors for wp-admin pages. #wpbody-content scans the main admin content area and avoids the WordPress toolbar and menu.</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>
|
|
<h3>Matching Rules</h3>
|
|
<label class="brt-check"><input type="checkbox" name="case_insensitive" value="1" <?php checked($settings['case_insensitive'], '1'); ?>> Match references regardless of capitalization <span>Detects forms such as jn 3:16 and JOHN 3:16.</span></label>
|
|
<label class="brt-check"><input type="checkbox" name="tag_chapters" value="1" <?php checked($settings['tag_chapters'], '1'); ?>> Detect chapter-only references <span>Allows references like Genesis 1. The tooltip shows the first verses up to the range limit.</span></label>
|
|
<label class="brt-check"><input type="checkbox" name="scan_comments" value="1" <?php checked($settings['scan_comments'], '1'); ?>> Scan public comments <span>Adds tooltip detection to WordPress comment text.</span></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>Text Integrity</h2>
|
|
<?php if (!$integrity): ?>
|
|
<p>No local Bible texts are installed yet.</p>
|
|
<?php else: ?>
|
|
<table class="widefat striped brt-table">
|
|
<thead><tr><th>Translation</th><th>Canonical books</th><th>Status</th></tr></thead>
|
|
<tbody>
|
|
<?php foreach ($integrity as $row): ?>
|
|
<tr>
|
|
<td><strong><?php echo esc_html($row['translation']); ?></strong></td>
|
|
<td><?php echo esc_html((int)$row['found'] . ' / ' . (int)$row['expected']); ?></td>
|
|
<td><?php echo $row['ok'] ? '<span class="brt-pill">Complete</span>' : esc_html('Missing: ' . implode(', ', $row['missing'])); ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<p class="description">This checks whether each installed text has at least one row for every canonical Bible book after source-code interpretation.</p>
|
|
<?php endif; ?>
|
|
</section>
|
|
<section class="brt-card brt-wide">
|
|
<h2>Open Text Policy</h2>
|
|
<p>This plugin is designed for Bible texts that are public domain, openly licensed, or otherwise legally permitted for local storage and display. It does not provide import tools for restricted commercial translations unless a future integration explicitly supports proper licensing.</p>
|
|
<p>The built-in importer currently supports selected open/public-domain eBible VPL packages.</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 text_integrity_summary() {
|
|
global $wpdb;
|
|
$expected = array_map(function($book) { return $book['abbr']; }, $this->book_registry());
|
|
$translations = $wpdb->get_col("SELECT DISTINCT translation FROM " . self::table() . " ORDER BY translation ASC");
|
|
$summary = [];
|
|
foreach ((array)$translations as $translation) {
|
|
$found = $wpdb->get_col($wpdb->prepare(
|
|
"SELECT DISTINCT book FROM " . self::table() . " WHERE translation = %s ORDER BY book ASC",
|
|
$translation
|
|
));
|
|
$found = array_values(array_unique(array_map('strtoupper', array_map('strval', $found ?: []))));
|
|
$missing = array_values(array_diff($expected, $found));
|
|
$summary[] = [
|
|
'translation' => strtoupper((string)$translation),
|
|
'expected' => count($expected),
|
|
'found' => count(array_intersect($expected, $found)),
|
|
'missing' => $missing,
|
|
'ok' => !$missing,
|
|
];
|
|
}
|
|
return $summary;
|
|
}
|
|
|
|
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';
|
|
$settings = [
|
|
'enabled' => !empty($_POST['enabled']) ? '1' : '0',
|
|
'admin_enabled' => !empty($_POST['admin_enabled']) ? '1' : '0',
|
|
'translation' => $translation,
|
|
'scope' => sanitize_textarea_field($_POST['scope'] ?? self::defaults()['scope']),
|
|
'admin_scope' => sanitize_textarea_field($_POST['admin_scope'] ?? self::defaults()['admin_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',
|
|
'case_insensitive' => !empty($_POST['case_insensitive']) ? '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);
|
|
$this->delete_temp_dir($dir);
|
|
return 'Could not open downloaded ' . $translation . ' archive.';
|
|
}
|
|
$zip->extractTo($dir);
|
|
$zip->close();
|
|
@unlink($tmp);
|
|
$file = trailingslashit($dir) . $info['file'];
|
|
if (!file_exists($file)) {
|
|
$this->delete_temp_dir($dir);
|
|
return $translation . ' VPL file was not found in archive.';
|
|
}
|
|
$handle = fopen($file, 'r');
|
|
if (!$handle) {
|
|
$this->delete_temp_dir($dir);
|
|
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']);
|
|
$this->delete_temp_dir($dir);
|
|
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_source_book($m[1], 'ebible_vpl');
|
|
$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;
|
|
foreach ($this->canonical_book_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]
|
|
));
|
|
}
|
|
foreach ($this->canonical_book_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 canonical_book_repairs() {
|
|
$repairs = [];
|
|
foreach ($this->book_registry() as $book) {
|
|
foreach (array_merge($book['aliases'], $book['source_aliases']['ebible_vpl']) as $alias) {
|
|
$key = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)$alias));
|
|
if ($key && $key !== $book['abbr']) $repairs[$key] = [$book['abbr'], $book['name']];
|
|
}
|
|
}
|
|
return $repairs;
|
|
}
|
|
|
|
private function canonical_book_name_repairs() {
|
|
$repairs = [];
|
|
foreach ($this->book_registry() as $book) {
|
|
foreach (array_merge([$book['name']], $book['aliases'], $book['source_aliases']['ebible_vpl']) as $alias) {
|
|
$key = $this->alias_key($alias);
|
|
if ($key) $repairs[$key] = [$book['abbr'], $book['name']];
|
|
}
|
|
}
|
|
return $repairs;
|
|
}
|
|
|
|
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-pop{box-sizing:border-box;position:fixed;z-index:999999;width:max-content;max-width:min(560px,calc(100vw - 28px));min-width:min(320px,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 *{box-sizing:border-box}.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:flex-start;justify-content:space-between;gap:10px;margin:0 0 12px}.brt-title{display:block;min-width:0;flex:1 1 auto;font-weight:800;font-size:15px;line-height:1.3;color:inherit;white-space:normal;overflow-wrap:anywhere}.brt-label{font-weight:700;color:#475569}.brt-pop.dark .brt-label{color:#a8b7cc}.brt-tabs{display:flex;flex:0 0 auto;max-width:210px;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-body{clear:both;display:block;min-height:1.55em}.brt-verse{display:block;margin:0 0 9px;color:inherit}.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{flex-direction:column}.brt-tabs{justify-content:flex-start;max-width:none}.brt-pop{min-width:min(280px,calc(100vw - 28px))}}';
|
|
}
|
|
|
|
private function frontend_js() {
|
|
return <<<'JS'
|
|
(function(){
|
|
if (!window.BibleReferenceTooltips || !BibleReferenceTooltips.rest) return;
|
|
const bookAliases = Array.isArray(BibleReferenceTooltips.bookAliases) ? BibleReferenceTooltips.bookAliases : [];
|
|
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')) 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);
|
|
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 = document.createElement('span');
|
|
el.className = 'brt-ref';
|
|
if (!BibleReferenceTooltips.underline) el.className += ' no-underline';
|
|
el.dataset.ref = ref;
|
|
el.textContent = ref;
|
|
el.tabIndex = 0;
|
|
return el;
|
|
}
|
|
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 separator = String(BibleReferenceTooltips.rest).includes('?') ? '&' : '?';
|
|
const url = BibleReferenceTooltips.rest + separator + '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 => {
|
|
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();
|
|
}
|