bible270 0.9.0 → 0.11.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -237
  3. data/README.md +159 -79
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +147 -0
  6. data/app/controllers/bible270/application_controller.rb +24 -3
  7. data/app/controllers/bible270/checkoffs_controller.rb +2 -3
  8. data/app/controllers/bible270/comments_controller.rb +6 -2
  9. data/app/controllers/bible270/days_controller.rb +5 -6
  10. data/app/controllers/bible270/profiles_controller.rb +45 -0
  11. data/app/controllers/bible270/readers_controller.rb +5 -4
  12. data/app/controllers/bible270/sessions_controller.rb +89 -26
  13. data/app/helpers/bible270/plan_helper.rb +22 -12
  14. data/app/mailers/bible270/application_mailer.rb +4 -1
  15. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  16. data/app/models/bible270/application_record.rb +1 -0
  17. data/app/models/bible270/checkoff.rb +4 -3
  18. data/app/models/bible270/comment.rb +26 -5
  19. data/app/models/bible270/reader.rb +167 -17
  20. data/app/models/bible270/setting.rb +57 -0
  21. data/app/models/bible270/sign_in_token.rb +5 -2
  22. data/app/views/bible270/admin/comments.html.erb +44 -0
  23. data/app/views/bible270/admin/index.html.erb +48 -0
  24. data/app/views/bible270/admin/show.html.erb +104 -0
  25. data/app/views/bible270/days/_start_date.html.erb +1 -1
  26. data/app/views/bible270/days/index.html.erb +9 -21
  27. data/app/views/bible270/profiles/edit.html.erb +44 -0
  28. data/app/views/bible270/readers/index.html.erb +2 -2
  29. data/app/views/bible270/readers/show.html.erb +5 -5
  30. data/app/views/bible270/sessions/new.html.erb +17 -3
  31. data/app/views/bible270/shared/_day_index.html.erb +12 -0
  32. data/app/views/bible270/shared/_header.html.erb +2 -0
  33. data/app/views/bible270/shared/_styles.html.erb +17 -7
  34. data/app/views/layouts/bible270/application.html.erb +2 -4
  35. data/config/routes.rb +38 -18
  36. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  37. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  38. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  39. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  40. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  41. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  42. data/db/migrate/20260101000007_create_bible270_settings.rb +15 -0
  43. data/lib/bible270/avatars.rb +46 -0
  44. data/lib/bible270/configuration.rb +96 -24
  45. data/lib/bible270/email_sign_in.rb +7 -7
  46. data/lib/bible270/engine.rb +1 -0
  47. data/lib/bible270/names.rb +47 -0
  48. data/lib/bible270/plan.rb +356 -102
  49. data/lib/bible270/versification.rb +84 -67
  50. data/lib/bible270/version.rb +2 -1
  51. data/lib/bible270.rb +8 -5
  52. data/lib/generators/bible270/install/install_generator.rb +521 -38
  53. data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -2
  54. metadata +15 -44
data/README.md CHANGED
@@ -1,26 +1,26 @@
1
1
  # Bible 270
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/bible270.svg)](https://badge.fury.io/rb/bible270) [![Gem Downloads](https://img.shields.io/gem/dt/bible270.svg?style=flat)](http://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
3
+ [![Gem Version](https://badge.fury.io/rb/bible270.svg?refresh=1)](https://badge.fury.io/rb/bible270) [![Gem Downloads](https://img.shields.io/gem/dt/bible270.svg?style=flat)](http://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
4
4
 
5
5
  A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-media-surfer) CMS in mind. Readers tick off each day's readings, post reflections, and see how everyone else is getting on, much like the social plans on Bible.com.
6
6
 
7
- Initially created for use by students and faculty of [Kingdom Movement School of Ministry](https://www.kmsm.life/), to read through all of Scripture together, during the school year. Thus it is potentially useful for any Bible School, Seminary, or Discipleship school with a 9 month school year.
7
+ Initially created for use by students and faculty of [Kingdom Movement School of Ministry](https://www.kmsm.life/), to read through all of Scripture together during the school year. Thus it is potentially useful for any Bible School, Seminary, or Discipleship school working on a 9 month schedule.
8
8
 
9
9
  ## The plan
10
10
 
11
11
  Three readings a day, sized by **verse count** so each day takes roughly the same time.
12
12
 
13
- - **Old Testament** — once through, with Psalms and Proverbs live in their own track. Whole chapters grouped so each day is ~73 verses (range 38–116).
14
- - **New Testament** — once through, a reading every day. 260 chapters over 270 days, so the 10 longest are halved (Luke 1, Matthew 26–27, Mark 14, Luke 9, 12, 22, John 6,8, Acts 7).
15
- - **Psalms & Proverbs** — Psalms once, Proverbs twice, interleaved in one track. Proverbs supplies 62 whole-chapter readings, leaving 208 days for the Psalms; longer psalms are divided to fill them. **Psalm 119 is pinned to 11 sections of exactly 16 verses** (176 = 11 × 16, i.e. two of its eight-verse acrostic stanzas per reading), and 41 psalms in total get divided so no single reading tops 20 verses.
13
+ - **Old Testament** — once through, with Psalms and Proverbs in their own track.
14
+ - **New Testament** — once through, a reading every day. 260 chapters over 270 days, long chapters are divided to even out the daily reading
15
+ - **Psalms & Proverbs** — once each, interleaved in one track. 181 chapters have to cover 270 days, so some are divided, shared between the two books by verse load. **A psalm of 25 verses or fewer is never divided**, keeping 131 of the 150 whole; longer psalms and Proverbs chapters break at their turns of thought (see `CHAPTER_BREAKS`). **Psalm 119 is 11 sections of 16 verses** — two of its eight-verse acrostic stanzas each. Proverbs lands on 89 days, near its proportional share. **A divided chapter is always read on consecutive days**, so nothing is inserted between the parts of a split psalm.
16
16
 
17
- Genesi → Malachi and Revelation 22 both land on day 270, as does the second Proverbs 31 — the first falls on day 135, the midpoint. Psalm 150 comes in on day 269. Every day carries all three tracks. A typical day is ~119 verses (range 76–175).
17
+ Genesis → Malachi and Revelation 22 both land on day 270, as does the second Proverbs 31 — the first falls on day 135, the midpoint. Psalm 150 comes in on day 269. Every day carries all three tracks. A typical day is ~119 verses (range 76–175).
18
18
 
19
19
  The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is stored. Portions come from per-chapter verse counts in `Bible270::Versification`. The DB only holds readers, check-offs, comments, and sign-in tokens.
20
20
 
21
21
  ## Requirements
22
22
 
23
- - **Ruby** >= 3.0. Plain Ruby, no C extensions. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
23
+ - **Ruby** >= 3.2. Plain Ruby, no C extensions. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
24
24
  - **Rails** >= 7.0. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
25
25
  - A host app, and Turbo if you want check-offs without a page reload.
26
26
 
@@ -42,29 +42,73 @@ gem "bible270", path: "<path_to_your_local_copy>/bible270" # local checkout
42
42
 
43
43
  ```bash
44
44
  bundle install
45
- bin/rails bible270:install:migrations
46
- bin/rails db:migrate
45
+ bin/rails generate bible270:install
46
+ ```
47
+
48
+ That's the whole install. The generator is interactive and walks you through it:
49
+
50
+ 1. asks where to mount the plan;
51
+ 2. asks which sign-in methods you want — email link, OmniAuth, or both — and adds any OmniAuth strategy gems to your `Gemfile`;
52
+ 3. copies the four migrations into your `db/migrate` and offers to run them;
53
+ 4. writes `config/initializers/bible270.rb` (and `omniauth.rb` if you chose social sign-in); and
54
+ 5. adds `mount Bible270::Engine, at: Bible270.config.mount_at` to `config/routes.rb`.
55
+
56
+ (Migrations run before the config is written, so a problem in a generated initializer can't block the database work.)
57
+
58
+ Every prompt has a default, so holding Enter gives a working install at `/daily-bread` with email and GitHub sign-in. It finishes by listing what's left for you — provider credentials, callback URLs, mailer setup.
59
+
60
+ The copied migrations are yours from then on, in your `schema.rb`. The engine doesn't add its own migration directory to your app's paths, so the copies are the only definitions in play.
61
+
62
+ Non-interactive (CI, re-runs, scripted setup):
63
+
64
+ ```bash
65
+ bin/rails generate bible270:install --defaults \
66
+ --mount-at=/daily-bread --providers=github,google_oauth2 \
67
+ --mailer-from=no-reply@example.org --no-migrate
47
68
  ```
48
69
 
49
- That copies four migrations into your `db/migrate` — yours from then on, in your `schema.rb`. The engine doesn't touch your migration paths, so the copies are the only definitions in play.
70
+ | Flag | Effect |
71
+ |------|--------|
72
+ | `--defaults` | Ask nothing, accept every default |
73
+ | `--mount-at=PATH` | Where to mount (default `/daily-bread`) |
74
+ | `--providers=a,b` | OmniAuth providers; `--providers=` for none |
75
+ | `--email` / `--no-email` | Email sign-in on or off |
76
+ | `--mailer-from=ADDR` | From: address for sign-in emails |
77
+ | `--bundle` / `--no-bundle` | Run `bundle install` after adding strategy gems |
78
+ | `--migrate` / `--no-migrate` | Run `db:migrate` at the end |
79
+
80
+ The installer is safe to re-run: it leaves an existing `mount` line alone, skips strategy gems already in your Gemfile, and Rails prompts before overwriting an initializer.
50
81
 
51
- Mount it:
82
+ <details>
83
+ <summary>Doing it by hand instead</summary>
84
+
85
+ ```ruby
86
+ # config/initializers/bible270.rb
87
+ Bible270.configure do |config|
88
+ config.mount_at = "/daily-bread"
89
+ config.email_sign_in = true
90
+ config.mailer_from = "no-reply@example.com"
91
+ config.omniauth_providers = [] # or [:github], plus an OmniAuth initializer
92
+ end
93
+ ```
52
94
 
53
95
  ```ruby
54
96
  # config/routes.rb
55
97
  mount Bible270::Engine, at: Bible270.config.mount_at
56
98
  ```
57
99
 
58
- Optionally generate the initializers:
59
-
60
100
  ```bash
61
- bin/rails generate bible270:install --providers=github
101
+ bin/rails bible270:install:migrations
102
+ bin/rails db:migrate
62
103
  ```
63
104
 
64
- You're live at `/daily-bread`.
105
+ See [Authentication](#authentication) for the OmniAuth initializer.
106
+ </details>
65
107
 
66
108
  ### Upgrading
67
109
 
110
+ No need to re-run the generator — just pick up any new migrations:
111
+
68
112
  ```bash
69
113
  bundle update bible270
70
114
  bin/rails bible270:install:migrations # skips ones you already have
@@ -82,8 +126,7 @@ Bible270.configure do |config|
82
126
  end
83
127
  ```
84
128
 
85
- Routes read it directly; OmniAuth reads `Bible270.config.auth_path_prefix` (just
86
- `"<mount_at>/auth"`). The engine builds its own sign-in links from `request.script_name` at runtime, so there's no third spot to update.
129
+ Routes read it directly; OmniAuth reads `Bible270.config.auth_path_prefix` (just `"<mount_at>/auth"`). The engine builds its own sign-in links from `request.script_name` at runtime, so there's no third spot to update.
87
130
 
88
131
  Values are normalised — `"read270"`, `"/read270"`, `"/read270/"` all become `/read270`. Nested paths and mounting at `/` work too.
89
132
 
@@ -100,8 +143,7 @@ Two ways in, so **nobody's shut out**:
100
143
  1. **Email link** — type an address, get a one-time link, click it. No password, no third-party account. The default, and what makes this usable by people with no GitHub or Google login.
101
144
  2. **Social sign-in** via OmniAuth — for people who'd rather.
102
145
 
103
- Either works alone: `omniauth_providers = []` for email-only, `email_sign_in = false` for
104
- social-only. Reading is always public; only ticking off and commenting need an account.
146
+ Either works alone: `omniauth_providers = []` for email-only, `email_sign_in = false` for social-only. Reading is always public; only ticking off and commenting need an account.
105
147
 
106
148
  ### Email links
107
149
 
@@ -109,7 +151,8 @@ social-only. Reading is always public; only ticking off and commenting need an a
109
151
  Bible270.configure do |config|
110
152
  config.email_sign_in = true
111
153
  config.mailer_from = "no-reply@example.com"
112
- config.email_sign_in_ask_name = true # let readers pick their display name
154
+ config.email_sign_in_ask_name = true
155
+ config.email_sign_in_require_name = true # first AND last name # let readers pick their display name
113
156
  # config.email_sign_in_ttl = 20 * 60 # link lifetime, seconds
114
157
  # config.email_sign_in_max_per_window = 5 # per address
115
158
  # config.email_sign_in_window = 15 * 60
@@ -120,39 +163,30 @@ end
120
163
  All it needs is working Action Mailer delivery.
121
164
 
122
165
  - Tokens: 256-bit, URL-safe, single-use, 20-minute expiry.
123
- - **Only a SHA-256 digest is stored** — no password column anywhere, so the table is useless to
124
- anyone reading your DB.
166
+ - **Only a SHA-256 digest is stored** — no password column anywhere, so the table is useless to anyone reading your DB.
125
167
  - Claiming is a conditional update, so a double-clicked link still signs you in once.
126
- - "Check your inbox" reads the same whether the address was known, unknown, or rate-limited — no
127
- account enumeration.
168
+ - "Check your inbox" reads the same whether the address was known, unknown, or rate-limited — no account enumeration.
128
169
  - Display name comes from the reader, else the address (`mary.anne.smith@…` → "Mary Anne Smith").
129
170
  - `Bible270::SignInToken.sweep!` clears spent tokens. Good cron fodder.
130
171
 
131
- Email readers get `provider: "email"` in the same columns OmniAuth uses, so everything downstream
132
- treats them identically.
172
+ Email readers get `provider: "email"` in the same columns OmniAuth uses, so everything downstream treats them identically.
133
173
 
134
174
  ### Social sign-in
135
175
 
136
- The generator writes both initializers for you and adds the `mount` line:
137
-
138
- ```bash
139
- bin/rails generate bible270:install --providers=github
140
- ```
176
+ `bin/rails generate bible270:install` sets this up: it adds the strategy gem to your `Gemfile`
177
+ (`omniauth` and `omniauth-rails_csrf_protection` already ride along with bible270), writes the
178
+ OmniAuth initializer, and prints the callback URL to register with each provider — for example
179
+ `https://example.com/daily-bread/auth/github/callback`.
141
180
 
142
- Add the strategy gem (`omniauth` and `omniauth-rails_csrf_protection` ride along with bible270):
143
-
144
- ```ruby
145
- gem "omniauth-github"
146
- ```
147
-
148
- Register the callback with the provider — `https://example.com/daily-bread/auth/github/callback`.
149
-
150
- Doing it by hand? The one thing to get right is lining up `path_prefix` with the mount point:
181
+ Adding a provider later, or wiring it up yourself? Add its strategy gem, list it in `config.omniauth_providers`, and register it with OmniAuth. The one thing to get right is lining up `path_prefix` with the mount point:
151
182
 
152
183
  ```ruby
153
184
  # config/initializers/omniauth.rb
154
185
  Rails.application.config.middleware.use OmniAuth::Builder do
155
- path_prefix Bible270.config.auth_path_prefix
186
+ # `options` is merged into every provider declared after it, so it comes first.
187
+ # OmniAuth::Builder has no path_prefix method — this is the scoped equivalent.
188
+ options path_prefix: Bible270.config.auth_path_prefix
189
+
156
190
  provider :github, ENV["GITHUB_CLIENT_ID"], ENV["GITHUB_CLIENT_SECRET"]
157
191
  end
158
192
 
@@ -170,12 +204,9 @@ end
170
204
 
171
205
  Worth knowing:
172
206
 
173
- - **Sign-in is a POST.** OmniAuth 2.0+ refuses GET
174
- ([CVE-2015-9284](https://nvd.nist.gov/vuln/detail/CVE-2015-9284)), so the engine's controls are
175
- `button_to` forms with CSRF tokens. Build your own and it must POST to `<mount>/auth/:provider`.
207
+ - **Sign-in is a POST.** OmniAuth 2.0+ refuses GET ([CVE-2015-9284](https://nvd.nist.gov/vuln/detail/CVE-2015-9284)), so the engine's controls are `button_to` forms with CSRF tokens. Build your own and it must POST to `<mount>/auth/:provider`.
176
208
  - One provider → a button in the header. Several → a sign-in page at `<mount>/sign_in`.
177
- - Sign-in carries the current path as `origin`, so clicking a check-off while signed out brings you
178
- back to that day. Origins must be local paths.
209
+ - Sign-in carries the current path as `origin`, so clicking a check-off while signed out brings you back to that day. Origins must be local paths.
179
210
  - `reset_session` on sign-in and sign-out, against session fixation.
180
211
  - Stored: provider, uid, display name, email, avatar URL. No tokens, no passwords.
181
212
 
@@ -192,8 +223,42 @@ config.current_reader_resolver = lambda do |controller|
192
223
  end
193
224
  ```
194
225
 
195
- `Reader.for_owner` links to any host model polymorphically. Heads up: CMS admin auth is separate
196
- from public visitors, so for a public plan the email/OmniAuth route above is usually what you want.
226
+ `Reader.for_owner` links to any host model polymorphically. Heads up: CMS admin auth is separate from public visitors, so for a public plan the email/OmniAuth route above is usually what you want.
227
+
228
+ ## Admin panel
229
+
230
+ At `<mount>/admin`: remove readers, adjust their completions, move them to a given day. Unreachable —
231
+ every route 404s rather than 403s — unless you say who may use it:
232
+
233
+ ```ruby
234
+ config.admin_emails = %w[andrew@example.org]
235
+ # or
236
+ config.admin_resolver = ->(reader) { reader.email.to_s.end_with?('@example.org') }
237
+ ```
238
+
239
+ Admins get an extra "Admin" link in the header. From a reader's page you can:
240
+
241
+ - **move them to a day** — "put them on day 42 as of today" back-dates the start date; check-offs are
242
+ keyed to day numbers, so nothing in their history moves;
243
+ - **set their progress** — mark complete through day N (this also clears anything after, so it sets
244
+ progress exactly), or click days in the grid to toggle them;
245
+ - **remove them**, with every check-off and reflection they've written;
246
+ - **moderate reflections** at `<mount>/admin/comments`. Reflections are visible the moment they're
247
+ written; hiding one takes it off the day and community pages without deleting it, and is
248
+ reversible. Delete is there too, for anything that shouldn't be kept.
249
+
250
+ Break points can also live in your app rather than the gem, and be changed without a restart:
251
+
252
+ ```ruby
253
+ config.chapter_breaks = { 'Psalm 78' => [20, 39, 55] }
254
+ config.chapter_breaks_path = Rails.root.join('config/bible270_breaks.yml')
255
+ ```
256
+
257
+ ```yaml
258
+ # config/bible270_breaks.yml — re-read whenever it changes
259
+ Psalm 35: []
260
+ Psalm 78: [20, 39, 55]
261
+ ```
197
262
 
198
263
  ## Start dates
199
264
 
@@ -211,9 +276,7 @@ config.allow_reader_start_date = true # can readers set their own?
211
276
  | a date | false | Everyone pinned to the community date, form hidden |
212
277
  | nil | false | Fully undated — no calendar anywhere |
213
278
 
214
- Dated plans show each day's date with a **Today** badge, a "Go to today" link, and whether you're
215
- ahead or behind. Readers set their own from the overview. Changing a start date only re-maps the
216
- calendar — check-offs and reflections are keyed to day numbers, so nothing moves.
279
+ Dated plans show each day's date with a **Today** badge, a "Go to today" link, and whether you're ahead or behind. Readers set their own from the overview. Changing a start date only re-maps the calendar — check-offs and reflections are keyed to day numbers, so nothing moves.
217
280
 
218
281
  Date helpers are pure functions, usable anywhere:
219
282
 
@@ -232,8 +295,10 @@ Bible270.configure do |config|
232
295
  config.mount_at = "/daily-bread" # single source of truth for the path
233
296
  config.app_name = "Daily Bread"
234
297
  config.tagline = "A 270-day journey through Scripture"
298
+ config.admin_emails = %w[andrew@example.org]
235
299
 
236
- config.start_date = nil # community start date, or nil for undated
300
+ # config.start_date = nil # community start date, or nil for undated
301
+ config.start_date = Date.new(2026, 9, 6)
237
302
  config.allow_reader_start_date = true
238
303
 
239
304
  config.parent_controller = "ActionController::Base" # or "::ApplicationController"
@@ -262,8 +327,7 @@ References link to Bible Gateway by default — point `passage_url_builder` at y
262
327
 
263
328
  ## With ComfortableMediaSurfer
264
329
 
265
- The CMS serves your content; this is a separate mounted app. Either **just link to it** (mount, add a nav link, done — the engine renders its own themed pages), or **match your chrome** by setting `config.parent_controller = "::ApplicationController"` and `config.layout = "layouts/application"`
266
- so it sits inside your header and footer. All engine CSS is prefixed `b270-`, so nothing collides.
330
+ The CMS serves your content; this is a separate mounted app. Either **just link to it** (mount, add a nav link, done — the engine renders its own themed pages), or **match your chrome** by setting `config.parent_controller = "::ApplicationController"` and `config.layout = "layouts/application"` so it sits inside your header and footer. All engine CSS is prefixed `b270-`, so nothing collides.
267
331
 
268
332
  ## Data model
269
333
 
@@ -309,6 +373,31 @@ The whole schedule falls out of a few constants in `Bible270::Plan`:
309
373
  | `DAYS` | `270` | plan length |
310
374
  | `PROVERBS_PASSES` | `2` | Proverbs laps. Each pass is 31 readings, so this decides how many days are left for the Psalms |
311
375
  | `PSALM_119_SECTION_SIZE` | `16` | verses per Psalm 119 section. 176 divides evenly by 8, 11, 16, 22 and 44 |
376
+ | `CHAPTER_BREAKS` | `{}` | where specific chapters break — see below |
377
+
378
+ ### Choosing where a chapter breaks
379
+
380
+ By default a divided chapter is cut into equal parts. To break it somewhere meaningful instead, add
381
+ an entry to `CHAPTER_BREAKS` in `lib/bible270/plan.rb`. Keys are `[book, chapter]`; the value lists
382
+ the **last verse of every reading except the final one**:
383
+
384
+ ```ruby
385
+ CHAPTER_BREAKS = {
386
+ ['Luke', 1] => [25, 56], # => Luke 1:1–25, 1:26–56, 1:57–80
387
+ ['Psalm', 78] => [39] # => Psalm 78:1–39, 78:40–72
388
+ }.freeze
389
+ ```
390
+
391
+ The number of breaks fixes how many readings that chapter becomes, and the rest of the plan
392
+ re-divides around it so both tracks still fill exactly 270 days — give Luke 1 a third reading and
393
+ some other long chapter quietly gives one up. Verse coverage stays exact either way.
394
+
395
+ Break points are validated on use: they must be ascending, distinct, and within the chapter, or you
396
+ get an `ArgumentError` naming the entry rather than silently overlapping readings. Pinning more
397
+ readings in total than there are days also raises, instead of producing a plan that doesn't fit.
398
+
399
+ Psalm 119 is pinned to `PSALM_119_SECTION_SIZE` sections by default; an entry in `CHAPTER_BREAKS`
400
+ overrides that.
312
401
 
313
402
  Change `PROVERBS_PASSES` or `DAYS` and both the New Testament and the Psalms re-divide automatically
314
403
  to fill whatever's left. Divisions always go to whichever chapter currently carries the heaviest
@@ -318,45 +407,29 @@ All computed at load and memoized — no generated schedule, so nothing to migra
318
407
 
319
408
  ## Scaling
320
409
 
321
- The leaderboard counts completed days in Ruby from grouped check-offs — fine for hundreds of
322
- readers. Past a few thousand, cache a `days_completed` counter on `Reader` via a `Checkoff`
323
- `after_commit` and sort in SQL.
410
+ The leaderboard counts completed days in Ruby from grouped check-offs — fine for hundreds of readers. Past a few thousand, cache a `days_completed` counter on `Reader` via a `Checkoff` `after_commit` and sort in SQL.
324
411
 
325
412
  ## Troubleshooting
326
413
 
327
- **`DuplicateMigrationNameError: Multiple migrations have the name CreateBible270Readers`**
328
-
329
- 0.6.2 and earlier wrongly added the engine's `db/migrate` to your app's paths *and* shipped
330
- `install:migrations`, so copying defined everything twice. `bundle update bible270` to >= 0.6.3,
331
- keep your copies, migrate.
332
-
333
414
  **404 on the provider callback**
334
415
 
335
- `path_prefix` doesn't match the mount point. Use `Bible270.config.auth_path_prefix` rather than a
336
- literal, and check the URL registered with the provider matches. Setting `mount_at` in an initializer
337
- Rails loads *after* `omniauth.rb` gives you the default prefix — see [Mount point](#mount-point).
416
+ `path_prefix` doesn't match the mount point. Use `Bible270.config.auth_path_prefix` rather than a literal, and check the URL registered with the provider matches. Setting `mount_at` in an initializer Rails loads *after* `omniauth.rb` gives you the default prefix — see [Mount point](#bible270-mount-point-in-your-rails-app).
338
417
 
339
418
  **Sign-in button does nothing, or `OmniAuth::AuthenticityError`**
340
419
 
341
- Has to be a POST with a CSRF token. The engine's controls already are — if you built your own, make
342
- it a `button_to`. Also check `omniauth-rails_csrf_protection` is in the bundle.
420
+ Has to be a POST with a CSRF token. The engine's controls already are — if you built your own, make it a `button_to`. Also check `omniauth-rails_csrf_protection` is in the bundle.
343
421
 
344
422
  **No email arrives**
345
423
 
346
- Needs working Action Mailer delivery — the engine just calls `deliver_now`. Check `mailer_from` is
347
- an address your relay accepts, and watch the logs. Failures never surface to the reader, on purpose,
348
- since that message can't reveal which addresses exist.
424
+ Needs working Action Mailer delivery — the engine just calls `deliver_now`. Check `mailer_from` is an address your relay accepts, and watch the logs. Failures never surface to the reader, on purpose, since that message can't reveal which addresses exist.
349
425
 
350
426
  **"That link has expired or was already used"**
351
427
 
352
- Links are single-use, 20-minute life. Some mail scanners and link-preview bots fetch URLs before the
353
- recipient clicks, burning the token. If it keeps happening, lengthen `email_sign_in_ttl` or find
354
- what's prefetching.
428
+ Links are single-use, 20-minute life. Some mail scanners and link-preview bots fetch URLs before the recipient clicks, burning the token. If it keeps happening, lengthen `email_sign_in_ttl` or find what's prefetching.
355
429
 
356
430
  **Everything's unstyled in my layout**
357
431
 
358
- The engine's CSS rides in a partial its own layout renders. Using your layout? Add
359
- `<%= render "bible270/shared/styles" %>` to the `<head>`, or style the `b270-*` classes yourself.
432
+ The engine's CSS rides in a partial its own layout renders. Using your layout? Add `<%= render "bible270/shared/styles" %>` to the `<head>`, or style the `b270-*` classes yourself.
360
433
 
361
434
  ## Development
362
435
 
@@ -365,6 +438,14 @@ bundle install
365
438
  rake test
366
439
  ```
367
440
 
441
+ ### Get an email link for local testing
442
+
443
+ in the rails console: `rails c`
444
+ ```
445
+ _record, raw = Bible270::SignInToken.issue!("John.Smith@example.com")
446
+ Bible270::Engine.routes.url_helpers.email_sign_in_url(token: raw, host: "localhost:3333")
447
+ ```
448
+
368
449
  Plan logic is fully unit-tested with no Rails dependency.
369
450
 
370
451
  ## Contributing
@@ -381,11 +462,10 @@ Bug reports and pull requests are welcome at <https://github.com/avonderluft/bib
381
462
  A few things that'll make review quick:
382
463
 
383
464
  - Keep plan logic free of Rails so it stays unit-testable.
384
- - Anything touching the reading schedule needs a test pinning the expected references — the plan is
385
- deterministic, so assert exact values.
465
+ - Anything touching the reading schedule needs a test pinning the expected references — the plan is deterministic, so assert exact values.
386
466
  - Prefix new CSS classes and helpers with `b270`.
387
467
  - Note anything user-visible in `CHANGELOG.md`.
388
468
 
389
469
  ## License
390
470
 
391
- MIT. See `MIT-LICENSE`.
471
+ MIT. See `MIT-LICENSE`.
data/Rakefile CHANGED
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
- require "bundler/gem_tasks" # provides build / install / release
3
- require "rake/testtask"
2
+
3
+ require 'bundler/gem_tasks' # provides build / install / release
4
+ require 'rake/testtask'
4
5
 
5
6
  Rake::TestTask.new(:test) do |t|
6
- t.libs << "test" << "lib"
7
- t.pattern = "test/**/*_test.rb"
7
+ t.libs << 'test' << 'lib'
8
+ t.pattern = 'test/**/*_test.rb'
8
9
  t.warning = false
9
10
  end
10
11
 
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Small administrative panel: remove readers, adjust their completions, and
5
+ # move them to a given day of the plan.
6
+ #
7
+ # Access is controlled by Bible270.config.admin_emails / admin_resolver. With
8
+ # neither set the panel is unreachable, and every action 404s rather than 403s
9
+ # so its existence isn't advertised.
10
+ class AdminController < ApplicationController
11
+ before_action :require_admin!
12
+ before_action :load_reader,
13
+ only: %i[show destroy update_start update_name complete_through toggle_day]
14
+ before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
15
+
16
+ # ---- enrolment --------------------------------------------------------
17
+
18
+ def update_enrollment
19
+ if params[:state] == 'closed'
20
+ Setting.close_enrollment!
21
+ redirect_to admin_path, notice: 'Closed to new readers. Existing readers can still sign in.'
22
+ else
23
+ Setting.open_enrollment!
24
+ redirect_to admin_path, notice: 'Open to new readers.'
25
+ end
26
+ end
27
+
28
+ def index
29
+ @enrollment_closed = Setting.enrollment_closed?
30
+ @enrollment_closed_at = Setting.enrollment_closed_at
31
+ @readers = Reader.all.to_a.sort_by(&:sort_name)
32
+ counts = Checkoff.group(:reader_id, :day).count
33
+ @days_completed = Hash.new(0)
34
+ counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.required_track_count(day) }
35
+ end
36
+
37
+ def show
38
+ @days_completed = @reader.days_completed
39
+ @comments = @reader.comments.order(created_at: :desc).limit(50)
40
+ end
41
+
42
+ # ---- reflections ------------------------------------------------------
43
+
44
+ # Everything is visible by default; this lists it all so anything unsuitable
45
+ # can be taken down.
46
+ def comments
47
+ scope = Comment.includes(:reader).order(created_at: :desc)
48
+ @filter = params[:filter].to_s
49
+ scope = scope.hidden if @filter == 'hidden'
50
+ scope = scope.approved if @filter == 'visible'
51
+ @comments = scope.limit(200)
52
+ @hidden_count = Comment.hidden.count
53
+ end
54
+
55
+ def hide_comment
56
+ @comment.hide!
57
+ redirect_back_to_comments "Hidden #{@comment.reader.display_name}'s reflection on day #{@comment.day}."
58
+ end
59
+
60
+ def unhide_comment
61
+ @comment.unhide!
62
+ redirect_back_to_comments "Restored #{@comment.reader.display_name}'s reflection on day #{@comment.day}."
63
+ end
64
+
65
+ def destroy_comment
66
+ day = @comment.day
67
+ @comment.destroy
68
+ redirect_back_to_comments "Deleted the reflection on day #{day}."
69
+ end
70
+
71
+ def destroy
72
+ name = @reader.display_name
73
+ @reader.destroy
74
+ redirect_to admin_path, notice: "Removed #{name} and all their check-offs and reflections."
75
+ end
76
+
77
+ # Put the reader on a given day as of today, or set an explicit start date.
78
+ def update_start
79
+ if params[:start_date].present?
80
+ # set_start_date!, not update_start_date!: an admin is not subject to
81
+ # config.allow_reader_start_date, which governs what readers may do to
82
+ # their own dates.
83
+ if @reader.set_start_date!(params[:start_date])
84
+ redirect_to admin_reader_path(@reader),
85
+ notice: "Start date set to #{@reader.started_on.strftime('%B %-d, %Y')}."
86
+ else
87
+ redirect_to admin_reader_path(@reader),
88
+ alert: "Couldn't read #{params[:start_date].inspect} as a date."
89
+ end
90
+ elsif params[:day].present? && @reader.restart_on!(day: params[:day])
91
+ redirect_to admin_reader_path(@reader),
92
+ notice: "#{@reader.display_name} is now on day #{params[:day]}."
93
+ else
94
+ redirect_to admin_reader_path(@reader), alert: 'Give a start date or a day between 1 and 270.'
95
+ end
96
+ end
97
+
98
+ def update_name
99
+ if @reader.update_names(params[:first_name], params[:last_name])
100
+ redirect_to admin_reader_path(@reader), notice: 'Name updated.'
101
+ else
102
+ redirect_to admin_reader_path(@reader), alert: 'Both a first and last name are needed.'
103
+ end
104
+ end
105
+
106
+ def complete_through
107
+ day = params[:day].to_i
108
+ if @reader.mark_through!(day)
109
+ redirect_to admin_reader_path(@reader),
110
+ notice: day.zero? ? 'All check-offs cleared.' : "Marked days 1-#{day} complete."
111
+ else
112
+ redirect_to admin_reader_path(@reader), alert: 'Give a day between 0 and 270.'
113
+ end
114
+ end
115
+
116
+ def toggle_day
117
+ day = params[:day].to_i
118
+ if @reader.toggle_day!(day)
119
+ redirect_to admin_reader_path(@reader, anchor: "day-#{day}")
120
+ else
121
+ redirect_to admin_reader_path(@reader), alert: 'That day is outside the plan.'
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def require_admin!
128
+ return if Bible270.config.admin_configured? && Bible270.config.admin?(current_reader)
129
+
130
+ raise ActionController::RoutingError, 'Not Found'
131
+ end
132
+
133
+ def load_comment
134
+ @comment = Comment.find_by(id: params[:id])
135
+ redirect_to(admin_comments_path, alert: 'Reflection not found.') if @comment.nil?
136
+ end
137
+
138
+ def redirect_back_to_comments(notice)
139
+ redirect_to(params[:return_to].presence || admin_comments_path, notice: notice)
140
+ end
141
+
142
+ def load_reader
143
+ @reader = Reader.find_by(id: params[:id])
144
+ redirect_to(admin_path, alert: 'Reader not found.') if @reader.nil?
145
+ end
146
+ end
147
+ end
@@ -1,16 +1,37 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class ApplicationController < Bible270.config.parent_controller.constantize
5
+ # Rails only auto-includes an isolated engine's helpers when the controller's
6
+ # superclass is *exactly* ActionController::Base:
7
+ #
8
+ # # ActionController::Railties::Helpers#inherited
9
+ # klass.helpers_path = namespace.railtie_helpers_paths
10
+ # klass.helper :all if klass.superclass == ActionController::Base && ...
11
+ #
12
+ # config.parent_controller normally points at the host's ApplicationController,
13
+ # so that check fails and the b270_* helpers are never mixed in — every view
14
+ # calling one then raises NoMethodError. helpers_path is still set to this
15
+ # engine's app/helpers, so asking for them explicitly is all that's needed.
16
+ helper :all
17
+ # ...and name it outright, in case helpers_path resolves to the host app's
18
+ # helpers rather than this engine's.
19
+ helper Bible270::PlanHelper
20
+
4
21
  layout :bible270_layout
5
22
 
6
- helper_method :current_reader, :signed_in?, :b270_config
23
+ helper_method :current_reader, :signed_in?, :b270_config, :enrollment_closed?
7
24
 
8
- private
25
+ private
9
26
 
10
27
  def bible270_layout
11
28
  Bible270.config.layout
12
29
  end
13
30
 
31
+ def enrollment_closed?
32
+ Setting.enrollment_closed?
33
+ end
34
+
14
35
  def b270_config
15
36
  Bible270.config
16
37
  end
@@ -39,7 +60,7 @@ module Bible270
39
60
  format.turbo_stream { head :unauthorized }
40
61
  format.html do
41
62
  redirect_to sign_in_path(origin: request.fullpath),
42
- alert: "Please sign in to take part."
63
+ alert: 'Please sign in to take part.'
43
64
  end
44
65
  end
45
66
  false
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class CheckoffsController < ApplicationController
4
5
  def toggle
@@ -7,9 +8,7 @@ module Bible270
7
8
  @day = params[:day].to_i
8
9
  @track = params[:track].to_s
9
10
 
10
- unless Plan.valid_day?(@day) && Plan.present_tracks(@day).include?(@track)
11
- head :bad_request and return
12
- end
11
+ head :bad_request and return unless Plan.valid_day?(@day) && Plan.present_tracks(@day).include?(@track)
13
12
 
14
13
  existing = current_reader.checkoffs.find_by(day: @day, track: @track)
15
14
  if existing
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class CommentsController < ApplicationController
4
5
  def create
@@ -17,7 +18,10 @@ module Bible270
17
18
  end
18
19
  else
19
20
  respond_to do |format|
20
- format.turbo_stream { render turbo_stream: turbo_stream.replace("new_comment_form", partial: "bible270/comments/form", locals: { day: @day, comment: @comment }) }
21
+ format.turbo_stream do
22
+ render turbo_stream: turbo_stream.replace('new_comment_form', partial: 'bible270/comments/form',
23
+ locals: { day: @day, comment: @comment })
24
+ end
21
25
  format.html { redirect_to day_path(@day), alert: @comment.errors.full_messages.to_sentence }
22
26
  end
23
27
  end
@@ -37,7 +41,7 @@ module Bible270
37
41
  end
38
42
  end
39
43
 
40
- private
44
+ private
41
45
 
42
46
  def comment_params
43
47
  params.require(:comment).permit(:body, :track)