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
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class CreateBible270Readers < ActiveRecord::Migration[7.0]
3
4
  def change
4
5
  create_table :bible270_readers do |t|
@@ -8,10 +9,10 @@ class CreateBible270Readers < ActiveRecord::Migration[7.0]
8
9
  t.string :provider
9
10
  t.string :uid
10
11
  t.references :owner, polymorphic: true, index: true
11
- t.date :started_on
12
+ t.date :started_on
12
13
  t.timestamps
13
14
  end
14
15
  add_index :bible270_readers, %i[provider uid], unique: true,
15
- name: "idx_b270_readers_provider_uid"
16
+ name: 'idx_b270_readers_provider_uid'
16
17
  end
17
18
  end
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class CreateBible270Checkoffs < ActiveRecord::Migration[7.0]
3
4
  def change
4
5
  create_table :bible270_checkoffs do |t|
5
6
  t.references :reader, null: false, index: true,
6
- foreign_key: { to_table: :bible270_readers }
7
+ foreign_key: { to_table: :bible270_readers }
7
8
  t.integer :day, null: false
8
9
  t.string :track, null: false
9
10
  t.timestamps
10
11
  end
11
12
  add_index :bible270_checkoffs, %i[reader_id day track], unique: true,
12
- name: "idx_b270_checkoffs_unique"
13
+ name: 'idx_b270_checkoffs_unique'
13
14
  add_index :bible270_checkoffs, :day
14
15
  end
15
16
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class CreateBible270Comments < ActiveRecord::Migration[7.0]
3
4
  def change
4
5
  create_table :bible270_comments do |t|
5
6
  t.references :reader, null: false, index: true,
6
- foreign_key: { to_table: :bible270_readers }
7
- t.integer :day, null: false
7
+ foreign_key: { to_table: :bible270_readers }
8
+ t.integer :day, null: false
8
9
  t.string :track
9
- t.text :body, null: false
10
+ t.text :body, null: false
10
11
  t.timestamps
11
12
  end
12
13
  add_index :bible270_comments, %i[day created_at]
@@ -1,18 +1,19 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class CreateBible270SignInTokens < ActiveRecord::Migration[7.0]
3
4
  def change
4
5
  create_table :bible270_sign_in_tokens do |t|
5
6
  t.string :email, null: false
6
7
  t.string :token_digest, null: false
7
8
  t.string :display_name
8
- t.datetime :expires_at, null: false
9
+ t.datetime :expires_at, null: false
9
10
  t.datetime :consumed_at
10
11
  t.timestamps
11
12
  end
12
13
 
13
14
  add_index :bible270_sign_in_tokens, :token_digest, unique: true,
14
- name: "idx_b270_signin_tokens_digest"
15
+ name: 'idx_b270_signin_tokens_digest'
15
16
  add_index :bible270_sign_in_tokens, %i[email created_at],
16
- name: "idx_b270_signin_tokens_email"
17
+ name: 'idx_b270_signin_tokens_email'
17
18
  end
18
19
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddNamesToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :first_name, :string
6
+ add_column :bible270_readers, :last_name, :string
7
+ add_column :bible270_sign_in_tokens, :first_name, :string
8
+ add_column :bible270_sign_in_tokens, :last_name, :string
9
+
10
+ add_index :bible270_readers, %i[last_name first_name], name: 'idx_b270_readers_name'
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddApprovalToBible270Comments < ActiveRecord::Migration[7.0]
4
+ def change
5
+ # Reflections are visible as soon as they are written; moderation is for
6
+ # taking something down after the fact, not for gatekeeping every post.
7
+ add_column :bible270_comments, :approved, :boolean, null: false, default: true
8
+ add_column :bible270_comments, :moderated_at, :datetime
9
+ add_index :bible270_comments, %i[approved day]
10
+ end
11
+ end
@@ -4,8 +4,8 @@
4
4
  # here: the CGI library was removed from Ruby's default gems in Ruby 4.0 (only
5
5
  # cgi/escape remains), so requiring it emits a deprecation warning there.
6
6
  # URI.encode_www_form_component produces byte-identical output to CGI.escape.
7
- require "uri"
8
- require "bible270/plan"
7
+ require 'uri'
8
+ require 'bible270/plan'
9
9
 
10
10
  module Bible270
11
11
  class Configuration
@@ -27,9 +27,36 @@ module Bible270
27
27
  # Leave nil to use the built-in session-based sign-in (OmniAuth).
28
28
  attr_accessor :current_reader_resolver
29
29
 
30
+ # Where the engine is mounted in the host application. Set this once, in
31
+ # config/initializers/bible270.rb, and use it everywhere the path is needed:
32
+ #
33
+ # # config/routes.rb
34
+ # mount Bible270::Engine, at: Bible270.config.mount_at
35
+ #
36
+ # # config/initializers/omniauth.rb
37
+ # path_prefix Bible270.config.auth_path_prefix
38
+ #
39
+ # Accepts "daily-bread" or "/daily-bread"; stored with a leading slash and
40
+ # no trailing one.
41
+ attr_reader :mount_at
42
+
43
+ def mount_at=(value)
44
+ path = value.to_s.strip
45
+ path = "/#{path}" unless path.start_with?('/')
46
+ path = path.chomp('/')
47
+ @mount_at = path.empty? ? '/' : path
48
+ end
49
+
50
+ # The prefix OmniAuth's middleware should serve its routes under. Derived
51
+ # from mount_at unless omniauth_path_prefix is set explicitly.
52
+ def auth_path_prefix
53
+ return omniauth_path_prefix if omniauth_path_prefix
54
+
55
+ mount_at == '/' ? '/auth' : "#{mount_at}/auth"
56
+ end
57
+
30
58
  # Where to send the reader after a successful sign-in / sign-out.
31
59
  attr_accessor :after_sign_in_path
32
- attr_accessor :after_sign_out_path
33
60
 
34
61
  # ---- OmniAuth (built-in sign-in) --------------------------------------
35
62
 
@@ -50,17 +77,17 @@ module Bible270
50
77
 
51
78
  # Human labels for providers we know; anything else is titleised.
52
79
  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"
80
+ github: 'GitHub', gitlab: 'GitLab', google_oauth2: 'Google', google: 'Google',
81
+ facebook: 'Facebook', twitter: 'Twitter', apple: 'Apple', discord: 'Discord',
82
+ microsoft_graph: 'Microsoft', azure_activedirectory_v2: 'Microsoft',
83
+ openid_connect: 'OpenID Connect', saml: 'SSO'
57
84
  }.freeze
58
85
 
59
86
  def omniauth_providers=(list)
60
87
  @omniauth_providers = Array(list).map do |entry|
61
88
  key, label = entry.is_a?(Array) ? entry : [entry, nil]
62
89
  key = key.to_sym
63
- [key, (label || PROVIDER_LABELS[key] || key.to_s.tr("_", " ").split.map(&:capitalize).join(" "))]
90
+ [key, label || PROVIDER_LABELS[key] || key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')]
64
91
  end
65
92
  end
66
93
 
@@ -81,12 +108,52 @@ module Bible270
81
108
 
82
109
  # Simple abuse guard: at most N links per address per window (seconds).
83
110
  attr_accessor :email_sign_in_window
84
- attr_accessor :email_sign_in_max_per_window
111
+ attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :admin_emails, :admin_resolver
85
112
 
86
113
  # Whether a reader may type the display name shown beside their reflections
87
114
  # when signing in by email (otherwise it's derived from the address).
88
115
  attr_accessor :email_sign_in_ask_name
89
116
 
117
+ # Write the sign-in link to the log. nil (default) means development and test
118
+ # only; true forces it on, for smoke-testing a production build locally
119
+ # before mail is wired up; false disables it everywhere. The link is a bearer
120
+ # credential, so true is not for a deployment real users can reach.
121
+ attr_accessor :email_sign_in_log_link
122
+
123
+ # Require a first AND last name when signing in by email. Names identify
124
+ # people to each other beside their reflections, so this is on by default.
125
+ attr_accessor :email_sign_in_require_name
126
+
127
+ # Who may reach the admin panel. Either a list of email addresses, or a
128
+ # lambda taking the current reader and returning true/false:
129
+ #
130
+ # config.admin_emails = %w[andrew@example.org]
131
+ # config.admin_resolver = ->(reader) { reader.email.end_with?('@example.org') }
132
+ #
133
+ # With neither set the panel is unreachable and its routes 404.
134
+ # Break points set from the host app, overriding Plan::CHAPTER_BREAKS.
135
+ # Keys may be ['Psalm', 18] or 'Psalm 18'; [] keeps a chapter whole.
136
+ attr_accessor :chapter_breaks
137
+
138
+ # Optional YAML file holding the same thing, re-read whenever it changes so
139
+ # breaks can be tuned without restarting:
140
+ #
141
+ # # config/bible270_breaks.yml
142
+ # Psalm 35: []
143
+ # Psalm 78: [20, 39, 55]
144
+ attr_accessor :chapter_breaks_path
145
+
146
+ def admin?(reader)
147
+ return false if reader.nil?
148
+ return !!admin_resolver.call(reader) if admin_resolver.respond_to?(:call)
149
+
150
+ Array(admin_emails).map { |e| e.to_s.downcase }.include?(reader.email.to_s.downcase)
151
+ end
152
+
153
+ def admin_configured?
154
+ admin_resolver.respond_to?(:call) || Array(admin_emails).any?
155
+ end
156
+
90
157
  # Send the sign-in email via Active Job (deliver_later) instead of inline.
91
158
  # Leave false unless you have a queue backend configured.
92
159
  attr_accessor :email_sign_in_deliver_later
@@ -104,16 +171,14 @@ module Bible270
104
171
  found = omniauth_providers.find { |k, _| k.to_s == key.to_s }
105
172
  return found.last if found
106
173
 
107
- PROVIDER_LABELS[key.to_sym] || key.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
174
+ PROVIDER_LABELS[key.to_sym] || key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')
108
175
  end
109
176
 
110
177
  # Default Bible translation and a builder for external passage links.
111
178
  attr_accessor :bible_version
112
- attr_accessor :passage_url_builder
113
179
 
114
180
  # Public labels.
115
181
  attr_accessor :app_name
116
- attr_accessor :tagline
117
182
 
118
183
  # Optional community-wide start date for the plan. Set this when everyone
119
184
  # reads together as a cohort (e.g. a church starting on a set Sunday):
@@ -132,30 +197,37 @@ module Bible270
132
197
  attr_accessor :require_sign_in_to_participate
133
198
 
134
199
  def initialize
135
- @parent_controller = "ActionController::Base"
136
- @layout = "bible270/application"
200
+ @parent_controller = 'ActionController::Base'
201
+ @layout = 'bible270/application'
137
202
  @current_reader_resolver = nil
138
203
  @after_sign_in_path = nil
139
204
  @after_sign_out_path = nil
140
- @bible_version = "ESV"
141
- @passage_url_builder = lambda do |reference, version|
205
+ @bible_version = 'NKJV'
206
+ @passage_url_builder = ->(reference, version) do
142
207
  search = URI.encode_www_form_component(reference)
143
208
  version = URI.encode_www_form_component(version)
144
209
  "https://www.biblegateway.com/passage/?search=#{search}&version=#{version}"
145
210
  end
146
- @app_name = "Daily Bread"
147
- @tagline = "A 270-day journey through Scripture"
211
+ @app_name = 'Daily Bread'
212
+ @tagline = 'Journeying through Scripture in 9 months'
148
213
  @require_sign_in_to_participate = true
214
+ self.mount_at = '/daily-bread'
149
215
  @start_date = nil
150
216
  @allow_reader_start_date = true
151
217
  self.omniauth_providers = [:github]
152
218
  @omniauth_path_prefix = nil
153
219
  @email_sign_in = true
154
- @mailer_from = "no-reply@example.com"
220
+ @mailer_from = 'no-reply@example.com'
155
221
  @email_sign_in_ttl = 20 * 60 # 20 minutes
156
222
  @email_sign_in_window = 15 * 60 # 15 minutes
157
223
  @email_sign_in_max_per_window = 5
158
224
  @email_sign_in_ask_name = true
225
+ @email_sign_in_require_name = true
226
+ @email_sign_in_log_link = nil
227
+ @chapter_breaks = {}
228
+ @chapter_breaks_path = nil
229
+ @admin_emails = []
230
+ @admin_resolver = nil
159
231
  @email_sign_in_deliver_later = false
160
232
  end
161
233
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
- require "digest"
3
+ require 'securerandom'
4
+ require 'digest'
5
5
 
6
6
  module Bible270
7
7
  # Pure helpers for passwordless ("magic link") email sign-in. Kept free of
@@ -11,14 +11,14 @@ module Bible270
11
11
  # digest of it, so a leaked database can't be used to sign in.
12
12
  module EmailSignIn
13
13
  # Deliberately permissive: real validation is "did the link get clicked".
14
- EMAIL_RE = /\A[^@\s,;:<>"]+@[^@\s,;:<>"]+\.[A-Za-z]{2,}\z/
14
+ EMAIL_RE = %r{\A[^@\s,;:<>"]+@[^@\s,;:<>"]+\.[A-Za-z]{2,}\z}
15
15
 
16
- module_function
16
+ module_function
17
17
 
18
18
  # Trim, strip a mailto:, and downcase. Returns nil for anything unusable.
19
19
  def normalize_email(value)
20
20
  email = value.to_s.strip
21
- email = email[7..] if email.downcase.start_with?("mailto:")
21
+ email = email[7..] if email.downcase.start_with?('mailto:')
22
22
  email = email.strip.downcase
23
23
  return nil if email.empty?
24
24
  return nil unless email.match?(EMAIL_RE)
@@ -58,11 +58,11 @@ module Bible270
58
58
  # The part of an address we can use as a default display name:
59
59
  # "mary.anne.smith@example.com" => "Mary Anne Smith"
60
60
  def display_name_from(email)
61
- local = normalize_email(email)&.split("@")&.first
61
+ local = normalize_email(email)&.split('@')&.first
62
62
  return nil if local.nil? || local.empty?
63
63
 
64
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(" ")
65
+ local.tr('._+-', ' ').split.map { |w| w[0].upcase + (w[1..] || '') }.join(' ')
66
66
  end
67
67
  end
68
68
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class Engine < ::Rails::Engine
4
5
  isolate_namespace Bible270