bible270 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10dd20a51ceee2f549e465ad8826eaa1211c6722c08f2ecf6e81f5ec885eeb90
4
- data.tar.gz: 598254caeb9eb32a2464663895c64ec0c3eec85cc98705b8da0ed05facb10141
3
+ metadata.gz: cb5049bbbd494dff154cb3061567f7184f13c3eb55bc625f823e3cf6204addfb
4
+ data.tar.gz: 04fe5e22682dc2975b1548ed5a5ee8b5402909ff60000130794fcae8611a0abf
5
5
  SHA512:
6
- metadata.gz: 3e587617e2a3d06e1e26cead51a0b96c87a6468b93e53bb03b60ab7a87931cc602f4b3ec5fb72c6b712a0b01a1dffc44a2d15422510e8064b7da881d44d9d31b
7
- data.tar.gz: b64e61b9682538bf19be6aa6dc428ebc67b8e43808192e29fa6cb04aeac8747e6264ba320c18c6cdc800b6c7a14fafb6eafc1f352fbf7f55b4572248a1699922
6
+ metadata.gz: 0d9b760049053e5d85156bc010ad363da6817f3baee350fad8d1955ba11455fe509fce4fe03747e5ce0f78dd8c2f2737f983646c1a7c437567844365b8c9438f
7
+ data.tar.gz: 0dc11557e3316ed5cb4c64ba224892392baf64c0fae3b0958e15b13f3a57f036ad69564f5540c90ea3d6409d0162d9593a526981b43e460cf87e7931e5d2edd3
data/CHANGELOG.md CHANGED
@@ -4,7 +4,35 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.6.0...main)**
7
+ ### Added
8
+
9
+ - show Blue Letter Bible Scripture popups when readers hover over references in rendered reflections, replies, and Markdown previews; popups follow each reader's selected translation, handle original-language choices by testament, and rescan content added by Turbo without forcing a page reload
10
+
11
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.6.2...main)**
12
+
13
+ ## [1.6.2] - 2026-09-14
14
+
15
+ ### Fixed
16
+
17
+ - keep ordered and unordered Markdown lists compact when writers leave blank lines between items, removing visible indentation and extra line-break artifacts from previews and rendered reflections
18
+
19
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.6.1...v1.6.2)**
20
+
21
+ ## [1.6.1] - 2026-09-14
22
+
23
+ ### Added
24
+
25
+ - let administrators edit any reflection or reply inline while preserving its recorded author and retaining the existing restrictions for ordinary readers
26
+
27
+ ### Changed
28
+
29
+ - select only the `list item` placeholder when inserting a list, and continue unordered or incrementing ordered list markers when the writer presses Enter
30
+
31
+ ### Fixed
32
+
33
+ - render pasted `http://` and `https://` URLs as safe links in Markdown previews and displayed reflections without nesting explicit Markdown links or swallowing trailing punctuation
34
+
35
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.6.0...v1.6.1)**
8
36
 
9
37
  ## [1.6.0] - 2026-09-14
10
38
 
@@ -701,7 +729,9 @@ This is the initial public release.
701
729
 
702
730
  * Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
703
731
 
704
- [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.6.0...HEAD
732
+ [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.6.2...HEAD
733
+ [1.6.2]: https://github.com/avonderluft/bible270/releases/tag/v1.6.2
734
+ [1.6.1]: https://github.com/avonderluft/bible270/releases/tag/v1.6.1
705
735
  [1.6.0]: https://github.com/avonderluft/bible270/releases/tag/v1.6.0
706
736
  [1.5.1]: https://github.com/avonderluft/bible270/releases/tag/v1.5.1
707
737
  [1.5.0]: https://github.com/avonderluft/bible270/releases/tag/v1.5.0
data/README.md CHANGED
@@ -23,7 +23,7 @@ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is sto
23
23
  ## Requirements
24
24
 
25
25
  - **Ruby** >= 3.2. Plain Ruby, no C extensions. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
26
- - **Rails** >= 7.0. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
26
+ - **Rails** >= 7.2. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
27
27
  - A host app, and Turbo if you want check-offs without a page reload.
28
28
 
29
29
  Mutation controls expose pending, success, and retry feedback when JavaScript runs, while ordinary HTML
@@ -507,6 +507,21 @@ dedicated browser tab so moving among the day's passages does not
507
507
  leave a trail of open tabs; Ctrl-click, Cmd-click, middle-click, and the browser's context menu still
508
508
  open additional tabs when a reader wants to compare passages.
509
509
 
510
+ Rendered reflections and replies also use [Blue Letter Bible ScriptTagger](https://www.blueletterbible.org/webtools/BLB_ScriptTagger.cfm)
511
+ to show Scripture text when a reader hovers over a reference. The popup follows that reader's selected
512
+ translation (or the site default for a visitor); `HEB/GRK` and `ALLGRK` choose the appropriate original
513
+ language separately for Old and New Testament references. The engine layout loads this automatically.
514
+ If `config.layout` points to a host layout, render the integration once near the end of its `<body>`:
515
+
516
+ ```erb
517
+ <%= render "bible270/shared/scripture_popups" %>
518
+ ```
519
+
520
+ ScriptTagger loads JavaScript and CSS from Blue Letter Bible at runtime. A host with Content Security
521
+ Policy enabled must allow `https://www.blueletterbible.org` in its existing `script-src` and `style-src`
522
+ directives. This sends the visitor's IP address and normal request metadata to Blue Letter Bible when an
523
+ engine page loads; hovering a reference also sends that reference and selected translation to BLB.
524
+
510
525
  ## With ComfortableMediaSurfer
511
526
 
512
527
  The CMS serves your content; this is a separate mounted app. Either **just link to it** (mount, add a nav link, done — the engine renders its own themed pages), or **match your chrome** by setting `config.parent_controller = "::ApplicationController"` and `config.layout = "layouts/application"` so it sits inside your header and footer. All engine CSS is prefixed `b270-`, so nothing collides.
@@ -67,6 +67,7 @@ module Bible270
67
67
  @reader_sort = READER_SORT_OPTIONS.key?(params[:sort]) ? params[:sort] : 'first_name'
68
68
  @reader_report = ReaderProgressReport.new(completed_days: @days_completed, sort: @reader_sort)
69
69
  @readers = @reader_report.sorted_readers
70
+ @recent_reader_activities = Checkoff.recent_activity_by_reader(@readers.map(&:id))
70
71
  @run_start_date = Setting.run_start_date
71
72
  @configured_start_date = Bible270.config.start_date
72
73
  @run_start_date_overridden = Setting.run_start_date_overridden?
@@ -181,6 +181,16 @@ module Bible270
181
181
  local&.strftime('%b %-d, %Y at %-I:%M %p')
182
182
  end
183
183
 
184
+ def b270_reader_activity_description(activity)
185
+ if activity.day_completed?
186
+ readings = Plan.readings_for(activity.day).values.compact.join(', ')
187
+ "completed Day #{activity.day} (#{readings})"
188
+ else
189
+ reference = Plan.parts_for(activity.day, activity.track).fetch(activity.part)
190
+ "checked off “#{reference}” for Day #{activity.day}"
191
+ end
192
+ end
193
+
184
194
  # Writers may revise their own words; administrators may also correct or
185
195
  # moderate any reflection without changing its recorded author.
186
196
  def b270_can_edit?(comment)
@@ -5,6 +5,8 @@ module Bible270
5
5
  class Checkoff < ApplicationRecord
6
6
  self.table_name = 'bible270_checkoffs'
7
7
 
8
+ RecentActivity = Struct.new(:occurred_at, :day, :track, :part, :day_completed?, keyword_init: true)
9
+
8
10
  belongs_to :reader, class_name: 'Bible270::Reader'
9
11
 
10
12
  validates :day, inclusion: { in: 1..Plan::DAYS }
@@ -14,6 +16,31 @@ module Bible270
14
16
  validate :track_present_on_day
15
17
  validate :part_within_reading
16
18
 
19
+ # The most recently created check-off still present for each reader. This uses
20
+ # three bulk queries regardless of the number of readers displayed in Admin.
21
+ def self.recent_activity_by_reader(reader_ids)
22
+ latest_ids = where(reader_id: reader_ids).group(:reader_id).maximum(:id).values
23
+ latest_checkoffs = where(id: latest_ids).index_by(&:reader_id)
24
+ day_counts = recent_activity_day_counts(latest_checkoffs.values)
25
+
26
+ latest_checkoffs.transform_values do |checkoff|
27
+ RecentActivity.new(
28
+ occurred_at: checkoff.created_at,
29
+ day: checkoff.day,
30
+ track: checkoff.track,
31
+ part: checkoff.part,
32
+ day_completed?: day_counts.fetch([checkoff.reader_id, checkoff.day], 0) >= Plan.total_parts(checkoff.day)
33
+ )
34
+ end
35
+ end
36
+
37
+ def self.recent_activity_day_counts(checkoffs)
38
+ return {} if checkoffs.empty?
39
+
40
+ where(reader_id: checkoffs.map(&:reader_id), day: checkoffs.map(&:day)).group(:reader_id, :day).count
41
+ end
42
+ private_class_method :recent_activity_day_counts
43
+
17
44
  private
18
45
 
19
46
  def track_present_on_day
@@ -89,6 +89,13 @@
89
89
  · <%= @days_completed[reader.id] %>/<%= Bible270::Plan::DAYS %> days
90
90
  <% if reader.dated? %>· started <%= reader.effective_start_date.strftime('%b %-d, %Y') %><% end %>
91
91
  </div>
92
+ <div class="sub b270-reader-activity">
93
+ <% if (activity = @recent_reader_activities[reader.id]) %>
94
+ <%= b270_date(activity.occurred_at) %>: <%= b270_reader_activity_description(activity) %>
95
+ <% else %>
96
+ No reading activity yet
97
+ <% end %>
98
+ </div>
92
99
  </div>
93
100
  <div class="stat">
94
101
  <span class="big"><%= (@days_completed[reader.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span>
@@ -139,7 +139,9 @@
139
139
  if (!response.ok) throw new Error("Preview unavailable");
140
140
  return response.text();
141
141
  }).then((html) => {
142
+ preview.removeAttribute("data-b270-blb-tagged");
142
143
  preview.innerHTML = html;
144
+ window.Bible270ScripturePopups?.scan(preview);
143
145
  }).catch((error) => {
144
146
  if (error.name !== "AbortError") preview.textContent = "Preview unavailable. Return to Write and try again.";
145
147
  });
@@ -0,0 +1,131 @@
1
+ <% popup_translations = Bible270::Translations.blue_letter_tagger_translations(b270_bible_version) %>
2
+ <script src="https://www.blueletterbible.org/assets/scripts/blbToolTip/BLB_ScriptTagger-min.js"
3
+ type="text/javascript" nonce="<%= content_security_policy_nonce %>" data-turbo-eval="false"></script>
4
+ <script nonce="<%= content_security_policy_nonce %>"
5
+ data-old-testament-translation="<%= popup_translations.fetch(:old_testament) %>"
6
+ data-new-testament-translation="<%= popup_translations.fetch(:new_testament) %>">
7
+ (() => {
8
+ const settings = document.currentScript.dataset;
9
+ const requestedTranslations = {
10
+ oldTestament: settings.oldTestamentTranslation,
11
+ newTestament: settings.newTestamentTranslation
12
+ };
13
+ if (window.Bible270ScripturePopups) {
14
+ window.Bible270ScripturePopups.configure(requestedTranslations);
15
+ window.Bible270ScripturePopups.scan();
16
+ return;
17
+ }
18
+
19
+ const tagger = window.BLB && window.BLB.Tagger;
20
+ if (!tagger) return;
21
+
22
+ const translations = { ...requestedTranslations };
23
+ const selector = ".b270-cbody";
24
+ const pending = new Set();
25
+ let retry;
26
+
27
+ function configure(requested) {
28
+ translations.oldTestament = requested.oldTestament;
29
+ translations.newTestament = requested.newTestament;
30
+ tagger.Translation = translations.oldTestament;
31
+ }
32
+
33
+ configure(requestedTranslations);
34
+ tagger.HyperLinks = "hover";
35
+ tagger.HideTanslationAbbrev = false;
36
+ tagger.HideTranslationAbbrev = false;
37
+ tagger.TargetNewWindow = true;
38
+ tagger.DarkTheme = false;
39
+ tagger.Style = "par";
40
+ tagger.NoSearchTagNames = "h1 h2 h3 h4";
41
+ tagger.NoSearchClassNames = "noTag doNotTag b270-ref b270-reflink b270-blb-existing-link";
42
+
43
+ function ready() {
44
+ return tagger.RegExSet && tagger.NoSearchTags && tagger.NoSearchClasses &&
45
+ window.YAHOO && window.YAHOO.util && window.YAHOO.util.Dom && window.BLB.BookNames;
46
+ }
47
+
48
+ function explicitTranslation(text, translation) {
49
+ const escaped = translation.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ return new RegExp(`(?:^|[\\s,(])${escaped}\\)?$`, "i").test(text);
51
+ }
52
+
53
+ function newTestamentReference(reference) {
54
+ const normalized = tagger.normalizeReference(reference);
55
+ const book = normalized.split(" ", 1)[0].replaceAll("_", " ").toLowerCase();
56
+ const canonical = window.BLB.BookNames[book];
57
+ return Number(window.BLB.BookOrder[canonical]) >= 40;
58
+ }
59
+
60
+ const originalInsertRefNode = tagger.insertRefNode;
61
+ tagger.insertRefNode = function(reference, text, translation, node) {
62
+ let popupTranslation = translation;
63
+ const testamentSpecific = translations.oldTestament !== translations.newTestament;
64
+ if (testamentSpecific && !explicitTranslation(text, translation)) {
65
+ popupTranslation = newTestamentReference(reference) ?
66
+ translations.newTestament : translations.oldTestament;
67
+ }
68
+ return originalInsertRefNode.call(tagger, reference, text, popupTranslation, node);
69
+ };
70
+
71
+ const originalWalkDomTree = tagger.walkDomTree;
72
+ tagger.walkDomTree = function(root, linkedAnchor, depth) {
73
+ if (root === document.body) {
74
+ window.Bible270ScripturePopups.scan(root);
75
+ return 0;
76
+ }
77
+ return originalWalkDomTree.call(tagger, root, linkedAnchor, depth);
78
+ };
79
+
80
+ function collect(root) {
81
+ if (!root) return;
82
+ if (root.nodeType === Node.ELEMENT_NODE && root.matches(selector)) pending.add(root);
83
+ if (root.querySelectorAll) root.querySelectorAll(selector).forEach((body) => pending.add(body));
84
+ }
85
+
86
+ function flush() {
87
+ retry = null;
88
+ if (!ready()) {
89
+ retry = window.setTimeout(flush, 25);
90
+ return;
91
+ }
92
+
93
+ const bodies = Array.from(pending);
94
+ pending.clear();
95
+ bodies.forEach((body) => {
96
+ if (!body.isConnected || body.dataset.b270BlbTagged === "true") return;
97
+
98
+ body.querySelectorAll("a").forEach((link) => link.classList.add("b270-blb-existing-link"));
99
+ body.dataset.b270BlbTagged = "true";
100
+ tagger.walkDomTree(body);
101
+ });
102
+ }
103
+
104
+ function scan(root = document) {
105
+ collect(root);
106
+ if (pending.size && !retry) retry = window.setTimeout(flush, 0);
107
+ }
108
+
109
+ function reset(root = document) {
110
+ root.querySelectorAll(`${selector} a.BLBST_a`).forEach((link) => {
111
+ link.replaceWith(document.createTextNode(link.textContent));
112
+ });
113
+ root.querySelectorAll(selector).forEach((body) => {
114
+ body.removeAttribute("data-b270-blb-tagged");
115
+ });
116
+ document.getElementById("scriptDiv")?.remove();
117
+ tagger.ContainerToHide = null;
118
+ }
119
+
120
+ window.Bible270ScripturePopups = { configure, scan };
121
+ new MutationObserver((mutations) => {
122
+ mutations.forEach((mutation) => mutation.addedNodes.forEach((node) => {
123
+ if (node.nodeType === Node.ELEMENT_NODE) scan(node);
124
+ }));
125
+ }).observe(document.documentElement, { childList: true, subtree: true });
126
+ document.addEventListener("turbo:load", () => scan());
127
+ document.addEventListener("turbo:before-cache", () => reset());
128
+ window.addEventListener("pageshow", () => scan());
129
+ scan();
130
+ })();
131
+ </script>
@@ -23,5 +23,6 @@
23
23
  <%= render 'bible270/shared/day_index' unless content_for?(:hide_day_index) %>
24
24
  <%= b270_footer %>
25
25
  </div>
26
+ <%= render "bible270/shared/scripture_popups" %>
26
27
  </body>
27
28
  </html>
@@ -87,6 +87,21 @@ module Bible270
87
87
  VERSIONS.dig(normalize(code), :gateway) || normalize(code)
88
88
  end
89
89
 
90
+ # ScriptTagger accepts one default translation, but Bible270's two original-
91
+ # language choices vary by testament. Returning both lets the browser select
92
+ # the correct BLB text for each detected reference.
93
+ def blue_letter_tagger_translations(code)
94
+ translation = resolve(code)
95
+ case translation
96
+ when ORIGINAL_LANGUAGES
97
+ { old_testament: 'WLC', new_testament: 'mGNT' }
98
+ when ALL_GREEK
99
+ { old_testament: 'LXX', new_testament: 'mGNT' }
100
+ else
101
+ { old_testament: translation, new_testament: translation }
102
+ end
103
+ end
104
+
90
105
  # [[label, code], ...] for a select field.
91
106
  def options
92
107
  codes.map { |code| ["#{code} — #{short_label(code)}", code] }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.6.2'
4
+ VERSION = '1.7.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bible270
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.2
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -187,6 +187,7 @@ files:
187
187
  - app/views/bible270/shared/_passage_source.html.erb
188
188
  - app/views/bible270/shared/_progress.html.erb
189
189
  - app/views/bible270/shared/_reflections.html.erb
190
+ - app/views/bible270/shared/_scripture_popups.html.erb
190
191
  - app/views/bible270/shared/_styles.html.erb
191
192
  - app/views/bible270/shared/_track_bars.html.erb
192
193
  - app/views/bible270/sign_in_mailer/magic_link.html.erb
@@ -261,7 +262,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
261
262
  - !ruby/object:Gem::Version
262
263
  version: '0'
263
264
  requirements: []
264
- rubygems_version: 4.0.16
265
+ rubygems_version: 4.0.20
265
266
  specification_version: 4
266
267
  summary: 'A mountable Rails engine: a 270-day, verse-balanced Bible reading plan (OT
267
268
  and NT once, Psalms/Proverbs alongside) with per-user check-offs, comments, and