bible270 0.6.2
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 +7 -0
- data/CHANGELOG.md +181 -0
- data/MIT-LICENSE +18 -0
- data/README.md +373 -0
- data/Rakefile +11 -0
- data/app/controllers/bible270/application_controller.rb +48 -0
- data/app/controllers/bible270/checkoffs_controller.rb +32 -0
- data/app/controllers/bible270/comments_controller.rb +46 -0
- data/app/controllers/bible270/days_controller.rb +42 -0
- data/app/controllers/bible270/readers_controller.rb +46 -0
- data/app/controllers/bible270/sessions_controller.rb +126 -0
- data/app/helpers/bible270/plan_helper.rb +45 -0
- data/app/mailers/bible270/application_mailer.rb +7 -0
- data/app/mailers/bible270/sign_in_mailer.rb +14 -0
- data/app/models/bible270/application_record.rb +6 -0
- data/app/models/bible270/checkoff.rb +23 -0
- data/app/models/bible270/comment.rb +16 -0
- data/app/models/bible270/reader.rb +211 -0
- data/app/models/bible270/sign_in_token.rb +74 -0
- data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +4 -0
- data/app/views/bible270/comments/_comment.html.erb +15 -0
- data/app/views/bible270/comments/_form.html.erb +13 -0
- data/app/views/bible270/comments/create.turbo_stream.erb +7 -0
- data/app/views/bible270/comments/destroy.turbo_stream.erb +1 -0
- data/app/views/bible270/days/_reading.html.erb +25 -0
- data/app/views/bible270/days/_start_date.html.erb +43 -0
- data/app/views/bible270/days/index.html.erb +72 -0
- data/app/views/bible270/days/show.html.erb +50 -0
- data/app/views/bible270/readers/index.html.erb +24 -0
- data/app/views/bible270/readers/show.html.erb +41 -0
- data/app/views/bible270/sessions/new.html.erb +52 -0
- data/app/views/bible270/shared/_header.html.erb +20 -0
- data/app/views/bible270/shared/_styles.html.erb +147 -0
- data/app/views/bible270/sign_in_mailer/magic_link.html.erb +20 -0
- data/app/views/bible270/sign_in_mailer/magic_link.text.erb +7 -0
- data/app/views/layouts/bible270/application.html.erb +28 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260101000001_create_bible270_readers.rb +17 -0
- data/db/migrate/20260101000002_create_bible270_checkoffs.rb +15 -0
- data/db/migrate/20260101000003_create_bible270_comments.rb +14 -0
- data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +18 -0
- data/lib/bible270/configuration.rb +177 -0
- data/lib/bible270/email_sign_in.rb +68 -0
- data/lib/bible270/engine.rb +21 -0
- data/lib/bible270/plan.rb +352 -0
- data/lib/bible270/versification.rb +83 -0
- data/lib/bible270/version.rb +4 -0
- data/lib/bible270.rb +9 -0
- data/lib/generators/bible270/install/install_generator.rb +66 -0
- data/lib/generators/bible270/install/templates/bible270.rb.tt +41 -0
- data/lib/generators/bible270/install/templates/omniauth.rb.tt +25 -0
- metadata +158 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
class CreateBible270Comments < ActiveRecord::Migration[7.0]
|
|
3
|
+
def change
|
|
4
|
+
create_table :bible270_comments do |t|
|
|
5
|
+
t.references :reader, null: false, index: true,
|
|
6
|
+
foreign_key: { to_table: :bible270_readers }
|
|
7
|
+
t.integer :day, null: false
|
|
8
|
+
t.string :track
|
|
9
|
+
t.text :body, null: false
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
add_index :bible270_comments, %i[day created_at]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
class CreateBible270SignInTokens < ActiveRecord::Migration[7.0]
|
|
3
|
+
def change
|
|
4
|
+
create_table :bible270_sign_in_tokens do |t|
|
|
5
|
+
t.string :email, null: false
|
|
6
|
+
t.string :token_digest, null: false
|
|
7
|
+
t.string :display_name
|
|
8
|
+
t.datetime :expires_at, null: false
|
|
9
|
+
t.datetime :consumed_at
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :bible270_sign_in_tokens, :token_digest, unique: true,
|
|
14
|
+
name: "idx_b270_signin_tokens_digest"
|
|
15
|
+
add_index :bible270_sign_in_tokens, %i[email created_at],
|
|
16
|
+
name: "idx_b270_signin_tokens_email"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `uri` is a default gem on every supported Ruby. We deliberately avoid `cgi`
|
|
4
|
+
# here: the CGI library was removed from Ruby's default gems in Ruby 4.0 (only
|
|
5
|
+
# cgi/escape remains), so requiring it emits a deprecation warning there.
|
|
6
|
+
# URI.encode_www_form_component produces byte-identical output to CGI.escape.
|
|
7
|
+
require "uri"
|
|
8
|
+
require "bible270/plan"
|
|
9
|
+
|
|
10
|
+
module Bible270
|
|
11
|
+
class Configuration
|
|
12
|
+
# Controller the engine's controllers inherit from. Set this to your host's
|
|
13
|
+
# "::ApplicationController" if you want to share layout, auth and helpers.
|
|
14
|
+
attr_accessor :parent_controller
|
|
15
|
+
|
|
16
|
+
# Layout used to render engine pages.
|
|
17
|
+
attr_accessor :layout
|
|
18
|
+
|
|
19
|
+
# A callable (lambda) that receives the current controller and returns a
|
|
20
|
+
# Bible270::Reader (or nil). Use this to bridge your host's users:
|
|
21
|
+
#
|
|
22
|
+
# config.current_reader_resolver = ->(c) {
|
|
23
|
+
# u = c.send(:current_user)
|
|
24
|
+
# u && Bible270::Reader.for_owner(u, display_name: u.name, email: u.email)
|
|
25
|
+
# }
|
|
26
|
+
#
|
|
27
|
+
# Leave nil to use the built-in session-based sign-in (OmniAuth).
|
|
28
|
+
attr_accessor :current_reader_resolver
|
|
29
|
+
|
|
30
|
+
# Where to send the reader after a successful sign-in / sign-out.
|
|
31
|
+
attr_accessor :after_sign_in_path
|
|
32
|
+
attr_accessor :after_sign_out_path
|
|
33
|
+
|
|
34
|
+
# ---- OmniAuth (built-in sign-in) --------------------------------------
|
|
35
|
+
|
|
36
|
+
# Providers to offer on the sign-in screen. Accepts symbols, or pairs of
|
|
37
|
+
# [provider, label] when you want to override the displayed name:
|
|
38
|
+
#
|
|
39
|
+
# config.omniauth_providers = [:github]
|
|
40
|
+
# config.omniauth_providers = [:github, [:google_oauth2, "Google"]]
|
|
41
|
+
#
|
|
42
|
+
# The host application supplies the matching strategy gems and registers
|
|
43
|
+
# them in the OmniAuth builder (see the README, or run the install generator).
|
|
44
|
+
attr_reader :omniauth_providers
|
|
45
|
+
|
|
46
|
+
# OmniAuth's path_prefix, if you need to override it. Normally leave this
|
|
47
|
+
# nil: the engine derives "<mount point>/auth" from the request, which
|
|
48
|
+
# matches a builder configured with path_prefix: "<mount point>/auth".
|
|
49
|
+
attr_accessor :omniauth_path_prefix
|
|
50
|
+
|
|
51
|
+
# Human labels for providers we know; anything else is titleised.
|
|
52
|
+
PROVIDER_LABELS = {
|
|
53
|
+
github: "GitHub", gitlab: "GitLab", google_oauth2: "Google", google: "Google",
|
|
54
|
+
facebook: "Facebook", twitter: "Twitter", apple: "Apple", discord: "Discord",
|
|
55
|
+
microsoft_graph: "Microsoft", azure_activedirectory_v2: "Microsoft",
|
|
56
|
+
openid_connect: "OpenID Connect", saml: "SSO"
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
def omniauth_providers=(list)
|
|
60
|
+
@omniauth_providers = Array(list).map do |entry|
|
|
61
|
+
key, label = entry.is_a?(Array) ? entry : [entry, nil]
|
|
62
|
+
key = key.to_sym
|
|
63
|
+
[key, (label || PROVIDER_LABELS[key] || key.to_s.tr("_", " ").split.map(&:capitalize).join(" "))]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def omniauth_provider_keys = omniauth_providers.map(&:first)
|
|
68
|
+
|
|
69
|
+
# ---- Email sign-in (passwordless magic link) --------------------------
|
|
70
|
+
|
|
71
|
+
# Offer sign-in by email link, so readers who don't have (or don't want to
|
|
72
|
+
# use) a GitHub/Google/etc. account can still take part. Requires the host
|
|
73
|
+
# app to have Action Mailer delivery configured.
|
|
74
|
+
attr_accessor :email_sign_in
|
|
75
|
+
|
|
76
|
+
# From: address for the sign-in email.
|
|
77
|
+
attr_accessor :mailer_from
|
|
78
|
+
|
|
79
|
+
# How long a magic link stays valid (seconds).
|
|
80
|
+
attr_accessor :email_sign_in_ttl
|
|
81
|
+
|
|
82
|
+
# Simple abuse guard: at most N links per address per window (seconds).
|
|
83
|
+
attr_accessor :email_sign_in_window
|
|
84
|
+
attr_accessor :email_sign_in_max_per_window
|
|
85
|
+
|
|
86
|
+
# Whether a reader may type the display name shown beside their reflections
|
|
87
|
+
# when signing in by email (otherwise it's derived from the address).
|
|
88
|
+
attr_accessor :email_sign_in_ask_name
|
|
89
|
+
|
|
90
|
+
# Send the sign-in email via Active Job (deliver_later) instead of inline.
|
|
91
|
+
# Leave false unless you have a queue backend configured.
|
|
92
|
+
attr_accessor :email_sign_in_deliver_later
|
|
93
|
+
|
|
94
|
+
def email_sign_in? = !!@email_sign_in
|
|
95
|
+
|
|
96
|
+
# Any way at all for a new person to sign in?
|
|
97
|
+
def any_sign_in_method?
|
|
98
|
+
email_sign_in? || omniauth_providers.any?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def single_provider? = omniauth_providers.size == 1
|
|
102
|
+
|
|
103
|
+
def label_for_provider(key)
|
|
104
|
+
found = omniauth_providers.find { |k, _| k.to_s == key.to_s }
|
|
105
|
+
return found.last if found
|
|
106
|
+
|
|
107
|
+
PROVIDER_LABELS[key.to_sym] || key.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Default Bible translation and a builder for external passage links.
|
|
111
|
+
attr_accessor :bible_version
|
|
112
|
+
attr_accessor :passage_url_builder
|
|
113
|
+
|
|
114
|
+
# Public labels.
|
|
115
|
+
attr_accessor :app_name
|
|
116
|
+
attr_accessor :tagline
|
|
117
|
+
|
|
118
|
+
# Optional community-wide start date for the plan. Set this when everyone
|
|
119
|
+
# reads together as a cohort (e.g. a church starting on a set Sunday):
|
|
120
|
+
#
|
|
121
|
+
# config.start_date = Date.new(2026, 9, 6) # or the string "2026-09-06"
|
|
122
|
+
#
|
|
123
|
+
# Leave nil for an undated plan, where each reader's own start date applies
|
|
124
|
+
# (defaulting to the day they first check something off).
|
|
125
|
+
attr_reader :start_date
|
|
126
|
+
|
|
127
|
+
# Whether an individual reader may set or change their own start date. When
|
|
128
|
+
# false, everyone is pinned to config.start_date.
|
|
129
|
+
attr_accessor :allow_reader_start_date
|
|
130
|
+
|
|
131
|
+
# Only signed-in readers may check off and comment (viewing is always open).
|
|
132
|
+
attr_accessor :require_sign_in_to_participate
|
|
133
|
+
|
|
134
|
+
def initialize
|
|
135
|
+
@parent_controller = "ActionController::Base"
|
|
136
|
+
@layout = "bible270/application"
|
|
137
|
+
@current_reader_resolver = nil
|
|
138
|
+
@after_sign_in_path = nil
|
|
139
|
+
@after_sign_out_path = nil
|
|
140
|
+
@bible_version = "ESV"
|
|
141
|
+
@passage_url_builder = lambda do |reference, version|
|
|
142
|
+
search = URI.encode_www_form_component(reference)
|
|
143
|
+
version = URI.encode_www_form_component(version)
|
|
144
|
+
"https://www.biblegateway.com/passage/?search=#{search}&version=#{version}"
|
|
145
|
+
end
|
|
146
|
+
@app_name = "Daily Bread"
|
|
147
|
+
@tagline = "A 270-day journey through Scripture"
|
|
148
|
+
@require_sign_in_to_participate = true
|
|
149
|
+
@start_date = nil
|
|
150
|
+
@allow_reader_start_date = true
|
|
151
|
+
self.omniauth_providers = [:github]
|
|
152
|
+
@omniauth_path_prefix = nil
|
|
153
|
+
@email_sign_in = true
|
|
154
|
+
@mailer_from = "no-reply@example.com"
|
|
155
|
+
@email_sign_in_ttl = 20 * 60 # 20 minutes
|
|
156
|
+
@email_sign_in_window = 15 * 60 # 15 minutes
|
|
157
|
+
@email_sign_in_max_per_window = 5
|
|
158
|
+
@email_sign_in_ask_name = true
|
|
159
|
+
@email_sign_in_deliver_later = false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Accepts a Date, Time, or "YYYY-MM-DD" string.
|
|
163
|
+
def start_date=(value)
|
|
164
|
+
@start_date = Plan.to_date(value)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
class << self
|
|
169
|
+
def config
|
|
170
|
+
@config ||= Configuration.new
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def configure
|
|
174
|
+
yield config
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Bible270
|
|
7
|
+
# Pure helpers for passwordless ("magic link") email sign-in. Kept free of
|
|
8
|
+
# Rails so the security-relevant bits can be unit tested on their own.
|
|
9
|
+
#
|
|
10
|
+
# We never store a password and never store the raw token — only a SHA-256
|
|
11
|
+
# digest of it, so a leaked database can't be used to sign in.
|
|
12
|
+
module EmailSignIn
|
|
13
|
+
# Deliberately permissive: real validation is "did the link get clicked".
|
|
14
|
+
EMAIL_RE = /\A[^@\s,;:<>"]+@[^@\s,;:<>"]+\.[A-Za-z]{2,}\z/
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Trim, strip a mailto:, and downcase. Returns nil for anything unusable.
|
|
19
|
+
def normalize_email(value)
|
|
20
|
+
email = value.to_s.strip
|
|
21
|
+
email = email[7..] if email.downcase.start_with?("mailto:")
|
|
22
|
+
email = email.strip.downcase
|
|
23
|
+
return nil if email.empty?
|
|
24
|
+
return nil unless email.match?(EMAIL_RE)
|
|
25
|
+
return nil if email.length > 254 # RFC 5321 practical maximum
|
|
26
|
+
|
|
27
|
+
email
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def valid_email?(value)
|
|
31
|
+
!normalize_email(value).nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A URL-safe, high-entropy single-use token (256 bits).
|
|
35
|
+
def generate_token
|
|
36
|
+
SecureRandom.urlsafe_base64(32)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# What we actually persist. Hex digest keeps it index-friendly.
|
|
40
|
+
def digest_token(token)
|
|
41
|
+
return nil if token.to_s.empty?
|
|
42
|
+
|
|
43
|
+
Digest::SHA256.hexdigest(token.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Constant-time comparison, for callers that need to compare digests
|
|
47
|
+
# directly rather than looking them up.
|
|
48
|
+
def secure_compare(a, b)
|
|
49
|
+
a = a.to_s
|
|
50
|
+
b = b.to_s
|
|
51
|
+
return false unless a.bytesize == b.bytesize
|
|
52
|
+
|
|
53
|
+
res = 0
|
|
54
|
+
a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
|
|
55
|
+
res.zero?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The part of an address we can use as a default display name:
|
|
59
|
+
# "mary.anne.smith@example.com" => "Mary Anne Smith"
|
|
60
|
+
def display_name_from(email)
|
|
61
|
+
local = normalize_email(email)&.split("@")&.first
|
|
62
|
+
return nil if local.nil? || local.empty?
|
|
63
|
+
|
|
64
|
+
# NB: the hyphen must come last or tr reads "_-+" as a range.
|
|
65
|
+
local.tr("._+-", " ").split.map { |w| w[0].upcase + (w[1..] || "") }.join(" ")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Bible270
|
|
3
|
+
class Engine < ::Rails::Engine
|
|
4
|
+
isolate_namespace Bible270
|
|
5
|
+
|
|
6
|
+
config.generators do |g|
|
|
7
|
+
g.test_framework :minitest
|
|
8
|
+
g.orm :active_record
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Make the engine's migrations available to the host via
|
|
12
|
+
# bin/rails bible270:install:migrations
|
|
13
|
+
initializer "bible270.append_migrations" do |app|
|
|
14
|
+
unless app.root.to_s.match?(root.to_s)
|
|
15
|
+
config.paths["db/migrate"].expanded.each do |path|
|
|
16
|
+
app.config.paths["db/migrate"] << path
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "bible270/versification"
|
|
5
|
+
|
|
6
|
+
module Bible270
|
|
7
|
+
# Pure-Ruby, deterministic reading-plan generator. No database required.
|
|
8
|
+
#
|
|
9
|
+
# Daily portions are balanced by VERSE COUNT (a canonical, translation-neutral
|
|
10
|
+
# proxy for how much text there is), not by chapter count — so a day landing on
|
|
11
|
+
# Psalm 119 (176 verses) is not treated as equal to a day on Psalm 117 (2 verses).
|
|
12
|
+
#
|
|
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.
|
|
20
|
+
#
|
|
21
|
+
# Genesis→Malachi finishes on day 270, as does the second pass through the NT.
|
|
22
|
+
module Plan
|
|
23
|
+
DAYS = 270
|
|
24
|
+
|
|
25
|
+
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]
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
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]
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
PP = [["Psalm", 150], ["Proverbs", 31]].freeze
|
|
47
|
+
|
|
48
|
+
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" }
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# ---- shared helpers ---------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def flatten(track)
|
|
59
|
+
track.flat_map { |book, chapters| (1..chapters).map { |c| [book, c] } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def verses_for(book, chapter)
|
|
63
|
+
Versification.verses(book, chapter)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Collapse consecutive whole chapters in the same book: "Genesis 1–3, Exodus 1"
|
|
67
|
+
def format_reference(chapters)
|
|
68
|
+
return nil if chapters.empty?
|
|
69
|
+
|
|
70
|
+
parts = []
|
|
71
|
+
i = 0
|
|
72
|
+
while i < chapters.size
|
|
73
|
+
book = chapters[i][0]
|
|
74
|
+
start = chapters[i][1]
|
|
75
|
+
j = i
|
|
76
|
+
while j + 1 < chapters.size &&
|
|
77
|
+
chapters[j + 1][0] == book &&
|
|
78
|
+
chapters[j + 1][1] == chapters[j][1] + 1
|
|
79
|
+
j += 1
|
|
80
|
+
end
|
|
81
|
+
last = chapters[j][1]
|
|
82
|
+
parts << (start == last ? "#{book} #{start}" : "#{book} #{start}\u2013#{last}")
|
|
83
|
+
i = j + 1
|
|
84
|
+
end
|
|
85
|
+
parts.join(", ")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Group ordered items (each [book, chapter]) into `bins` contiguous groups so
|
|
89
|
+
# that the total weight per group is as even as possible, while guaranteeing
|
|
90
|
+
# every group gets at least one item.
|
|
91
|
+
def balance_by_weight(items, weights, bins)
|
|
92
|
+
n = items.size
|
|
93
|
+
total = weights.sum.to_f
|
|
94
|
+
groups = Array.new(bins) { [] }
|
|
95
|
+
idx = 0
|
|
96
|
+
running = 0.0
|
|
97
|
+
(0...bins).each do |d|
|
|
98
|
+
target = (d + 1) * total / bins
|
|
99
|
+
# every bin takes at least one item
|
|
100
|
+
groups[d] << items[idx]
|
|
101
|
+
running += weights[idx]
|
|
102
|
+
idx += 1
|
|
103
|
+
# then add more only while it brings this bin's cumulative closer to the
|
|
104
|
+
# target boundary (round-to-nearest), keeping one item in reserve per
|
|
105
|
+
# remaining bin
|
|
106
|
+
while idx < n && (n - idx) > (bins - d - 1)
|
|
107
|
+
w = weights[idx]
|
|
108
|
+
break if running >= target
|
|
109
|
+
# stop if overshooting the target would be worse than stopping short
|
|
110
|
+
break if (running + w - target) > (target - running)
|
|
111
|
+
|
|
112
|
+
groups[d] << items[idx]
|
|
113
|
+
running += w
|
|
114
|
+
idx += 1
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
groups[bins - 1].concat(items[idx...n]) if idx < n
|
|
118
|
+
groups
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ---- Old Testament ----------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def ot_groups
|
|
124
|
+
@ot_groups ||= begin
|
|
125
|
+
chapters = flatten(OT)
|
|
126
|
+
weights = chapters.map { |b, c| verses_for(b, c) }
|
|
127
|
+
balance_by_weight(chapters, weights, DAYS)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def ot_plan
|
|
132
|
+
@ot_plan ||= ot_groups.map { |g| format_reference(g) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ot_verse_loads
|
|
136
|
+
@ot_verse_loads ||= ot_groups.map { |g| g.sum { |b, c| verses_for(b, c) } }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# ---- New Testament (read through TWICE) -------------------------------
|
|
140
|
+
#
|
|
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.
|
|
145
|
+
|
|
146
|
+
NT_PASSES = 2
|
|
147
|
+
|
|
148
|
+
def nt_days_per_pass = DAYS / NT_PASSES
|
|
149
|
+
|
|
150
|
+
def nt_chapters
|
|
151
|
+
@nt_chapters ||= flatten(NT) * NT_PASSES
|
|
152
|
+
end
|
|
153
|
+
|
|
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
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def nt_plan
|
|
164
|
+
@nt_plan ||= nt_groups.map { |g| format_reference(g) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def nt_verse_loads
|
|
168
|
+
@nt_verse_loads ||= nt_groups.map { |g| g.sum { |b, c| verses_for(b, c) } }
|
|
169
|
+
end
|
|
170
|
+
|
|
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
|
|
175
|
+
|
|
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
|
|
178
|
+
|
|
179
|
+
# ---- Psalms & Proverbs (whole-chapter companion, cycles ~1.9x) --------
|
|
180
|
+
#
|
|
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
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def pp_cycle_length = pp_base_portions.size
|
|
236
|
+
|
|
237
|
+
def pp_plan
|
|
238
|
+
@pp_plan ||= (0...DAYS).map { |d| format_pp(pp_base_portions[d % pp_cycle_length]) }
|
|
239
|
+
end
|
|
240
|
+
|
|
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
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
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
|
|
262
|
+
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
|
+
end
|
|
271
|
+
end
|
|
272
|
+
parts.join(", ")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# ---- public API -------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def readings_for(day)
|
|
278
|
+
{ "ot" => ot_plan[day - 1], "nt" => nt_plan[day - 1], "pp" => pp_plan[day - 1] }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def present_tracks(day)
|
|
282
|
+
readings_for(day).select { |_, ref| ref }.keys
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def required_track_count(day) = present_tracks(day).size
|
|
286
|
+
|
|
287
|
+
def valid_day?(day) = day.is_a?(Integer) && day >= 1 && day <= DAYS
|
|
288
|
+
|
|
289
|
+
# ---- calendar mapping (all pure functions; nil start_date = undated) ----
|
|
290
|
+
|
|
291
|
+
# Coerce whatever we were handed into a Date (or nil).
|
|
292
|
+
def to_date(value)
|
|
293
|
+
case value
|
|
294
|
+
when nil then nil
|
|
295
|
+
when ::Date then value
|
|
296
|
+
when ::Time then value.to_date
|
|
297
|
+
when ::String then (::Date.parse(value) rescue nil)
|
|
298
|
+
else value.respond_to?(:to_date) ? value.to_date : nil
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The calendar date a given plan day falls on.
|
|
303
|
+
def date_for(day, start_date)
|
|
304
|
+
start = to_date(start_date)
|
|
305
|
+
return nil unless start && valid_day?(day)
|
|
306
|
+
|
|
307
|
+
start + (day - 1)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The last day of the plan.
|
|
311
|
+
def end_date_for(start_date)
|
|
312
|
+
date_for(DAYS, start_date)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Which plan day a calendar date corresponds to. By default the result is
|
|
316
|
+
# clamped into 1..DAYS; pass clamp: false to get the raw (possibly out of
|
|
317
|
+
# range) offset, which is how you tell "hasn't started yet" from "day 1".
|
|
318
|
+
def day_for(date, start_date, clamp: true)
|
|
319
|
+
start = to_date(start_date)
|
|
320
|
+
on = to_date(date)
|
|
321
|
+
return nil unless start && on
|
|
322
|
+
|
|
323
|
+
offset = (on - start).to_i + 1
|
|
324
|
+
clamp ? offset.clamp(1, DAYS) : offset
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def before_start?(date, start_date)
|
|
328
|
+
raw = day_for(date, start_date, clamp: false)
|
|
329
|
+
raw ? raw < 1 : false
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def after_end?(date, start_date)
|
|
333
|
+
raw = day_for(date, start_date, clamp: false)
|
|
334
|
+
raw ? raw > DAYS : false
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def totals
|
|
338
|
+
{
|
|
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
|
|
346
|
+
ot_verses: flatten(OT).sum { |b, c| verses_for(b, c) },
|
|
347
|
+
nt_verses: nt_chapters.sum { |b, c| verses_for(b, c) },
|
|
348
|
+
days: DAYS
|
|
349
|
+
}
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|