bible270 0.6.3 → 0.10.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 +150 -164
- data/README.md +287 -272
- data/Rakefile +5 -4
- data/app/controllers/bible270/admin_controller.rb +119 -0
- data/app/controllers/bible270/application_controller.rb +19 -2
- 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/readers_controller.rb +5 -4
- data/app/controllers/bible270/sessions_controller.rb +60 -26
- data/app/helpers/bible270/plan_helper.rb +11 -8
- 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 +78 -14
- 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 +30 -0
- data/app/views/bible270/admin/show.html.erb +89 -0
- data/app/views/bible270/days/index.html.erb +10 -13
- 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 +9 -3
- data/app/views/bible270/shared/_header.html.erb +1 -0
- data/app/views/bible270/shared/_styles.html.erb +10 -7
- data/app/views/layouts/bible270/application.html.erb +4 -3
- data/config/routes.rb +32 -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/lib/bible270/configuration.rb +91 -19
- data/lib/bible270/email_sign_in.rb +7 -7
- data/lib/bible270/engine.rb +1 -0
- data/lib/bible270/plan.rb +449 -150
- data/lib/bible270/versification.rb +84 -67
- data/lib/bible270/version.rb +2 -1
- data/lib/bible270.rb +6 -5
- data/lib/generators/bible270/install/install_generator.rb +520 -39
- data/lib/generators/bible270/install/templates/bible270.rb.tt +8 -2
- data/lib/generators/bible270/install/templates/omniauth.rb.tt +6 -4
- metadata +17 -29
data/lib/bible270/plan.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'bible270/versification'
|
|
5
6
|
|
|
6
7
|
module Bible270
|
|
7
8
|
# Pure-Ruby, deterministic reading-plan generator. No database required.
|
|
@@ -10,48 +11,49 @@ module Bible270
|
|
|
10
11
|
# proxy for how much text there is), not by chapter count — so a day landing on
|
|
11
12
|
# Psalm 119 (176 verses) is not treated as equal to a day on Psalm 117 (2 verses).
|
|
12
13
|
#
|
|
13
|
-
# * Old Testament – read once
|
|
14
|
-
# day is ~equal in verses (~73/day)
|
|
15
|
-
# * New Testament – read
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
14
|
+
# * Old Testament – read once (excluding Psalms and Proverbs); WHOLE chapters
|
|
15
|
+
# grouped so each day is ~equal in verses (~73/day)
|
|
16
|
+
# * New Testament – read once, one reading every day; the 10 longest chapters
|
|
17
|
+
# (Luke 1 and friends) are divided in two so 260 chapters fill 270 days
|
|
18
|
+
# * Psalms/Proverbs – Psalms once and Proverbs TWICE, interleaved in one track.
|
|
19
|
+
# Longer psalms are divided so the two fill exactly 270 days;
|
|
20
|
+
# Psalm 119 is pinned to 11 sections of 16 verses.
|
|
20
21
|
#
|
|
21
|
-
# Genesis→Malachi
|
|
22
|
+
# Genesis → Malachi, Revelation 22, Psalm 150 and the second Proverbs 31 all land
|
|
23
|
+
# in the closing days.
|
|
22
24
|
module Plan
|
|
23
25
|
DAYS = 270
|
|
24
26
|
|
|
25
27
|
OT = [
|
|
26
|
-
[
|
|
27
|
-
[
|
|
28
|
-
[
|
|
29
|
-
[
|
|
30
|
-
[
|
|
31
|
-
[
|
|
32
|
-
[
|
|
33
|
-
[
|
|
34
|
-
[
|
|
28
|
+
['Genesis', 50], ['Exodus', 40], ['Leviticus', 27], ['Numbers', 36],
|
|
29
|
+
['Deuteronomy', 34], ['Joshua', 24], ['Judges', 21], ['Ruth', 4],
|
|
30
|
+
['1 Samuel', 31], ['2 Samuel', 24], ['1 Kings', 22], ['2 Kings', 25],
|
|
31
|
+
['1 Chronicles', 29], ['2 Chronicles', 36], ['Ezra', 10], ['Nehemiah', 13],
|
|
32
|
+
['Esther', 10], ['Job', 42], ['Ecclesiastes', 12], ['Song of Solomon', 8],
|
|
33
|
+
['Isaiah', 66], ['Jeremiah', 52], ['Lamentations', 5], ['Ezekiel', 48],
|
|
34
|
+
['Daniel', 12], ['Hosea', 14], ['Joel', 3], ['Amos', 9], ['Obadiah', 1],
|
|
35
|
+
['Jonah', 4], ['Micah', 7], ['Nahum', 3], ['Habakkuk', 3], ['Zephaniah', 3],
|
|
36
|
+
['Haggai', 2], ['Zechariah', 14], ['Malachi', 4]
|
|
35
37
|
].freeze
|
|
36
38
|
|
|
37
39
|
NT = [
|
|
38
|
-
[
|
|
39
|
-
[
|
|
40
|
-
[
|
|
41
|
-
[
|
|
42
|
-
[
|
|
43
|
-
[
|
|
40
|
+
['Matthew', 28], ['Mark', 16], ['Luke', 24], ['John', 21], ['Acts', 28],
|
|
41
|
+
['Romans', 16], ['1 Corinthians', 16], ['2 Corinthians', 13], ['Galatians', 6],
|
|
42
|
+
['Ephesians', 6], ['Philippians', 4], ['Colossians', 4], ['1 Thessalonians', 5],
|
|
43
|
+
['2 Thessalonians', 3], ['1 Timothy', 6], ['2 Timothy', 4], ['Titus', 3],
|
|
44
|
+
['Philemon', 1], ['Hebrews', 13], ['James', 5], ['1 Peter', 5], ['2 Peter', 3],
|
|
45
|
+
['1 John', 5], ['2 John', 1], ['3 John', 1], ['Jude', 1], ['Revelation', 22]
|
|
44
46
|
].freeze
|
|
45
47
|
|
|
46
|
-
PP = [[
|
|
48
|
+
PP = [['Psalm', 150], ['Proverbs', 31]].freeze
|
|
47
49
|
|
|
48
50
|
TRACKS = {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
'ot' => { key: 'ot', label: 'Old Testament', color: '#2f6a67' },
|
|
52
|
+
'nt' => { key: 'nt', label: 'New Testament', color: '#8a2f3b' },
|
|
53
|
+
'pp' => { key: 'pp', label: 'Psalms & Proverbs', color: '#a5812c' }
|
|
52
54
|
}.freeze
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
module_function
|
|
55
57
|
|
|
56
58
|
# ---- shared helpers ---------------------------------------------------
|
|
57
59
|
|
|
@@ -82,7 +84,7 @@ module Bible270
|
|
|
82
84
|
parts << (start == last ? "#{book} #{start}" : "#{book} #{start}\u2013#{last}")
|
|
83
85
|
i = j + 1
|
|
84
86
|
end
|
|
85
|
-
parts.join(
|
|
87
|
+
parts.join(', ')
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
# Group ordered items (each [book, chapter]) into `bins` contiguous groups so
|
|
@@ -118,10 +120,204 @@ module Bible270
|
|
|
118
120
|
groups
|
|
119
121
|
end
|
|
120
122
|
|
|
123
|
+
# ---- dividing chapters to fill a fixed number of days ------------------
|
|
124
|
+
#
|
|
125
|
+
# Whole chapters are preferred; when a track has fewer chapters than days to
|
|
126
|
+
# fill, the longest chapters are divided until the counts match. Each extra
|
|
127
|
+
# division goes to whichever chapter currently carries the heaviest reading,
|
|
128
|
+
# so the longest are always split first.
|
|
129
|
+
|
|
130
|
+
# weights: verses per chapter, in order. pinned: {index => fixed part count}.
|
|
131
|
+
# Returns the number of readings each chapter becomes.
|
|
132
|
+
def divide_to_fill(weights, target, pinned = {})
|
|
133
|
+
parts = Array.new(weights.size, 1)
|
|
134
|
+
pinned.each { |i, n| parts[i] = n }
|
|
135
|
+
|
|
136
|
+
if parts.sum > target
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"pinned chapter breaks need #{parts.sum} readings but only #{target} are available; " \
|
|
139
|
+
'remove some CHAPTER_BREAKS entries or lengthen the plan'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
candidates = (0...weights.size).reject { |i| pinned.key?(i) }
|
|
143
|
+
|
|
144
|
+
while parts.sum < target && candidates.any?
|
|
145
|
+
heaviest = candidates.max_by { |i| weights[i].fdiv(parts[i]) }
|
|
146
|
+
parts[heaviest] += 1
|
|
147
|
+
end
|
|
148
|
+
parts
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Turn [book, chapter] pairs plus a part count into readings. A reading is
|
|
152
|
+
# [book, chapter] when whole, or [book, chapter, from, to] when it's a slice.
|
|
153
|
+
def expand_readings(chapters, parts)
|
|
154
|
+
chapters.each_with_index.flat_map do |(book, chapter), idx|
|
|
155
|
+
n = parts[idx]
|
|
156
|
+
next [[book, chapter]] if n == 1
|
|
157
|
+
|
|
158
|
+
boundaries(book, chapter, n).map { |from, to| [book, chapter, from, to] }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# [[from, to], ...] for a chapter divided into n readings: the break points
|
|
163
|
+
# from CHAPTER_BREAKS when given, otherwise equal parts.
|
|
164
|
+
def boundaries(book, chapter, parts)
|
|
165
|
+
total = verses_for(book, chapter)
|
|
166
|
+
explicit = chapter_breaks_for(book, chapter)
|
|
167
|
+
last_verses = explicit || even_break_points(total, parts)
|
|
168
|
+
|
|
169
|
+
from = 1
|
|
170
|
+
(last_verses + [total]).map do |to|
|
|
171
|
+
range = [from, to]
|
|
172
|
+
from = to + 1
|
|
173
|
+
range
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def even_break_points(total, parts)
|
|
178
|
+
base = total / parts
|
|
179
|
+
extra = total % parts
|
|
180
|
+
v = 0
|
|
181
|
+
(parts - 1).times.map do |k|
|
|
182
|
+
v += base + (k < extra ? 1 : 0)
|
|
183
|
+
v
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Validated on the way out so a typo fails loudly instead of silently
|
|
188
|
+
# producing overlapping or out-of-range readings.
|
|
189
|
+
def chapter_breaks_for(book, chapter)
|
|
190
|
+
points = configured_breaks[[book, chapter]]
|
|
191
|
+
return nil if points.nil?
|
|
192
|
+
|
|
193
|
+
total = verses_for(book, chapter)
|
|
194
|
+
unless points == points.sort.uniq && points.all? { |v| v.is_a?(Integer) && v.between?(1, total - 1) }
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
"CHAPTER_BREAKS[#{[book, chapter].inspect}] must be ascending, distinct " \
|
|
197
|
+
"verse numbers between 1 and #{total - 1}, got #{points.inspect}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
points
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# {index => reading count} for chapters whose breaks are set explicitly.
|
|
204
|
+
def pinned_from_breaks(chapters)
|
|
205
|
+
chapters.each_with_index.filter_map do |(book, chapter), idx|
|
|
206
|
+
points = configured_breaks[[book, chapter]]
|
|
207
|
+
[idx, points.size + 1] if points
|
|
208
|
+
end.to_h
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def segment_length(segment)
|
|
212
|
+
segment.size == 2 ? verses_for(segment[0], segment[1]) : (segment[3] - segment[2] + 1)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# "Luke 2" for a whole chapter, "Luke 1:1\u201340" for part of one.
|
|
216
|
+
def format_segment(segment)
|
|
217
|
+
book, chapter, from, to = segment
|
|
218
|
+
return "#{book} #{chapter}" if from.nil?
|
|
219
|
+
|
|
220
|
+
from == to ? "#{book} #{chapter}:#{from}" : "#{book} #{chapter}:#{from}\u2013#{to}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Chapters that ended up divided, as {[book, chapter] => reading count}.
|
|
224
|
+
def divided(chapters, parts)
|
|
225
|
+
chapters.each_with_index.select { |_, i| parts[i] > 1 }
|
|
226
|
+
.to_h { |ch, i| [ch, parts[i]] }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ---- memoisation ------------------------------------------------------
|
|
230
|
+
#
|
|
231
|
+
# Everything derived from the plan is cached together so that reset! (used by
|
|
232
|
+
# the tests, and by anything that changes CHAPTER_BREAKS at runtime) drops it
|
|
233
|
+
# all at once rather than leaving a half-rebuilt plan.
|
|
234
|
+
|
|
235
|
+
def memo(key)
|
|
236
|
+
fingerprint = configured_breaks
|
|
237
|
+
if @cache_fingerprint != fingerprint
|
|
238
|
+
@cache = {}
|
|
239
|
+
@cache_fingerprint = fingerprint
|
|
240
|
+
end
|
|
241
|
+
@cache ||= {}
|
|
242
|
+
return @cache[key] if @cache.key?(key)
|
|
243
|
+
|
|
244
|
+
@cache[key] = yield
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def reset!
|
|
248
|
+
@cache = {}
|
|
249
|
+
@cache_fingerprint = nil
|
|
250
|
+
true
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# ---- where the break points come from ---------------------------------
|
|
254
|
+
#
|
|
255
|
+
# Three sources, later winning: the CHAPTER_BREAKS constant, then
|
|
256
|
+
# Bible270.config.chapter_breaks, then a YAML file at
|
|
257
|
+
# Bible270.config.chapter_breaks_path. The file is re-read whenever it
|
|
258
|
+
# changes, so break points can be tuned without restarting the server.
|
|
259
|
+
|
|
260
|
+
def configured_breaks
|
|
261
|
+
base = CHAPTER_BREAKS.merge(config_breaks)
|
|
262
|
+
path = breaks_file_path
|
|
263
|
+
path ? base.merge(file_breaks(path)) : base
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def config_breaks
|
|
267
|
+
return {} unless bible270_config.respond_to?(:chapter_breaks)
|
|
268
|
+
|
|
269
|
+
normalize_breaks(bible270_config.chapter_breaks || {})
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def breaks_file_path
|
|
273
|
+
return nil unless bible270_config.respond_to?(:chapter_breaks_path)
|
|
274
|
+
|
|
275
|
+
path = bible270_config.chapter_breaks_path
|
|
276
|
+
path && File.exist?(path.to_s) ? path.to_s : nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def bible270_config
|
|
280
|
+
Bible270.respond_to?(:config) ? Bible270.config : nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Cached against mtime and size: an edit is picked up, an unchanged file is
|
|
284
|
+
# not re-parsed on every request.
|
|
285
|
+
def file_breaks(path)
|
|
286
|
+
stat = File.stat(path)
|
|
287
|
+
stamp = [path, stat.mtime.to_f, stat.size]
|
|
288
|
+
return @file_breaks if @file_breaks_stamp == stamp
|
|
289
|
+
|
|
290
|
+
@file_breaks_stamp = stamp
|
|
291
|
+
@file_breaks = parse_breaks_file(path)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def parse_breaks_file(path)
|
|
295
|
+
raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
|
|
296
|
+
raise ArgumentError, "#{path} must map \"Book Chapter\" to a list of verses" unless raw.is_a?(Hash)
|
|
297
|
+
|
|
298
|
+
normalize_breaks(raw)
|
|
299
|
+
rescue Psych::SyntaxError => e
|
|
300
|
+
raise ArgumentError, "#{path} is not valid YAML: #{e.message}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Keys may be ['Psalm', 18] or 'Psalm 18'; a nil value means "keep whole".
|
|
304
|
+
def normalize_breaks(hash)
|
|
305
|
+
hash.to_h { |key, value| [break_key(key), Array(value).map(&:to_i)] }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def break_key(key)
|
|
309
|
+
return [key[0].to_s, key[1].to_i] if key.is_a?(Array)
|
|
310
|
+
|
|
311
|
+
match = key.to_s.strip.match(/\A(.+?)\s+(\d+)\z/)
|
|
312
|
+
raise ArgumentError, "chapter break key #{key.inspect} must look like 'Psalm 18'" if match.nil?
|
|
313
|
+
|
|
314
|
+
[match[1], match[2].to_i]
|
|
315
|
+
end
|
|
316
|
+
|
|
121
317
|
# ---- Old Testament ----------------------------------------------------
|
|
122
318
|
|
|
123
319
|
def ot_groups
|
|
124
|
-
|
|
320
|
+
memo(:ot_groups) do
|
|
125
321
|
chapters = flatten(OT)
|
|
126
322
|
weights = chapters.map { |b, c| verses_for(b, c) }
|
|
127
323
|
balance_by_weight(chapters, weights, DAYS)
|
|
@@ -129,153 +325,231 @@ module Bible270
|
|
|
129
325
|
end
|
|
130
326
|
|
|
131
327
|
def ot_plan
|
|
132
|
-
|
|
328
|
+
memo(:ot_plan) { ot_groups.map { |g| format_reference(g) } }
|
|
133
329
|
end
|
|
134
330
|
|
|
135
331
|
def ot_verse_loads
|
|
136
|
-
|
|
332
|
+
memo(:ot_verse_loads) { ot_groups.map { |g| g.sum { |b, c| verses_for(b, c) } } }
|
|
137
333
|
end
|
|
138
334
|
|
|
139
|
-
# ---- New Testament (read
|
|
335
|
+
# ---- New Testament (read once, one reading every day) ------------------
|
|
140
336
|
#
|
|
141
|
-
# 260 chapters
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
337
|
+
# 260 chapters over 270 days, so the 10 longest chapters are each divided in
|
|
338
|
+
# two — Luke 1, Matthew 26 and 27, Mark 14, Luke 9, 12 and 22, John 6 and 8,
|
|
339
|
+
# and Acts 7. That gives exactly one reading a day with no days off, and
|
|
340
|
+
# brings the longest reading down from Luke 1's 80 verses to 58.
|
|
145
341
|
|
|
146
|
-
NT_PASSES =
|
|
342
|
+
NT_PASSES = 1
|
|
147
343
|
|
|
148
|
-
def
|
|
344
|
+
def nt_chapters = memo(:nt_chapters) { flatten(NT) }
|
|
149
345
|
|
|
150
|
-
def
|
|
151
|
-
|
|
346
|
+
def nt_parts
|
|
347
|
+
memo(:nt_parts) { divide_to_fill(nt_chapters.map { |b, c| verses_for(b, c) }, DAYS, pinned_from_breaks(nt_chapters)) }
|
|
152
348
|
end
|
|
153
349
|
|
|
154
|
-
def
|
|
155
|
-
|
|
156
|
-
chapters = flatten(NT)
|
|
157
|
-
weights = chapters.map { |b, c| verses_for(b, c) }
|
|
158
|
-
one_pass = balance_by_weight(chapters, weights, nt_days_per_pass)
|
|
159
|
-
one_pass * NT_PASSES
|
|
160
|
-
end
|
|
350
|
+
def nt_readings
|
|
351
|
+
memo(:nt_readings) { expand_readings(nt_chapters, nt_parts) }
|
|
161
352
|
end
|
|
162
353
|
|
|
163
354
|
def nt_plan
|
|
164
|
-
|
|
355
|
+
memo(:nt_plan) { nt_readings.map { |seg| format_segment(seg) } }
|
|
165
356
|
end
|
|
166
357
|
|
|
167
358
|
def nt_verse_loads
|
|
168
|
-
|
|
359
|
+
memo(:nt_verse_loads) { nt_readings.map { |seg| segment_length(seg) } }
|
|
169
360
|
end
|
|
170
361
|
|
|
171
|
-
#
|
|
172
|
-
def nt_content_days
|
|
173
|
-
@nt_content_days ||= nt_plan.count { |r| !r.nil? }
|
|
174
|
-
end
|
|
362
|
+
# Every day now carries a New Testament reading.
|
|
363
|
+
def nt_content_days = memo(:nt_content_days) { nt_plan.count { |r| !r.nil? } }
|
|
175
364
|
|
|
176
|
-
|
|
177
|
-
def nt_second_pass_start_day = nt_days_per_pass + 1
|
|
365
|
+
def nt_rest_days = []
|
|
178
366
|
|
|
179
|
-
|
|
367
|
+
def divided_nt_chapters = memo(:divided_nt_chapters) { divided(nt_chapters, nt_parts) }
|
|
368
|
+
|
|
369
|
+
# ---- Psalms & Proverbs -------------------------------------------------
|
|
180
370
|
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
371
|
+
# Psalms once and Proverbs once, interleaved in one daily track.
|
|
372
|
+
#
|
|
373
|
+
# 150 + 31 = 181 chapters have to cover 270 days, so 89 of them are divided.
|
|
374
|
+
# Two rules shape which:
|
|
375
|
+
#
|
|
376
|
+
# * A psalm of PSALM_WHOLE_MAX_VERSES verses or fewer is never divided.
|
|
377
|
+
# That locks 131 of the 150 psalms whole.
|
|
378
|
+
# * Psalm 119 is pinned to sections of PSALM_119_SECTION_SIZE verses.
|
|
379
|
+
#
|
|
380
|
+
# The remaining divisions are shared between the eligible psalms and Proverbs
|
|
381
|
+
# by verse load — each one goes to whichever chapter currently carries the
|
|
382
|
+
# heaviest reading — so both books end up with readings of about the same
|
|
383
|
+
# length and Proverbs lands on roughly its proportional share of the days.
|
|
384
|
+
# CHAPTER_BREAKS overrides any of it.
|
|
385
|
+
#
|
|
386
|
+
# A divided chapter is always read on consecutive days: readings are grouped
|
|
387
|
+
# by chapter and the two books interleaved a whole chapter at a time, so
|
|
388
|
+
# nothing is ever inserted between the parts of a split psalm.
|
|
389
|
+
|
|
390
|
+
# ---- where divided chapters break -------------------------------------
|
|
391
|
+
#
|
|
392
|
+
# EDIT THIS to choose your own break points. Keys are [book, chapter];
|
|
393
|
+
# the value lists the LAST VERSE of every reading except the final one.
|
|
394
|
+
#
|
|
395
|
+
# ['Luke', 1] => [25, 56] # => Luke 1:1-25, 1:26-56, 1:57-80
|
|
396
|
+
# ['Psalm', 78] => [39] # => Psalm 78:1-39, 78:40-72
|
|
397
|
+
#
|
|
398
|
+
# The number of readings a chapter becomes is then fixed by the number of
|
|
399
|
+
# breaks you give it, and the rest of the plan re-divides around it to still
|
|
400
|
+
# fill exactly DAYS days. Anything not listed here is split into equal parts.
|
|
401
|
+
CHAPTER_BREAKS = {
|
|
402
|
+
['Psalm', 9] => [], ['Psalm', 10] => [], # Stay whole
|
|
403
|
+
['Psalm', 18] => [24], # => Psalm 18:1–24, 18:25–50
|
|
404
|
+
['Psalm', 22] => [21], # => Psalm 22:1–21, 22:22–31
|
|
405
|
+
['Psalm', 31] => [], ['Psalm', 33] => [], ['Psalm', 34] => [], ['Psalm', 38] => [], # Stay whole
|
|
406
|
+
['Psalm', 44] => [], ['Psalm', 49] => [], ['Psalm', 50] => [], ['Psalm', 51] => [], # Stay whole
|
|
407
|
+
['Psalm', 55] => [], ['Psalm', 59] => [], ['Psalm', 65] => [], ['Psalm', 66] => [], # Stay whole
|
|
408
|
+
['Psalm', 68] => [18], # => Psalm 68:1–18, 68:19–35
|
|
409
|
+
['Psalm', 69] => [18 ], # => Psalm 69:1–18, 69:19–36
|
|
410
|
+
['Psalm', 78] => [20, 39, 55], # => 78:1–20, 21–39, 40–55, 56–72
|
|
411
|
+
['Psalm', 37] => [20], # => Psalm 37:1–20, 37:21–40
|
|
412
|
+
['Matthew', 26] => [35], # => Matthew 26:1–35, 26:36–75
|
|
413
|
+
['Matthew', 27] => [31], # => Matthew 27:1–31
|
|
414
|
+
['Mark', 14] => [31], # => Mark 14:1–31, 14:32–72
|
|
415
|
+
['Luke', 1] => [25, 56], # => Luke 1:1–25, 1:26–56, 1:57–80
|
|
416
|
+
['Luke', 9] => [36], # => Luke 9:1–36, 9:37–62
|
|
417
|
+
['Luke', 11] => [26], # => Luke 11:1–26, 11:27–54
|
|
418
|
+
['Luke', 22] => [38], # => Luke 22:1–38, 22:39–71
|
|
419
|
+
['John', 6] => [40], # => John 6:1–40, 6:41–71
|
|
420
|
+
['Acts', 7] => [36], # => Acts 7:1–36, 7:37–60
|
|
421
|
+
|
|
422
|
+
# -- Psalms over 25 verses, divided at their turns of thought ----------
|
|
423
|
+
['Psalm', 35] => [], # kept whole
|
|
424
|
+
['Psalm', 73] => [14], # the envious lament | the sanctuary
|
|
425
|
+
['Psalm', 89] => [18, 37], # hymn | covenant oracle | lament
|
|
426
|
+
['Psalm', 102] => [17], # affliction | Zion restored
|
|
427
|
+
['Psalm', 104] => [18], # creation ordered | creation sustained
|
|
428
|
+
['Psalm', 105] => [22], # covenant and Joseph | exodus
|
|
429
|
+
['Psalm', 106] => [23], # praise and wilderness | Canaan and mercy
|
|
430
|
+
['Psalm', 107] => [16, 32], # wanderers and prisoners | sick and storm-tossed | coda
|
|
431
|
+
['Psalm', 109] => [20], # the imprecation | the plea
|
|
432
|
+
['Psalm', 118] => [18], # thanksgiving | the gate of the LORD
|
|
433
|
+
['Psalm', 136] => [], # the refrain psalm, kept whole
|
|
434
|
+
|
|
435
|
+
# -- Proverbs: the discourses of 1-9 and the appendices of 30-31 have
|
|
436
|
+
# real structure, so they break at it. Chapters 10-29 are collections
|
|
437
|
+
# of independent sayings with no narrative flow, so they are divided
|
|
438
|
+
# evenly; add entries here if you prefer particular groupings.
|
|
439
|
+
['Proverbs', 1] => [7, 19], # prologue | enticement of sinners | Wisdom calls
|
|
440
|
+
['Proverbs', 2] => [9], # the search for wisdom | its protection
|
|
441
|
+
['Proverbs', 3] => [12, 26], # trust and discipline | wisdom's worth | neighbourly justice
|
|
442
|
+
['Proverbs', 4] => [9, 19], # a father's teaching | the two paths | guard the heart
|
|
443
|
+
['Proverbs', 5] => [14], # the adulteress | your own cistern
|
|
444
|
+
['Proverbs', 6] => [19], # surety, sluggard, seven abominations | adultery
|
|
445
|
+
['Proverbs', 7] => [5, 23], # keep my commands | the seduction | her house
|
|
446
|
+
['Proverbs', 8] => [11, 21], # Wisdom calls | her worth | Wisdom at creation
|
|
447
|
+
['Proverbs', 9] => [6, 12], # Wisdom's feast | scoffer and wise | Folly's feast
|
|
448
|
+
['Proverbs', 30] => [9, 17], # Agur's confession | four sayings | the numbered proverbs
|
|
449
|
+
['Proverbs', 22] => [16], # sayings | words of the wise
|
|
450
|
+
['Proverbs', 23] => [8, 21, 28],
|
|
451
|
+
['Proverbs', 24] => [12, 22],
|
|
452
|
+
['Proverbs', 26] => [12, 19], # the fool | the sluggard | the quarreller
|
|
453
|
+
['Proverbs', 27] => [10, 17],
|
|
454
|
+
['Proverbs', 28] => [12, 18],
|
|
455
|
+
['Proverbs', 31] => [9] # King Lemuel | the woman of valour
|
|
456
|
+
}.freeze
|
|
457
|
+
|
|
458
|
+
# A psalm this short or shorter is never divided.
|
|
459
|
+
PSALM_WHOLE_MAX_VERSES = 25
|
|
460
|
+
|
|
461
|
+
PSALM_119_SECTION_SIZE = 16
|
|
462
|
+
|
|
463
|
+
def psalm_chapters
|
|
464
|
+
memo(:psalm_chapters) { (1..Versification.chapter_count('Psalm')).map { |c| ['Psalm', c] } }
|
|
233
465
|
end
|
|
234
466
|
|
|
235
|
-
def
|
|
467
|
+
def proverbs_chapters
|
|
468
|
+
memo(:proverbs_chapters) { (1..Versification.chapter_count('Proverbs')).map { |c| ['Proverbs', c] } }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def pp_chapters = memo(:pp_chapters) { psalm_chapters + proverbs_chapters }
|
|
236
472
|
|
|
237
|
-
|
|
238
|
-
|
|
473
|
+
# Psalms and Proverbs are divided together against a single budget of DAYS
|
|
474
|
+
# readings, so the two books balance against each other by verse load rather
|
|
475
|
+
# than each being squeezed into a fixed number of days.
|
|
476
|
+
def pp_parts
|
|
477
|
+
memo(:pp_parts) do
|
|
478
|
+
chapters = pp_chapters
|
|
479
|
+
weights = chapters.map { |b, c| verses_for(b, c) }
|
|
480
|
+
divide_to_fill(weights, DAYS, pp_pinned_parts(chapters, weights))
|
|
481
|
+
end
|
|
239
482
|
end
|
|
240
483
|
|
|
241
|
-
def
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
484
|
+
def pp_pinned_parts(chapters, weights)
|
|
485
|
+
pinned = {}
|
|
486
|
+
chapters.each_with_index do |(book, chapter), idx|
|
|
487
|
+
next unless book == 'Psalm'
|
|
488
|
+
|
|
489
|
+
pinned[idx] = 1 if weights[idx] <= PSALM_WHOLE_MAX_VERSES
|
|
490
|
+
pinned[idx] = weights[idx] / PSALM_119_SECTION_SIZE if chapter == 119
|
|
246
491
|
end
|
|
492
|
+
# An explicit entry in CHAPTER_BREAKS always wins.
|
|
493
|
+
pinned.merge(pinned_from_breaks(chapters))
|
|
247
494
|
end
|
|
248
495
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
496
|
+
def psalm_parts = memo(:psalm_parts) { pp_parts.first(psalm_chapters.size) }
|
|
497
|
+
def proverbs_parts = memo(:proverbs_parts) { pp_parts.last(proverbs_chapters.size) }
|
|
498
|
+
|
|
499
|
+
def psalm_readings = memo(:psalm_readings) { expand_readings(psalm_chapters, psalm_parts) }
|
|
500
|
+
def proverbs_readings = memo(:proverbs_readings) { expand_readings(proverbs_chapters, proverbs_parts) }
|
|
501
|
+
|
|
502
|
+
# Readings grouped by chapter, so a divided chapter stays together.
|
|
503
|
+
def psalm_groups = memo(:psalm_groups) { group_by_chapter(psalm_readings) }
|
|
504
|
+
def proverbs_groups = memo(:proverbs_groups) { group_by_chapter(proverbs_readings) }
|
|
505
|
+
|
|
506
|
+
def group_by_chapter(readings)
|
|
507
|
+
readings.chunk_while { |a, b| a[0] == b[0] && a[1] == b[1] }.to_a
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Interleave the two books a whole chapter at a time, spacing the Proverbs
|
|
511
|
+
# chapters evenly through the Psalms. Because a group is emitted in one go,
|
|
512
|
+
# nothing can land between the parts of a divided chapter.
|
|
513
|
+
def pp_readings
|
|
514
|
+
memo(:pp_readings) do
|
|
515
|
+
psalms = psalm_groups
|
|
516
|
+
proverbs = proverbs_groups
|
|
517
|
+
placed = 0
|
|
518
|
+
days = []
|
|
519
|
+
|
|
520
|
+
psalms.each_with_index do |group, i|
|
|
521
|
+
days.concat(group)
|
|
522
|
+
due = ((i + 1) * proverbs.size) / psalms.size
|
|
523
|
+
while placed < due
|
|
524
|
+
days.concat(proverbs[placed])
|
|
525
|
+
placed += 1
|
|
262
526
|
end
|
|
263
|
-
last = portion[j][1]
|
|
264
|
-
parts << (ch == last ? "#{book} #{ch}" : "#{book} #{ch}\u2013#{last}")
|
|
265
|
-
i = j + 1
|
|
266
|
-
else # part of a chapter
|
|
267
|
-
book, ch, from, to = s
|
|
268
|
-
parts << (from == to ? "#{book} #{ch}:#{from}" : "#{book} #{ch}:#{from}\u2013#{to}")
|
|
269
|
-
i += 1
|
|
270
527
|
end
|
|
528
|
+
days.concat(proverbs[placed..].to_a.flatten(1)) if placed < proverbs.size
|
|
529
|
+
days
|
|
271
530
|
end
|
|
272
|
-
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def pp_plan = memo(:pp_plan) { pp_readings.map { |seg| format_segment(seg) } }
|
|
534
|
+
def pp_verse_loads = memo(:pp_verse_loads) { pp_readings.map { |seg| segment_length(seg) } }
|
|
535
|
+
|
|
536
|
+
# 1-indexed days carrying a Proverbs reading.
|
|
537
|
+
def proverbs_days
|
|
538
|
+
memo(:proverbs_days) { (1..DAYS).select { |d| pp_readings[d - 1][0] == 'Proverbs' } }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def divided_psalms
|
|
542
|
+
memo(:divided_psalms) { divided(psalm_chapters, psalm_parts).to_h { |ch, n| [ch[1], n] } }
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def divided_proverbs
|
|
546
|
+
memo(:divided_proverbs) { divided(proverbs_chapters, proverbs_parts).to_h { |ch, n| [ch[1], n] } }
|
|
273
547
|
end
|
|
274
548
|
|
|
275
549
|
# ---- public API -------------------------------------------------------
|
|
276
550
|
|
|
277
551
|
def readings_for(day)
|
|
278
|
-
{
|
|
552
|
+
{ 'ot' => ot_plan[day - 1], 'nt' => nt_plan[day - 1], 'pp' => pp_plan[day - 1] }
|
|
279
553
|
end
|
|
280
554
|
|
|
281
555
|
def present_tracks(day)
|
|
@@ -286,6 +560,21 @@ module Bible270
|
|
|
286
560
|
|
|
287
561
|
def valid_day?(day) = day.is_a?(Integer) && day >= 1 && day <= DAYS
|
|
288
562
|
|
|
563
|
+
# Blank means "the whole day", which is stored as NULL. Select fields submit
|
|
564
|
+
# an empty string rather than nil, so this has to be normalised before
|
|
565
|
+
# validation or the inclusion check rejects it.
|
|
566
|
+
def normalize_track(value)
|
|
567
|
+
track = value.to_s.strip
|
|
568
|
+
return nil if track.empty?
|
|
569
|
+
|
|
570
|
+
track
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def valid_track?(value)
|
|
574
|
+
track = normalize_track(value)
|
|
575
|
+
track.nil? || TRACKS.key?(track)
|
|
576
|
+
end
|
|
577
|
+
|
|
289
578
|
# ---- calendar mapping (all pure functions; nil start_date = undated) ----
|
|
290
579
|
|
|
291
580
|
# Coerce whatever we were handed into a Date (or nil).
|
|
@@ -294,7 +583,11 @@ module Bible270
|
|
|
294
583
|
when nil then nil
|
|
295
584
|
when ::Date then value
|
|
296
585
|
when ::Time then value.to_date
|
|
297
|
-
when ::String then
|
|
586
|
+
when ::String then begin
|
|
587
|
+
::Date.parse(value)
|
|
588
|
+
rescue StandardError
|
|
589
|
+
nil
|
|
590
|
+
end
|
|
298
591
|
else value.respond_to?(:to_date) ? value.to_date : nil
|
|
299
592
|
end
|
|
300
593
|
end
|
|
@@ -336,15 +629,21 @@ module Bible270
|
|
|
336
629
|
|
|
337
630
|
def totals
|
|
338
631
|
{
|
|
339
|
-
ot: flatten(OT).size,
|
|
340
|
-
nt: flatten(NT).size,
|
|
341
|
-
nt_passes: NT_PASSES,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
632
|
+
ot: flatten(OT).size, # 748 chapters
|
|
633
|
+
nt: flatten(NT).size, # 260 chapters, read once
|
|
634
|
+
nt_passes: NT_PASSES,
|
|
635
|
+
nt_readings: nt_readings.size, # 270 - one every day
|
|
636
|
+
nt_divided: divided_nt_chapters.size, # 10 long chapters halved
|
|
637
|
+
psalms: Versification.chapter_count('Psalm'), # 150
|
|
638
|
+
psalm_readings: psalm_readings.size, # 208
|
|
639
|
+
proverbs: Versification.chapter_count('Proverbs'), # 31
|
|
640
|
+
proverbs_readings: proverbs_readings.size,
|
|
641
|
+
proverbs_divided: divided_proverbs.size,
|
|
642
|
+
psalms_whole_max_verses: PSALM_WHOLE_MAX_VERSES,
|
|
643
|
+
pp: flatten(PP).size, # 181 chapters
|
|
644
|
+
pp_verses: flatten(PP).sum { |b, c| verses_for(b, c) },
|
|
346
645
|
ot_verses: flatten(OT).sum { |b, c| verses_for(b, c) },
|
|
347
|
-
nt_verses:
|
|
646
|
+
nt_verses: flatten(NT).sum { |b, c| verses_for(b, c) },
|
|
348
647
|
days: DAYS
|
|
349
648
|
}
|
|
350
649
|
end
|