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/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.
|
|
@@ -14,45 +15,45 @@ module Bible270
|
|
|
14
15
|
# grouped so each day is ~equal in verses (~73/day)
|
|
15
16
|
# * New Testament – read once, one reading every day; the 10 longest chapters
|
|
16
17
|
# (Luke 1 and friends) are divided in two so 260 chapters fill 270 days
|
|
17
|
-
# * Psalms/Proverbs – Psalms
|
|
18
|
+
# * Psalms/Proverbs – Psalms and Proverbs once, interleaved in one track.
|
|
18
19
|
# Longer psalms are divided so the two fill exactly 270 days;
|
|
19
20
|
# Psalm 119 is pinned to 11 sections of 16 verses.
|
|
20
21
|
#
|
|
21
|
-
# Genesis→Malachi, Revelation 22, Psalm 150 and the second Proverbs 31 all land
|
|
22
|
+
# Genesis → Malachi, Revelation 22, Psalm 150 and the second Proverbs 31 all land
|
|
22
23
|
# in the closing days.
|
|
23
24
|
module Plan
|
|
24
25
|
DAYS = 270
|
|
25
26
|
|
|
26
27
|
OT = [
|
|
27
|
-
[
|
|
28
|
-
[
|
|
29
|
-
[
|
|
30
|
-
[
|
|
31
|
-
[
|
|
32
|
-
[
|
|
33
|
-
[
|
|
34
|
-
[
|
|
35
|
-
[
|
|
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]
|
|
36
37
|
].freeze
|
|
37
38
|
|
|
38
39
|
NT = [
|
|
39
|
-
[
|
|
40
|
-
[
|
|
41
|
-
[
|
|
42
|
-
[
|
|
43
|
-
[
|
|
44
|
-
[
|
|
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]
|
|
45
46
|
].freeze
|
|
46
47
|
|
|
47
|
-
PP = [[
|
|
48
|
+
PP = [['Psalm', 150], ['Proverbs', 31]].freeze
|
|
48
49
|
|
|
49
50
|
TRACKS = {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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' }
|
|
53
54
|
}.freeze
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
module_function
|
|
56
57
|
|
|
57
58
|
# ---- shared helpers ---------------------------------------------------
|
|
58
59
|
|
|
@@ -83,7 +84,7 @@ module Bible270
|
|
|
83
84
|
parts << (start == last ? "#{book} #{start}" : "#{book} #{start}\u2013#{last}")
|
|
84
85
|
i = j + 1
|
|
85
86
|
end
|
|
86
|
-
parts.join(
|
|
87
|
+
parts.join(', ')
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
# Group ordered items (each [book, chapter]) into `bins` contiguous groups so
|
|
@@ -131,6 +132,13 @@ module Bible270
|
|
|
131
132
|
def divide_to_fill(weights, target, pinned = {})
|
|
132
133
|
parts = Array.new(weights.size, 1)
|
|
133
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
|
+
|
|
134
142
|
candidates = (0...weights.size).reject { |i| pinned.key?(i) }
|
|
135
143
|
|
|
136
144
|
while parts.sum < target && candidates.any?
|
|
@@ -147,19 +155,59 @@ module Bible270
|
|
|
147
155
|
n = parts[idx]
|
|
148
156
|
next [[book, chapter]] if n == 1
|
|
149
157
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
160
174
|
end
|
|
161
175
|
end
|
|
162
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
|
+
|
|
163
211
|
def segment_length(segment)
|
|
164
212
|
segment.size == 2 ? verses_for(segment[0], segment[1]) : (segment[3] - segment[2] + 1)
|
|
165
213
|
end
|
|
@@ -175,13 +223,101 @@ module Bible270
|
|
|
175
223
|
# Chapters that ended up divided, as {[book, chapter] => reading count}.
|
|
176
224
|
def divided(chapters, parts)
|
|
177
225
|
chapters.each_with_index.select { |_, i| parts[i] > 1 }
|
|
178
|
-
|
|
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(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(%r{\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]
|
|
179
315
|
end
|
|
180
316
|
|
|
181
317
|
# ---- Old Testament ----------------------------------------------------
|
|
182
318
|
|
|
183
319
|
def ot_groups
|
|
184
|
-
|
|
320
|
+
memo(:ot_groups) do
|
|
185
321
|
chapters = flatten(OT)
|
|
186
322
|
weights = chapters.map { |b, c| verses_for(b, c) }
|
|
187
323
|
balance_by_weight(chapters, weights, DAYS)
|
|
@@ -189,11 +325,11 @@ module Bible270
|
|
|
189
325
|
end
|
|
190
326
|
|
|
191
327
|
def ot_plan
|
|
192
|
-
|
|
328
|
+
memo(:ot_plan) { ot_groups.map { |g| format_reference(g) } }
|
|
193
329
|
end
|
|
194
330
|
|
|
195
331
|
def ot_verse_loads
|
|
196
|
-
|
|
332
|
+
memo(:ot_verse_loads) { ot_groups.map { |g| g.sum { |b, c| verses_for(b, c) } } }
|
|
197
333
|
end
|
|
198
334
|
|
|
199
335
|
# ---- New Testament (read once, one reading every day) ------------------
|
|
@@ -205,117 +341,215 @@ module Bible270
|
|
|
205
341
|
|
|
206
342
|
NT_PASSES = 1
|
|
207
343
|
|
|
208
|
-
def nt_chapters =
|
|
344
|
+
def nt_chapters = memo(:nt_chapters) { flatten(NT) }
|
|
209
345
|
|
|
210
346
|
def nt_parts
|
|
211
|
-
|
|
347
|
+
memo(:nt_parts) { divide_to_fill(nt_chapters.map { |b, c| verses_for(b, c) }, DAYS, pinned_from_breaks(nt_chapters)) }
|
|
212
348
|
end
|
|
213
349
|
|
|
214
350
|
def nt_readings
|
|
215
|
-
|
|
351
|
+
memo(:nt_readings) { expand_readings(nt_chapters, nt_parts) }
|
|
216
352
|
end
|
|
217
353
|
|
|
218
354
|
def nt_plan
|
|
219
|
-
|
|
355
|
+
memo(:nt_plan) { nt_readings.map { |seg| format_segment(seg) } }
|
|
220
356
|
end
|
|
221
357
|
|
|
222
358
|
def nt_verse_loads
|
|
223
|
-
|
|
359
|
+
memo(:nt_verse_loads) { nt_readings.map { |seg| segment_length(seg) } }
|
|
224
360
|
end
|
|
225
361
|
|
|
226
362
|
# Every day now carries a New Testament reading.
|
|
227
|
-
def nt_content_days =
|
|
363
|
+
def nt_content_days = memo(:nt_content_days) { nt_plan.count { |r| !r.nil? } }
|
|
228
364
|
|
|
229
365
|
def nt_rest_days = []
|
|
230
366
|
|
|
231
|
-
def divided_nt_chapters =
|
|
367
|
+
def divided_nt_chapters = memo(:divided_nt_chapters) { divided(nt_chapters, nt_parts) }
|
|
232
368
|
|
|
233
369
|
# ---- Psalms & Proverbs -------------------------------------------------
|
|
234
370
|
#
|
|
235
|
-
# Psalms once and Proverbs
|
|
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:
|
|
236
375
|
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
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.
|
|
239
379
|
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
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.
|
|
245
385
|
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
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.
|
|
248
389
|
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
251
457
|
|
|
252
|
-
|
|
458
|
+
# A psalm this short or shorter is never divided.
|
|
459
|
+
PSALM_WHOLE_MAX_VERSES = 25
|
|
253
460
|
|
|
254
|
-
|
|
461
|
+
PSALM_119_SECTION_SIZE = 16
|
|
255
462
|
|
|
256
463
|
def psalm_chapters
|
|
257
|
-
|
|
464
|
+
memo(:psalm_chapters) { (1..Versification.chapter_count('Psalm')).map { |c| ['Psalm', c] } }
|
|
258
465
|
end
|
|
259
466
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def psalm_parts
|
|
263
|
-
@psalm_parts ||= begin
|
|
264
|
-
weights = psalm_chapters.map { |b, c| verses_for(b, c) }
|
|
265
|
-
i119 = 118
|
|
266
|
-
divide_to_fill(weights, psalm_reading_count, i119 => weights[i119] / PSALM_119_SECTION_SIZE)
|
|
267
|
-
end
|
|
467
|
+
def proverbs_chapters
|
|
468
|
+
memo(:proverbs_chapters) { (1..Versification.chapter_count('Proverbs')).map { |c| ['Proverbs', c] } }
|
|
268
469
|
end
|
|
269
470
|
|
|
270
|
-
def
|
|
271
|
-
@psalm_readings ||= expand_readings(psalm_chapters, psalm_parts)
|
|
272
|
-
end
|
|
471
|
+
def pp_chapters = memo(:pp_chapters) { psalm_chapters + proverbs_chapters }
|
|
273
472
|
|
|
274
|
-
# Proverbs
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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))
|
|
279
481
|
end
|
|
280
482
|
end
|
|
281
483
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
end
|
|
484
|
+
def pp_pinned_parts(chapters, weights)
|
|
485
|
+
pinned = {}
|
|
486
|
+
chapters.each_with_index do |(book, chapter), idx|
|
|
487
|
+
next unless book == 'Psalm'
|
|
287
488
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
@pp_readings ||= begin
|
|
291
|
-
psalms = psalm_readings.dup
|
|
292
|
-
proverbs = proverbs_readings.dup
|
|
293
|
-
(1..DAYS).map { |day| proverbs_day?(day) ? proverbs.shift : psalms.shift }
|
|
489
|
+
pinned[idx] = 1 if weights[idx] <= PSALM_WHOLE_MAX_VERSES
|
|
490
|
+
pinned[idx] = weights[idx] / PSALM_119_SECTION_SIZE if chapter == 119
|
|
294
491
|
end
|
|
492
|
+
# An explicit entry in CHAPTER_BREAKS always wins.
|
|
493
|
+
pinned.merge(pinned_from_breaks(chapters))
|
|
295
494
|
end
|
|
296
495
|
|
|
297
|
-
def
|
|
298
|
-
|
|
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
|
|
299
508
|
end
|
|
300
509
|
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
days.concat(proverbs[placed..].to_a.flatten(1)) if placed < proverbs.size
|
|
529
|
+
days
|
|
530
|
+
end
|
|
303
531
|
end
|
|
304
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
|
+
|
|
305
536
|
# 1-indexed days carrying a Proverbs reading.
|
|
306
537
|
def proverbs_days
|
|
307
|
-
|
|
538
|
+
memo(:proverbs_days) { (1..DAYS).select { |d| pp_readings[d - 1][0] == 'Proverbs' } }
|
|
308
539
|
end
|
|
309
540
|
|
|
310
|
-
# Psalms that end up divided, as {chapter => number of readings}.
|
|
311
541
|
def divided_psalms
|
|
312
|
-
|
|
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] } }
|
|
313
547
|
end
|
|
314
548
|
|
|
315
549
|
# ---- public API -------------------------------------------------------
|
|
316
550
|
|
|
317
551
|
def readings_for(day)
|
|
318
|
-
{
|
|
552
|
+
{ 'ot' => ot_plan[day - 1], 'nt' => nt_plan[day - 1], 'pp' => pp_plan[day - 1] }
|
|
319
553
|
end
|
|
320
554
|
|
|
321
555
|
def present_tracks(day)
|
|
@@ -326,6 +560,21 @@ module Bible270
|
|
|
326
560
|
|
|
327
561
|
def valid_day?(day) = day.is_a?(Integer) && day >= 1 && day <= DAYS
|
|
328
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
|
+
|
|
329
578
|
# ---- calendar mapping (all pure functions; nil start_date = undated) ----
|
|
330
579
|
|
|
331
580
|
# Coerce whatever we were handed into a Date (or nil).
|
|
@@ -334,7 +583,11 @@ module Bible270
|
|
|
334
583
|
when nil then nil
|
|
335
584
|
when ::Date then value
|
|
336
585
|
when ::Time then value.to_date
|
|
337
|
-
when ::String then
|
|
586
|
+
when ::String then begin
|
|
587
|
+
::Date.parse(value)
|
|
588
|
+
rescue StandardError
|
|
589
|
+
nil
|
|
590
|
+
end
|
|
338
591
|
else value.respond_to?(:to_date) ? value.to_date : nil
|
|
339
592
|
end
|
|
340
593
|
end
|
|
@@ -381,12 +634,13 @@ module Bible270
|
|
|
381
634
|
nt_passes: NT_PASSES,
|
|
382
635
|
nt_readings: nt_readings.size, # 270 - one every day
|
|
383
636
|
nt_divided: divided_nt_chapters.size, # 10 long chapters halved
|
|
384
|
-
psalms: Versification.chapter_count(
|
|
637
|
+
psalms: Versification.chapter_count('Psalm'), # 150
|
|
385
638
|
psalm_readings: psalm_readings.size, # 208
|
|
386
|
-
proverbs: Versification.chapter_count(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
390
644
|
pp_verses: flatten(PP).sum { |b, c| verses_for(b, c) },
|
|
391
645
|
ot_verses: flatten(OT).sum { |b, c| verses_for(b, c) },
|
|
392
646
|
nt_verses: flatten(NT).sum { |b, c| verses_for(b, c) },
|