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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -164
  3. data/README.md +287 -272
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +119 -0
  6. data/app/controllers/bible270/application_controller.rb +19 -2
  7. data/app/controllers/bible270/checkoffs_controller.rb +2 -3
  8. data/app/controllers/bible270/comments_controller.rb +6 -2
  9. data/app/controllers/bible270/days_controller.rb +5 -6
  10. data/app/controllers/bible270/readers_controller.rb +5 -4
  11. data/app/controllers/bible270/sessions_controller.rb +60 -26
  12. data/app/helpers/bible270/plan_helper.rb +11 -8
  13. data/app/mailers/bible270/application_mailer.rb +4 -1
  14. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  15. data/app/models/bible270/application_record.rb +1 -0
  16. data/app/models/bible270/checkoff.rb +4 -3
  17. data/app/models/bible270/comment.rb +26 -5
  18. data/app/models/bible270/reader.rb +78 -14
  19. data/app/models/bible270/sign_in_token.rb +5 -2
  20. data/app/views/bible270/admin/comments.html.erb +44 -0
  21. data/app/views/bible270/admin/index.html.erb +30 -0
  22. data/app/views/bible270/admin/show.html.erb +89 -0
  23. data/app/views/bible270/days/index.html.erb +10 -13
  24. data/app/views/bible270/readers/index.html.erb +2 -2
  25. data/app/views/bible270/readers/show.html.erb +5 -5
  26. data/app/views/bible270/sessions/new.html.erb +9 -3
  27. data/app/views/bible270/shared/_header.html.erb +1 -0
  28. data/app/views/bible270/shared/_styles.html.erb +10 -7
  29. data/app/views/layouts/bible270/application.html.erb +4 -3
  30. data/config/routes.rb +32 -18
  31. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  32. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  33. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  34. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  35. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  36. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  37. data/lib/bible270/configuration.rb +91 -19
  38. data/lib/bible270/email_sign_in.rb +7 -7
  39. data/lib/bible270/engine.rb +1 -0
  40. data/lib/bible270/plan.rb +449 -150
  41. data/lib/bible270/versification.rb +84 -67
  42. data/lib/bible270/version.rb +2 -1
  43. data/lib/bible270.rb +6 -5
  44. data/lib/generators/bible270/install/install_generator.rb +520 -39
  45. data/lib/generators/bible270/install/templates/bible270.rb.tt +8 -2
  46. data/lib/generators/bible270/install/templates/omniauth.rb.tt +6 -4
  47. metadata +17 -29
data/lib/bible270/plan.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
- require "bible270/versification"
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, cover to cover; WHOLE chapters grouped so each
14
- # day is ~equal in verses (~73/day)
15
- # * New Testament – read through TWICE (~2 chapters/day, every day)
16
- # * Psalms/Proverbs – a WHOLE-CHAPTER daily companion. With only 181 chapters
17
- # over 270 days it cycles ~1.9x. Chapters stay intact; very
18
- # short ones merge with a neighbour, and the one excessively
19
- # long chapter (Psalm 119) is divided into two readings.
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 finishes on day 270, as does the second pass through the NT.
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
- ["Genesis", 50], ["Exodus", 40], ["Leviticus", 27], ["Numbers", 36],
27
- ["Deuteronomy", 34], ["Joshua", 24], ["Judges", 21], ["Ruth", 4],
28
- ["1 Samuel", 31], ["2 Samuel", 24], ["1 Kings", 22], ["2 Kings", 25],
29
- ["1 Chronicles", 29], ["2 Chronicles", 36], ["Ezra", 10], ["Nehemiah", 13],
30
- ["Esther", 10], ["Job", 42], ["Ecclesiastes", 12], ["Song of Solomon", 8],
31
- ["Isaiah", 66], ["Jeremiah", 52], ["Lamentations", 5], ["Ezekiel", 48],
32
- ["Daniel", 12], ["Hosea", 14], ["Joel", 3], ["Amos", 9], ["Obadiah", 1],
33
- ["Jonah", 4], ["Micah", 7], ["Nahum", 3], ["Habakkuk", 3], ["Zephaniah", 3],
34
- ["Haggai", 2], ["Zechariah", 14], ["Malachi", 4]
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
- ["Matthew", 28], ["Mark", 16], ["Luke", 24], ["John", 21], ["Acts", 28],
39
- ["Romans", 16], ["1 Corinthians", 16], ["2 Corinthians", 13], ["Galatians", 6],
40
- ["Ephesians", 6], ["Philippians", 4], ["Colossians", 4], ["1 Thessalonians", 5],
41
- ["2 Thessalonians", 3], ["1 Timothy", 6], ["2 Timothy", 4], ["Titus", 3],
42
- ["Philemon", 1], ["Hebrews", 13], ["James", 5], ["1 Peter", 5], ["2 Peter", 3],
43
- ["1 John", 5], ["2 John", 1], ["3 John", 1], ["Jude", 1], ["Revelation", 22]
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 = [["Psalm", 150], ["Proverbs", 31]].freeze
48
+ PP = [['Psalm', 150], ['Proverbs', 31]].freeze
47
49
 
48
50
  TRACKS = {
49
- "ot" => { key: "ot", label: "Old Testament", color: "#2f6a67" },
50
- "nt" => { key: "nt", label: "New Testament", color: "#8a2f3b" },
51
- "pp" => { key: "pp", label: "Psalms & Proverbs", color: "#a5812c" }
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
- module_function
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
- @ot_groups ||= begin
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
- @ot_plan ||= ot_groups.map { |g| format_reference(g) }
328
+ memo(:ot_plan) { ot_groups.map { |g| format_reference(g) } }
133
329
  end
134
330
 
135
331
  def ot_verse_loads
136
- @ot_verse_loads ||= ot_groups.map { |g| g.sum { |b, c| verses_for(b, c) } }
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 through TWICE) -------------------------------
335
+ # ---- New Testament (read once, one reading every day) ------------------
140
336
  #
141
- # 260 chapters x 2 passes = 520 chapter-readings over 270 days, so ~2 chapters
142
- # a day and no rest days. Each pass gets its own half of the plan (135 days),
143
- # so Revelation 22 lands on day 135 and again on day 270. Within a pass, whole
144
- # chapters are grouped so each day is ~equal in verses.
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 = 2
342
+ NT_PASSES = 1
147
343
 
148
- def nt_days_per_pass = DAYS / NT_PASSES
344
+ def nt_chapters = memo(:nt_chapters) { flatten(NT) }
149
345
 
150
- def nt_chapters
151
- @nt_chapters ||= flatten(NT) * NT_PASSES
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 nt_groups
155
- @nt_groups ||= begin
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
- @nt_plan ||= nt_groups.map { |g| format_reference(g) }
355
+ memo(:nt_plan) { nt_readings.map { |seg| format_segment(seg) } }
165
356
  end
166
357
 
167
358
  def nt_verse_loads
168
- @nt_verse_loads ||= nt_groups.map { |g| g.sum { |b, c| verses_for(b, c) } }
359
+ memo(:nt_verse_loads) { nt_readings.map { |seg| segment_length(seg) } }
169
360
  end
170
361
 
171
- # Days on which the NT track has content (now every day).
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
- # 1-indexed day on which the second pass through the NT begins.
177
- def nt_second_pass_start_day = nt_days_per_pass + 1
365
+ def nt_rest_days = []
178
366
 
179
- # ---- Psalms & Proverbs (whole-chapter companion, cycles ~1.9x) --------
367
+ def divided_nt_chapters = memo(:divided_nt_chapters) { divided(nt_chapters, nt_parts) }
368
+
369
+ # ---- Psalms & Proverbs -------------------------------------------------
180
370
  #
181
- # Chapters are kept WHOLE. Because there are only 181 Psalms/Proverbs
182
- # chapters but 270 days, the track reads through roughly twice, cycling.
183
- # Two adjustments keep daily portions roughly even without chopping text:
184
- # * very short chapters merge with a neighbour (no trivial 2-verse days)
185
- # * only excessively long chapters (> PP_LONG_CHAPTER verses) are split
186
- # into balanced parts — in practice just Psalms 78, 89, and 119.
187
-
188
- PP_MIN_DAY = 12 # keep merging until a portion reaches at least this many verses
189
- PP_DAY_TARGET = 22 # soft cap: stop merging once a portion would exceed this
190
- PP_LONG_CHAPTER = 100 # only an excessively long chapter is split; in the whole
191
- # Psalter+Proverbs that is Psalm 119 (176v) alone, since
192
- # the next longest chapter is Psalm 78 at 72 verses
193
- PP_SPLIT_PARTS = 2 # such a chapter is divided into this many readings
194
-
195
- # The base cycle of readings (whole chapters, short ones merged, long ones
196
- # split). Each portion is an array of segments; a segment is either
197
- # [book, chapter] (whole) or [book, chapter, from, to] (part of a chapter).
198
- def pp_base_portions
199
- @pp_base_portions ||= begin
200
- units = flatten(PP).map { |b, c| [b, c, verses_for(b, c)] }
201
- portions = []
202
- buf = []
203
- buflen = 0
204
- units.each do |book, ch, len|
205
- if len > PP_LONG_CHAPTER
206
- unless buf.empty?
207
- portions << buf
208
- buf = []
209
- buflen = 0
210
- end
211
- nparts = PP_SPLIT_PARTS
212
- base = len / nparts
213
- extra = len % nparts
214
- v = 1
215
- nparts.times do |i|
216
- size = base + (i < extra ? 1 : 0)
217
- portions << [[book, ch, v, v + size - 1]]
218
- v += size
219
- end
220
- else
221
- if !buf.empty? && buflen >= PP_MIN_DAY && buflen + len > PP_DAY_TARGET
222
- portions << buf
223
- buf = []
224
- buflen = 0
225
- end
226
- buf << [book, ch]
227
- buflen += len
228
- end
229
- end
230
- portions << buf unless buf.empty?
231
- portions
232
- end
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 pp_cycle_length = pp_base_portions.size
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
- def pp_plan
238
- @pp_plan ||= (0...DAYS).map { |d| format_pp(pp_base_portions[d % pp_cycle_length]) }
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 pp_verse_loads
242
- @pp_verse_loads ||= (0...DAYS).map do |d|
243
- pp_base_portions[d % pp_cycle_length].sum do |s|
244
- s.size == 2 ? verses_for(s[0], s[1]) : (s[3] - s[2] + 1)
245
- end
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
- # Format a portion, collapsing consecutive whole chapters ("Psalm 24–25")
250
- # and rendering split parts as verse ranges ("Psalm 119:1–22").
251
- def format_pp(portion)
252
- parts = []
253
- i = 0
254
- while i < portion.size
255
- s = portion[i]
256
- if s.size == 2 # whole chapter
257
- book, ch = s
258
- j = i
259
- while j + 1 < portion.size && portion[j + 1].size == 2 &&
260
- portion[j + 1][0] == book && portion[j + 1][1] == portion[j][1] + 1
261
- j += 1
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
- parts.join(", ")
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
- { "ot" => ot_plan[day - 1], "nt" => nt_plan[day - 1], "pp" => pp_plan[day - 1] }
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 (::Date.parse(value) rescue nil)
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, # 748 chapters
340
- nt: flatten(NT).size, # 260 chapters per pass
341
- nt_passes: NT_PASSES, # read through twice
342
- nt_chapters_read: nt_chapters.size, # 520 chapter-readings
343
- pp: flatten(PP).size, # 181 chapters
344
- pp_verses: flatten(PP).sum { |b, c| verses_for(b, c) }, # 3376 verses
345
- pp_cycle: pp_cycle_length, # base portions before cycling
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: nt_chapters.sum { |b, c| verses_for(b, c) },
646
+ nt_verses: flatten(NT).sum { |b, c| verses_for(b, c) },
348
647
  days: DAYS
349
648
  }
350
649
  end