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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +196 -237
- data/README.md +159 -79
- data/Rakefile +5 -4
- data/app/controllers/bible270/admin_controller.rb +147 -0
- data/app/controllers/bible270/application_controller.rb +24 -3
- data/app/controllers/bible270/checkoffs_controller.rb +2 -3
- data/app/controllers/bible270/comments_controller.rb +6 -2
- data/app/controllers/bible270/days_controller.rb +5 -6
- data/app/controllers/bible270/profiles_controller.rb +45 -0
- data/app/controllers/bible270/readers_controller.rb +5 -4
- data/app/controllers/bible270/sessions_controller.rb +89 -26
- data/app/helpers/bible270/plan_helper.rb +22 -12
- data/app/mailers/bible270/application_mailer.rb +4 -1
- data/app/mailers/bible270/sign_in_mailer.rb +1 -0
- data/app/models/bible270/application_record.rb +1 -0
- data/app/models/bible270/checkoff.rb +4 -3
- data/app/models/bible270/comment.rb +26 -5
- data/app/models/bible270/reader.rb +167 -17
- data/app/models/bible270/setting.rb +57 -0
- data/app/models/bible270/sign_in_token.rb +5 -2
- data/app/views/bible270/admin/comments.html.erb +44 -0
- data/app/views/bible270/admin/index.html.erb +48 -0
- data/app/views/bible270/admin/show.html.erb +104 -0
- data/app/views/bible270/days/_start_date.html.erb +1 -1
- data/app/views/bible270/days/index.html.erb +9 -21
- data/app/views/bible270/profiles/edit.html.erb +44 -0
- data/app/views/bible270/readers/index.html.erb +2 -2
- data/app/views/bible270/readers/show.html.erb +5 -5
- data/app/views/bible270/sessions/new.html.erb +17 -3
- data/app/views/bible270/shared/_day_index.html.erb +12 -0
- data/app/views/bible270/shared/_header.html.erb +2 -0
- data/app/views/bible270/shared/_styles.html.erb +17 -7
- data/app/views/layouts/bible270/application.html.erb +2 -4
- data/config/routes.rb +38 -18
- data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
- data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
- data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
- data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
- data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
- data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
- data/db/migrate/20260101000007_create_bible270_settings.rb +15 -0
- data/lib/bible270/avatars.rb +46 -0
- data/lib/bible270/configuration.rb +96 -24
- data/lib/bible270/email_sign_in.rb +7 -7
- data/lib/bible270/engine.rb +1 -0
- data/lib/bible270/names.rb +47 -0
- data/lib/bible270/plan.rb +356 -102
- data/lib/bible270/versification.rb +84 -67
- data/lib/bible270/version.rb +2 -1
- data/lib/bible270.rb +8 -5
- data/lib/generators/bible270/install/install_generator.rb +521 -38
- data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -2
- metadata +15 -44
data/README.md
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
# Bible 270
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/bible270) [](http://rubygems.org/gems/bible270) [](https://github.com/avonderluft/bible270/releases)
|
|
3
|
+
[](https://badge.fury.io/rb/bible270) [](http://rubygems.org/gems/bible270) [](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
|
|
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
|
|
14
|
-
- **New Testament** — once through, a reading every day. 260 chapters over 270 days,
|
|
15
|
-
- **Psalms & Proverbs** —
|
|
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
|
-
|
|
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.
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
+
bin/rails bible270:install:migrations
|
|
102
|
+
bin/rails db:migrate
|
|
62
103
|
```
|
|
63
104
|
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3
|
-
require
|
|
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 <<
|
|
7
|
-
t.pattern =
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
44
|
+
private
|
|
41
45
|
|
|
42
46
|
def comment_params
|
|
43
47
|
params.require(:comment).permit(:body, :track)
|