bible270 0.17.1 → 0.18.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: 70091a35da0211de345d0a946f8fe25aea94c7656d7f9fb7b305aa63c6a1b7e6
4
- data.tar.gz: 98e6db18fb16bdb784b7b2ad095b05860a43a81cb3111279f33b7285345bcbd0
3
+ metadata.gz: 7ba4b388cfe909e84f06899d72159471aab1a506154fc72854659d64320a3390
4
+ data.tar.gz: ebcf629ca6a12f57bf87d72073115c9a4655cec15e018e9349813aaab11a2e41
5
5
  SHA512:
6
- metadata.gz: e03f11e9596c52e59413478a3c5c615ce5bc9f4289ecbed3a037906d51d70e515d56767d18324ba331dded40de502d0d6b7c741c603836d7e351d147375187df
7
- data.tar.gz: f2496c1342b3be1625927fdbbecd333c1792d96c8c52742359bdfc9cb66e9de7ac0f3617b20b291ee081811e4d937a92e3dbbecc9c5ee29a3c3d0788db9f473a
6
+ metadata.gz: 1b202223f06388e4b7f95d53b52f84cc84018ed7ac7ad8e49c48467525e7d76a0bd60185e46bd9b6223a83d7939666b6d7e7d6f1360ca625967452691d1fc528
7
+ data.tar.gz: 8c0b363492837cf5fb715ad13effdee46829cb34dad426e2bbfa8b24c4834f61ea16b2411b5c2e0fc0098d6b0fa397e8fe0cee487a763670f75f8f9fbb2bbb6c
data/CHANGELOG.md CHANGED
@@ -6,7 +6,51 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
6
6
 
7
7
  See the changes since the last release:
8
8
 
9
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.16.0...main)**
9
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.17.2...main)**
10
+
11
+ ## [0.17.2] - 2026-08-08
12
+
13
+ ### Added
14
+
15
+ - reader Bible translation preferences editable by admin users
16
+ - admin broadcast email tools
17
+
18
+ ### Changed
19
+
20
+ - extract first and last names from OmniAuth display names
21
+ - expand CI coverage reporting and add Ruby 4.0 coverage
22
+
23
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.17.1...v0.17.2)**
24
+
25
+ ## [0.17.1] - 2026-08-06
26
+
27
+ ### Added
28
+
29
+ - likes for reflections
30
+
31
+ ### Changed
32
+
33
+ - display timestamps using the local time zone
34
+ - rename reflections to 'posts' in UI headers, because we like alliteration
35
+
36
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.17.0...v0.17.1)**
37
+
38
+ ## [0.17.0] - 2026-08-05
39
+
40
+ ### Added
41
+
42
+ - a reflections page for browsing recent comments
43
+ - the ability for readers to edit their reflections
44
+
45
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.16.1...v0.17.0)**
46
+
47
+ ## [0.16.1] - 2026-08-05
48
+
49
+ ### Changed
50
+
51
+ - refactor mailer URL helpers to use the engine's routes
52
+
53
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.16.0...v0.16.1)**
10
54
 
11
55
  ## [0.16.0] - 2026-08-05
12
56
 
@@ -19,8 +63,6 @@ See the changes since the last release:
19
63
 
20
64
  ## [0.15.3] - 2026-08-04
21
65
 
22
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.2...v0.15.3)**
23
-
24
66
  ### Added
25
67
 
26
68
  - `Bible270::Checkoff.reset_column_information` to migrations to prevent `UnknownAttributeError` during backfills.
@@ -30,9 +72,9 @@ See the changes since the last release:
30
72
 
31
73
  - 'Today' now derived from system time, not UTC
32
74
 
33
- ## [0.15.2] - 2026-08-04
75
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.2...v0.15.3)**
34
76
 
35
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.1...v0.15.2)**
77
+ ## [0.15.2] - 2026-08-04
36
78
 
37
79
  ### Added
38
80
 
@@ -42,9 +84,9 @@ See the changes since the last release:
42
84
 
43
85
  - consistent reader sort order by first name in 'People' and 'Admin'
44
86
 
45
- ## [0.15.1] - 2026-08-03
87
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.1...v0.15.2)**
46
88
 
47
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.0...v0.15.1)**
89
+ ## [0.15.1] - 2026-08-03
48
90
 
49
91
  ### Added
50
92
 
@@ -59,9 +101,9 @@ See the changes since the last release:
59
101
 
60
102
  - old zip file of tests
61
103
 
62
- ## [0.15.0] - 2026-08-02
104
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.0...v0.15.1)**
63
105
 
64
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.14.1...v0.15.0)**
106
+ ## [0.15.0] - 2026-08-02
65
107
 
66
108
  ### Added
67
109
 
@@ -73,9 +115,9 @@ See the changes since the last release:
73
115
 
74
116
  - mobile session requiring sign-in every time: use persistent sessions using 'remember-me' cookie
75
117
 
76
- ## [0.14.1] - 2026-07-30
118
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.14.1...v0.15.0)**
77
119
 
78
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.14.0...v0.14.1)**
120
+ ## [0.14.1] - 2026-07-30
79
121
 
80
122
  ### Changed
81
123
 
@@ -87,9 +129,9 @@ See the changes since the last release:
87
129
  - display 'Start' not 'Continue' if reader is on day 1
88
130
  - adjust badge vertical alignment in styles
89
131
 
90
- ## [0.14.0] - 2026-07-30
132
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.14.0...v0.14.1)**
91
133
 
92
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.13.1...v0.14.0)**
134
+ ## [0.14.0] - 2026-07-30
93
135
 
94
136
  ### Added
95
137
 
@@ -107,16 +149,10 @@ See the changes since the last release:
107
149
  wrote an initializer that crashed the host app on boot. A test now renders every generator template
108
150
  and compiles the result, across three mount points.
109
151
 
110
- ## [0.13.1] — 2026-07-29
111
-
112
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.13.0...v0.13.1)**
113
-
114
- Nothing consequential.
152
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.13.0...v0.14.0)**
115
153
 
116
154
  ## [0.13.0] — 2026-07-29
117
155
 
118
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.12.0...v0.13.0)**
119
-
120
156
  ### Added
121
157
 
122
158
  - favicon support
@@ -127,9 +163,9 @@ Nothing consequential.
127
163
 
128
164
  - Correct verb for reading prompt: 'Start' and 'Continue
129
165
 
130
- ## [0.12.0] — 2026-07-29
166
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.12.0...v0.13.0)**
131
167
 
132
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.11.0...v0.12.0)**
168
+ ## [0.12.0] — 2026-07-29
133
169
 
134
170
  ### Added
135
171
 
@@ -141,9 +177,9 @@ Nothing consequential.
141
177
  - Top menu shows 'Progress' in place of user display name
142
178
  - Dependabot gem updates to minitest and rubocop
143
179
 
144
- ## [0.11.0] — 2026-07-28
180
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.11.0...v0.12.0)**
145
181
 
146
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.10.0...v0.11.0)**
182
+ ## [0.11.0] — 2026-07-28
147
183
 
148
184
  ### Added
149
185
 
@@ -195,9 +231,9 @@ Nothing consequential.
195
231
  - Migrations: `20260101000007` (settings). Active Storage tables are the host app's
196
232
  (`bin/rails active_storage:install`).
197
233
 
198
- ## [0.10.0] — 2026-07-26
234
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.10.0...v0.11.0)**
199
235
 
200
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.9.0...v0.10.0)**
236
+ ## [0.10.0] — 2026-07-26
201
237
 
202
238
  ### Reading plan
203
239
 
@@ -270,9 +306,9 @@ Nothing consequential.
270
306
 
271
307
  - Table names changed from `bible_reading_plan_*` in the 0.9.0 line; this is a fresh install.
272
308
 
273
- ## [0.9.0] — 2026-07-25
309
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.9.0...v0.10.0)**
274
310
 
275
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.6.2...v0.9.0)**
311
+ ## [0.9.0] — 2026-07-25
276
312
 
277
313
  ### Changed
278
314
 
@@ -290,6 +326,8 @@ Nothing consequential.
290
326
  * `Plan.nt_groups`, `format_pp_segment`, `pp_segment_length` (superseded by the shared `format_segment` and `segment_length`), and `totals[:nt_rest_days]`.
291
327
  * `nt_rest_days` remains but now always returns an empty array.
292
328
 
329
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.6.2...v0.9.0)**
330
+
293
331
  ## [0.6.2] — 2026-07-25
294
332
 
295
333
  This is the initial public release.
@@ -352,6 +390,10 @@ This is the initial public release.
352
390
 
353
391
  * 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.
354
392
 
393
+ [0.17.2]: https://github.com/avonderluft/bible270/releases/tag/v0.17.2
394
+ [0.17.1]: https://github.com/avonderluft/bible270/releases/tag/v0.17.1
395
+ [0.17.0]: https://github.com/avonderluft/bible270/releases/tag/v0.17.0
396
+ [0.16.1]: https://github.com/avonderluft/bible270/releases/tag/v0.16.1
355
397
  [0.16.0]: https://github.com/avonderluft/bible270/releases/tag/v0.16.0
356
398
  [0.15.3]: https://github.com/avonderluft/bible270/releases/tag/v0.15.3
357
399
  [0.15.2]: https://github.com/avonderluft/bible270/releases/tag/v0.15.2
@@ -359,7 +401,6 @@ This is the initial public release.
359
401
  [0.15.0]: https://github.com/avonderluft/bible270/releases/tag/v0.15.0
360
402
  [0.14.1]: https://github.com/avonderluft/bible270/releases/tag/v0.14.1
361
403
  [0.14.0]: https://github.com/avonderluft/bible270/releases/tag/v0.14.0
362
- [0.13.1]: https://github.com/avonderluft/bible270/releases/tag/v0.13.1
363
404
  [0.13.0]: https://github.com/avonderluft/bible270/releases/tag/v0.13.0
364
405
  [0.12.0]: https://github.com/avonderluft/bible270/releases/tag/v0.12.0
365
406
  [0.11.0]: https://github.com/avonderluft/bible270/releases/tag/v0.11.0
data/README.md CHANGED
@@ -117,6 +117,29 @@ bin/rails bible270:install:migrations # skips ones you already have
117
117
  bin/rails db:migrate
118
118
  ```
119
119
 
120
+ ### Reconciling a copied database
121
+
122
+ A copied database can contain the Bible270 tables while its `schema_migrations` versions do not match
123
+ the timestamped migration copies in the local app. In that case, preview the reconciliation:
124
+
125
+ ```bash
126
+ bin/rails bible270:migrations:reconcile
127
+ ```
128
+
129
+ The task recognizes Bible270 migrations—and the related generated Active Storage migration used for
130
+ reader avatars—by name, then verifies their tables, columns, current indexes, and foreign keys. It
131
+ does not treat an existing table alone as sufficient. After reviewing the list and backing up the
132
+ database, record only the migrations already reflected in the schema:
133
+
134
+ ```bash
135
+ APPLY=1 bin/rails bible270:migrations:reconcile
136
+ bin/rails db:migrate
137
+ ```
138
+
139
+ Preview mode never writes to the database. Apply mode only inserts verified local versions into
140
+ `schema_migrations`; it does not create, remove, or alter application tables. Migrations whose changes
141
+ are missing remain pending for the final `db:migrate` command.
142
+
120
143
  ## Bible270 Mount point in your Rails app
121
144
 
122
145
  The path lives in exactly **one** place, `config.mount_at`:
@@ -361,7 +384,10 @@ config.favicon = false # none; the host's favicon applies
361
384
  It's emitted by the engine's layout, so if you point `config.layout` at your own layout, add
362
385
  `<%= b270_favicon_tag %>` to its `<head>`.
363
386
 
364
- References link to Bible Gateway by default point `passage_url_builder` at your another reader if you wish, or your own reader if you host one.
387
+ References link to Bible Gateway by default. Readers choose Bible Gateway or Blue Letter Bible from
388
+ their profile, and an admin can set the same source for any reader. The `HEB/GRK` option always uses
389
+ Blue Letter Bible: WLC for Old Testament, Psalms, and Proverbs readings, and mGNT for New Testament
390
+ readings. Set `passage_url_builder` to use another default reader, or a reader you host yourself.
365
391
 
366
392
  ## With ComfortableMediaSurfer
367
393
 
@@ -371,7 +397,7 @@ The CMS serves your content; this is a separate mounted app. Either **just link
371
397
 
372
398
  | Table | Holds |
373
399
  |-------|-------|
374
- | `bible270_readers` | identity: display name, avatar, provider/uid or polymorphic `owner`, `started_on` |
400
+ | `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation and passage-reader preferences |
375
401
  | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`), unique-indexed |
376
402
  | `bible270_comments` | a reflection on a day, optionally scoped to a track, public to all |
377
403
  | `bible270_sign_in_tokens` | magic-link tokens: email, **digest only**, expiry, consumed-at |
data/Rakefile CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'bundler/gem_tasks' # provides build / install / release
4
4
  require 'rake/testtask'
5
+ require 'coveralls/rake/task'
5
6
 
6
7
  Rake::TestTask.new(:test) do |t|
7
8
  t.libs << 'test' << 'lib'
@@ -9,4 +10,6 @@ Rake::TestTask.new(:test) do |t|
9
10
  t.warning = false
10
11
  end
11
12
 
13
+ Coveralls::RakeTask.new
14
+
12
15
  task default: :test
@@ -8,6 +8,9 @@ module Bible270
8
8
  # neither set the panel is unreachable, and every action 404s rather than 403s
9
9
  # so its existence isn't advertised.
10
10
  class AdminController < ApplicationController
11
+ LAST_BROADCAST_AT = 'last_broadcast_at'
12
+ LAST_BROADCAST_SUBJECT = 'last_broadcast_subject'
13
+
11
14
  before_action :require_admin!
12
15
  before_action :load_reader,
13
16
  only: %i[show destroy update_start update_profile remove_avatar complete_through toggle_day]
@@ -32,6 +35,9 @@ module Bible270
32
35
  counts = Checkoff.group(:reader_id, :day).count
33
36
  @days_completed = Hash.new(0)
34
37
  counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
38
+ @reachable = Reader.where.not(email: [nil, '']).count
39
+ @last_broadcast_at = parsed_time(Setting.read(LAST_BROADCAST_AT))
40
+ @last_broadcast_subject = Setting.read(LAST_BROADCAST_SUBJECT)
35
41
  end
36
42
 
37
43
  def show
@@ -75,6 +81,53 @@ module Bible270
75
81
  end
76
82
 
77
83
  # Put the reader on a given day as of today, or set an explicit start date.
84
+ def broadcast
85
+ subject = params[:subject].to_s.strip
86
+ body = params[:body].to_s.strip
87
+
88
+ if subject.empty? || body.empty?
89
+ return redirect_to(admin_path, alert: 'A message needs both a subject and something to say.')
90
+ end
91
+
92
+ recipients = Reader.where.not(email: [nil, '']).order(:id)
93
+ return redirect_to(admin_path, alert: 'Nobody has an email address.') if recipients.empty?
94
+
95
+ sent = deliver_broadcast(recipients, subject, body)
96
+ Setting.write(LAST_BROADCAST_AT, Time.current.iso8601)
97
+ Setting.write(LAST_BROADCAST_SUBJECT, subject)
98
+
99
+ redirect_to admin_path,
100
+ notice: "Sent to #{sent} #{'reader'.pluralize(sent)}."
101
+ end
102
+
103
+ def update_bible_version
104
+ reader = Reader.find_by(id: params[:id])
105
+ return redirect_to(admin_path, alert: 'No such reader.') if reader.nil?
106
+
107
+ code = params[:bible_version].to_s
108
+ source = params[:passage_source].presence || Reader::DEFAULT_PASSAGE_SOURCE
109
+ unless Reader::PASSAGE_SOURCES.include?(source)
110
+ return redirect_to admin_reader_path(reader), alert: 'That is not a reading-link source on the list.'
111
+ end
112
+
113
+ # Blank means "no preference": the reader follows the site default, which is
114
+ # not the same as choosing whichever translation the site happens to use now.
115
+ if code.empty?
116
+ reader.update(bible_version: nil, passage_source: source)
117
+ return redirect_to admin_reader_path(reader),
118
+ notice: "#{reader.display_name} now follows the site default " \
119
+ "(#{Bible270.config.bible_version})."
120
+ end
121
+
122
+ if reader.update_bible_version(code)
123
+ reader.update_column(:passage_source, source)
124
+ redirect_to admin_reader_path(reader),
125
+ notice: "#{reader.display_name} now reads the #{reader.bible_version_label}."
126
+ else
127
+ redirect_to admin_reader_path(reader), alert: 'That is not a translation on the list.'
128
+ end
129
+ end
130
+
78
131
  def update_start
79
132
  if params[:start_date].present?
80
133
  # set_start_date!, not update_start_date!: an admin is not subject to
@@ -138,6 +191,33 @@ module Bible270
138
191
  end
139
192
  end
140
193
 
194
+ # Failures are counted rather than raised: one bad address must not stop the
195
+ # rest of the message going out, and the admin is told how many were sent.
196
+ # A stored timestamp that cannot be read is treated as absent rather than
197
+ # blowing up the admin page.
198
+ def parsed_time(value)
199
+ return nil if value.blank?
200
+
201
+ Time.parse(value.to_s)
202
+ rescue ArgumentError, TypeError
203
+ nil
204
+ end
205
+
206
+ def deliver_broadcast(recipients, subject, body)
207
+ later = Bible270.config.registration_notice_deliver_later
208
+ sent = 0
209
+
210
+ recipients.find_each do |reader|
211
+ mail = NoticeMailer.broadcast(reader_id: reader.id, subject: subject, body: body)
212
+ later ? mail.deliver_later : mail.deliver_now
213
+ sent += 1
214
+ rescue StandardError => e
215
+ Rails.logger.error("[bible270] broadcast to reader #{reader.id} failed: #{e.class}: #{e.message}")
216
+ end
217
+
218
+ sent
219
+ end
220
+
141
221
  private
142
222
 
143
223
  def require_admin!
@@ -21,6 +21,8 @@ module Bible270
21
21
  if params[:bible_version].present? && !current_reader.update_bible_version(params[:bible_version])
22
22
  problems << 'a translation from the list'
23
23
  end
24
+ source = params[:passage_source].presence || Reader::DEFAULT_PASSAGE_SOURCE
25
+ problems << 'a reading-link source from the list' unless current_reader.update_passage_source(source)
24
26
  if params[:avatar].present? && !current_reader.attach_avatar(params[:avatar])
25
27
  problems << (current_reader.errors[:avatar].first || 'a valid image')
26
28
  end
@@ -36,7 +36,9 @@ module Bible270
36
36
  remember_reader!(reader)
37
37
  @current_reader = reader
38
38
 
39
- redirect_to destination, notice: "Welcome #{reader.first_name}."
39
+ # first_name is filled in for every route now, but a bridged host user may
40
+ # still arrive without one.
41
+ redirect_to destination, notice: "Welcome #{reader.first_name.presence || reader.display_name}."
40
42
  end
41
43
 
42
44
  def destroy
@@ -112,13 +112,24 @@ module Bible270
112
112
  def b270_passage_url(reference, version = nil)
113
113
  return '#' if reference.blank?
114
114
 
115
- Bible270.config.passage_url_builder.call(reference, version || b270_bible_version)
115
+ selected_version = version || b270_bible_version
116
+ if selected_version != Translations::ORIGINAL_LANGUAGES && current_reader&.blue_letter_bible?
117
+ return Translations.passage_url(reference, selected_version, blue_letter: true)
118
+ end
119
+
120
+ Bible270.config.passage_url_builder.call(reference, selected_version)
116
121
  end
117
122
 
118
123
  # Small grey "(NKJV)" after a reading, so it's clear which translation the
119
- # link opens in.
120
- def b270_version_tag(version = nil)
121
- content_tag :span, "(#{version || b270_bible_version})", class: 'b270-version'
124
+ # link opens in. The original-language preference names the actual language
125
+ # used for each track rather than repeating the combined preference code.
126
+ def b270_version_tag(version = nil, track: nil)
127
+ label = version || b270_bible_version
128
+ if label == Translations::ORIGINAL_LANGUAGES && track
129
+ label = track.to_s == 'nt' ? 'Greek' : 'Hebrew'
130
+ end
131
+
132
+ content_tag :span, "(#{label})", class: 'b270-version'
122
133
  end
123
134
 
124
135
  def b270_avatar_src(reader)
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
4
+ <div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
5
+ <p style="margin:0 0 16px;font-size:15px">
6
+ <%= @reader.first_name.presence || @reader.display_name %>,
7
+ </p>
8
+
9
+ <div style="font-size:15px;line-height:1.6"><%= simple_format(@body) %></div>
10
+
11
+ <p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #d7cfbf;font-size:12px;color:#9a9082">
12
+ <%= @app_name %><% if @plan_url %> ·
13
+ <a href="<%= @plan_url %>" style="color:#a5812c"><%= @plan_url %></a><% end %>
14
+ </p>
15
+ </div>
16
+ </body>
17
+ </html>
@@ -0,0 +1,7 @@
1
+ <%= @reader.first_name.presence || @reader.display_name %>,
2
+
3
+ <%= @body %>
4
+
5
+ --
6
+ <%= @app_name %><% if @plan_url %>
7
+ <%= @plan_url %><% end %>
@@ -31,6 +31,22 @@ module Bible270
31
31
  subject: "#{@app_name}: #{@author.display_name} mentioned you on day #{@comment.day}"
32
32
  end
33
33
 
34
+ # A message from whoever runs the plan to one reader. Sent individually rather
35
+ # than as one email with everyone in bcc: a bcc list of a hundred addresses is
36
+ # a spam signal, and it means nobody can be greeted by name or unsubscribe
37
+ # meaningfully.
38
+ def broadcast(reader_id:, subject:, body:)
39
+ @reader = Reader.find_by(id: reader_id)
40
+ return message.perform_deliveries = false if @reader.nil? || @reader.email.blank?
41
+ return message.perform_deliveries = false if subject.to_s.strip.empty? || body.to_s.strip.empty?
42
+
43
+ @body = body.to_s
44
+ @app_name = Bible270.config.app_name
45
+ @plan_url = plan_url
46
+
47
+ mail to: @reader.email, subject: subject.to_s.strip
48
+ end
49
+
34
50
  private
35
51
 
36
52
  # A mailer has no request, so the host is config.mailer_host or nothing. No
@@ -41,6 +57,10 @@ module Bible270
41
57
  # config.mount_at was worse than useless — ignored when blank, and overriding
42
58
  # the true prefix when not, so a config that disagreed with routes.rb would
43
59
  # have produced links to nowhere.
60
+ def plan_url
61
+ engine_url(:root_url)
62
+ end
63
+
44
64
  def day_url_for(day)
45
65
  engine_url(:day_url, day)
46
66
  end
@@ -25,8 +25,12 @@ 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'
30
+
28
31
  validates :display_name, presence: true
29
32
  validates :uid, uniqueness: { scope: :provider }, allow_nil: true
33
+ validates :passage_source, inclusion: { in: PASSAGE_SOURCES }
30
34
 
31
35
  # Build/refresh a reader from an OmniAuth auth hash. Tolerant of the various
32
36
  # shapes strategies return (OmniAuth::AuthHash, plain Hash, missing info).
@@ -45,10 +49,37 @@ module Bible270
45
49
  image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
46
50
  reader.email = email if email.present?
47
51
  reader.avatar_url = image if image.present?
52
+ assign_names_from(reader, info)
48
53
  reader.save
49
54
  reader
50
55
  end
51
56
 
57
+ # Providers hand back one display name far more often than two fields, so the
58
+ # name is split when they do not. Without this an OmniAuth reader had no
59
+ # first_name — which made the greeting read "Welcome ." and, more seriously,
60
+ # made them unmentionable: Reader.mentioned_in only considers readers who have
61
+ # one.
62
+ #
63
+ # Only filled in when empty, so a reader who has since corrected their name on
64
+ # their profile does not have it overwritten at every sign-in.
65
+ def self.assign_names_from(reader, info)
66
+ return if reader.first_name.present?
67
+
68
+ given = dig_auth(info, :first_name)
69
+ family = dig_auth(info, :last_name)
70
+
71
+ if given.present?
72
+ reader.first_name = given
73
+ reader.last_name = family
74
+ return
75
+ end
76
+
77
+ split = Names.split_display_name(reader.display_name)
78
+ reader.first_name = split ? split[:first_name] : reader.display_name.to_s.split.first
79
+ reader.last_name = split ? split[:last_name] : nil
80
+ end
81
+ private_class_method :assign_names_from
82
+
52
83
  # Find or create the reader behind a verified email address. Uses the same
53
84
  # provider/uid identity columns as OmniAuth, with provider "email", so an
54
85
  # email reader is indistinguishable from any other downstream.
@@ -137,6 +168,16 @@ module Bible270
137
168
  update(bible_version: Translations.normalize(code))
138
169
  end
139
170
 
171
+ def update_passage_source(source)
172
+ source = source.to_s
173
+ return false unless PASSAGE_SOURCES.include?(source)
174
+
175
+ update(passage_source: source)
176
+ end
177
+
178
+ def bible_gateway? = passage_source == 'bible_gateway'
179
+ def blue_letter_bible? = passage_source == 'blue_letter_bible'
180
+
140
181
  # Readers are listed by first name, matching how they are shown — the display
141
182
  # name is "First Last", so sorting on it needs only case folding. Surname
142
183
  # order was inconsistent with the community page and read oddly next to names
@@ -47,4 +47,31 @@
47
47
  <p style="color:var(--faint)">No readers yet.</p>
48
48
  <% end %>
49
49
 
50
+ <div class="b270-panel">
51
+ <h2 class="b270-subhead">Write to everyone</h2>
52
+
53
+ <% if @reachable.zero? %>
54
+ <p class="b270-hint" style="margin-top:0">No reader has an email address yet.</p>
55
+ <% else %>
56
+ <p class="b270-hint" style="margin-top:0">
57
+ Goes to <strong><%= @reachable %></strong>
58
+ <%= @reachable == 1 ? 'reader' : 'readers' %> individually, not as one message with
59
+ everyone in bcc — so each is greeted by name.
60
+ <% if @last_broadcast_at.present? %>
61
+ <br>Last sent <%= b270_datetime(@last_broadcast_at) %><%
62
+ %><% if @last_broadcast_subject.present? %>: <em><%= @last_broadcast_subject %></em><% end %>.
63
+ <% end %>
64
+ </p>
65
+
66
+ <%= form_with url: admin_broadcast_path, method: :post, class: "b270-form",
67
+ data: { turbo_confirm: "Send this to #{@reachable} #{@reachable == 1 ? 'reader' : 'readers'}?" } do %>
68
+ <%= text_field_tag :subject, params[:subject], placeholder: "Subject", class: "b270-input" %>
69
+ <%= text_area_tag :body, params[:body], placeholder: "What would you like to say?", rows: 6 %>
70
+ <div class="row">
71
+ <%= submit_tag "Send", class: "b270-btn" %>
72
+ </div>
73
+ <% end %>
74
+ <% end %>
75
+ </div>
76
+
50
77
  <p style="margin-top:18px"><%= link_to '← Back to the plan', root_path %></p>
@@ -92,6 +92,29 @@
92
92
  <%= render "bible270/shared/reflections", comments: @comments, moderate: true %>
93
93
  </div>
94
94
 
95
+ <div class="b270-panel">
96
+ <h2 class="b270-subhead">Which translation they read</h2>
97
+ <p class="b270-hint" style="margin-top:0">
98
+ Reading links open in this translation.
99
+ <% if @reader.bible_version.blank? %>
100
+ They have expressed no preference, so they follow the site default
101
+ (<%= Bible270.config.bible_version %>).
102
+ <% end %>
103
+ </p>
104
+
105
+ <%= form_with url: admin_reader_version_path(@reader), method: :patch, class: "b270-form" do %>
106
+ <div class="row">
107
+ <%= select_tag :bible_version,
108
+ options_for_select([["Site default (#{Bible270.config.bible_version})", ""]] +
109
+ Bible270::Translations.options,
110
+ @reader.bible_version.to_s),
111
+ class: "b270-select", style: "width:auto" %>
112
+ </div>
113
+ <%= render "bible270/shared/passage_source", reader: @reader %>
114
+ <%= submit_tag "Save", class: "b270-btn" %>
115
+ <% end %>
116
+ </div>
117
+
95
118
  <div class="b270-panel">
96
119
  <h2 class="b270-subhead">Remove</h2>
97
120
  <p class="b270-hint" style="margin-top:0">Deletes the reader together with every check-off and
@@ -35,7 +35,7 @@
35
35
  <% end %>
36
36
  </ul>
37
37
  <div class="b270-parts-meta">
38
- <%= read_parts.size %> of <%= parts.size %> chapters <%= b270_version_tag %>
38
+ <%= read_parts.size %> of <%= parts.size %> chapters <%= b270_version_tag(track: track) %>
39
39
  </div>
40
40
  </div>
41
41
  <% else %>
@@ -50,7 +50,7 @@
50
50
  <% end %>
51
51
  <div class="b270-rbody">
52
52
  <div class="b270-cat"><%= t[:label] %></div>
53
- <div class="b270-ref"><%= link_to ref, b270_passage_url(ref), target: "_blank", rel: "noopener" %> <%= b270_version_tag %></div>
53
+ <div class="b270-ref"><%= link_to ref, b270_passage_url(ref), target: "_blank", rel: "noopener" %> <%= b270_version_tag(track: track) %></div>
54
54
  </div>
55
55
  <% end %>
56
56
  </div>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
4
+ <div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
5
+ <p style="margin:0 0 16px;font-size:15px">
6
+ <%= @reader.first_name.presence || @reader.display_name %>,
7
+ </p>
8
+
9
+ <div style="font-size:15px;line-height:1.6"><%= simple_format(@body) %></div>
10
+
11
+ <p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #d7cfbf;font-size:12px;color:#9a9082">
12
+ <%= @app_name %><% if @plan_url %> ·
13
+ <a href="<%= @plan_url %>" style="color:#a5812c"><%= @plan_url %></a><% end %>
14
+ </p>
15
+ </div>
16
+ </body>
17
+ </html>
@@ -0,0 +1,7 @@
1
+ <%= @reader.first_name.presence || @reader.display_name %>,
2
+
3
+ <%= @body %>
4
+
5
+ --
6
+ <%= @app_name %><% if @plan_url %>
7
+ <%= @plan_url %><% end %>
@@ -39,6 +39,7 @@
39
39
  class: "b270-select" %>
40
40
  <span class="b270-hint" style="margin:0">Reading links open in this translation.</span>
41
41
  </label>
42
+ <%= render "bible270/shared/passage_source", reader: current_reader %>
42
43
 
43
44
  <%= submit_tag "Save", class: "b270-btn" %>
44
45
  <% end %>
@@ -0,0 +1,13 @@
1
+ <% original_languages = reader.effective_bible_version == Bible270::Translations::ORIGINAL_LANGUAGES %>
2
+ <div class="b270-source-choices" role="radiogroup" aria-label="Reading link source">
3
+ <label class="b270-source-choice <%= 'disabled' if original_languages %>">
4
+ <%= radio_button_tag :passage_source, "bible_gateway",
5
+ !original_languages && reader.bible_gateway?, disabled: original_languages %>
6
+ <span>Bible Gateway</span>
7
+ </label>
8
+ <label class="b270-source-choice <%= 'disabled' if original_languages %>">
9
+ <%= radio_button_tag :passage_source, "blue_letter_bible",
10
+ original_languages || reader.blue_letter_bible?, disabled: original_languages %>
11
+ <span>Blue Letter Bible</span>
12
+ </label>
13
+ </div>
@@ -134,6 +134,15 @@
134
134
  .b270-parts-meta{margin-top:10px;font-size:12.5px;color:var(--faint)}
135
135
  .b270-version{font-size:11.5px;color:var(--faint);font-weight:400;letter-spacing:.02em;white-space:nowrap}
136
136
  .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}
137
+ .b270-source-choices{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin:8px 0 2px;color:var(--muted);font-size:12.5px}
138
+ .b270-source-choice{display:inline-flex;align-items:center;gap:7px;cursor:pointer;white-space:nowrap}
139
+ .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}
140
+ .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)}
141
+ .b270-source-choice input:checked{background:var(--gold);border-color:var(--gold)}
142
+ .b270-source-choice input:checked::before{transform:scale(1)}
143
+ .b270-source-choice input:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
144
+ .b270-source-choice.disabled{cursor:default;opacity:.55}
145
+ .b270-source-choice input:disabled{cursor:default}
137
146
  .b270-ref a{text-decoration:none;border-bottom:1px solid transparent}
138
147
  .b270-ref a:hover{border-bottom-color:var(--gold)}
139
148
  .b270-reading.done .b270-ref{color:var(--muted)}
@@ -183,6 +192,7 @@
183
192
  .b270-comment.b270-editing{background:rgba(165,129,44,.05);border-radius:10px;padding:12px}
184
193
  .b270-editing textarea{min-height:64px}
185
194
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
195
+ .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}
186
196
  .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}
187
197
  .b270-form .row{display:flex;align-items:center;gap:10px;margin-top:10px}
188
198
  .b270 .b270-btn{border:none;border-radius:10px;padding:8px 16px;margin-top:4px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;background:var(--ink);color:var(--paper);text-decoration:none;display:inline-block}
data/config/routes.rb CHANGED
@@ -22,10 +22,12 @@ Bible270::Engine.routes.draw do
22
22
  delete 'admin/readers/:id', to: 'admin#destroy'
23
23
  patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
24
24
  delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
25
+ patch 'admin/readers/:id/version', to: 'admin#update_bible_version', as: :admin_reader_version
25
26
  patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
26
27
  patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
27
28
  post 'admin/readers/:id/days/:day', to: 'admin#toggle_day', as: :admin_reader_day
28
29
 
30
+ post 'admin/broadcast', to: 'admin#broadcast', as: :admin_broadcast
29
31
  patch 'admin/enrollment', to: 'admin#update_enrollment', as: :admin_enrollment
30
32
  get 'admin/comments', to: 'admin#comments', as: :admin_comments
31
33
  patch 'admin/comments/:id/hide', to: 'admin#hide_comment', as: :admin_hide_comment
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddBlueLetterBibleToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :blue_letter_bible, :boolean, default: false, null: false
6
+ end
7
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddPassageSourceToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def up
5
+ add_column :bible270_readers, :passage_source, :string, default: 'bible_gateway', null: false
6
+
7
+ return unless column_exists?(:bible270_readers, :blue_letter_bible)
8
+
9
+ execute <<~SQL.squish
10
+ UPDATE bible270_readers
11
+ SET passage_source = 'blue_letter_bible'
12
+ WHERE blue_letter_bible = TRUE
13
+ SQL
14
+ remove_column :bible270_readers, :blue_letter_bible
15
+ end
16
+
17
+ def down
18
+ add_column :bible270_readers, :blue_letter_bible, :boolean, default: false, null: false
19
+ execute <<~SQL.squish
20
+ UPDATE bible270_readers
21
+ SET blue_letter_bible = TRUE
22
+ WHERE passage_source = 'blue_letter_bible'
23
+ SQL
24
+ remove_column :bible270_readers, :passage_source
25
+ end
26
+ end
@@ -347,12 +347,7 @@ module Bible270
347
347
  @after_sign_out_path = nil
348
348
  @bible_version = 'NKJV'
349
349
  @bible_versions = Translations::VERSIONS.keys
350
- @passage_url_builder = ->(reference, version) do
351
- search = URI.encode_www_form_component(reference)
352
- # Translations.gateway_code maps 'NASB95' to Bible Gateway's 'NASB1995'.
353
- version = URI.encode_www_form_component(Translations.gateway_code(version))
354
- "https://www.biblegateway.com/passage/?search=#{search}&version=#{version}"
355
- end
350
+ @passage_url_builder = ->(reference, version) { Translations.passage_url(reference, version) }
356
351
  @app_name = 'Daily Bread'
357
352
  @tagline = 'Journeying through Scripture in 9 months'
358
353
  @time_zone = nil
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Repairs schema_migrations after a database is copied without matching migration
5
+ # bookkeeping. A migration is safe to mark only when every table, column and
6
+ # current index it contributes is already present.
7
+ class MigrationReconciler
8
+ Status = Struct.new(:migration, :missing, keyword_init: true) do
9
+ def complete? = missing.empty?
10
+ end
11
+
12
+ CHECKS = {
13
+ 'CreateBible270Readers' => :check_readers,
14
+ 'CreateBible270Checkoffs' => :check_checkoffs,
15
+ 'CreateBible270Comments' => :check_comments,
16
+ 'CreateBible270SignInTokens' => :check_sign_in_tokens,
17
+ 'AddNamesToBible270Readers' => :check_names,
18
+ 'AddApprovalToBible270Comments' => :check_approval,
19
+ 'CreateBible270Settings' => :check_settings,
20
+ 'AddBibleVersionToBible270Readers' => :check_bible_version,
21
+ 'AddPartToBible270Checkoffs' => :check_checkoff_parts,
22
+ 'AddRememberTokenToBible270Readers' => :check_remember_token,
23
+ 'AddMentionNoticesToBible270Readers' => :check_mention_notices,
24
+ 'AddParentToBible270Comments' => :check_comment_parent,
25
+ 'CreateBible270Likes' => :check_likes,
26
+ 'AddBlueLetterBibleToBible270Readers' => :check_blue_letter_bible,
27
+ 'AddPassageSourceToBible270Readers' => :check_passage_source,
28
+ 'CreateActiveStorageTables' => :check_active_storage
29
+ }.freeze
30
+
31
+ def self.for_current_database
32
+ context = ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths)
33
+ new(connection: ActiveRecord::Base.connection, migration_context: context)
34
+ end
35
+
36
+ def initialize(connection:, migration_context:)
37
+ @connection = connection
38
+ @migration_context = migration_context
39
+ end
40
+
41
+ def statuses
42
+ applied = migration_context.get_all_versions.map(&:to_i)
43
+ migration_context.migrations.filter_map do |migration|
44
+ check = CHECKS[migration.name]
45
+ next unless check
46
+ next if applied.include?(migration.version.to_i)
47
+
48
+ Status.new(migration: migration, missing: public_send(check))
49
+ end
50
+ end
51
+
52
+ def mark_applied!(status)
53
+ raise ArgumentError, "#{status.migration.name} is not reflected in the schema" unless status.complete?
54
+
55
+ migration_context.schema_migration.create_version(status.migration.version.to_s)
56
+ end
57
+
58
+ def check_readers
59
+ table_missing(
60
+ :bible270_readers,
61
+ columns: %i[id display_name email avatar_url provider uid owner_type owner_id started_on created_at updated_at],
62
+ indexes: [
63
+ { columns: %i[owner_type owner_id] },
64
+ { columns: %i[provider uid], unique: true }
65
+ ]
66
+ )
67
+ end
68
+
69
+ def check_checkoffs
70
+ table_missing(
71
+ :bible270_checkoffs,
72
+ columns: %i[id reader_id day track created_at updated_at],
73
+ indexes: [{ columns: %i[reader_id] }, { columns: %i[day] }],
74
+ foreign_keys: [{ column: :reader_id, to_table: :bible270_readers }]
75
+ )
76
+ end
77
+
78
+ def check_comments
79
+ table_missing(
80
+ :bible270_comments,
81
+ columns: %i[id reader_id day track body created_at updated_at],
82
+ indexes: [{ columns: %i[reader_id] }, { columns: %i[day created_at] }],
83
+ foreign_keys: [{ column: :reader_id, to_table: :bible270_readers }]
84
+ )
85
+ end
86
+
87
+ def check_sign_in_tokens
88
+ table_missing(
89
+ :bible270_sign_in_tokens,
90
+ columns: %i[id email token_digest display_name expires_at consumed_at created_at updated_at],
91
+ indexes: [
92
+ { columns: %i[token_digest], unique: true },
93
+ { columns: %i[email created_at] }
94
+ ]
95
+ )
96
+ end
97
+
98
+ def check_names
99
+ table_missing(
100
+ :bible270_readers,
101
+ columns: %i[first_name last_name],
102
+ indexes: [{ columns: %i[last_name first_name] }]
103
+ ) + table_missing(:bible270_sign_in_tokens, columns: %i[first_name last_name])
104
+ end
105
+
106
+ def check_approval
107
+ table_missing(
108
+ :bible270_comments,
109
+ columns: %i[approved moderated_at],
110
+ indexes: [{ columns: %i[approved day] }]
111
+ )
112
+ end
113
+
114
+ def check_settings
115
+ table_missing(
116
+ :bible270_settings,
117
+ columns: %i[id key value created_at updated_at],
118
+ indexes: [{ columns: %i[key], unique: true }]
119
+ )
120
+ end
121
+
122
+ def check_bible_version
123
+ table_missing(:bible270_readers, columns: %i[bible_version])
124
+ end
125
+
126
+ def check_checkoff_parts
127
+ table_missing(
128
+ :bible270_checkoffs,
129
+ columns: %i[part],
130
+ indexes: [{ columns: %i[reader_id day track part], unique: true }]
131
+ )
132
+ end
133
+
134
+ def check_remember_token
135
+ table_missing(
136
+ :bible270_readers,
137
+ columns: %i[remember_token],
138
+ indexes: [{ columns: %i[remember_token] }]
139
+ )
140
+ end
141
+
142
+ def check_mention_notices
143
+ table_missing(:bible270_readers, columns: %i[notify_on_mention])
144
+ end
145
+
146
+ def check_comment_parent
147
+ table_missing(
148
+ :bible270_comments,
149
+ columns: %i[parent_id],
150
+ indexes: [{ columns: %i[parent_id] }],
151
+ foreign_keys: [{ column: :parent_id, to_table: :bible270_comments }]
152
+ )
153
+ end
154
+
155
+ def check_likes
156
+ table_missing(
157
+ :bible270_likes,
158
+ columns: %i[id reader_id comment_id created_at updated_at],
159
+ indexes: [
160
+ { columns: %i[reader_id comment_id], unique: true },
161
+ { columns: %i[comment_id] }
162
+ ],
163
+ foreign_keys: [
164
+ { column: :reader_id, to_table: :bible270_readers },
165
+ { column: :comment_id, to_table: :bible270_comments }
166
+ ]
167
+ )
168
+ end
169
+
170
+ def check_blue_letter_bible
171
+ return [] if connection.column_exists?(:bible270_readers, :passage_source)
172
+
173
+ table_missing(:bible270_readers, columns: %i[blue_letter_bible])
174
+ end
175
+
176
+ def check_passage_source
177
+ table_missing(:bible270_readers, columns: %i[passage_source])
178
+ end
179
+
180
+ # The Bible270 installer can install Active Storage for reader avatars. A
181
+ # copied database can therefore have the same timestamp mismatch for Rails'
182
+ # generated Active Storage migration as it has for the engine migrations.
183
+ def check_active_storage
184
+ table_missing(
185
+ :active_storage_blobs,
186
+ columns: %i[id key filename content_type metadata service_name byte_size checksum created_at],
187
+ indexes: [{ columns: %i[key], unique: true }]
188
+ ) + table_missing(
189
+ :active_storage_attachments,
190
+ columns: %i[id name record_type record_id blob_id created_at],
191
+ indexes: [
192
+ { columns: %i[blob_id] },
193
+ { columns: %i[record_type record_id name blob_id], unique: true }
194
+ ],
195
+ foreign_keys: [{ column: :blob_id, to_table: :active_storage_blobs }]
196
+ ) + table_missing(
197
+ :active_storage_variant_records,
198
+ columns: %i[id blob_id variation_digest],
199
+ indexes: [{ columns: %i[blob_id variation_digest], unique: true }],
200
+ foreign_keys: [{ column: :blob_id, to_table: :active_storage_blobs }]
201
+ )
202
+ end
203
+
204
+ private
205
+
206
+ attr_reader :connection, :migration_context
207
+
208
+ def table_missing(table, columns:, indexes: [], foreign_keys: [])
209
+ return ["table #{table}"] unless connection.data_source_exists?(table)
210
+
211
+ missing_columns(table, columns) +
212
+ missing_indexes(table, indexes) +
213
+ missing_foreign_keys(table, foreign_keys)
214
+ end
215
+
216
+ def missing_columns(table, expected_columns)
217
+ existing = connection.columns(table).map { |column| column.name.to_s }
218
+ expected_columns.filter_map do |column|
219
+ "column #{table}.#{column}" unless existing.include?(column.to_s)
220
+ end
221
+ end
222
+
223
+ def missing_indexes(table, expected_indexes)
224
+ existing = connection.indexes(table)
225
+ expected_indexes.filter_map do |expected|
226
+ columns = expected.fetch(:columns).map(&:to_s)
227
+ next if existing.any? { |index| matching_index?(index, columns, expected) }
228
+
229
+ qualifier = expected[:unique] ? 'unique index' : 'index'
230
+ "#{qualifier} #{table}(#{columns.join(', ')})"
231
+ end
232
+ end
233
+
234
+ def matching_index?(index, columns, expected)
235
+ index.columns == columns && (!expected.key?(:unique) || index.unique == expected[:unique])
236
+ end
237
+
238
+ def missing_foreign_keys(table, expected_foreign_keys)
239
+ existing = connection.foreign_keys(table)
240
+ expected_foreign_keys.filter_map do |expected|
241
+ next if existing.any? { |foreign_key| matching_foreign_key?(foreign_key, expected) }
242
+
243
+ "foreign key #{table}.#{expected[:column]} -> #{expected[:to_table]}"
244
+ end
245
+ end
246
+
247
+ def matching_foreign_key?(foreign_key, expected)
248
+ foreign_key.column.to_s == expected.fetch(:column).to_s &&
249
+ foreign_key.to_table.to_s == expected.fetch(:to_table).to_s
250
+ end
251
+ end
252
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+ require 'bible270/versification'
5
+
3
6
  module Bible270
4
7
  # The translations a reader may choose between, and how each maps onto the
5
8
  # external reader's own code. Kept free of Rails so it can be unit tested.
@@ -11,14 +14,22 @@ module Bible270
11
14
  # `label` is the full name, for prose. `short` is what goes in the select,
12
15
  # where "New American Standard Bible 1995" overflows the box.
13
16
  VERSIONS = {
14
- 'NKJV' => { label: 'New King James Version', short: 'New King James Version', gateway: 'NKJV' },
15
- 'NASB95' => { label: 'New American Standard Bible 1995', short: 'New American Standard 1995', gateway: 'NASB1995' },
16
- 'LSB' => { label: 'Legacy Standard Bible', short: 'Legacy Standard Version', gateway: 'LSB' },
17
- 'ESV' => { label: 'English Standard Version', short: 'English Standard Version', gateway: 'ESV' },
18
- 'KJV' => { label: 'King James Version', short: 'King James Version', gateway: 'KJV' }
17
+ 'NKJV' => { label: 'New King James Version', short: 'New King James Version', gateway: 'NKJV', blue_letter: 'nkjv' },
18
+ 'NASB95' => { label: 'New American Standard Bible 1995', short: 'New American Standard 1995', gateway: 'NASB1995', blue_letter: 'nasb95' },
19
+ 'LSB' => { label: 'Legacy Standard Bible', short: 'Legacy Standard Version', gateway: 'LSB', blue_letter: 'lsb' },
20
+ 'ESV' => { label: 'English Standard Version', short: 'English Standard Version', gateway: 'ESV', blue_letter: 'esv' },
21
+ 'KJV' => { label: 'King James Version', short: 'King James Version', gateway: 'KJV', blue_letter: 'kjv' },
22
+ 'HEB/GRK' => { label: 'Hebrew and Greek', short: 'Hebrew and Greek', gateway: 'WLC', blue_letter: 'wlc/mgnt' }
19
23
  }.freeze
20
24
 
21
25
  DEFAULT = 'NKJV'
26
+ ORIGINAL_LANGUAGES = 'HEB/GRK'
27
+ BLUE_LETTER_BASE_URL = 'https://www.blueletterbible.org'
28
+ BLUE_LETTER_BOOK_CODES = %w[
29
+ gen exo lev num deu jos jdg rut 1sa 2sa 1ki 2ki 1ch 2ch ezr neh est job psa pro ecc sng
30
+ isa jer lam eze dan hos joe amo oba jon mic nah hab zep hag zec mal mat mar luk joh act rom
31
+ 1co 2co gal eph php col 1th 2th 1ti 2ti tit phm heb jas 1pe 2pe 1jo 2jo 3jo jud rev
32
+ ].freeze
22
33
 
23
34
  module_function
24
35
 
@@ -57,6 +68,51 @@ module Bible270
57
68
  codes.map { |code| ["#{code} — #{short_label(code)}", code] }
58
69
  end
59
70
 
71
+ def passage_url(reference, version, blue_letter: false)
72
+ if blue_letter || normalize(version) == ORIGINAL_LANGUAGES
73
+ return blue_letter_url(reference, version)
74
+ end
75
+
76
+ search = URI.encode_www_form_component(reference)
77
+ gateway = URI.encode_www_form_component(gateway_code(version))
78
+ "https://www.biblegateway.com/passage/?search=#{search}&version=#{gateway}"
79
+ end
80
+
81
+ # Blue Letter Bible identifies a verse with its canonical chapter number times
82
+ # 1,000 plus the verse number: Genesis 1:1 is 1001 and Matthew 1:1 is 930001.
83
+ # A plan reference can span chapters or books; BLB has no equivalent passage
84
+ # query URL, so link to the first chapter/verse and let its chapter navigation
85
+ # carry the reader onward.
86
+ def blue_letter_url(reference, version = ORIGINAL_LANGUAGES)
87
+ books = Versification::VERSES.keys
88
+ book_index = books.index { |book| reference.to_s.start_with?("#{book} ") }
89
+ return "#{BLUE_LETTER_BASE_URL}/" unless book_index
90
+
91
+ book = books[book_index]
92
+ location = reference.to_s.delete_prefix("#{book} ")
93
+ match = location.match(%r{\A(\d+)(?::(\d+))?})
94
+ return "#{BLUE_LETTER_BASE_URL}/" unless match
95
+
96
+ chapter = match[1].to_i
97
+ verse = match[2]&.to_i || 1
98
+ chapter_verses = Versification::VERSES[book]
99
+ return "#{BLUE_LETTER_BASE_URL}/" unless chapter.between?(1, chapter_verses.length)
100
+ return "#{BLUE_LETTER_BASE_URL}/" unless verse.between?(1, chapter_verses[chapter - 1])
101
+
102
+ canonical_chapter = books.first(book_index).sum { |prior| Versification::VERSES[prior].length } + chapter
103
+ new_testament = book_index >= books.index('Matthew')
104
+ reader = if normalize(version) == ORIGINAL_LANGUAGES
105
+ new_testament ? 'mgnt' : 'wlc'
106
+ else
107
+ VERSIONS.dig(normalize(version), :blue_letter)
108
+ end
109
+ return "#{BLUE_LETTER_BASE_URL}/" if reader.to_s.empty?
110
+
111
+ anchor = (canonical_chapter * 1000) + verse
112
+ "#{BLUE_LETTER_BASE_URL}/#{reader}/#{BLUE_LETTER_BOOK_CODES[book_index]}/" \
113
+ "#{chapter}/#{verse}/s_#{anchor}"
114
+ end
115
+
60
116
  # Falls back to the configured default, then to ESV, so a link is never built
61
117
  # with a blank version.
62
118
  def resolve(code)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '0.17.1'
4
+ VERSION = '0.18.0'
5
5
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bible270/migration_reconciler'
4
+
5
+ namespace :bible270 do
6
+ namespace :migrations do
7
+ desc 'Reconcile copied Bible270/Active Storage tables with schema_migrations (set APPLY=1 to write)'
8
+ task reconcile: :environment do
9
+ reconciler = Bible270::MigrationReconciler.for_current_database
10
+ statuses = reconciler.statuses
11
+
12
+ if statuses.empty?
13
+ puts 'No pending Bible270 or related Active Storage migrations were found.'
14
+ next
15
+ end
16
+
17
+ complete, incomplete = statuses.partition(&:complete?)
18
+
19
+ puts 'Already reflected in the database schema:'
20
+ if complete.empty?
21
+ puts ' (none)'
22
+ else
23
+ complete.each do |status|
24
+ puts " #{status.migration.version} #{status.migration.name}"
25
+ end
26
+ end
27
+
28
+ unless incomplete.empty?
29
+ puts
30
+ puts 'Still missing schema changes (these will remain pending):'
31
+ incomplete.each do |status|
32
+ puts " #{status.migration.version} #{status.migration.name}"
33
+ status.missing.each { |item| puts " - #{item}" }
34
+ end
35
+ end
36
+
37
+ puts
38
+ unless ENV['APPLY'] == '1'
39
+ puts 'Preview only; schema_migrations was not changed.'
40
+ puts 'Review the list, back up the database, then rerun with APPLY=1.'
41
+ next
42
+ end
43
+
44
+ if complete.empty?
45
+ puts 'Nothing was safe to mark as applied.'
46
+ next
47
+ end
48
+
49
+ ActiveRecord::Base.transaction do
50
+ complete.each { |status| reconciler.mark_applied!(status) }
51
+ end
52
+
53
+ puts "Marked #{complete.size} verified #{'migration'.pluralize(complete.size)} as applied."
54
+ puts 'Run bin/rails db:migrate to apply the migrations whose schema changes are still missing.'
55
+ end
56
+ end
57
+ 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: 0.17.1
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -91,6 +91,8 @@ files:
91
91
  - app/controllers/bible270/sessions_controller.rb
92
92
  - app/helpers/bible270/plan_helper.rb
93
93
  - app/mailers/bible270/application_mailer.rb
94
+ - app/mailers/bible270/broadcast.html.erb
95
+ - app/mailers/bible270/broadcast.text.erb
94
96
  - app/mailers/bible270/notice_mailer.rb
95
97
  - app/mailers/bible270/sign_in_mailer.rb
96
98
  - app/models/bible270/application_record.rb
@@ -119,6 +121,8 @@ files:
119
121
  - app/views/bible270/days/show.html.erb
120
122
  - app/views/bible270/likes/_likes.html.erb
121
123
  - app/views/bible270/likes/toggle.turbo_stream.erb
124
+ - app/views/bible270/notice_mailer/broadcast.html.erb
125
+ - app/views/bible270/notice_mailer/broadcast.text.erb
122
126
  - app/views/bible270/notice_mailer/mentioned.html.erb
123
127
  - app/views/bible270/notice_mailer/mentioned.text.erb
124
128
  - app/views/bible270/notice_mailer/new_reader.html.erb
@@ -132,6 +136,7 @@ files:
132
136
  - app/views/bible270/shared/_footer.html.erb
133
137
  - app/views/bible270/shared/_grid_legend.html.erb
134
138
  - app/views/bible270/shared/_header.html.erb
139
+ - app/views/bible270/shared/_passage_source.html.erb
135
140
  - app/views/bible270/shared/_progress.html.erb
136
141
  - app/views/bible270/shared/_reflections.html.erb
137
142
  - app/views/bible270/shared/_styles.html.erb
@@ -153,6 +158,8 @@ files:
153
158
  - db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb
154
159
  - db/migrate/20260101000012_add_parent_to_bible270_comments.rb
155
160
  - db/migrate/20260101000013_create_bible270_likes.rb
161
+ - db/migrate/20260101000014_add_blue_letter_bible_to_bible270_readers.rb
162
+ - db/migrate/20260101000015_add_passage_source_to_bible270_readers.rb
156
163
  - lib/bible270.rb
157
164
  - lib/bible270/avatars.rb
158
165
  - lib/bible270/configuration.rb
@@ -160,6 +167,7 @@ files:
160
167
  - lib/bible270/engine.rb
161
168
  - lib/bible270/favicon.rb
162
169
  - lib/bible270/mentions.rb
170
+ - lib/bible270/migration_reconciler.rb
163
171
  - lib/bible270/names.rb
164
172
  - lib/bible270/plan.rb
165
173
  - lib/bible270/translations.rb
@@ -168,6 +176,7 @@ files:
168
176
  - lib/generators/bible270/install/install_generator.rb
169
177
  - lib/generators/bible270/install/templates/bible270.rb.tt
170
178
  - lib/generators/bible270/install/templates/omniauth.rb.tt
179
+ - lib/tasks/bible270_migrations.rake
171
180
  homepage: https://github.com/avonderluft/bible270
172
181
  licenses:
173
182
  - MIT