commit c517c9ee1cdcaa53fcb53ffba806de6038a28e62 Author: Jason Eugene Garrison Date: Sat Jul 11 12:18:05 2026 -0500 Create public WordPress plugin workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c63024 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +Thumbs.db + +node_modules/ +vendor/ + +*.zip +*.log +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..34b7093 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# WordPress Public Workspace + +This workspace contains Christ IT WordPress plugins and themes being prepared for public distribution. + +The source of truth here is Git. WordPress.org SVN is treated as a release target only, not as the development workspace. + +## Current Packages + +- `plugins/bible-reference-tooltips/` - standalone Bible reference detection, local open-text imports, and frontend hover/focus cards. + +## Release Boundary + +Do not remove any corresponding internal package until the public package is accepted, published, installed on the target production site, and verified on the public update path. diff --git a/plugins/bible-reference-tooltips/CHANGELOG.md b/plugins/bible-reference-tooltips/CHANGELOG.md new file mode 100644 index 0000000..b8e0432 --- /dev/null +++ b/plugins/bible-reference-tooltips/CHANGELOG.md @@ -0,0 +1,106 @@ +# Bible Reference Tooltips Changelog + +## 1.1.0 - 2026-07-11 + +- Forked the former internal Bible tooltip package into a standalone public plugin named Bible Reference Tooltips. +- Renamed the plugin slug, text domain, REST namespace, script/style handles, admin action names, option key, and local verse table for the public package. +- Removed internal menu placement and internal logging dependencies from the public package. +- Added public release metadata, GPL license headers, and WordPress.org-oriented naming. +- Kept local Bible text imports, frontend reference detection, hover/focus cards, translation tabs, Biblia.com links, Logos links, skipped-tag controls, comment scanning, and James alias/backfill repairs. + +## 1.0.14 - 2026-06-25 + +- Restored known-good tooltip behavior from the internal package history before preparing the public fork. + +## 1.0.12 - 2026-06-13 + +- Remembers the visitor's selected tooltip translation in browser storage so future tooltip opens use the same translation by default. +- Improves REST lookup error messages with clearer reasons for unrecognized references, missing verse rows, and missing local translations. +- Adds structured lookup events for successful lookups and lookup failures, including reference, translation, book, chapter, verse, and candidate diagnostics. + +## 1.0.11 - 2026-06-13 + +- Adds a targeted James backfill repair for installed WEB, KJV, ASV, and YLT local Bible texts when the translation exists but has no James rows. +- Adds a just-in-time REST fallback so a James lookup can backfill the selected translation and retry immediately instead of returning an empty tooltip. +- Confirms the eBible VPL source stores James as `JAM`, then imports those rows into the canonical `JAS` lookup key. + +## 1.0.10 - 2026-06-13 + +- Fixes an intermittent frontend scanner bug where the global Bible-reference regex could carry stale `lastIndex` state between text nodes and skip valid references such as `James 1:5`. +- Adds a non-global detection regex for TreeWalker filtering while keeping the global regex only for wrapping detected references. +- Makes frontend parsing more forgiving for James references with a period after the book name or spacing around the colon, such as `James. 1:5` and `James 1: 5`. +- Bumps the runtime version so browsers receive the repaired inline scanner and existing James alias repairs run again. + +## 1.0.9 - 2026-06-13 + +- Fixes frontend Bible-reference detection for James shorthand with periods, including `Jas.`, `Jm.`, and related aliases. +- Corrects the frontend case-sensitivity flag so normal mode detects lowercase and mixed-case Bible references. +- Expands James backend lookup and repair aliases, then forces the runtime alias repair to run again for existing local Bible tables. + +## 1.0.8 - 2026-05-31 + +- Expands local verse lookup aliases so James and other common book-code variants can resolve even when imported rows use older shorthand. +- Runs the alias repair on the public runtime once per version, so existing local Bible tables heal without needing to visit the admin screen first. +- Smooths tooltip loading by delaying the loading shell briefly and fading the main hover card in, removing the small flash on first hover. + +## 1.0.7 - 2026-05-30 + +- Adds direct James aliases for eBible packages that store James as `JAM`, `JMS`, or related shorthand instead of the canonical `JAS` code. +- Adds a one-time local verse table alias repair for common open-Bible book-code variants so existing installs can resolve affected references without a full reinstall. + +## 1.0.6 - 2026-05-28 + +- Adds RefTagger-style controls for link targets, external Bible links, Logos links/icons, tooltip behavior, comment scanning, chapter-only references, improper casing, and skipped HTML tags. +- Adds safer frontend reference scanning with configurable skipped tags and optional enhancement of existing Biblia.com, Ref.ly, and Logos links. +- Adds frontend Bible and Logos link rendering while keeping local verse text lookups for hover/focus tooltip content. + +## 1.0.5 - 2026-05-28 + +- Rebuilds the Bible library admin from a single dropdown into a checkbox table with bulk install, update, default, and removal actions. +- Makes installed translations automatically available as frontend tooltip tabs while keeping one default opening translation. +- Tightens translation import parsing and book-code normalization, including `JOH` for John, `JOE` for Joel, and `PHI` for Philippians. +- Keeps tooltip translation tabs visible when a selected translation is missing a verse instead of replacing the card with a blank/error-only state. +- Makes the frontend default translation fall back to an installed local text when the saved setting points at a missing translation. + +## 1.0.4 - 2026-05-28 + +- Changes Bible imports to update one selected translation while preserving other installed translations. +- Adds Young's Literal Translation as a fourth public-domain eBible VPL import option. +- Adds a default translation selector and exposes installed translations as quick-switch buttons in the frontend card. +- Reworks frontend cards so they anchor beside the detected reference instead of following the mouse. +- Keeps hover cards open long enough to move onto them, making translation buttons and future links usable. +- Formats verse ranges with each verse on its own line and bold verse numbers. +- Makes automatic card theming follow the active site theme instead of browser preference. + +## 1.0.3 - 2026-05-26 + +- Adds fuzzy backend book normalization so detected abbreviations such as `jn 3:16` resolve to the canonical John lookup. +- Adds alternate stored book-code lookup variants for translation packages that use codes such as `JOH` or `JOHN` instead of `JHN`. +- Normalizes imported VPL book codes before saving rows so future installs use stable canonical book keys. +- Adds American Standard Version 1901 as another public-domain eBible import option. +- Falls back to the only installed local translation when the frontend requests a stale translation setting. + +## 1.0.2 - 2026-05-26 + +- Adds broad frontend Bible book abbreviation detection, including short forms such as `Jn 3:16`, `Rom 8:28`, and numbered-book aliases. +- Removes the test seed installer UI so the admin focuses on full Bible installs only. +- Removes the redundant active translation picker because installing a Bible now replaces the single active translation. +- Clarifies the range limit setting used for references such as `John 3:16-20`. + +## 1.0.1 - 2026-05-26 + +- Fixes frontend scanner timing so references are detected even when the inline script loads after DOMContentLoaded. +- Changes the default scan scope to the full page body so references in blocks, headers, footers, widgets, and theme text can be detected. +- Adds auto tooltip theme mode that follows common dark-mode body or document classes when active. +- Adds King James Version import support from the eBible VPL package. +- Makes seed and full-Bible imports replace the currently installed Bible so only one translation is active at a time. +- Keeps verse text lazy-loaded through the REST endpoint on hover/focus, with a small browser-side cache for repeated references. + +## 1.0.0 - 2026-05-26 + +- Initial standalone plugin release. +- Adds a local verse database table for open Bible texts. +- Adds admin controls for enabling frontend scanning, choosing translation, setting scan selectors, choosing tooltip theme, and limiting verse ranges. +- Adds a test seed installer for immediate hover-card testing. +- Adds a World English Bible downloader/importer using the eBible VPL package. +- Adds frontend Bible-reference detection and hover tooltips powered by a local REST lookup endpoint. diff --git a/plugins/bible-reference-tooltips/LICENSE b/plugins/bible-reference-tooltips/LICENSE new file mode 100644 index 0000000..70d4eeb --- /dev/null +++ b/plugins/bible-reference-tooltips/LICENSE @@ -0,0 +1,9 @@ +Bible Reference Tooltips is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by the Free +Software Foundation, either version 2 of the License, or any later version. + +Bible Reference Tooltips is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +or FITNESS FOR A PARTICULAR PURPOSE. + +See https://www.gnu.org/licenses/gpl-2.0.html for the full license text. diff --git a/plugins/bible-reference-tooltips/bible-reference-tooltips.php b/plugins/bible-reference-tooltips/bible-reference-tooltips.php new file mode 100644 index 0000000..acb4636 --- /dev/null +++ b/plugins/bible-reference-tooltips/bible-reference-tooltips.php @@ -0,0 +1,1400 @@ +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 '
' . $content . '
'; + } + + public function mark_comment_scope($content) { + $settings = $this->settings(); + if (is_admin() || is_feed() || empty($settings['scan_comments'])) return $content; + return '
' . $content . '
'; + } + + 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'] ?? ''); + ?> +
+

đź“– Bible Reference Tooltips

+

Open Bible hover cards powered by your own WordPress database. No remote lookup is needed during page views.

+

+ + +
+
+

Installed Texts

+

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.

+
+ + +
+ + +
+ + + + $info): + $row = $installed_by_code[$code] ?? null; + $is_default = strtoupper($settings['translation']) === $code; + ?> + + + + + + + + + +
TranslationStatusSourceDefault

Not installed'; ?>
Default' : ''; ?>
+

If a translation looks installed but returns blank cards, run “Install / Update selected” to rebuild those rows with the current parser.

+
+
+
+

Settings

+
+ + + + + +
+ + +
+ + + + +
+ + +
+

Logos Links

+ + +
+ + +
+

Matching Rules

+ + + + + +
+ display_skip_tags() as $tag => $label): ?> + + +
+ +
+
+
+

Open Text Policy

+

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.

+
+
+ +
+ 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 = '
' + esc(activeRef) + '
Loading local Bible text...
'; + 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 = '
' + esc(activeRef) + '

' + esc(err.message || 'Verse not available locally.') + '

'; + 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 ''; + }).join(''); + const label = BibleReferenceTooltips.showTranslation && data.translation_label ? ' (' + esc(data.translation_label) + ')' : ''; + const title = data.reference || activeRef || ''; + const errorReason = data.reason ? '' + esc(String(data.reason).replace(/_/g, ' ')) + '' : ''; + const verses = data.ok === false ? '

' + esc(data.message || 'Verse text is not available locally for this reference.') + '

' + errorReason : (Array.isArray(data.verses) && data.verses.length ? data.verses.map(row => { + return '

' + esc(row.verse) + '.' + esc(row.text) + '

'; + }).join('') : '

' + esc(data.text || '') + '

'); + pop.innerHTML = '
' + esc(title) + label + '' + tabs + '
' + verses + '
Local Bible lookup'; + } + 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 = '
' + esc(activeRef) + '

' + esc(err.message || 'Verse text is not available locally for this reference.') + '

'; + } + }); + document.body.appendChild(pop); + } + function revealPop(){ + if (!pop) return; + pop.classList.remove('ready'); + window.requestAnimationFrame(() => { + if (pop) pop.classList.add('ready'); + }); + } + function place(target){ + if (!pop) return; + const pad = 14; + const rect = target.getBoundingClientRect(); + let x = Math.min(rect.left, window.innerWidth - pop.offsetWidth - pad); + let y = rect.bottom + 10; + if (y + pop.offsetHeight > window.innerHeight - pad) y = Math.max(pad, rect.top - pop.offsetHeight - 10); + pop.style.left = Math.max(pad, x) + 'px'; + pop.style.top = Math.max(pad, y) + 'px'; + } + function scheduleClear(){ hideTimer = window.setTimeout(clear, 140); } + function clear(){ window.clearTimeout(loadingTimer); requestToken++; if (pop) { pop.remove(); pop = null; } activeRef = ''; activeTrigger = null; } + function resolvedTheme(){ + if (BibleReferenceTooltips.theme === 'light') return 'light'; + if (BibleReferenceTooltips.theme === 'dark') return 'dark'; + const doc = document.documentElement; + const body = document.body; + const darkClass = doc.classList.contains('dark') || body.classList.contains('dark') || body.classList.contains('dark-mode') || doc.dataset.theme === 'dark' || body.dataset.theme === 'dark'; + if (darkClass) return 'dark'; + return 'light'; + } + function esc(s){ return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } + if (BibleReferenceTooltips.showTooltips) { + document.addEventListener('mouseover', show); + document.addEventListener('focusin', show); + document.addEventListener('mouseout', e => { + const ref = e.target.closest('.brt-ref'); + if (ref && (!e.relatedTarget || (!ref.contains(e.relatedTarget) && (!pop || !pop.contains(e.relatedTarget))))) scheduleClear(); + }); + document.addEventListener('focusout', e => { if (e.target.closest('.brt-ref')) scheduleClear(); }); + } + function initScan(){ + const selectors = (BibleReferenceTooltips.scope || 'body').split(',').map(s => s.trim()).filter(Boolean); + if (BibleReferenceTooltips.scanComments) selectors.push('.brt-comment-scope'); + const roots = selectors.flatMap(s => Array.from(document.querySelectorAll(s))); + Array.from(new Set(roots.length ? roots : [document.body])).forEach(root => { + enhanceExistingLinks(root); + scan(root); + }); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initScan, {once:true}); + else initScan(); +})(); +JS; + } + } + + register_activation_hook(__FILE__, ['Bible_Reference_Tooltips', 'activate']); + register_deactivation_hook(__FILE__, ['Bible_Reference_Tooltips', 'deactivate']); + new Bible_Reference_Tooltips(); +} diff --git a/plugins/bible-reference-tooltips/readme.txt b/plugins/bible-reference-tooltips/readme.txt new file mode 100644 index 0000000..9763e6b --- /dev/null +++ b/plugins/bible-reference-tooltips/readme.txt @@ -0,0 +1,57 @@ +=== Bible Reference Tooltips === +Contributors: jasoneugenegarrison +Tags: bible, scripture, references, tooltips, verses +Requires at least: 6.0 +Tested up to: 7.0.1 +Requires PHP: 7.4 +Stable tag: 1.1.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Detect Bible references in WordPress content and show local hover cards from open Bible texts stored in your own database. + +== Description == + +Bible Reference Tooltips scans public WordPress content for Bible references such as John 3:16, Romans 8:28, or James 1:5. Detected references can be underlined, linked, and shown with hover or keyboard-focus tooltip cards. + +Verse text is looked up from local open/public-domain Bible texts installed into your WordPress database. The plugin currently supports World English Bible, King James Version, American Standard Version, and Young's Literal Translation imports from eBible VPL packages. + +Features include: + +* Frontend Bible reference detection. +* Hover and focus tooltip cards. +* Local verse storage with no remote lookup required during page views. +* Multiple installed translations with quick-switch tabs in the card. +* Biblia.com links and optional Logos Bible Software links. +* Configurable scan selectors, skipped tags, comment scanning, tooltip theme, and verse range limit. + +== Installation == + +1. Upload the plugin folder to `/wp-content/plugins/`. +2. Activate Bible Reference Tooltips in WordPress. +3. Open Settings > Bible Reference Tooltips. +4. Install one or more open Bible texts. +5. Adjust scanning and tooltip settings as needed. + +== Frequently Asked Questions == + += Does this require an external Bible API? = + +No. Public page views use local verse rows stored in your WordPress database. The admin import tool downloads open Bible text packages when you choose to install or update a translation. + += Which translations are included? = + +The plugin can import WEB, KJV, ASV, and YLT from open eBible VPL packages. Modern commercial translations generally require separate licensing and are not bundled. + += Does it work without a specific theme? = + +Yes. The plugin is standalone and appears under Settings in the WordPress admin. + +== Changelog == + += 1.1.0 = + +* Forked the internal package into a standalone public plugin. +* Renamed the plugin, slug, text domain, REST namespace, handles, options, and local table for public distribution. +* Removed internal menu placement and logging dependencies. +* Added public release metadata and WordPress.org-oriented readme.