bible270 1.1.7 → 1.3.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/README.md +19 -8
  4. data/app/controllers/bible270/admin_controller.rb +28 -1
  5. data/app/controllers/bible270/application_controller.rb +22 -8
  6. data/app/controllers/bible270/profiles_controller.rb +18 -8
  7. data/app/controllers/bible270/readers_controller.rb +7 -0
  8. data/app/helpers/bible270/plan_helper.rb +7 -5
  9. data/app/mailers/bible270/notice_mailer.rb +12 -0
  10. data/app/models/bible270/comment.rb +13 -4
  11. data/app/models/bible270/reader.rb +74 -5
  12. data/app/views/bible270/admin/show.html.erb +24 -0
  13. data/app/views/bible270/comments/_form.html.erb +12 -1
  14. data/app/views/bible270/days/_reading.html.erb +2 -2
  15. data/app/views/bible270/days/show.html.erb +1 -0
  16. data/app/views/bible270/profiles/edit.html.erb +7 -8
  17. data/app/views/bible270/shared/_comment_notification_choices.html.erb +17 -0
  18. data/app/views/bible270/shared/_interaction_ui.html.erb +28 -0
  19. data/app/views/bible270/shared/_mention_typeahead.html.erb +177 -0
  20. data/app/views/bible270/shared/_passage_source.html.erb +7 -3
  21. data/app/views/bible270/shared/_styles.html.erb +17 -3
  22. data/config/routes.rb +4 -2
  23. data/db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb +7 -0
  24. data/db/migrate/20260101000019_change_passage_source_default_to_bible_com.rb +8 -0
  25. data/lib/bible270/migration_reconciler.rb +14 -0
  26. data/lib/bible270/translations.rb +72 -19
  27. data/lib/bible270/version.rb +1 -1
  28. data/lib/generators/bible270/install/install_generator.rb +1 -1
  29. data/lib/generators/bible270/install/templates/bible270.rb.tt +1 -1
  30. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7014361816b071aaacc54d569f247ff116cc2c5c0f49ea4490e46a2e62375f4
4
- data.tar.gz: d61942b56c9409563d03e8b76e2f3dea0ba6a73d0119e4d0dade38bdcb177ec2
3
+ metadata.gz: 90443d781de608736a3f9fdb35b68b9a85d24d2b2b4f56c3980f54975d239949
4
+ data.tar.gz: cb3dede7983920cf33aaf31b4d6070ec142874063702071512ad60aa387d8899
5
5
  SHA512:
6
- metadata.gz: ee0357bfb87e84ab59b359ba11a274c380834587008263b7229e3d8f3edd04c02a1211045ebabec90545c4384c0404d872913c1caa2461609ec09aa4339bce88
7
- data.tar.gz: 2e413b639bdccfbc194a44a7e30051d5c304ed2205625c3b3c79749da20f463178c538b6b9db43233843d783e2bfb91fce2e43121f55b7ff798a55798564a1a7
6
+ metadata.gz: '02703879de6569e5f254de78bd32e4b9c911ca4daef9ca3bc4942ad1816baec285582f10bb1b4d9080d97dfd7588d9c343234cf0a5f2596f82fdfe758113f5a6'
7
+ data.tar.gz: 8e219a377d05acbe62ccea4bc39cf25a1b902bf98cc42495b82c53dbc9282104c14fb3abc55eed09ea910a84cd0e9b05c5063d8836b2eac3798f504fe914653d
data/CHANGELOG.md CHANGED
@@ -4,11 +4,34 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## Unreleased Changes
6
6
 
7
+ ### Added
8
+
9
+ - let readers choose reflection emails for every new reflection and reply, only replies to their reflections and mentions of them, or none; administrators can manage the same setting, pending migrations are explained instead of silently hiding it, and existing preferences retain their previous behavior
10
+ - suggest up to five unambiguous reader handles only while a signed-in writer types `@`, with keyboard and touch selection and no change to server-authoritative mention resolution
11
+ - add Bible.com as the first-listed and default reader/admin Scripture source for every offered translation, using chapter links and WLC Hebrew or THGNT Greek when `HEB/GRK` is selected; existing reader choices are preserved
12
+ - add `ALLGRK — LXX OT and Greek NT` immediately after `HEB/GRK`, using Blue Letter Bible's mGNT/LXX routing or Bible.com's GRCBRENT Old Testament and THGNT New Testament
13
+
14
+ ### Fixed
15
+
16
+ - refresh stale `Reader` column information after an in-process database migration so newly available reflection email controls do not remain hidden until the web process restarts
17
+
18
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.1.7...main)**
19
+
20
+ ## [1.1.7] - 2026-08-27
21
+
22
+ ### Fixed
23
+
24
+ - silently recover checkoffs from expired or rotated sessions: refresh remembered sessions and CSRF tokens before resumed interactions, handle remaining stale-token races without a flash or Rails 422 page, and retry the intended checkoff once with a fresh token
25
+
26
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.1.6...v1.1.7)**
27
+
28
+ ## [1.1.6] - 2026-08-25
29
+
7
30
  ### Changed
8
31
 
9
32
  - show administrators a quiet, linked completed-day count on Community reader rows while keeping progress hidden—and its aggregate query skipped—for ordinary readers and visitors; the link stays unadorned at rest and uses the site’s gold hover and keyboard-focus treatment
10
33
 
11
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.1.5...main)**
34
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.1.5...v1.1.6)**
12
35
 
13
36
  ## [1.1.5] - 2026-08-25
14
37
 
@@ -552,6 +575,7 @@ This is the initial public release.
552
575
 
553
576
  * 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.
554
577
 
578
+ [1.1.7]: https://github.com/avonderluft/bible270/releases/tag/v1.1.7
555
579
  [1.1.6]: https://github.com/avonderluft/bible270/releases/tag/v1.1.6
556
580
  [1.1.5]: https://github.com/avonderluft/bible270/releases/tag/v1.1.5
557
581
  [1.1.4]: https://github.com/avonderluft/bible270/releases/tag/v1.1.4
data/README.md CHANGED
@@ -282,9 +282,15 @@ The Reflections page lists conversations by their latest approved reply, support
282
282
  filters, and paginates older threads. Signed-in readers see which conversations became active since
283
283
  their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
284
284
  cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
285
+ Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
286
+ work without JavaScript.
285
287
 
286
- After upgrading, copy and run migration `20260101000017`, which stores each reader's last visit to the
287
- Reflections page:
288
+ When `config.mention_notifications` is enabled, each reader can request email for every new reflection
289
+ and reply, only replies to their reflections and mentions of them, or none. Administrators can change
290
+ the same preference from the reader's Admin page. Migration
291
+ `20260101000018` adds the broad opt-in without changing any existing reader's choice. Migration
292
+ `20260101000017` stores each reader's last visit to the Reflections page. After upgrading, install and
293
+ run the current migrations:
288
294
 
289
295
  ```bash
290
296
  bin/rails bible270:install:migrations
@@ -398,7 +404,7 @@ Bible270.configure do |config|
398
404
  config.allow_reader_start_date = true
399
405
 
400
406
  config.daily_reminders = false # schedule reminders:send every 15 minutes
401
- config.mention_notifications = true # replies and @mentions; readers may opt out
407
+ config.mention_notifications = true # reflection/reply emails; readers choose the scope
402
408
  config.mailer_host = "example.com" # links in reminder/notice email
403
409
 
404
410
  config.parent_controller = "ActionController::Base" # or "::ApplicationController"
@@ -456,10 +462,14 @@ config.favicon = false # none; the host's favicon applies
456
462
  It's emitted by the engine's layout, so if you point `config.layout` at your own layout, add
457
463
  `<%= b270_favicon_tag %>` to its `<head>`.
458
464
 
459
- References link to Bible Gateway by default. Readers choose Bible Gateway or Blue Letter Bible from
460
- their profile, and an admin can set the same source for any reader. The `HEB/GRK` option always uses
461
- Blue Letter Bible: WLC for Old Testament, Psalms, and Proverbs readings, and mGNT for New Testament
462
- readings. Scripture links share one dedicated browser tab so moving among the day's passages does not
465
+ References link to Bible.com by default. Readers choose Bible.com, Bible Gateway, or Blue Letter Bible
466
+ from their profile, and an admin can set the same source for any reader. With `HEB/GRK`,
467
+ Bible Gateway is unavailable: Blue Letter Bible uses WLC for Old Testament, Psalms, and Proverbs and
468
+ mGNT for New Testament, while Bible.com uses WLC and THGNT respectively. The `ALLGRK LXX OT and
469
+ Greek NT` option also excludes Bible Gateway: Blue Letter Bible uses its mGNT/LXX route for every
470
+ track, while Bible.com uses GRCBRENT for the Old Testament and THGNT for the New Testament. Bible.com
471
+ links open the whole chapter rather than its individual-verse introduction page. Scripture links share one
472
+ dedicated browser tab so moving among the day's passages does not
463
473
  leave a trail of open tabs; Ctrl-click, Cmd-click, middle-click, and the browser's context menu still
464
474
  open additional tabs when a reader wants to compare passages.
465
475
 
@@ -487,7 +497,8 @@ GET /day/:day days#show readings, who's finished, refle
487
497
  POST /day/:day/toggle/:track checkoffs#toggle
488
498
  POST /day/:day/comments comments#create
489
499
  DELETE /comments/:id comments#destroy
490
- GET /community readers#index leaderboard
500
+ GET /community readers#index reader directory
501
+ GET /mention-suggestions readers#mention_suggestions (signed-in JSON)
491
502
  GET /readers/:id readers#show
492
503
  GET /sign_in sessions#new email form + provider list
493
504
  POST /sign_in/email sessions#email_link
@@ -13,7 +13,8 @@ module Bible270
13
13
 
14
14
  before_action :require_admin!
15
15
  before_action :load_reader,
16
- only: %i[show destroy update_start update_profile remove_avatar complete_through toggle_day]
16
+ only: %i[show destroy update_start update_profile update_notifications remove_avatar complete_through
17
+ toggle_day]
17
18
  before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
18
19
 
19
20
  # ---- enrolment --------------------------------------------------------
@@ -69,6 +70,7 @@ module Bible270
69
70
  end
70
71
 
71
72
  def show
73
+ @comment_notifications_available = comment_notifications_available_for?(@reader)
72
74
  @days_completed = @reader.days_completed
73
75
  @comments = @reader.comments.order(created_at: :desc).limit(50)
74
76
  end
@@ -194,6 +196,24 @@ module Bible270
194
196
  end
195
197
  end
196
198
 
199
+ def update_notifications
200
+ unless Bible270.config.mention_notifications
201
+ return redirect_to admin_reader_path(@reader), alert: 'Reflection emails are disabled for this installation.'
202
+ end
203
+ unless comment_notifications_available_for?(@reader)
204
+ return redirect_to admin_reader_path(@reader),
205
+ alert: 'Run the pending Bible270 database migration before changing reflection emails.'
206
+ end
207
+
208
+ level = params[:comment_notifications].to_s
209
+ unless Reader::COMMENT_NOTIFICATION_LEVELS.include?(level)
210
+ return redirect_to admin_reader_path(@reader), alert: 'Choose a reflection email setting from the list.'
211
+ end
212
+
213
+ @reader.update_comment_notification_level!(level)
214
+ redirect_to admin_reader_path(@reader), notice: 'Reflection email setting updated.'
215
+ end
216
+
197
217
  def remove_avatar
198
218
  @reader.remove_avatar!
199
219
  redirect_to admin_reader_path(@reader), notice: "Removed #{@reader.display_name}'s picture."
@@ -247,6 +267,13 @@ module Bible270
247
267
 
248
268
  private
249
269
 
270
+ def comment_notifications_available_for?(reader)
271
+ return false unless Bible270.config.mention_notifications && Reader.comment_notification_columns?
272
+
273
+ reader.reload unless reader.has_attribute?(:notify_on_all_comments)
274
+ true
275
+ end
276
+
250
277
  def require_admin!
251
278
  return if Bible270.config.admin_configured? && Bible270.config.admin?(current_reader)
252
279
 
@@ -104,14 +104,28 @@ module Bible270
104
104
  end
105
105
 
106
106
  # A suspended mobile tab can outlive its Rails session cookie. The page still
107
- # contains the old session's CSRF token, so Rails rejects its next form before
108
- # current_reader has a chance to restore the session from the remember cookie.
109
- # Never execute that unverified request; return to a fresh page for a safe retry.
110
- def recover_from_stale_page(error)
111
- Rails.logger.info("[bible270] refreshing a page after #{error.class}")
112
- redirect_back fallback_location: root_path, allow_other_host: false,
113
- alert: 'This page had been open for a while, so it was refreshed. Please try again.',
114
- status: :see_other
107
+ # contains the old session's CSRF token, so Rails would normally raise before
108
+ # current_reader could restore the session from the remember cookie. Redirect
109
+ # directly from Rails' forgery callback; rescue_from remains a fallback for an
110
+ # authenticity exception raised anywhere else in the request.
111
+ def handle_unverified_request
112
+ recover_from_stale_page
113
+ end
114
+
115
+ def recover_from_stale_page(error = nil)
116
+ reason = error ? error.class : 'an unverified request'
117
+ Rails.logger.info("[bible270] silently refreshing a page after #{reason}")
118
+
119
+ if stale_turbo_checkoff?
120
+ response.set_header('X-Bible270-Refresh-Session', 'true')
121
+ head :conflict
122
+ else
123
+ redirect_back fallback_location: root_path, allow_other_host: false, status: :see_other
124
+ end
125
+ end
126
+
127
+ def stale_turbo_checkoff?
128
+ controller_name == 'checkoffs' && action_name == 'toggle' && request.format.turbo_stream?
115
129
  end
116
130
 
117
131
  # Guard participation (checking off / commenting). Viewing is always open.
@@ -14,9 +14,9 @@ module Bible270
14
14
 
15
15
  def update
16
16
  @comment_notifications_available = comment_notifications_available?
17
- @notify_on_mention = requested_comment_notifications
17
+ @comment_notification_level = requested_comment_notification_level
18
18
  @daily_reminders_available = daily_reminders_available?
19
- problems = []
19
+ problems = [comment_notification_problem].compact
20
20
  problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
21
21
  params[:last_name])
22
22
  if params[:bible_version].present? && !current_reader.update_bible_version(params[:bible_version])
@@ -35,7 +35,7 @@ module Bible270
35
35
  end
36
36
 
37
37
  if problems.empty?
38
- current_reader.update!(notify_on_mention: @notify_on_mention) if @comment_notifications_available
38
+ current_reader.update_comment_notification_level!(@comment_notification_level) if @comment_notifications_available
39
39
  if @daily_reminders_available
40
40
  current_reader.update!(daily_reminders: requested_daily_reminders,
41
41
  daily_reminder_time: requested_daily_reminder_time)
@@ -59,17 +59,27 @@ module Bible270
59
59
 
60
60
  def load_comment_notification_state
61
61
  @comment_notifications_available = comment_notifications_available?
62
- @notify_on_mention = current_reader.wants_comment_notifications?
62
+ @comment_notification_level = current_reader.comment_notification_level
63
63
  end
64
64
 
65
65
  def comment_notifications_available?
66
- Bible270.config.mention_notifications && current_reader.has_attribute?(:notify_on_mention)
66
+ return false unless Bible270.config.mention_notifications && Reader.comment_notification_columns?
67
+
68
+ current_reader.reload unless current_reader.has_attribute?(:notify_on_all_comments)
69
+ true
70
+ end
71
+
72
+ def requested_comment_notification_level
73
+ return current_reader.comment_notification_level unless params.key?(:comment_notifications)
74
+
75
+ params[:comment_notifications].to_s
67
76
  end
68
77
 
69
- def requested_comment_notifications
70
- return current_reader.wants_comment_notifications? unless params.key?(:notify_on_mention)
78
+ def comment_notification_problem
79
+ return unless @comment_notifications_available
80
+ return if Reader::COMMENT_NOTIFICATION_LEVELS.include?(@comment_notification_level)
71
81
 
72
- params[:notify_on_mention] == '1'
82
+ 'a reflection email preference from the list'
73
83
  end
74
84
 
75
85
  def load_daily_reminder_state
@@ -35,5 +35,12 @@ module Bible270
35
35
  @days_completed = @reader.days_completed
36
36
  @recent_comments = @reader.comments.recent.limit(20)
37
37
  end
38
+
39
+ def mention_suggestions
40
+ return head :unauthorized unless signed_in?
41
+
42
+ response.headers['Cache-Control'] = 'private, no-store'
43
+ render json: { suggestions: Reader.mention_suggestions(params[:q], except: current_reader) }
44
+ end
38
45
  end
39
46
  end
@@ -115,7 +115,9 @@ module Bible270
115
115
  return '#' if reference.blank?
116
116
 
117
117
  selected_version = version || b270_bible_version
118
- if selected_version != Translations::ORIGINAL_LANGUAGES && current_reader&.blue_letter_bible?
118
+ source = current_reader&.passage_source.presence || Reader::DEFAULT_PASSAGE_SOURCE
119
+ return Translations.passage_url(reference, selected_version, bible_com: true) if source == 'bible_com'
120
+ if source == 'blue_letter_bible' || Translations.original_languages?(selected_version)
119
121
  return Translations.passage_url(reference, selected_version, blue_letter: true)
120
122
  end
121
123
 
@@ -125,8 +127,8 @@ module Bible270
125
127
  # The named target and noopener are the secure no-JavaScript fallback.
126
128
  # Interaction UI retains one opener-backed tab handle on desktop. iOS Safari
127
129
  # cannot foreground a reused background tab, so it closes and replaces that
128
- # script-opened tab on each click. The two fixed providers are intentionally
129
- # trusted for this enhanced path.
130
+ # script-opened tab on each click. The three fixed providers are trusted for
131
+ # this enhanced path.
130
132
  def b270_passage_link(reference, **options)
131
133
  data = options.fetch(:data, {}).merge(b270_passage_link: true)
132
134
  options.merge!(data: data, target: PASSAGE_LINK_TARGET, rel: 'noopener')
@@ -138,8 +140,8 @@ module Bible270
138
140
  # used for each track rather than repeating the combined preference code.
139
141
  def b270_version_tag(version = nil, track: nil)
140
142
  label = version || b270_bible_version
141
- if label == Translations::ORIGINAL_LANGUAGES && track
142
- label = track.to_s == 'nt' ? 'Greek' : 'Hebrew'
143
+ if Translations.original_languages?(label) && track
144
+ label = label == Translations::ALL_GREEK || track.to_s == 'nt' ? 'Greek' : 'Hebrew'
143
145
  end
144
146
 
145
147
  content_tag :span, "(#{label})", class: 'b270-version'
@@ -36,6 +36,18 @@ module Bible270
36
36
  deliver_comment_notice("#{@app_name}: #{@author.display_name} replied on day #{@comment.day}")
37
37
  end
38
38
 
39
+ def comment_posted(comment_id:, reader_id:)
40
+ return message.perform_deliveries = false unless prepare_comment_notice(comment_id, reader_id)
41
+ return message.perform_deliveries = false unless @reader.wants_all_comment_notifications?
42
+ return message.perform_deliveries = false if @comment.reader_id == @reader.id
43
+
44
+ kind = @comment.reply? ? 'reply' : 'reflection'
45
+ @heading = "#{@author.display_name} posted a #{kind}"
46
+ @introduction = "#{@author.display_name} posted a #{kind} on day #{@comment.day}."
47
+ @reason = 'You are getting this because you asked to be emailed about every new reflection and reply.'
48
+ deliver_comment_notice("#{@app_name}: #{@author.display_name} posted a #{kind} on day #{@comment.day}")
49
+ end
50
+
39
51
  def daily_reminder(reader_id:, day:, on: Bible270.today)
40
52
  @reader = Reader.find_by(id: reader_id)
41
53
  return message.perform_deliveries = false unless Bible270.config.daily_reminders?
@@ -155,11 +155,13 @@ module Bible270
155
155
  end
156
156
 
157
157
  def notify_comment_readers
158
- return unless Bible270.config.mention_notifications
158
+ return unless Bible270.config.mention_notifications && approved?
159
159
 
160
160
  replied_to = reply_notification_reader
161
161
  deliver_comment_notice(replied_to, :replied) if replied_to
162
- notify_mentioned_readers(except: [replied_to&.id].compact)
162
+ directly_notified = [reader_id, replied_to&.id].compact
163
+ directly_notified.concat(notify_mentioned_readers(except: directly_notified))
164
+ notify_all_comment_readers(except: directly_notified)
163
165
  end
164
166
 
165
167
  def notify_newly_mentioned_readers
@@ -171,13 +173,20 @@ module Bible270
171
173
  end
172
174
 
173
175
  def notify_mentioned_readers(except: [])
174
- return unless Bible270.config.mention_notifications
176
+ return [] unless Bible270.config.mention_notifications
175
177
 
176
- Reader.mentioned_in(body).each do |mentioned|
178
+ Reader.mentioned_in(body).each_with_object([]) do |mentioned, notified|
177
179
  next if mentioned.id == reader_id
178
180
  next if except.include?(mentioned.id)
179
181
 
180
182
  deliver_comment_notice(mentioned, :mentioned)
183
+ notified << mentioned.id
184
+ end
185
+ end
186
+
187
+ def notify_all_comment_readers(except: [])
188
+ Reader.all_comment_notification_recipients.where.not(id: except).find_each do |recipient|
189
+ deliver_comment_notice(recipient, :comment_posted)
181
190
  end
182
191
  end
183
192
 
@@ -25,12 +25,15 @@ module Bible270
25
25
  # transaction rolls back.
26
26
  after_create_commit :notify_of_registration
27
27
 
28
- PASSAGE_SOURCES = %w[bible_gateway blue_letter_bible].freeze
29
- DEFAULT_PASSAGE_SOURCE = 'bible_gateway'
28
+ PASSAGE_SOURCES = %w[bible_com bible_gateway blue_letter_bible].freeze
29
+ DEFAULT_PASSAGE_SOURCE = 'bible_com'
30
30
  DAILY_REMINDER_TIME_FORMAT = %r{\A(?:[01]\d|2[0-3]):[0-5]\d\z}
31
31
  DAILY_REMINDER_MINUTES = %w[00 15 30 45].freeze
32
32
  DAILY_REMINDER_COLUMNS = %w[daily_reminders daily_reminder_time last_daily_reminder_sent_on].freeze
33
33
  REFLECTIONS_SEEN_COLUMN = 'reflections_seen_at'
34
+ COMMENT_NOTIFICATION_LEVELS = %w[all personal none].freeze
35
+ COMMENT_NOTIFICATION_COLUMNS = %w[notify_on_mention notify_on_all_comments].freeze
36
+ MENTION_SUGGESTION_LIMIT = 5
34
37
 
35
38
  validates :display_name, presence: true
36
39
  validates :uid, uniqueness: { scope: :provider }, allow_nil: true
@@ -233,6 +236,7 @@ module Bible270
233
236
 
234
237
  def bible_gateway? = passage_source == 'bible_gateway'
235
238
  def blue_letter_bible? = passage_source == 'blue_letter_bible'
239
+ def bible_com? = passage_source == 'bible_com'
236
240
 
237
241
  # Readers are listed by first name, matching how they are shown — the display
238
242
  # name is "First Last", so sorting on it needs only case folding. Surname
@@ -244,11 +248,76 @@ module Bible270
244
248
 
245
249
  # ---- mentions ----------------------------------------------------------
246
250
 
247
- # Older copied databases may briefly lack the preference column while their
251
+ # Older copied databases may briefly lack preference columns while their
248
252
  # migrations are being reconciled. Preserve the historical opted-in behavior
249
- # until the column is available rather than breaking reflection delivery.
253
+ # until the original column is available rather than breaking reflection delivery.
250
254
  def wants_comment_notifications?
251
- !has_attribute?(:notify_on_mention) || self[:notify_on_mention] != false
255
+ wants_all_comment_notifications? || !has_attribute?(:notify_on_mention) || self[:notify_on_mention] != false
256
+ end
257
+
258
+ def wants_all_comment_notifications?
259
+ has_attribute?(:notify_on_all_comments) && self[:notify_on_all_comments] == true
260
+ end
261
+
262
+ def comment_notification_level
263
+ return 'all' if wants_all_comment_notifications?
264
+
265
+ wants_comment_notifications? ? 'personal' : 'none'
266
+ end
267
+
268
+ def update_comment_notification_level!(level)
269
+ raise ArgumentError, 'unknown reflection email preference' unless COMMENT_NOTIFICATION_LEVELS.include?(level)
270
+
271
+ update!(notify_on_mention: level != 'none', notify_on_all_comments: level == 'all')
272
+ end
273
+
274
+ def self.comment_notification_columns?
275
+ return true if (COMMENT_NOTIFICATION_COLUMNS - column_names).empty?
276
+
277
+ # A migration commonly runs in a one-off process while the web process keeps
278
+ # its old schema cache. Refresh once before reporting that the migration is
279
+ # still pending, so the controls appear without requiring an application restart.
280
+ reset_column_information
281
+ (COMMENT_NOTIFICATION_COLUMNS - column_names).empty?
282
+ rescue ActiveRecord::StatementInvalid
283
+ false
284
+ end
285
+
286
+ def self.all_comment_notification_recipients
287
+ return none unless comment_notification_columns?
288
+
289
+ where(notify_on_all_comments: true).where.not(email: [nil, ''])
290
+ end
291
+
292
+ # Small, deterministic suggestions for the reflection composer. Full-handle
293
+ # collisions are omitted because the server must never guess which reader a
294
+ # mention intended.
295
+ def self.mention_suggestions(query, except: nil)
296
+ needle = Mentions.normalize(query.to_s.first(61))
297
+ candidates = where.not(first_name: [nil, '']).where.not(last_name: [nil, ''])
298
+ candidates = candidates.where.not(id: except.id) if except
299
+ unique = candidates.to_a.group_by { |reader| canonical_mention_handle(reader) }
300
+ .select { |handle, readers| handle.present? && readers.one? }
301
+
302
+ ranked = unique.filter_map do |handle, readers|
303
+ reader = readers.first
304
+ surname = Mentions.normalize(reader.last_name)
305
+ rank = if needle.empty? || handle.start_with?(needle)
306
+ 0
307
+ elsif surname.start_with?(needle)
308
+ 1
309
+ end
310
+ next unless rank
311
+
312
+ [{ name: reader.display_name, handle: "@#{handle}" }, rank, handle, reader.id]
313
+ end
314
+ ranked.sort_by { |_, rank, handle, id| [rank, handle, id] }
315
+ .first(MENTION_SUGGESTION_LIMIT)
316
+ .map(&:first)
317
+ end
318
+
319
+ def self.canonical_mention_handle(reader)
320
+ Mentions.handles_for(reader.first_name, reader.last_name).last
252
321
  end
253
322
 
254
323
  # The readers a piece of text mentions. A handle matching more than one reader
@@ -46,6 +46,30 @@
46
46
  <% end %>
47
47
  </div>
48
48
 
49
+ <div class="b270-panel">
50
+ <h2 class="b270-subhead">Email notifications</h2>
51
+ <% unless Bible270.config.mention_notifications %>
52
+ <p class="b270-hint">Reflection emails are disabled for this installation.</p>
53
+ <% else %>
54
+ <% unless @comment_notifications_available %>
55
+ <p class="b270-flash alert" style="margin:0">
56
+ Run the pending Bible270 database migration before changing reflection emails.
57
+ </p>
58
+ <% else %>
59
+ <% if @reader.email.blank? %>
60
+ <p class="b270-hint">This reader does not currently have an email address.</p>
61
+ <% end %>
62
+ <%= form_with url: admin_reader_notifications_path(@reader), method: :patch,
63
+ class: "b270-emailform" do %>
64
+ <%= render "bible270/shared/comment_notification_choices",
65
+ level: @reader.comment_notification_level,
66
+ personal_label: "Only replies to their reflections and mentions of them" %>
67
+ <%= submit_tag 'Save', class: 'b270-btn' %>
68
+ <% end %>
69
+ <% end %>
70
+ <% end %>
71
+ </div>
72
+
49
73
  <div class="b270-panel">
50
74
  <h2 class="b270-subhead">Where they are in the plan</h2>
51
75
  <%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
@@ -18,7 +18,18 @@
18
18
  </p>
19
19
  <% end %>
20
20
  <%= f.hidden_field :parent_id %>
21
- <%= f.text_area :body, placeholder: comment.parent_id.present? ? "Write your reply…" : "Share a reflection on today's reading…" %>
21
+ <div class="b270-mention-composer" data-b270-mention-composer
22
+ data-suggestions-url="<%= mention_suggestions_path %>">
23
+ <%= f.label :body, (posting_reply ? "Reply" : "Reflection"), class: "b270-sr-only" %>
24
+ <%= f.text_area :body,
25
+ placeholder: posting_reply ? "Write your reply…" : "Share a reflection on today's reading…",
26
+ role: "combobox", autocomplete: "off",
27
+ aria: { autocomplete: "list", expanded: "false", haspopup: "listbox",
28
+ controls: "b270-mention-options" } %>
29
+ <div class="b270-mention-options" id="b270-mention-options" role="listbox"
30
+ data-b270-mention-options hidden></div>
31
+ <p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
32
+ </div>
22
33
  <p class="b270-draft-status" data-b270-draft-status aria-live="polite"></p>
23
34
  <div class="row">
24
35
  <%= f.select :track,
@@ -22,7 +22,7 @@
22
22
  <% if signed_in? %>
23
23
  <%= button_to toggle_checkoff_part_path(day, track, index), class: "b270-toggle",
24
24
  params: { checked: part_done ? "0" : "1" },
25
- form: { data: { turbo_stream: true, b270_submit: true,
25
+ form: { data: { turbo_stream: true, b270_submit: true, b270_checkoff: true,
26
26
  b270_pending: "Saving reading…",
27
27
  b270_success: (part_done ? "Reading marked unread." : "Reading marked read."),
28
28
  b270_failure: "Couldn’t update that reading. Try again." } },
@@ -46,7 +46,7 @@
46
46
  <% if signed_in? %>
47
47
  <%= button_to toggle_checkoff_part_path(day, track, 0), class: "b270-toggle",
48
48
  params: { checked: done ? "0" : "1" },
49
- form: { data: { turbo_stream: true, b270_submit: true,
49
+ form: { data: { turbo_stream: true, b270_submit: true, b270_checkoff: true,
50
50
  b270_pending: "Saving reading…",
51
51
  b270_success: (done ? "Reading marked unread." : "Reading marked read."),
52
52
  b270_failure: "Couldn’t update that reading. Try again." } },
@@ -27,6 +27,7 @@
27
27
  <%= render "bible270/comments/form", day: @day, comment: @new_comment %>
28
28
  </div>
29
29
  <%= render "bible270/shared/comment_drafts" %>
30
+ <%= render "bible270/shared/mention_typeahead" %>
30
31
  <% end %>
31
32
  </div>
32
33
  </div>
@@ -38,14 +38,13 @@
38
38
  <%= render "bible270/shared/passage_source", reader: current_reader %>
39
39
 
40
40
  <% if @comment_notifications_available %>
41
- <div class="b270-field">
42
- <span>Email notifications</span>
43
- <label class="b270-reminder-optin">
44
- <%= hidden_field_tag :notify_on_mention, "0", id: nil %>
45
- <%= check_box_tag :notify_on_mention, "1", @notify_on_mention %>
46
- <span>Email me about replies and mentions</span>
47
- </label>
48
- </div>
41
+ <%= render "bible270/shared/comment_notification_choices",
42
+ level: @comment_notification_level,
43
+ personal_label: "Only replies to my reflections and mentions of me" %>
44
+ <% elsif Bible270.config.mention_notifications %>
45
+ <p class="b270-flash alert" style="margin:0">
46
+ Reflection email settings are temporarily unavailable while this installation finishes a database update.
47
+ </p>
49
48
  <% end %>
50
49
 
51
50
  <% if @daily_reminders_available %>
@@ -0,0 +1,17 @@
1
+ <fieldset class="b270-field b270-notification-field">
2
+ <legend>Reflection emails</legend>
3
+ <div class="b270-notification-choices">
4
+ <label class="b270-notification-choice">
5
+ <%= radio_button_tag :comment_notifications, "all", level == "all" %>
6
+ <span>Every new reflection and reply</span>
7
+ </label>
8
+ <label class="b270-notification-choice">
9
+ <%= radio_button_tag :comment_notifications, "personal", level == "personal" %>
10
+ <span><%= personal_label %></span>
11
+ </label>
12
+ <label class="b270-notification-choice">
13
+ <%= radio_button_tag :comment_notifications, "none", level == "none" %>
14
+ <span>None</span>
15
+ </label>
16
+ </div>
17
+ </fieldset>
@@ -132,11 +132,39 @@
132
132
  announce(form.dataset.b270Pending || "Saving…", "pending");
133
133
  });
134
134
 
135
+ function staleSessionResponse(fetchResponse) {
136
+ if (!fetchResponse) return false;
137
+ if (fetchResponse.statusCode === 422) return true;
138
+
139
+ return fetchResponse.statusCode === 409 &&
140
+ fetchResponse.response.headers.get("X-Bible270-Refresh-Session") === "true";
141
+ }
142
+
143
+ document.addEventListener("turbo:before-fetch-response", (event) => {
144
+ const target = event.target;
145
+ const form = target.closest && target.closest("form[data-b270-checkoff='true']");
146
+ const response = event.detail.fetchResponse;
147
+ if (!form || !staleSessionResponse(response)) return;
148
+
149
+ event.preventDefault();
150
+ if (form.dataset.b270RetriedAfterStaleSession === "true") {
151
+ window.location.reload();
152
+ return;
153
+ }
154
+
155
+ form.dataset.b270RetriedAfterStaleSession = "true";
156
+ resetForm(form);
157
+ refreshSession().then(() => form.requestSubmit()).catch(() => window.location.reload());
158
+ });
159
+
135
160
  document.addEventListener("turbo:submit-end", (event) => {
136
161
  const form = event.target;
137
162
  if (!form.matches("form[data-b270-submit='true']")) return;
138
163
 
139
164
  resetForm(form);
165
+ if (form.dataset.b270RetriedAfterStaleSession === "true" &&
166
+ staleSessionResponse(event.detail.fetchResponse)) return;
167
+
140
168
  if (event.detail.success) {
141
169
  announce(form.dataset.b270Success || "Saved.", "success");
142
170
  } else {
@@ -0,0 +1,177 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270MentionTypeahead) {
4
+ window.Bible270MentionTypeahead.reset();
5
+ return;
6
+ }
7
+
8
+ const cache = new Map();
9
+
10
+ function activeToken(textarea) {
11
+ if (textarea.selectionStart !== textarea.selectionEnd) return null;
12
+
13
+ const cursor = textarea.selectionStart;
14
+ const before = textarea.value.slice(0, cursor);
15
+ const match = before.match(/(?:^|[^\p{L}\p{N}_@])@([\p{L}\p{N}.'-]{0,61})$/u);
16
+ if (!match) return null;
17
+
18
+ return { start: cursor - match[1].length - 1, end: cursor, query: match[1] };
19
+ }
20
+
21
+ function close(composer, state) {
22
+ const textarea = composer.querySelector("textarea");
23
+ const options = composer.querySelector("[data-b270-mention-options]");
24
+ const status = composer.querySelector("[data-b270-mention-status]");
25
+ options.hidden = true;
26
+ options.replaceChildren();
27
+ textarea.setAttribute("aria-expanded", "false");
28
+ textarea.removeAttribute("aria-activedescendant");
29
+ status.textContent = "";
30
+ state.items = [];
31
+ state.active = -1;
32
+ state.token = null;
33
+ }
34
+
35
+ function selectActive(composer, state, index) {
36
+ if (!state.items.length) return;
37
+
38
+ state.active = (index + state.items.length) % state.items.length;
39
+ const textarea = composer.querySelector("textarea");
40
+ composer.querySelectorAll("[role='option']").forEach((option, optionIndex) => {
41
+ const selected = optionIndex === state.active;
42
+ option.setAttribute("aria-selected", selected ? "true" : "false");
43
+ if (selected) textarea.setAttribute("aria-activedescendant", option.id);
44
+ });
45
+ }
46
+
47
+ function insert(composer, state, item) {
48
+ const textarea = composer.querySelector("textarea");
49
+ const token = activeToken(textarea) || state.token;
50
+ if (!token) return close(composer, state);
51
+
52
+ const after = textarea.value.slice(token.end);
53
+ const separator = after === "" || /^[\p{L}\p{N}@]/u.test(after) ? " " : "";
54
+ textarea.value = `${textarea.value.slice(0, token.start)}${item.handle}${separator}${after}`;
55
+ const cursor = token.start + item.handle.length + separator.length;
56
+ textarea.setSelectionRange(cursor, cursor);
57
+ close(composer, state);
58
+ textarea.focus();
59
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
60
+ }
61
+
62
+ function render(composer, state, token, items) {
63
+ const textarea = composer.querySelector("textarea");
64
+ const options = composer.querySelector("[data-b270-mention-options]");
65
+ const status = composer.querySelector("[data-b270-mention-status]");
66
+ const current = activeToken(textarea);
67
+ if (!current || current.start !== token.start || current.query !== token.query) return;
68
+
69
+ const safeItems = items.filter((item) => item && typeof item.name === "string" &&
70
+ typeof item.handle === "string").slice(0, 5);
71
+ if (!safeItems.length) return close(composer, state);
72
+
73
+ options.replaceChildren();
74
+ state.items = safeItems;
75
+ state.token = token;
76
+ safeItems.forEach((item, index) => {
77
+ const option = document.createElement("button");
78
+ option.type = "button";
79
+ option.id = `b270-mention-option-${index}`;
80
+ option.className = "b270-mention-option";
81
+ option.setAttribute("role", "option");
82
+ option.setAttribute("aria-selected", "false");
83
+ option.textContent = `${item.name} ${item.handle}`;
84
+ option.addEventListener("pointerdown", (event) => event.preventDefault());
85
+ option.addEventListener("click", () => insert(composer, state, item));
86
+ options.append(option);
87
+ });
88
+ options.hidden = false;
89
+ textarea.setAttribute("aria-expanded", "true");
90
+ status.textContent = `${safeItems.length} mention suggestion${safeItems.length === 1 ? "" : "s"} available.`;
91
+ selectActive(composer, state, 0);
92
+ }
93
+
94
+ function request(composer, state, token) {
95
+ const key = token.query.toLocaleLowerCase();
96
+ if (cache.has(key)) return render(composer, state, token, cache.get(key));
97
+
98
+ if (state.abort) state.abort.abort();
99
+ state.abort = new AbortController();
100
+ const url = new URL(composer.dataset.suggestionsUrl, window.location.href);
101
+ url.searchParams.set("q", token.query);
102
+ fetch(url, {
103
+ credentials: "same-origin",
104
+ cache: "no-store",
105
+ headers: { Accept: "application/json" },
106
+ signal: state.abort.signal
107
+ }).then((response) => {
108
+ if (!response.ok) throw new Error("Mention suggestions unavailable");
109
+ return response.json();
110
+ }).then((body) => {
111
+ const items = Array.isArray(body.suggestions) ? body.suggestions : [];
112
+ cache.set(key, items);
113
+ render(composer, state, token, items);
114
+ }).catch((error) => {
115
+ if (error.name !== "AbortError") close(composer, state);
116
+ });
117
+ }
118
+
119
+ function update(composer, state) {
120
+ const textarea = composer.querySelector("textarea");
121
+ const token = activeToken(textarea);
122
+ window.clearTimeout(state.timer);
123
+ if (!token) return close(composer, state);
124
+
125
+ state.timer = window.setTimeout(() => request(composer, state, token), 120);
126
+ }
127
+
128
+ function initialize(composer) {
129
+ if (composer.dataset.b270MentionReady === "true") return;
130
+ composer.dataset.b270MentionReady = "true";
131
+ const textarea = composer.querySelector("textarea");
132
+ const state = { items: [], active: -1, token: null, timer: null, abort: null };
133
+
134
+ textarea.addEventListener("input", () => update(composer, state));
135
+ textarea.addEventListener("click", () => update(composer, state));
136
+ textarea.addEventListener("keydown", (event) => {
137
+ if (!state.items.length) return;
138
+
139
+ if (event.key === "ArrowDown") {
140
+ event.preventDefault();
141
+ selectActive(composer, state, state.active + 1);
142
+ } else if (event.key === "ArrowUp") {
143
+ event.preventDefault();
144
+ selectActive(composer, state, state.active - 1);
145
+ } else if (event.key === "Enter" && !event.shiftKey && state.active >= 0) {
146
+ event.preventDefault();
147
+ insert(composer, state, state.items[state.active]);
148
+ } else if (event.key === "Escape") {
149
+ event.preventDefault();
150
+ close(composer, state);
151
+ } else if (event.key === "Tab") {
152
+ close(composer, state);
153
+ }
154
+ });
155
+ textarea.addEventListener("blur", () => {
156
+ window.setTimeout(() => {
157
+ if (!composer.contains(document.activeElement)) close(composer, state);
158
+ }, 0);
159
+ });
160
+ }
161
+
162
+ function scan() {
163
+ document.querySelectorAll("[data-b270-mention-composer]").forEach(initialize);
164
+ }
165
+
166
+ function reset() {
167
+ cache.clear();
168
+ scan();
169
+ }
170
+
171
+ scan();
172
+ new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
173
+ window.addEventListener("pageshow", scan);
174
+ document.addEventListener("turbo:load", reset);
175
+ window.Bible270MentionTypeahead = { scan, reset };
176
+ })();
177
+ </script>
@@ -1,13 +1,17 @@
1
- <% original_languages = reader.effective_bible_version == Bible270::Translations::ORIGINAL_LANGUAGES %>
1
+ <% original_languages = Bible270::Translations.original_languages?(reader.effective_bible_version) %>
2
2
  <div class="b270-source-choices" role="radiogroup" aria-label="Reading link source">
3
+ <label class="b270-source-choice">
4
+ <%= radio_button_tag :passage_source, "bible_com", reader.bible_com? %>
5
+ <span>Bible.com</span>
6
+ </label>
3
7
  <label class="b270-source-choice <%= 'disabled' if original_languages %>">
4
8
  <%= radio_button_tag :passage_source, "bible_gateway",
5
9
  !original_languages && reader.bible_gateway?, disabled: original_languages %>
6
10
  <span>Bible Gateway</span>
7
11
  </label>
8
- <label class="b270-source-choice <%= 'disabled' if original_languages %>">
12
+ <label class="b270-source-choice">
9
13
  <%= radio_button_tag :passage_source, "blue_letter_bible",
10
- original_languages || reader.blue_letter_bible?, disabled: original_languages %>
14
+ reader.blue_letter_bible? || (original_languages && !reader.bible_com?) %>
11
15
  <span>Blue Letter Bible</span>
12
16
  </label>
13
17
  </div>
@@ -40,6 +40,11 @@
40
40
  .b270-field{display:grid;gap:5px;font-size:12.5px;color:var(--muted)}
41
41
  .b270-field input{font:inherit;font-size:15px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);width:100%}
42
42
  .b270-field input:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px rgba(165,129,44,.13)}
43
+ .b270-notification-field{margin:0;padding:0;border:0}
44
+ .b270-notification-field legend{padding:0;margin:0 0 3px}
45
+ .b270-notification-choices{display:grid;gap:7px}
46
+ .b270-notification-choice{display:flex;align-items:flex-start;gap:7px;line-height:1.35;cursor:pointer}
47
+ .b270-field .b270-notification-choice input[type=radio]{width:auto;margin:1px 0 0;padding:0;flex:none;accent-color:var(--gold)}
43
48
  .b270-reminder-row{display:flex;align-items:center;gap:12px;width:100%;flex-wrap:wrap}
44
49
  .b270-reminder-optin,.b270-reminder-time{display:inline-flex;align-items:center;gap:7px;color:var(--muted);white-space:nowrap}
45
50
  .b270-reminder-optin{position:relative;min-width:0}
@@ -181,9 +186,9 @@
181
186
  .b270-parts-meta{margin-top:10px;font-size:12.5px;color:var(--faint)}
182
187
  .b270-version{font-size:11.5px;color:var(--faint);font-weight:400;letter-spacing:.02em;white-space:nowrap}
183
188
  .b270-select{font:inherit;font-size:14px;padding:10px 30px 10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);width:100%;max-width:100%;text-overflow:ellipsis}
184
- .b270-source-choices{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin:8px 0 2px;color:var(--muted);font-size:12.5px}
185
- .b270-source-choice{display:inline-flex;align-items:center;gap:7px;cursor:pointer;white-space:nowrap}
186
- .b270-source-choice input{appearance:none;width:17px;height:17px;margin:0;border:1px solid var(--line);border-radius:3px;background:#fff;display:grid;place-content:center;cursor:pointer}
189
+ .b270-source-choices{display:flex;align-items:center;justify-content:space-between;gap:4px;flex-wrap:nowrap;width:100%;margin:8px 0 2px;color:var(--muted);font-size:clamp(10.5px,3.4vw,12.5px)}
190
+ .b270-source-choice{display:inline-flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}
191
+ .b270-source-choice input{appearance:none;width:14px;height:14px;margin:0;border:1px solid var(--line);border-radius:3px;background:#fff;display:grid;place-content:center;cursor:pointer;flex:none}
187
192
  .b270-source-choice input::before{content:"";width:9px;height:9px;transform:scale(0);transition:transform .1s;clip-path:polygon(14% 44%,0 59%,38% 100%,100% 18%,82% 0,37% 65%);background:var(--paper)}
188
193
  .b270-source-choice input:checked{background:var(--gold);border-color:var(--gold)}
189
194
  .b270-source-choice input:checked::before{transform:scale(1)}
@@ -247,6 +252,15 @@
247
252
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
248
253
  .b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
249
254
  .b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
255
+ .b270-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
256
+ .b270-mention-composer{position:relative}
257
+ .b270-mention-options{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 4px);overflow:hidden;
258
+ border:1px solid var(--line);border-radius:10px;background:var(--card);box-shadow:var(--shadow)}
259
+ .b270-mention-options[hidden]{display:none}
260
+ .b270 .b270-mention-option{display:block;width:100%;min-height:44px;padding:10px 12px;border:0;border-radius:0;
261
+ background:var(--card);color:var(--ink);font:inherit;font-size:13px;text-align:left;cursor:pointer}
262
+ .b270-mention-option+ .b270-mention-option{border-top:1px solid var(--line-soft)}
263
+ .b270-mention-option:hover,.b270-mention-option[aria-selected=true]{background:var(--paper);color:var(--gold)}
250
264
  .b270-draft-status{min-height:16px;margin:4px 0 -4px;font-size:11.5px;color:var(--faint)}
251
265
  .b270-form .row{display:flex;align-items:center;gap:10px;margin-top:10px}
252
266
  .b270-form .row select{max-width:100%}
data/config/routes.rb CHANGED
@@ -20,8 +20,9 @@ Bible270::Engine.routes.draw do
20
20
  get 'admin', to: 'admin#index', as: :admin
21
21
  get 'admin/readers/:id', to: 'admin#show', as: :admin_reader
22
22
  delete 'admin/readers/:id', to: 'admin#destroy'
23
- patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
24
- delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
23
+ patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
24
+ patch 'admin/readers/:id/notifications', to: 'admin#update_notifications', as: :admin_reader_notifications
25
+ delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
25
26
  patch 'admin/readers/:id/version', to: 'admin#update_bible_version', as: :admin_reader_version
26
27
  patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
27
28
  patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
@@ -45,6 +46,7 @@ Bible270::Engine.routes.draw do
45
46
  get 'progress', to: 'readers#progress', as: :progress
46
47
 
47
48
  get 'community', to: 'readers#index', as: :community
49
+ get 'mention-suggestions', to: 'readers#mention_suggestions', as: :mention_suggestions, defaults: { format: :json }
48
50
  get 'readers/:id', to: 'readers#show', as: :reader
49
51
 
50
52
  # Built-in OmniAuth sign-in. The request phase (POST <mount>/auth/:provider)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddAllCommentNoticesToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :notify_on_all_comments, :boolean, null: false, default: false
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ChangePassageSourceDefaultToBibleCom < ActiveRecord::Migration[7.0]
4
+ def change
5
+ change_column_default :bible270_readers, :passage_source,
6
+ from: 'bible_gateway', to: 'bible_com'
7
+ end
8
+ end
@@ -27,6 +27,8 @@ module Bible270
27
27
  'AddPassageSourceToBible270Readers' => :check_passage_source,
28
28
  'AddDailyRemindersToBible270Readers' => :check_daily_reminders,
29
29
  'AddReflectionsSeenAtToBible270Readers' => :check_reflections_seen_at,
30
+ 'AddAllCommentNoticesToBible270Readers' => :check_all_comment_notices,
31
+ 'ChangePassageSourceDefaultToBibleCom' => :check_passage_source_default,
30
32
  'CreateActiveStorageTables' => :check_active_storage
31
33
  }.freeze
32
34
 
@@ -190,6 +192,18 @@ module Bible270
190
192
  table_missing(:bible270_readers, columns: %i[reflections_seen_at])
191
193
  end
192
194
 
195
+ def check_all_comment_notices
196
+ table_missing(:bible270_readers, columns: %i[notify_on_all_comments])
197
+ end
198
+
199
+ def check_passage_source_default
200
+ missing = table_missing(:bible270_readers, columns: %i[passage_source])
201
+ return missing if missing.any?
202
+
203
+ column = connection.columns(:bible270_readers).find { |candidate| candidate.name == 'passage_source' }
204
+ column&.default == 'bible_com' ? [] : ['column bible270_readers.passage_source default bible_com']
205
+ end
206
+
193
207
  # The Bible270 installer can install Active Storage for reader avatars. A
194
208
  # copied database can therefore have the same timestamp mismatch for Rails'
195
209
  # generated Active Storage migration as it has for the engine migrations.
@@ -20,17 +20,36 @@ module Bible270
20
20
  'ESV' => { label: 'English Standard Version', short: 'English Standard Version', gateway: 'ESV', blue_letter: 'esv' },
21
21
  'KJV' => { label: 'King James Version', short: 'King James Version', gateway: 'KJV', blue_letter: 'kjv' },
22
22
  'YLT' => { label: 'Young\'s Literal Translation', short: 'Young\'s Literal Translation', gateway: 'YLT', blue_letter: 'ylt' },
23
- 'HEB/GRK' => { label: 'Hebrew and Greek', short: 'Hebrew and Greek', gateway: 'WLC', blue_letter: 'wlc/mgnt' }
23
+ 'HEB/GRK' => { label: 'Hebrew and Greek', short: 'Hebrew and Greek', gateway: 'WLC', blue_letter: 'wlc/mgnt' },
24
+ 'ALLGRK' => { label: 'LXX Old Testament and Greek New Testament', short: 'LXX OT and Greek NT', gateway: 'LXX', blue_letter: 'mgnt' }
24
25
  }.freeze
25
26
 
26
27
  DEFAULT = 'NKJV'
27
28
  ORIGINAL_LANGUAGES = 'HEB/GRK'
29
+ ALL_GREEK = 'ALLGRK'
30
+ ORIGINAL_LANGUAGE_CODES = [ORIGINAL_LANGUAGES, ALL_GREEK].freeze
28
31
  BLUE_LETTER_BASE_URL = 'https://www.blueletterbible.org'
32
+ BIBLE_COM_BASE_URL = 'https://www.bible.com/bible'
33
+ BIBLE_COM_VERSIONS = {
34
+ 'NKJV' => [114, 'NKJV'], 'NASB95' => [100, 'NASB1995'], 'LSB' => [3345, 'LSB'],
35
+ 'ESV' => [59, 'ESV'], 'KJV' => [1, 'KJV'], 'YLT' => [821, 'YLT98']
36
+ }.freeze
37
+ BIBLE_COM_ORIGINAL_LANGUAGES = {
38
+ old_testament: [3585, 'WLC'], new_testament: [2270, 'THGNT']
39
+ }.freeze
40
+ BIBLE_COM_ALL_GREEK = {
41
+ old_testament: [2503, 'GRCBRENT'], new_testament: [2270, 'THGNT']
42
+ }.freeze
29
43
  BLUE_LETTER_BOOK_CODES = %w[
30
44
  gen exo lev num deu jos jdg rut 1sa 2sa 1ki 2ki 1ch 2ch ezr neh est job psa pro ecc sng
31
45
  isa jer lam eze dan hos joe amo oba jon mic nah hab zep hag zec mal mat mar luk joh act rom
32
46
  1co 2co gal eph php col 1th 2th 1ti 2ti tit phm heb jas 1pe 2pe 1jo 2jo 3jo jud rev
33
47
  ].freeze
48
+ BIBLE_COM_BOOK_CODES = %w[
49
+ GEN EXO LEV NUM DEU JOS JDG RUT 1SA 2SA 1KI 2KI 1CH 2CH EZR NEH EST JOB PSA PRO ECC SNG
50
+ ISA JER LAM EZK DAN HOS JOL AMO OBA JON MIC NAM HAB ZEP HAG ZEC MAL MAT MRK LUK JHN ACT ROM
51
+ 1CO 2CO GAL EPH PHP COL 1TH 2TH 1TI 2TI TIT PHM HEB JAS 1PE 2PE 1JN 2JN 3JN JUD REV
52
+ ].freeze
34
53
 
35
54
  module_function
36
55
 
@@ -50,6 +69,10 @@ module Bible270
50
69
  code.to_s.strip.upcase
51
70
  end
52
71
 
72
+ def original_languages?(code)
73
+ ORIGINAL_LANGUAGE_CODES.include?(normalize(code))
74
+ end
75
+
53
76
  def label(code)
54
77
  VERSIONS.dig(normalize(code), :label)
55
78
  end
@@ -69,10 +92,9 @@ module Bible270
69
92
  codes.map { |code| ["#{code} — #{short_label(code)}", code] }
70
93
  end
71
94
 
72
- def passage_url(reference, version, blue_letter: false)
73
- if blue_letter || normalize(version) == ORIGINAL_LANGUAGES
74
- return blue_letter_url(reference, version)
75
- end
95
+ def passage_url(reference, version, blue_letter: false, bible_com: false)
96
+ return bible_com_url(reference, version) if bible_com
97
+ return blue_letter_url(reference, version) if blue_letter || original_languages?(version)
76
98
 
77
99
  search = URI.encode_www_form_component(reference)
78
100
  gateway = URI.encode_www_form_component(gateway_code(version))
@@ -85,25 +107,17 @@ module Bible270
85
107
  # query URL, so link to the first chapter/verse and let its chapter navigation
86
108
  # carry the reader onward.
87
109
  def blue_letter_url(reference, version = ORIGINAL_LANGUAGES)
88
- books = Versification::VERSES.keys
89
- book_index = books.index { |book| reference.to_s.start_with?("#{book} ") }
90
- return "#{BLUE_LETTER_BASE_URL}/" unless book_index
91
-
92
- book = books[book_index]
93
- location = reference.to_s.delete_prefix("#{book} ")
94
- match = location.match(%r{\A(\d+)(?::(\d+))?})
95
- return "#{BLUE_LETTER_BASE_URL}/" unless match
96
-
97
- chapter = match[1].to_i
98
- verse = match[2]&.to_i || 1
99
- chapter_verses = Versification::VERSES[book]
100
- return "#{BLUE_LETTER_BASE_URL}/" unless chapter.between?(1, chapter_verses.length)
101
- return "#{BLUE_LETTER_BASE_URL}/" unless verse.between?(1, chapter_verses[chapter - 1])
110
+ start = reference_start(reference)
111
+ return "#{BLUE_LETTER_BASE_URL}/" unless start
102
112
 
113
+ book_index, chapter, verse = start
114
+ books = Versification::VERSES.keys
103
115
  canonical_chapter = books.first(book_index).sum { |prior| Versification::VERSES[prior].length } + chapter
104
116
  new_testament = book_index >= books.index('Matthew')
105
117
  reader = if normalize(version) == ORIGINAL_LANGUAGES
106
118
  new_testament ? 'mgnt' : 'wlc'
119
+ elsif normalize(version) == ALL_GREEK
120
+ 'mgnt'
107
121
  else
108
122
  VERSIONS.dig(normalize(version), :blue_letter)
109
123
  end
@@ -114,6 +128,45 @@ module Bible270
114
128
  "#{chapter}/#{verse}/s_#{anchor}"
115
129
  end
116
130
 
131
+ def bible_com_url(reference, version)
132
+ start = reference_start(reference)
133
+ return BIBLE_COM_BASE_URL unless start
134
+
135
+ book_index, chapter, = start
136
+ version_id, version_code = bible_com_version(version, book_index)
137
+ return BIBLE_COM_BASE_URL unless version_id && version_code
138
+
139
+ "#{BIBLE_COM_BASE_URL}/#{version_id}/#{BIBLE_COM_BOOK_CODES[book_index]}." \
140
+ "#{chapter}.#{version_code}"
141
+ end
142
+
143
+ def bible_com_version(version, book_index)
144
+ normalized = normalize(version)
145
+ testament = book_index >= Versification::VERSES.keys.index('Matthew') ? :new_testament : :old_testament
146
+ return BIBLE_COM_ORIGINAL_LANGUAGES[testament] if normalized == ORIGINAL_LANGUAGES
147
+ return BIBLE_COM_ALL_GREEK[testament] if normalized == ALL_GREEK
148
+
149
+ BIBLE_COM_VERSIONS[normalized]
150
+ end
151
+
152
+ def reference_start(reference)
153
+ books = Versification::VERSES.keys
154
+ book_index = books.index { |book| reference.to_s.start_with?("#{book} ") }
155
+ return unless book_index
156
+
157
+ book = books[book_index]
158
+ match = reference.to_s.delete_prefix("#{book} ").match(%r{\A(\d+)(?::(\d+))?})
159
+ return unless match
160
+
161
+ chapter = match[1].to_i
162
+ verse = match[2]&.to_i || 1
163
+ chapter_verses = Versification::VERSES[book]
164
+ return unless chapter.between?(1, chapter_verses.length)
165
+ return unless verse.between?(1, chapter_verses[chapter - 1])
166
+
167
+ [book_index, chapter, verse]
168
+ end
169
+
117
170
  # Falls back to the configured default, then to ESV, so a link is never built
118
171
  # with a blank version.
119
172
  def resolve(code)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.1.7'
4
+ VERSION = '1.3.0'
5
5
  end
@@ -517,7 +517,7 @@ module Bible270
517
517
  # Readers choose an opt-in time only when enabled. This application owns
518
518
  # scheduling: run `bin/rails bible270:reminders:send` every 15 minutes.
519
519
  config.daily_reminders = false
520
- config.mention_notifications = true # direct replies and @mentions; readers may opt out
520
+ config.mention_notifications = true # reflection/reply emails; readers choose the scope
521
521
  # config.mailer_host = 'example.com' # links back to the day and profile
522
522
 
523
523
  # --- Reading links --------------------------------------------------------
@@ -60,7 +60,7 @@ Bible270.configure do |config|
60
60
  # Readers choose an opt-in time on their profile only when this is enabled.
61
61
  # The host app owns scheduling: run `bin/rails bible270:reminders:send` every 15 minutes.
62
62
  config.daily_reminders = false
63
- config.mention_notifications = true # direct replies and @mentions; readers may opt out
63
+ config.mention_notifications = true # reflection/reply emails; readers choose the scope
64
64
  # config.mailer_host = "example.com" # required for day/profile links in email
65
65
 
66
66
  # --- Reading links ---------------------------------------------------------
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.1.7
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -137,12 +137,14 @@ files:
137
137
  - app/views/bible270/readers/show.html.erb
138
138
  - app/views/bible270/sessions/new.html.erb
139
139
  - app/views/bible270/shared/_comment_drafts.html.erb
140
+ - app/views/bible270/shared/_comment_notification_choices.html.erb
140
141
  - app/views/bible270/shared/_day_index.html.erb
141
142
  - app/views/bible270/shared/_footer.html.erb
142
143
  - app/views/bible270/shared/_grid_legend.html.erb
143
144
  - app/views/bible270/shared/_header.html.erb
144
145
  - app/views/bible270/shared/_interaction_status.html.erb
145
146
  - app/views/bible270/shared/_interaction_ui.html.erb
147
+ - app/views/bible270/shared/_mention_typeahead.html.erb
146
148
  - app/views/bible270/shared/_nav_links.html.erb
147
149
  - app/views/bible270/shared/_passage_source.html.erb
148
150
  - app/views/bible270/shared/_progress.html.erb
@@ -170,6 +172,8 @@ files:
170
172
  - db/migrate/20260101000015_add_passage_source_to_bible270_readers.rb
171
173
  - db/migrate/20260101000016_add_daily_reminders_to_bible270_readers.rb
172
174
  - db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb
175
+ - db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb
176
+ - db/migrate/20260101000019_change_passage_source_default_to_bible_com.rb
173
177
  - lib/bible270.rb
174
178
  - lib/bible270/avatars.rb
175
179
  - lib/bible270/configuration.rb