bible270 0.14.1 → 0.15.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/README.md +2 -2
- data/app/controllers/bible270/admin_controller.rb +1 -1
- data/app/controllers/bible270/application_controller.rb +40 -0
- data/app/controllers/bible270/checkoffs_controller.rb +8 -3
- data/app/controllers/bible270/days_controller.rb +6 -3
- data/app/controllers/bible270/readers_controller.rb +1 -1
- data/app/controllers/bible270/sessions_controller.rb +10 -0
- data/app/models/bible270/checkoff.rb +12 -1
- data/app/models/bible270/reader.rb +85 -15
- data/app/views/bible270/days/_reading.html.erb +51 -11
- data/app/views/bible270/shared/_styles.html.erb +20 -1
- data/config/routes.rb +3 -0
- data/db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb +59 -0
- data/db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb +11 -0
- data/lib/bible270/configuration.rb +14 -2
- data/lib/bible270/plan.rb +36 -0
- data/lib/bible270/versification.rb +11 -1
- data/lib/bible270/version.rb +1 -1
- data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -1
- metadata +17 -2
- data/lib/bible270/bible270.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1a3221c4afb7b3ba3e568f4b783a763aed4d86080dfbd13807bae0bc71e61a1
|
|
4
|
+
data.tar.gz: 30b56e503e05712358fe1314c657aa4224e68eee742cbfaad53f5b4587625c96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 622094718971b6b74ea4e5d81440d19eb1dd4eb6d2d9ae6ce2695e51b6a4f80d413c6fc7bcd08f30e428cb8808b596a875370730e21836999c08293adf7778a6
|
|
7
|
+
data.tar.gz: 67437bd6d453421a84c6c08e8589b4fb92666e387d107d4dd7c4828b6b264eace3d2dc88d5ed0e7bb3e0da8581323db66d9c257bd0c88748b60ec4699fc0e2aa
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bible 270
|
|
2
2
|
|
|
3
|
-
[](https://rubygems.org/gems/bible270) [](https://rubygems.org/gems/bible270) [](https://github.com/avonderluft/bible270/releases)
|
|
3
|
+
[](https://rubygems.org/gems/bible270) [](https://coveralls.io/github/avonderluft/bible270?branch=main?cacheSeconds=60) [](https://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
|
|
|
@@ -506,4 +506,4 @@ A few things that'll make review quick:
|
|
|
506
506
|
|
|
507
507
|
## License
|
|
508
508
|
|
|
509
|
-
[MIT-LICENSE](./MIT-LICENSE).
|
|
509
|
+
[MIT-LICENSE](./MIT-LICENSE).
|
|
@@ -31,7 +31,7 @@ module Bible270
|
|
|
31
31
|
@readers = Reader.all.to_a.sort_by(&:sort_name)
|
|
32
32
|
counts = Checkoff.group(:reader_id, :day).count
|
|
33
33
|
@days_completed = Hash.new(0)
|
|
34
|
-
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.
|
|
34
|
+
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def show
|
|
@@ -22,6 +22,8 @@ module Bible270
|
|
|
22
22
|
|
|
23
23
|
helper_method :current_reader, :signed_in?, :b270_config, :enrollment_closed?
|
|
24
24
|
|
|
25
|
+
REMEMBER_COOKIE = :bible270_remember
|
|
26
|
+
|
|
25
27
|
private
|
|
26
28
|
|
|
27
29
|
def bible270_layout
|
|
@@ -44,9 +46,47 @@ module Bible270
|
|
|
44
46
|
resolver.call(self)
|
|
45
47
|
elsif (id = session[:bible270_reader_id])
|
|
46
48
|
Reader.find_by(id: id)
|
|
49
|
+
else
|
|
50
|
+
reader_from_remember_cookie
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# A session cookie dies when the browser restarts, which on a phone can be
|
|
55
|
+
# daily. This restores the session from a long-lived signed cookie carrying
|
|
56
|
+
# the reader id and a rotating token, so the reader stays signed in.
|
|
57
|
+
def reader_from_remember_cookie
|
|
58
|
+
return nil unless Bible270.config.remember_signed_in?
|
|
59
|
+
|
|
60
|
+
payload = cookies.encrypted[REMEMBER_COOKIE]
|
|
61
|
+
return nil if payload.blank?
|
|
62
|
+
|
|
63
|
+
id, token = payload.to_s.split(':', 2)
|
|
64
|
+
reader = Reader.from_remember_cookie(id, token)
|
|
65
|
+
return forget_reader! if reader.nil?
|
|
66
|
+
|
|
67
|
+
# Re-establish the session so the rest of the request behaves as normal.
|
|
68
|
+
session[:bible270_reader_id] = reader.id
|
|
69
|
+
reader
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Encrypted, so the payload can be neither read nor forged by the client.
|
|
73
|
+
def remember_reader!(reader)
|
|
74
|
+
return unless Bible270.config.remember_signed_in?
|
|
75
|
+
|
|
76
|
+
cookies.encrypted[REMEMBER_COOKIE] = {
|
|
77
|
+
value: "#{reader.id}:#{reader.remember_token!}",
|
|
78
|
+
expires: Bible270.config.remember_for.to_i.seconds.from_now,
|
|
79
|
+
httponly: true,
|
|
80
|
+
same_site: :lax,
|
|
81
|
+
secure: request.ssl?
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def forget_reader!
|
|
86
|
+
cookies.delete(REMEMBER_COOKIE)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
50
90
|
def signed_in?
|
|
51
91
|
current_reader.present?
|
|
52
92
|
end
|
|
@@ -7,15 +7,20 @@ module Bible270
|
|
|
7
7
|
|
|
8
8
|
@day = params[:day].to_i
|
|
9
9
|
@track = params[:track].to_s
|
|
10
|
+
# Part 0 is the first chapter of the reading. Requests without one predate
|
|
11
|
+
# per-chapter check-offs and mean the first part.
|
|
12
|
+
@part = params[:part].to_i
|
|
10
13
|
|
|
11
|
-
head :bad_request and return unless Plan.valid_day?(@day) &&
|
|
14
|
+
head :bad_request and return unless Plan.valid_day?(@day) &&
|
|
15
|
+
Plan.present_tracks(@day).include?(@track) &&
|
|
16
|
+
Plan.valid_part?(@day, @track, @part)
|
|
12
17
|
|
|
13
|
-
existing = current_reader.checkoffs.find_by(day: @day, track: @track)
|
|
18
|
+
existing = current_reader.checkoffs.find_by(day: @day, track: @track, part: @part)
|
|
14
19
|
if existing
|
|
15
20
|
existing.destroy
|
|
16
21
|
else
|
|
17
22
|
current_reader.ensure_started!
|
|
18
|
-
current_reader.checkoffs.create(day: @day, track: @track)
|
|
23
|
+
current_reader.checkoffs.create(day: @day, track: @track, part: @part)
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
@reader = current_reader.reload
|
|
@@ -9,7 +9,7 @@ module Bible270
|
|
|
9
9
|
@readers = Reader.order(updated_at: :desc).to_a
|
|
10
10
|
counts = Checkoff.group(:reader_id, :day).count
|
|
11
11
|
@days_completed = Hash.new(0)
|
|
12
|
-
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.
|
|
12
|
+
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
|
|
13
13
|
@readers = @readers.sort_by { |r| -@days_completed[r.id] }
|
|
14
14
|
|
|
15
15
|
@start_day = current_reader&.current_day || 1
|
|
@@ -28,10 +28,13 @@ module Bible270
|
|
|
28
28
|
@new_comment = Comment.new(day: @day)
|
|
29
29
|
@reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
# Every box on the day, not every track: an Old Testament reading of three
|
|
32
|
+
# chapters is three rows, so counting tracks would call someone finished
|
|
33
|
+
# who had only read the Old Testament.
|
|
34
|
+
required = Plan.total_parts(@day)
|
|
32
35
|
completer_ids = Checkoff.where(day: @day)
|
|
33
36
|
.group(:reader_id)
|
|
34
|
-
.having('COUNT(*) >= ?',
|
|
37
|
+
.having('COUNT(*) >= ?', required)
|
|
35
38
|
.pluck(:reader_id)
|
|
36
39
|
@completers = Reader.where(id: completer_ids).order(:display_name)
|
|
37
40
|
|
|
@@ -6,7 +6,7 @@ module Bible270
|
|
|
6
6
|
@readers = Reader.all.to_a
|
|
7
7
|
counts = Checkoff.group(:reader_id, :day).count
|
|
8
8
|
@days_completed = Hash.new(0)
|
|
9
|
-
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.
|
|
9
|
+
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
|
|
10
10
|
@readers.sort_by! { |r| [-@days_completed[r.id], r.display_name.to_s] }
|
|
11
11
|
end
|
|
12
12
|
|
|
@@ -33,12 +33,17 @@ module Bible270
|
|
|
33
33
|
# Rotate the session id to avoid session fixation, then sign in.
|
|
34
34
|
reset_session
|
|
35
35
|
session[:bible270_reader_id] = reader.id
|
|
36
|
+
remember_reader!(reader)
|
|
36
37
|
@current_reader = reader
|
|
37
38
|
|
|
38
39
|
redirect_to destination, notice: "Welcome #{reader.first_name}."
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def destroy
|
|
43
|
+
# Clear the long-lived cookie too, or the next request would sign them
|
|
44
|
+
# straight back in. The stored token is left alone so their other devices
|
|
45
|
+
# stay signed in; Reader#forget! is what revokes those.
|
|
46
|
+
forget_reader!
|
|
42
47
|
reset_session
|
|
43
48
|
@current_reader = nil
|
|
44
49
|
redirect_to(after_sign_out_path, notice: 'Signed out.')
|
|
@@ -109,6 +114,7 @@ module Bible270
|
|
|
109
114
|
|
|
110
115
|
reset_session
|
|
111
116
|
session[:bible270_reader_id] = reader.id
|
|
117
|
+
remember_reader!(reader)
|
|
112
118
|
@current_reader = reader
|
|
113
119
|
|
|
114
120
|
if name_needed?(reader)
|
|
@@ -174,6 +180,10 @@ module Bible270
|
|
|
174
180
|
return nil if value.include?('//') # protocol-relative URL
|
|
175
181
|
return nil if value.include?('\\') # browsers may normalise \ to /
|
|
176
182
|
return nil if value.include?('/auth/')
|
|
183
|
+
# Never return someone to sign-in or sign-out after signing in: harmless,
|
|
184
|
+
# but it reads as though the sign-in failed. Matched by path so it holds
|
|
185
|
+
# wherever the engine is mounted.
|
|
186
|
+
return nil if value.match?(%r{/sign_(in|out)(/|\z|\?)})
|
|
177
187
|
|
|
178
188
|
value
|
|
179
189
|
end
|
|
@@ -9,8 +9,10 @@ module Bible270
|
|
|
9
9
|
|
|
10
10
|
validates :day, inclusion: { in: 1..Plan::DAYS }
|
|
11
11
|
validates :track, inclusion: { in: Plan::TRACKS.keys }
|
|
12
|
-
validates :track, uniqueness: { scope: %i[reader_id day] }
|
|
12
|
+
validates :track, uniqueness: { scope: %i[reader_id day part] }
|
|
13
|
+
validates :part, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
13
14
|
validate :track_present_on_day
|
|
15
|
+
validate :part_within_reading
|
|
14
16
|
|
|
15
17
|
private
|
|
16
18
|
|
|
@@ -20,5 +22,14 @@ module Bible270
|
|
|
20
22
|
|
|
21
23
|
errors.add(:track, "is not part of day #{day}")
|
|
22
24
|
end
|
|
25
|
+
|
|
26
|
+
# Part 0 is the first chapter of the day's reading; an Old Testament reading
|
|
27
|
+
# of three chapters has parts 0, 1 and 2.
|
|
28
|
+
def part_within_reading
|
|
29
|
+
return if day.nil? || track.nil? || part.nil?
|
|
30
|
+
return if Plan.valid_part?(day, track, part)
|
|
31
|
+
|
|
32
|
+
errors.add(:part, "is not part of the #{track} reading on day #{day}")
|
|
33
|
+
end
|
|
23
34
|
end
|
|
24
35
|
end
|
|
@@ -140,6 +140,36 @@ module Bible270
|
|
|
140
140
|
[last_name, first_name].map { |n| n.to_s.strip.downcase }.join(' ').strip.presence || display_name.to_s.downcase
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
# ---- staying signed in -------------------------------------------------
|
|
144
|
+
|
|
145
|
+
# Generated on first use rather than at sign-up, so readers who never stay
|
|
146
|
+
# signed in never carry one.
|
|
147
|
+
def remember_token!
|
|
148
|
+
return remember_token if remember_token.present?
|
|
149
|
+
|
|
150
|
+
token = SecureRandom.urlsafe_base64(32)
|
|
151
|
+
update_column(:remember_token, token)
|
|
152
|
+
token
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Rotating the token invalidates every device at once.
|
|
156
|
+
def forget!
|
|
157
|
+
update_column(:remember_token, nil)
|
|
158
|
+
true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.from_remember_cookie(reader_id, token)
|
|
162
|
+
return nil if reader_id.blank? || token.blank?
|
|
163
|
+
|
|
164
|
+
reader = find_by(id: reader_id)
|
|
165
|
+
return nil if reader.nil? || reader.remember_token.blank?
|
|
166
|
+
|
|
167
|
+
# Constant-time comparison: the token is a credential.
|
|
168
|
+
return nil unless ActiveSupport::SecurityUtils.secure_compare(reader.remember_token, token.to_s)
|
|
169
|
+
|
|
170
|
+
reader
|
|
171
|
+
end
|
|
172
|
+
|
|
143
173
|
def avatar_uploaded?
|
|
144
174
|
self.class.avatar_uploads? && avatar.attached?
|
|
145
175
|
end
|
|
@@ -183,28 +213,44 @@ module Bible270
|
|
|
183
213
|
done = checked_count(day)
|
|
184
214
|
return :none if done.zero?
|
|
185
215
|
|
|
186
|
-
done >= Plan.
|
|
216
|
+
done >= Plan.total_parts(day) ? :complete : :partial
|
|
187
217
|
end
|
|
188
218
|
|
|
189
219
|
def read_tracks_for(day)
|
|
190
|
-
checkoffs.where(day: day).pluck(:track)
|
|
220
|
+
checkoffs.where(day: day).pluck(:track).uniq
|
|
191
221
|
end
|
|
192
222
|
|
|
193
|
-
|
|
194
|
-
|
|
223
|
+
# Which chapters of a track the reader has ticked on this day.
|
|
224
|
+
def read_parts_for(day, track)
|
|
225
|
+
checkoffs.where(day: day, track: track.to_s).pluck(:part)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def read?(day, track, part = nil)
|
|
229
|
+
return read_parts_for(day, track).include?(part) if part
|
|
230
|
+
|
|
231
|
+
# No part given: the track counts as read only when every chapter is.
|
|
232
|
+
read_parts_for(day, track).size >= Plan.part_count(day, track)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def track_partially_read?(day, track)
|
|
236
|
+
done = read_parts_for(day, track).size
|
|
237
|
+
done.positive? && done < Plan.part_count(day, track)
|
|
195
238
|
end
|
|
196
239
|
|
|
197
240
|
def day_complete?(day)
|
|
198
241
|
n = checked_counts[day].to_i
|
|
199
|
-
n.positive? && n >= Plan.
|
|
242
|
+
n.positive? && n >= Plan.total_parts(day)
|
|
200
243
|
end
|
|
201
244
|
|
|
202
245
|
def days_completed
|
|
203
|
-
checked_counts.count { |day, n| n >= Plan.
|
|
246
|
+
checked_counts.count { |day, n| n >= Plan.total_parts(day) }
|
|
204
247
|
end
|
|
205
248
|
|
|
249
|
+
# Days on which this track is *finished*. Counting rows would count chapters
|
|
250
|
+
# now that an Old Testament reading has one per chapter.
|
|
206
251
|
def days_read_in(track)
|
|
207
|
-
checkoffs.where(track: track.to_s).count
|
|
252
|
+
checkoffs.where(track: track.to_s).group(:day).count
|
|
253
|
+
.count { |day, done| done >= Plan.part_count(day, track) }
|
|
208
254
|
end
|
|
209
255
|
|
|
210
256
|
def completion_percent
|
|
@@ -240,13 +286,37 @@ module Bible270
|
|
|
240
286
|
def mark_day_complete!(day)
|
|
241
287
|
return false unless Plan.valid_day?(day)
|
|
242
288
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
289
|
+
# Deliberately not find_or_create_by!: since Rails 8.1 that creates first
|
|
290
|
+
# and rescues RecordNotUnique, but Checkoff validates uniqueness, so a
|
|
291
|
+
# duplicate raises RecordInvalid before the database is reached and is
|
|
292
|
+
# never rescued. Reading what exists first is also one query per day rather
|
|
293
|
+
# than one per chapter.
|
|
294
|
+
missing = missing_parts_on(day)
|
|
295
|
+
insert_checkoffs(day, missing) if missing.any?
|
|
296
|
+
|
|
246
297
|
reload_progress
|
|
247
298
|
true
|
|
248
299
|
end
|
|
249
300
|
|
|
301
|
+
# [[track, part], ...] not yet ticked on this day.
|
|
302
|
+
def missing_parts_on(day)
|
|
303
|
+
wanted = Plan.present_tracks(day).flat_map do |track|
|
|
304
|
+
Array.new(Plan.part_count(day, track)) { |part| [track, part] }
|
|
305
|
+
end
|
|
306
|
+
have = checkoffs.where(day: day).pluck(:track, :part)
|
|
307
|
+
|
|
308
|
+
wanted - have
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def insert_checkoffs(day, pairs)
|
|
312
|
+
now = Time.current
|
|
313
|
+
Checkoff.insert_all(
|
|
314
|
+
pairs.map do |track, part|
|
|
315
|
+
{ reader_id: id, day: day, track: track, part: part, created_at: now, updated_at: now }
|
|
316
|
+
end
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
250
320
|
def clear_day!(day)
|
|
251
321
|
return false unless Plan.valid_day?(day)
|
|
252
322
|
|
|
@@ -261,13 +331,12 @@ module Bible270
|
|
|
261
331
|
|
|
262
332
|
# Mark everything up to and including `day` complete, and clear anything after.
|
|
263
333
|
def mark_through!(day)
|
|
264
|
-
|
|
334
|
+
day = day.to_i
|
|
335
|
+
return false unless day.between?(0, Plan::DAYS)
|
|
265
336
|
|
|
266
337
|
transaction do
|
|
267
|
-
checkoffs.where(day: (day
|
|
268
|
-
(1..day
|
|
269
|
-
Plan.present_tracks(d).each { |t| checkoffs.find_or_create_by!(day: d, track: t) }
|
|
270
|
-
end
|
|
338
|
+
checkoffs.where(day: (day + 1)..).delete_all
|
|
339
|
+
(1..day).each { |d| mark_day_complete!(d) }
|
|
271
340
|
end
|
|
272
341
|
reload_progress
|
|
273
342
|
true
|
|
@@ -300,6 +369,7 @@ module Bible270
|
|
|
300
369
|
# Private, but declared this way rather than with a `private` section: methods
|
|
301
370
|
# defined below here are called from controllers and views.
|
|
302
371
|
private :notify_of_registration
|
|
372
|
+
private :missing_parts_on, :insert_checkoffs
|
|
303
373
|
|
|
304
374
|
def reload_progress
|
|
305
375
|
@checked_counts = nil
|
|
@@ -1,18 +1,58 @@
|
|
|
1
|
-
<%
|
|
1
|
+
<%
|
|
2
|
+
t = b270_track(track)
|
|
3
|
+
ref = readings[track]
|
|
4
|
+
parts = Bible270::Plan.parts_for(day, track)
|
|
5
|
+
read_parts = signed_in? ? current_reader.read_parts_for(day, track) : []
|
|
6
|
+
done = ref.present? && read_parts.size >= parts.size
|
|
7
|
+
%>
|
|
2
8
|
<% if ref %>
|
|
3
|
-
<div class="b270-reading <%= track %> <%= '
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
<div class="b270-reading <%= track %> <%= 'multi' if parts.size > 1 %> <%= 'done' if done %>"
|
|
10
|
+
id="reading_<%= day %>_<%= track %>">
|
|
11
|
+
<%# One box per chapter. A reading of a single chapter keeps the original
|
|
12
|
+
single-box layout; only multi-chapter Old Testament readings split. The
|
|
13
|
+
multi layout is a block, not a flex row, so each chapter's box sits in the
|
|
14
|
+
same column as the single box on the other tracks. %>
|
|
15
|
+
<% if parts.size > 1 %>
|
|
16
|
+
<div class="b270-rbody">
|
|
17
|
+
<div class="b270-cat"><%= t[:label] %></div>
|
|
18
|
+
<ul class="b270-parts">
|
|
19
|
+
<% parts.each_with_index do |part_ref, index| %>
|
|
20
|
+
<% part_done = read_parts.include?(index) %>
|
|
21
|
+
<li class="b270-part <%= 'done' if part_done %>">
|
|
22
|
+
<% if signed_in? %>
|
|
23
|
+
<%= button_to toggle_checkoff_part_path(day, track, index), class: "b270-toggle",
|
|
24
|
+
form: { data: { turbo_stream: true } },
|
|
25
|
+
aria: { label: (part_done ? "Mark #{part_ref} unread" : "Mark #{part_ref} read") } do %>
|
|
26
|
+
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
30
|
+
<% end %>
|
|
31
|
+
<span class="b270-ref">
|
|
32
|
+
<%= link_to part_ref, b270_passage_url(part_ref), target: "_blank", rel: "noopener" %>
|
|
33
|
+
</span>
|
|
34
|
+
</li>
|
|
35
|
+
<% end %>
|
|
36
|
+
</ul>
|
|
37
|
+
<div class="b270-parts-meta">
|
|
38
|
+
<%= read_parts.size %> of <%= parts.size %> chapters <%= b270_version_tag %>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<% else %>
|
|
42
|
+
<% if signed_in? %>
|
|
43
|
+
<%= button_to toggle_checkoff_part_path(day, track, 0), class: "b270-toggle",
|
|
44
|
+
form: { data: { turbo_stream: true } },
|
|
45
|
+
aria: { label: (done ? "Mark #{t[:label]} unread" : "Mark #{t[:label]} read") } do %>
|
|
46
|
+
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% else %>
|
|
7
49
|
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
8
50
|
<% end %>
|
|
9
|
-
|
|
10
|
-
|
|
51
|
+
<div class="b270-rbody">
|
|
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>
|
|
54
|
+
</div>
|
|
11
55
|
<% end %>
|
|
12
|
-
<div class="b270-rbody">
|
|
13
|
-
<div class="b270-cat"><%= t[:label] %></div>
|
|
14
|
-
<div class="b270-ref"><%= link_to ref, b270_passage_url(ref), target: "_blank", rel: "noopener" %> <%= b270_version_tag %></div>
|
|
15
|
-
</div>
|
|
16
56
|
</div>
|
|
17
57
|
<% else %>
|
|
18
58
|
<div class="b270-reading <%= track %> rest" id="reading_<%= day %>_<%= track %>">
|
|
@@ -108,6 +108,25 @@
|
|
|
108
108
|
.b270-rbody{flex:1;min-width:0}
|
|
109
109
|
.b270-cat{font-size:11px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint);font-weight:600}
|
|
110
110
|
.b270-ref{font-family:"Spectral",serif;font-size:19px;font-weight:500;margin-top:1px}
|
|
111
|
+
/* per-chapter check-offs: a smaller box per chapter, listed under the track */
|
|
112
|
+
/* A multi-chapter reading is laid out as a block rather than a flex row, so
|
|
113
|
+
each chapter's box sits in the same column as the single box on the other
|
|
114
|
+
tracks. The label is indented by that column's width (26px) plus the row
|
|
115
|
+
gap (15px) to stay level with the other tracks' labels. */
|
|
116
|
+
.b270-reading.multi{display:block}
|
|
117
|
+
.b270-reading.multi .b270-cat{margin-left:41px}
|
|
118
|
+
.b270-reading.multi .b270-parts-meta{margin-left:41px}
|
|
119
|
+
.b270-parts{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-direction:column;gap:8px}
|
|
120
|
+
.b270-part{display:flex;align-items:center;gap:15px}
|
|
121
|
+
.b270-part .b270-ref{font-size:19px}
|
|
122
|
+
/* A ticked chapter looks exactly like a ticked single reading: the box fills
|
|
123
|
+
in the track's colour and the tick appears. No strikethrough — the other
|
|
124
|
+
tracks don't strike their references either. */
|
|
125
|
+
.b270-reading.ot .b270-part.done .b270-check{background:var(--teal);border-color:var(--teal)}
|
|
126
|
+
.b270-reading.nt .b270-part.done .b270-check{background:var(--oxblood);border-color:var(--oxblood)}
|
|
127
|
+
.b270-reading.pp .b270-part.done .b270-check{background:var(--gold);border-color:var(--gold)}
|
|
128
|
+
.b270-part.done .b270-check svg{opacity:1}
|
|
129
|
+
.b270-parts-meta{margin-top:10px;font-size:12.5px;color:var(--faint)}
|
|
111
130
|
.b270-version{font-size:11.5px;color:var(--faint);font-weight:400;letter-spacing:.02em;white-space:nowrap}
|
|
112
131
|
.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}
|
|
113
132
|
.b270-ref a{text-decoration:none;border-bottom:1px solid transparent}
|
|
@@ -156,5 +175,5 @@
|
|
|
156
175
|
.b270-footer p{margin:0;line-height:1.5}
|
|
157
176
|
details.b270-index summary{cursor:pointer;font-family:"Spectral",serif;font-size:18px;font-weight:600;list-style:none;margin-top:30px}
|
|
158
177
|
details.b270-index summary::-webkit-details-marker{display:none}
|
|
159
|
-
@media (max-width:480px){ .b270-tm{grid-template-columns:104px 1fr auto} .b270-reading{padding:14px 15px;gap:12px} }
|
|
178
|
+
@media (max-width:480px){ .b270-tm{grid-template-columns:104px 1fr auto} .b270-reading{padding:14px 15px;gap:12px} .b270-reading.multi .b270-cat,.b270-reading.multi .b270-parts-meta{margin-left:38px} .b270-part{gap:12px} }
|
|
160
179
|
</style>
|
data/config/routes.rb
CHANGED
|
@@ -5,6 +5,9 @@ Bible270::Engine.routes.draw do
|
|
|
5
5
|
|
|
6
6
|
get 'day/:day', to: 'days#show', as: :day, constraints: { day: %r{\d+} }
|
|
7
7
|
|
|
8
|
+
# :part is the chapter within the reading; omitted means the first.
|
|
9
|
+
post 'day/:day/toggle/:track/:part', to: 'checkoffs#toggle', as: :toggle_checkoff_part,
|
|
10
|
+
constraints: { day: %r{\d+}, part: %r{\d+} }
|
|
8
11
|
post 'day/:day/toggle/:track', to: 'checkoffs#toggle', as: :toggle_checkoff,
|
|
9
12
|
constraints: { day: %r{\d+} }
|
|
10
13
|
post 'day/:day/comments', to: 'comments#create', as: :day_comments,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddPartToBible270Checkoffs < ActiveRecord::Migration[7.0]
|
|
4
|
+
# An Old Testament reading is usually several whole chapters, and readers get
|
|
5
|
+
# through them one at a time, so each chapter becomes a separately tickable
|
|
6
|
+
# part. Part 0 is the first chapter of that day's reading.
|
|
7
|
+
def up
|
|
8
|
+
add_column :bible270_checkoffs, :part, :integer, null: false, default: 0
|
|
9
|
+
|
|
10
|
+
old_index = 'idx_b270_checkoffs_unique'
|
|
11
|
+
remove_index :bible270_checkoffs, name: old_index if index_name_exists?(:bible270_checkoffs, old_index)
|
|
12
|
+
add_index :bible270_checkoffs, %i[reader_id day track part], unique: true,
|
|
13
|
+
name: 'idx_b270_checkoffs_part_unique'
|
|
14
|
+
|
|
15
|
+
expand_existing_checkoffs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def down
|
|
19
|
+
# Collapse back: a track counts as read if any of its chapters were.
|
|
20
|
+
execute <<~SQL.squish
|
|
21
|
+
DELETE FROM bible270_checkoffs
|
|
22
|
+
WHERE id NOT IN (
|
|
23
|
+
SELECT MIN(id) FROM bible270_checkoffs GROUP BY reader_id, day, track
|
|
24
|
+
)
|
|
25
|
+
SQL
|
|
26
|
+
|
|
27
|
+
remove_index :bible270_checkoffs, name: 'idx_b270_checkoffs_part_unique'
|
|
28
|
+
add_index :bible270_checkoffs, %i[reader_id day track], unique: true,
|
|
29
|
+
name: 'idx_b270_checkoffs_unique'
|
|
30
|
+
remove_column :bible270_checkoffs, :part
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Before this migration one row meant "the whole reading is done". Leaving
|
|
36
|
+
# those rows at part 0 would silently demote a finished multi-chapter day to
|
|
37
|
+
# one chapter of it, so each is expanded into a row per chapter.
|
|
38
|
+
def expand_existing_checkoffs
|
|
39
|
+
rows = select_all(<<~SQL.squish).to_a
|
|
40
|
+
SELECT id, reader_id, day, track FROM bible270_checkoffs WHERE part = 0
|
|
41
|
+
SQL
|
|
42
|
+
return if rows.empty?
|
|
43
|
+
|
|
44
|
+
now = Time.current
|
|
45
|
+
additions = rows.flat_map do |row|
|
|
46
|
+
count = Bible270::Plan.part_count(row['day'].to_i, row['track'])
|
|
47
|
+
next [] if count <= 1
|
|
48
|
+
|
|
49
|
+
(1...count).map do |part|
|
|
50
|
+
{ reader_id: row['reader_id'], day: row['day'], track: row['track'],
|
|
51
|
+
part: part, created_at: now, updated_at: now }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
return if additions.empty?
|
|
55
|
+
|
|
56
|
+
say "expanding #{rows.size} check-offs into #{rows.size + additions.size} chapter parts", true
|
|
57
|
+
Bible270::Checkoff.insert_all(additions)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddRememberTokenToBible270Readers < ActiveRecord::Migration[7.0]
|
|
4
|
+
# Lets a reader stay signed in across browser restarts. The cookie carries this
|
|
5
|
+
# token alongside the reader id, so rotating it signs that reader out
|
|
6
|
+
# everywhere — which a bare id in a cookie could not offer.
|
|
7
|
+
def change
|
|
8
|
+
add_column :bible270_readers, :remember_token, :string
|
|
9
|
+
add_index :bible270_readers, :remember_token, name: 'idx_b270_readers_remember'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -292,7 +292,7 @@ module Bible270
|
|
|
292
292
|
# from, so without this the notice names the reader but cannot link to them.
|
|
293
293
|
attr_accessor :mailer_host
|
|
294
294
|
|
|
295
|
-
attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :footer_html, :footer, :favicon, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver, :bible_version
|
|
295
|
+
attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :footer_html, :footer, :favicon, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver, :bible_version, :require_sign_in_to_participate
|
|
296
296
|
|
|
297
297
|
# Public labels.
|
|
298
298
|
attr_accessor :app_name
|
|
@@ -311,7 +311,18 @@ module Bible270
|
|
|
311
311
|
attr_accessor :allow_reader_start_date
|
|
312
312
|
|
|
313
313
|
# Only signed-in readers may check off and comment (viewing is always open).
|
|
314
|
-
|
|
314
|
+
# How long a reader stays signed in on a device, in seconds. The plan runs
|
|
315
|
+
# 270 days and most people read it on a phone, where a browser-session cookie
|
|
316
|
+
# is discarded within days — so this defaults to a year. Set nil or false to
|
|
317
|
+
# keep sign-in to the browser session only.
|
|
318
|
+
#
|
|
319
|
+
# Expressed in seconds rather than 1.year because this file has to load
|
|
320
|
+
# without ActiveSupport.
|
|
321
|
+
attr_accessor :remember_for
|
|
322
|
+
|
|
323
|
+
def remember_signed_in?
|
|
324
|
+
remember_for.to_i.positive?
|
|
325
|
+
end
|
|
315
326
|
|
|
316
327
|
def initialize
|
|
317
328
|
@parent_controller = 'ActionController::Base'
|
|
@@ -329,6 +340,7 @@ module Bible270
|
|
|
329
340
|
end
|
|
330
341
|
@app_name = 'Daily Bread'
|
|
331
342
|
@tagline = 'Journeying through Scripture in 9 months'
|
|
343
|
+
@remember_for = 365 * 24 * 60 * 60
|
|
332
344
|
@require_sign_in_to_participate = true
|
|
333
345
|
self.mount_at = '/daily-bread'
|
|
334
346
|
@start_date = nil
|
data/lib/bible270/plan.rb
CHANGED
|
@@ -558,6 +558,42 @@ module Bible270
|
|
|
558
558
|
|
|
559
559
|
def required_track_count(day) = present_tracks(day).size
|
|
560
560
|
|
|
561
|
+
# ---- parts of a reading -------------------------------------------------
|
|
562
|
+
#
|
|
563
|
+
# An Old Testament reading is usually several whole chapters (260 of the 270
|
|
564
|
+
# days, up to six), which readers get through one at a time. Each chapter is
|
|
565
|
+
# a separately tickable part. A New Testament or Psalms/Proverbs reading is a
|
|
566
|
+
# single chapter or a portion of one, so it has exactly one part.
|
|
567
|
+
#
|
|
568
|
+
# Parts are identified by position, so part 0 is always the first chapter of
|
|
569
|
+
# that day's reading.
|
|
570
|
+
|
|
571
|
+
def parts_for(day, track)
|
|
572
|
+
return [] unless valid_day?(day)
|
|
573
|
+
|
|
574
|
+
case track.to_s
|
|
575
|
+
when 'ot'
|
|
576
|
+
groups = ot_groups[day - 1]
|
|
577
|
+
groups.nil? ? [] : groups.map { |book, chapter| format_reference([[book, chapter]]) }
|
|
578
|
+
when 'nt', 'pp'
|
|
579
|
+
reading = readings_for(day)[track.to_s]
|
|
580
|
+
reading.nil? ? [] : [reading]
|
|
581
|
+
else
|
|
582
|
+
[]
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def part_count(day, track) = parts_for(day, track).size
|
|
587
|
+
|
|
588
|
+
def valid_part?(day, track, part)
|
|
589
|
+
part.is_a?(Integer) && part >= 0 && part < part_count(day, track)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Every tickable box on a day, across all three tracks.
|
|
593
|
+
def total_parts(day)
|
|
594
|
+
memo(:"total_parts_#{day}") { present_tracks(day).sum { |track| part_count(day, track) } }
|
|
595
|
+
end
|
|
596
|
+
|
|
561
597
|
def valid_day?(day) = day.is_a?(Integer) && day >= 1 && day <= DAYS
|
|
562
598
|
|
|
563
599
|
# Blank means "the whole day", which is stored as NULL. Select fields submit
|
|
@@ -93,7 +93,17 @@ module Bible270
|
|
|
93
93
|
|
|
94
94
|
module_function
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
# Guarded rather than indexing straight in: chapter 0 would become index -1
|
|
97
|
+
# and silently return the *last* chapter's verse count, and an over-large
|
|
98
|
+
# chapter would return nil for callers that expect a number. An unknown book
|
|
99
|
+
# still raises, since that is a programming error rather than bad data.
|
|
100
|
+
def verses(book, chapter)
|
|
101
|
+
counts = VERSES.fetch(book)
|
|
102
|
+
return 0 unless chapter.is_a?(Integer) && chapter.positive? && chapter <= counts.size
|
|
103
|
+
|
|
104
|
+
counts[chapter - 1]
|
|
105
|
+
end
|
|
106
|
+
|
|
97
107
|
def chapter_count(book) = VERSES.fetch(book).size
|
|
98
108
|
def total_verses(book) = VERSES.fetch(book).sum
|
|
99
109
|
end
|
data/lib/bible270/version.rb
CHANGED
|
@@ -73,7 +73,8 @@ Bible270.configure do |config|
|
|
|
73
73
|
# config.footer_partial = 'layouts/bible270_footer'
|
|
74
74
|
# config.footer_html = '<p>© 2026 …</p>'
|
|
75
75
|
# config.footer = false # no footer at all
|
|
76
|
-
#
|
|
76
|
+
#
|
|
77
77
|
# config.footer_placement = :after # engine's note, then yours
|
|
78
78
|
# config.footer_placement = :before # yours, then the engine's note
|
|
79
79
|
# config.footer_placement = :replace # default
|
|
80
|
+
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.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: turbo-rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.0'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: omniauth
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -118,9 +132,10 @@ files:
|
|
|
118
132
|
- db/migrate/20260101000006_add_approval_to_bible270_comments.rb
|
|
119
133
|
- db/migrate/20260101000007_create_bible270_settings.rb
|
|
120
134
|
- db/migrate/20260101000008_add_bible_version_to_bible270_readers.rb
|
|
135
|
+
- db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb
|
|
136
|
+
- db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb
|
|
121
137
|
- lib/bible270.rb
|
|
122
138
|
- lib/bible270/avatars.rb
|
|
123
|
-
- lib/bible270/bible270.rb
|
|
124
139
|
- lib/bible270/configuration.rb
|
|
125
140
|
- lib/bible270/email_sign_in.rb
|
|
126
141
|
- lib/bible270/engine.rb
|
data/lib/bible270/bible270.rb
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'bible270/version'
|
|
4
|
-
require 'bible270/plan'
|
|
5
|
-
require 'bible270/email_sign_in'
|
|
6
|
-
require 'bible270/names'
|
|
7
|
-
require 'bible270/avatars'
|
|
8
|
-
require 'bible270/translations'
|
|
9
|
-
require 'bible270/favicon'
|
|
10
|
-
require 'bible270/configuration'
|
|
11
|
-
require 'bible270/engine' if defined?(Rails::Engine)
|
|
12
|
-
|
|
13
|
-
module Bible270
|
|
14
|
-
end
|