bible270 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: caabc409caaf37d3eeb8a0dd6d0cddba68889fd8deb914e9755c5bb67a571b65
4
- data.tar.gz: 0dbe187325f50430c2e689307f521b4c2854980de9e409ffc667aaa30eb0102f
3
+ metadata.gz: 84d3ca14b2813767c7f837ec1844feaaff31b1ac12e37d691c95b8975313a9e9
4
+ data.tar.gz: 69374a41cfaed437a5bb0367ac66b7500949cda2654e9f80e5ab46f9f8822547
5
5
  SHA512:
6
- metadata.gz: 36bcfb1789a6a6248f4cbfb2e30e8b86878535295958720d121837816e2b7eb243c340002b39d189b10f4496dda2ff5da9915d3c55b50eaa0bb0eead710d450e
7
- data.tar.gz: c72aaa3f95ab86a4e5139a1d8c7e46e2762c8d9d796b84203fbd8c99ecc153158a1ce6cc1375ac4048025f6c9f93c0f918f3f923677ccaa13d16983ed24e5b70
6
+ metadata.gz: 71c2ef21eb61bb68359ed3cd202ecc0278082cc97a18e4f9886093799d065409fd0529775edeb1a42f33d25a6ceaac702f44d7c53b59fda82e2c91d894cb704f
7
+ data.tar.gz: 1cbd38d2f361892bb1cab9f981986598d3d03bc2c04ce63506d60907aca58b57ffd96f2dc9f607ead5dc1c6966086954898e60b7826955b9960c769aa8b8c26e
data/CHANGELOG.md CHANGED
@@ -7,11 +7,66 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html), tr
7
7
 
8
8
  See the changes since the last release:
9
9
 
10
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.10.0...main)**
10
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.11.0...main)**
11
+
12
+ ## [0.11.0] — 2026-07-28
13
+
14
+ ### Added
15
+
16
+ - **Readers can edit their own name and picture** at `<mount>/profile`, linked from the nav. An admin
17
+ can edit anyone's from that reader's admin page. Both go through one rule (`Bible270::Names`):
18
+ first and last required, whitespace squished, display name derived — so internal capitals and
19
+ particles survive (`vonderLuft`, `von der Luft`). A reader who arrived via OmniAuth with only a
20
+ display name gets the form pre-filled by splitting it.
21
+ - **Avatar uploads** — PNG, JPEG, GIF or WebP up to `config.avatar_max_bytes` (2MB), with a Remove
22
+ button. SVG is refused, since it can carry script. Needs Active Storage in the host app; without it
23
+ the field is hidden, the engine still loads, and avatars come from the sign-in provider as before.
24
+ No resizing: the original is served at CSS dimensions.
25
+ - **A run can be closed to new readers** from the admin panel. Existing readers carry on signing in;
26
+ nobody new can join. Enforced at all three points a reader can be created — the OmniAuth callback,
27
+ issuing an email link, and consuming one. Runtime state in a new `bible270_settings` table, so it
28
+ needs no deploy; `config.enrollment_open = false` launches closed.
29
+ - The **All 270 days** index now appears on every page, rendered from the layout rather than only on
30
+ the overview.
31
+
32
+ ### Changed
33
+
34
+ - **A returning reader needs only their email to sign in.** Names are optional on the sign-in form; a
35
+ new reader is asked for theirs on their profile straight after clicking the link. Requiring them
36
+ only for unknown addresses would have made the response differ between a known and an unknown one,
37
+ which is an account-enumeration oracle — and the closed-run banner exists for the same reason.
38
+ - Closing a run says so on the sign-in page before anyone types, since the "check your inbox"
39
+ response deliberately cannot distinguish a known address from an unknown one.
40
+
41
+ ### Fixed
42
+
43
+ - Setting a reader's start date from the admin panel failed with "That doesn't look like a date"
44
+ whenever `config.allow_reader_start_date` was off. That setting governs what a *reader* may change
45
+ about their own plan and was wrongly gating the admin action, which now uses an ungated setter.
46
+ - The day grid ran a query per day (`read_tracks_for`), so its 270 cells cost up to 270 queries. It
47
+ now reads the reader's single grouped count — which matters more now the grid is on every page.
48
+ - The nav's "Sign out" sat below the other items: `button_to` renders a form, and the shared
49
+ `.b270-linkbtn` class carries a top margin, smaller font and underline meant for use under a form.
50
+ - `Bible270::Configuration` referenced `Avatars` without requiring it, so instantiating it raised
51
+ `NameError` unless something else had already loaded avatars — which the app did by luck of require
52
+ order and the test suite did not.
53
+
54
+ ### Internal
55
+
56
+ - Tests now guard the classes of bug that bit during this run: every file under `lib/` must load on
57
+ its own *and* survive instantiating the configuration; every setting the code calls must exist on
58
+ `Configuration`; templates must not reference engine constants unqualified; a coloured link class
59
+ must outrank `.b270 a`; admin actions must not use reader-gated methods; and closure must be
60
+ enforced at all three creation points.
61
+ - Migrations: `20260101000007` (settings). Active Storage tables are the host app's
62
+ (`bin/rails active_storage:install`).
11
63
 
12
64
  ## [0.10.0] — 2026-07-26
13
65
 
66
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.9.0...v0.10.0)**
67
+
14
68
  ### Reading plan
69
+
15
70
  - **Proverbs is read once**, not twice, and its longer chapters are divided — at the discourse
16
71
  boundaries in chapters 1–9 and 30–31, evenly elsewhere, since 10–29 are collections of
17
72
  independent sayings.
@@ -31,6 +86,7 @@ See the changes since the last release:
31
86
  changes. Precedence: constant < config < file.
32
87
 
33
88
  ### Features
89
+
34
90
  - **Interactive installer** — `bin/rails generate bible270:install` asks where to mount, which
35
91
  sign-in methods to enable, writes the initializers, adds the `mount` line, copies the migrations
36
92
  and runs them. Scriptable with `--defaults` and friends.
@@ -49,6 +105,7 @@ See the changes since the last release:
49
105
  delete it outright, from a list filterable by visible/hidden or from the writer's own page.
50
106
 
51
107
  ### Fixed
108
+
52
109
  - Sign-in was broken: OmniAuth 2.0+ rejects GET on its request phase, so the controls are now POST
53
110
  forms with CSRF tokens. Open-redirect hardening on the return-to path, and `reset_session` on
54
111
  sign-in and sign-out.
@@ -77,6 +134,7 @@ See the changes since the last release:
77
134
  accessor fails the suite instead of raising `NoMethodError` on whichever request touches it.
78
135
 
79
136
  ### Notes
137
+
80
138
  - Table names changed from `bible_reading_plan_*` in the 0.9.0 line; this is a fresh install.
81
139
 
82
140
  ## [0.9.0] — 2026-07-25
@@ -176,6 +234,7 @@ This is the initial public release.
176
234
 
177
235
  * Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
178
236
 
237
+ [0.11.0]: https://github.com/avonderluft/bible270/releases/tag/v0.10.0
179
238
  [0.10.0]: https://github.com/avonderluft/bible270/releases/tag/v0.10.0
180
239
  [0.9.0]: https://github.com/avonderluft/bible270/releases/tag/v0.9.0
181
240
  [0.6.2]: https://github.com/avonderluft/bible270/releases/tag/v0.6.2
data/README.md CHANGED
@@ -1,18 +1,18 @@
1
1
  # Bible 270
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/bible270.svg)](https://badge.fury.io/rb/bible270) [![Gem Downloads](https://img.shields.io/gem/dt/bible270.svg?style=flat)](http://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
3
+ [![Gem Version](https://badge.fury.io/rb/bible270.svg?refresh=1)](https://badge.fury.io/rb/bible270) [![Gem Downloads](https://img.shields.io/gem/dt/bible270.svg?style=flat)](http://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
4
4
 
5
5
  A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-media-surfer) CMS in mind. Readers tick off each day's readings, post reflections, and see how everyone else is getting on, much like the social plans on Bible.com.
6
6
 
7
- Initially created for use by students and faculty of [Kingdom Movement School of Ministry](https://www.kmsm.life/), to read through all of Scripture together, during the school year. Thus it is potentially useful for any Bible School, Seminary, or Discipleship school with a 9 month school year.
7
+ Initially created for use by students and faculty of [Kingdom Movement School of Ministry](https://www.kmsm.life/), to read through all of Scripture together during the school year. Thus it is potentially useful for any Bible School, Seminary, or Discipleship school working on a 9 month schedule.
8
8
 
9
9
  ## The plan
10
10
 
11
11
  Three readings a day, sized by **verse count** so each day takes roughly the same time.
12
12
 
13
- - **Old Testament** — once through, with Psalms and Proverbs live in their own track. Whole chapters grouped so each day is ~73 verses (range 38–116).
14
- - **New Testament** — once through, a reading every day. 260 chapters over 270 days, so the 10 longest are halved (Luke 1, Matthew 26–27, Mark 14, Luke 9, 12, 22, John 6,8, Acts 7).
15
- - **Psalms & Proverbs** — once each, interleaved in one track. 181 chapters have to cover 270 days, so 89 are divided, shared between the two books by verse load. **A psalm of 25 verses or fewer is never divided**, keeping 131 of the 150 whole; longer psalms and Proverbs chapters break at their turns of thought (see `CHAPTER_BREAKS`). **Psalm 119 is 11 sections of 16 verses** — two of its eight-verse acrostic stanzas each. Proverbs lands on 89 days, near its proportional share. **A divided chapter is always read on consecutive days**, so nothing is inserted between the parts of a split psalm.
13
+ - **Old Testament** — once through, with Psalms and Proverbs in their own track.
14
+ - **New Testament** — once through, a reading every day. 260 chapters over 270 days, long chapters are divided to even out the daily reading
15
+ - **Psalms & Proverbs** — once each, interleaved in one track. 181 chapters have to cover 270 days, so some are divided, shared between the two books by verse load. **A psalm of 25 verses or fewer is never divided**, keeping 131 of the 150 whole; longer psalms and Proverbs chapters break at their turns of thought (see `CHAPTER_BREAKS`). **Psalm 119 is 11 sections of 16 verses** — two of its eight-verse acrostic stanzas each. Proverbs lands on 89 days, near its proportional share. **A divided chapter is always read on consecutive days**, so nothing is inserted between the parts of a split psalm.
16
16
 
17
17
  Genesis → Malachi and Revelation 22 both land on day 270, as does the second Proverbs 31 — the first falls on day 135, the midpoint. Psalm 150 comes in on day 269. Every day carries all three tracks. A typical day is ~119 verses (range 76–175).
18
18
 
@@ -20,7 +20,7 @@ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is sto
20
20
 
21
21
  ## Requirements
22
22
 
23
- - **Ruby** >= 3.0. Plain Ruby, no C extensions. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
23
+ - **Ruby** >= 3.2. Plain Ruby, no C extensions. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
24
24
  - **Rails** >= 7.0. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
25
25
  - A host app, and Turbo if you want check-offs without a page reload.
26
26
 
@@ -295,8 +295,10 @@ Bible270.configure do |config|
295
295
  config.mount_at = "/daily-bread" # single source of truth for the path
296
296
  config.app_name = "Daily Bread"
297
297
  config.tagline = "A 270-day journey through Scripture"
298
+ config.admin_emails = %w[andrew@example.org]
298
299
 
299
- config.start_date = nil # community start date, or nil for undated
300
+ # config.start_date = nil # community start date, or nil for undated
301
+ config.start_date = Date.new(2026, 9, 6)
300
302
  config.allow_reader_start_date = true
301
303
 
302
304
  config.parent_controller = "ActionController::Base" # or "::ApplicationController"
@@ -9,10 +9,25 @@ module Bible270
9
9
  # so its existence isn't advertised.
10
10
  class AdminController < ApplicationController
11
11
  before_action :require_admin!
12
- before_action :load_reader, only: %i[show destroy update_start complete_through toggle_day]
12
+ before_action :load_reader,
13
+ only: %i[show destroy update_start update_name complete_through toggle_day]
13
14
  before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
14
15
 
16
+ # ---- enrolment --------------------------------------------------------
17
+
18
+ def update_enrollment
19
+ if params[:state] == 'closed'
20
+ Setting.close_enrollment!
21
+ redirect_to admin_path, notice: 'Closed to new readers. Existing readers can still sign in.'
22
+ else
23
+ Setting.open_enrollment!
24
+ redirect_to admin_path, notice: 'Open to new readers.'
25
+ end
26
+ end
27
+
15
28
  def index
29
+ @enrollment_closed = Setting.enrollment_closed?
30
+ @enrollment_closed_at = Setting.enrollment_closed_at
16
31
  @readers = Reader.all.to_a.sort_by(&:sort_name)
17
32
  counts = Checkoff.group(:reader_id, :day).count
18
33
  @days_completed = Hash.new(0)
@@ -62,10 +77,15 @@ module Bible270
62
77
  # Put the reader on a given day as of today, or set an explicit start date.
63
78
  def update_start
64
79
  if params[:start_date].present?
65
- if @reader.update_start_date!(params[:start_date])
66
- redirect_to admin_reader_path(@reader), notice: 'Start date updated.'
80
+ # set_start_date!, not update_start_date!: an admin is not subject to
81
+ # config.allow_reader_start_date, which governs what readers may do to
82
+ # their own dates.
83
+ if @reader.set_start_date!(params[:start_date])
84
+ redirect_to admin_reader_path(@reader),
85
+ notice: "Start date set to #{@reader.started_on.strftime('%B %-d, %Y')}."
67
86
  else
68
- redirect_to admin_reader_path(@reader), alert: "That doesn't look like a date."
87
+ redirect_to admin_reader_path(@reader),
88
+ alert: "Couldn't read #{params[:start_date].inspect} as a date."
69
89
  end
70
90
  elsif params[:day].present? && @reader.restart_on!(day: params[:day])
71
91
  redirect_to admin_reader_path(@reader),
@@ -75,6 +95,14 @@ module Bible270
75
95
  end
76
96
  end
77
97
 
98
+ def update_name
99
+ if @reader.update_names(params[:first_name], params[:last_name])
100
+ redirect_to admin_reader_path(@reader), notice: 'Name updated.'
101
+ else
102
+ redirect_to admin_reader_path(@reader), alert: 'Both a first and last name are needed.'
103
+ end
104
+ end
105
+
78
106
  def complete_through
79
107
  day = params[:day].to_i
80
108
  if @reader.mark_through!(day)
@@ -20,7 +20,7 @@ module Bible270
20
20
 
21
21
  layout :bible270_layout
22
22
 
23
- helper_method :current_reader, :signed_in?, :b270_config
23
+ helper_method :current_reader, :signed_in?, :b270_config, :enrollment_closed?
24
24
 
25
25
  private
26
26
 
@@ -28,6 +28,10 @@ module Bible270
28
28
  Bible270.config.layout
29
29
  end
30
30
 
31
+ def enrollment_closed?
32
+ Setting.enrollment_closed?
33
+ end
34
+
31
35
  def b270_config
32
36
  Bible270.config
33
37
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # A reader editing their own name — the one thing about themselves they can
5
+ # change. Everything else about a reader comes from how they signed in.
6
+ class ProfilesController < ApplicationController
7
+ before_action :require_signed_in_reader
8
+
9
+ def edit
10
+ @names = current_reader.suggested_names
11
+ end
12
+
13
+ def update
14
+ problems = []
15
+ problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
16
+ params[:last_name])
17
+ if params[:avatar].present? && !current_reader.attach_avatar(params[:avatar])
18
+ problems << (current_reader.errors[:avatar].first || 'a valid image')
19
+ end
20
+
21
+ if problems.empty?
22
+ redirect_to reader_path(current_reader), notice: 'Your profile has been updated.'
23
+ else
24
+ @names = { first_name: params[:first_name], last_name: params[:last_name] }
25
+ flash.now[:alert] = "Please give #{problems.to_sentence}."
26
+ render :edit, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def remove_avatar
31
+ current_reader.remove_avatar!
32
+ redirect_to profile_path, notice: 'Your picture has been removed.'
33
+ end
34
+
35
+ private
36
+
37
+ # Unlike checking off a reading, there is nothing sensible to do here for a
38
+ # visitor who isn't signed in, so send them to sign in rather than 404.
39
+ def require_signed_in_reader
40
+ return if signed_in?
41
+
42
+ redirect_to sign_in_path(origin: request.fullpath), alert: 'Please sign in first.'
43
+ end
44
+ end
45
+ end
@@ -21,6 +21,10 @@ module Bible270
21
21
  auth = request.env['omniauth.auth']
22
22
  redirect_to(sign_in_path, alert: "Sign in didn't complete. Please try again.") and return unless auth
23
23
 
24
+ if enrollment_closed? && !Reader.omniauth_reader_exists?(auth['provider'], auth['uid'])
25
+ redirect_to(sign_in_path, alert: enrollment_closed_message) and return
26
+ end
27
+
24
28
  reader = Reader.from_omniauth(auth)
25
29
  redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return unless reader&.persisted?
26
30
 
@@ -60,17 +64,18 @@ module Bible270
60
64
  alert: "That doesn't look like an email address — please check and try again.") and return
61
65
  end
62
66
 
63
- first_name = params[:first_name].to_s.strip
64
- last_name = params[:last_name].to_s.strip
65
-
66
- if Bible270.config.email_sign_in_require_name && (first_name.empty? || last_name.empty?)
67
- redirect_to(sign_in_path(origin: origin, email: address),
68
- alert: 'Please give both your first and last name.') and return
69
- end
67
+ # Names are optional here. Requiring them only for unknown addresses would
68
+ # make the response differ, which tells a stranger whether an address
69
+ # already has an account. A new reader is asked for their name after they
70
+ # click the link instead (see email_callback).
71
+ names = Names.normalize(params[:first_name], params[:last_name])
70
72
 
71
- _record, raw = SignInToken.issue!(address, first_name: first_name, last_name: last_name)
73
+ _record, raw = SignInToken.issue!(address, first_name: names&.dig(:first_name),
74
+ last_name: names&.dig(:last_name))
72
75
 
73
- if raw
76
+ if raw && enrollment_closed? && !Reader.email_reader_exists?(address)
77
+ Rails.logger.info("[bible270] enrolment closed; no link sent to #{address}")
78
+ elsif raw
74
79
  deliver_sign_in_link(address, email_sign_in_url(token: raw, origin: origin))
75
80
  else
76
81
  # Silent to the reader by design; say so in the log or this is undebuggable.
@@ -92,8 +97,12 @@ module Bible270
92
97
  alert: 'That link has expired or was already used. Please request a new one.') and return
93
98
  end
94
99
 
100
+ if enrollment_closed? && !Reader.email_reader_exists?(token.email)
101
+ redirect_to(sign_in_path, alert: enrollment_closed_message) and return
102
+ end
103
+
95
104
  reader = Reader.from_email(token.email, first_name: token.first_name,
96
- last_name: token.last_name, display_name: token.display_name)
105
+ last_name: token.last_name, display_name: token.display_name)
97
106
  redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return unless reader&.persisted?
98
107
 
99
108
  destination = safe_origin(params[:origin]) || after_sign_in_path
@@ -102,6 +111,12 @@ module Bible270
102
111
  session[:bible270_reader_id] = reader.id
103
112
  @current_reader = reader
104
113
 
114
+ if name_needed?(reader)
115
+ redirect_to(profile_path,
116
+ notice: 'Welcome. Please add your name, so others know who they are reading with.')
117
+ return
118
+ end
119
+
105
120
  redirect_to destination, notice: "Welcome, #{reader.display_name}."
106
121
  end
107
122
 
@@ -137,6 +152,20 @@ module Bible270
137
152
  Rails.env.development? || Rails.env.test?
138
153
  end
139
154
 
155
+ # A reader who signed in by email and still has no first/last name. Applies
156
+ # only to email sign-in: an OmniAuth reader carries whatever name their
157
+ # provider gave.
158
+ def name_needed?(reader)
159
+ Bible270.config.email_sign_in_require_name &&
160
+ reader.provider == 'email' &&
161
+ reader.full_name.nil?
162
+ end
163
+
164
+ def enrollment_closed_message
165
+ 'This run of the plan is closed to new readers. If you have taken part before, ' \
166
+ 'sign in with the same email or account.'
167
+ end
168
+
140
169
  # Only ever redirect within this app, and never back into an auth route.
141
170
  def safe_origin(value)
142
171
  value = value.to_s
@@ -24,9 +24,18 @@ module Bible270
24
24
  Bible270.config.passage_url_builder.call(reference, Bible270.config.bible_version)
25
25
  end
26
26
 
27
+ # An uploaded avatar wins over whatever the sign-in provider gave us.
28
+ # Active Storage routes live in the host app, hence main_app.
29
+ def b270_avatar_src(reader)
30
+ return main_app.rails_blob_path(reader.avatar, only_path: true) if reader.avatar_uploaded?
31
+
32
+ reader.avatar_url.presence
33
+ end
34
+
27
35
  def b270_avatar(reader, size: 34)
28
- if reader.avatar_url.present?
29
- image_tag reader.avatar_url, class: 'b270-avatar', width: size, height: size, alt: reader.display_name
36
+ src = b270_avatar_src(reader)
37
+ if src
38
+ image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name
30
39
  else
31
40
  content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
32
41
  style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
@@ -36,12 +45,10 @@ module Bible270
36
45
  def b270_day_status_class(reader, day)
37
46
  return '' unless reader
38
47
 
39
- if reader.day_complete?(day)
40
- 'complete'
41
- elsif reader.read_tracks_for(day).any?
42
- 'partial'
43
- else
44
- ''
48
+ case reader.day_status(day)
49
+ when :complete then 'complete'
50
+ when :partial then 'partial'
51
+ else ''
45
52
  end
46
53
  end
47
54
  end
@@ -10,6 +10,14 @@ module Bible270
10
10
  has_many :comments, class_name: 'Bible270::Comment', dependent: :destroy
11
11
  belongs_to :owner, polymorphic: true, optional: true
12
12
 
13
+ # Active Storage is optional: an app may have it disabled, and the engine has
14
+ # to keep working there — readers simply can't upload, and any avatar from
15
+ # their sign-in provider is used instead.
16
+ AVATAR_UPLOADS = respond_to?(:has_one_attached)
17
+ has_one_attached :avatar if AVATAR_UPLOADS
18
+
19
+ def self.avatar_uploads? = AVATAR_UPLOADS
20
+
13
21
  validates :display_name, presence: true
14
22
  validates :uid, uniqueness: { scope: :provider }, allow_nil: true
15
23
 
@@ -37,6 +45,21 @@ module Bible270
37
45
  # Find or create the reader behind a verified email address. Uses the same
38
46
  # provider/uid identity columns as OmniAuth, with provider "email", so an
39
47
  # email reader is indistinguishable from any other downstream.
48
+ # Used when enrolment is closed: an existing reader may still sign in, a new
49
+ # one may not be created.
50
+ def self.email_reader_exists?(email)
51
+ address = EmailSignIn.normalize_email(email)
52
+ return false if address.nil?
53
+
54
+ exists?(provider: 'email', uid: address)
55
+ end
56
+
57
+ def self.omniauth_reader_exists?(provider, uid)
58
+ return false if provider.blank? || uid.blank?
59
+
60
+ exists?(provider: provider.to_s, uid: uid.to_s)
61
+ end
62
+
40
63
  def self.from_email(email, first_name: nil, last_name: nil, display_name: nil)
41
64
  address = EmailSignIn.normalize_email(email)
42
65
  return nil if address.nil?
@@ -87,6 +110,29 @@ module Bible270
87
110
  [last_name, first_name].map { |n| n.to_s.strip.downcase }.join(' ').strip.presence || display_name.to_s.downcase
88
111
  end
89
112
 
113
+ def avatar_uploaded?
114
+ self.class.avatar_uploads? && avatar.attached?
115
+ end
116
+
117
+ # Returns false and sets an error when the upload isn't acceptable.
118
+ def attach_avatar(upload)
119
+ return false unless self.class.avatar_uploads?
120
+
121
+ problem = Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size)
122
+ if problem
123
+ errors.add(:avatar, problem)
124
+ return false
125
+ end
126
+
127
+ avatar.attach(upload)
128
+ true
129
+ end
130
+
131
+ def remove_avatar!
132
+ avatar.purge if avatar_uploaded?
133
+ true
134
+ end
135
+
90
136
  def initials
91
137
  display_name.to_s.split(%r{\s+}).first(2).map { |w| w[0] }.join.upcase.presence || '?'
92
138
  end
@@ -96,6 +142,20 @@ module Bible270
96
142
  @checked_counts ||= checkoffs.group(:day).count
97
143
  end
98
144
 
145
+ # Tracks ticked on a day, counted from the single grouped query in
146
+ # checked_counts. Use this rather than read_tracks_for when rendering a grid:
147
+ # read_tracks_for costs a query per day, which is 270 of them per page.
148
+ def checked_count(day)
149
+ checked_counts[day].to_i
150
+ end
151
+
152
+ def day_status(day)
153
+ done = checked_count(day)
154
+ return :none if done.zero?
155
+
156
+ done >= Plan.required_track_count(day) ? :complete : :partial
157
+ end
158
+
99
159
  def read_tracks_for(day)
100
160
  checkoffs.where(day: day).pluck(:track)
101
161
  end
@@ -127,6 +187,23 @@ module Bible270
127
187
  end
128
188
 
129
189
  # Day number implied by the calendar, if a start date is set.
190
+ # Set the name shown beside this reader's reflections. Returns false when
191
+ # either half is missing, so callers can re-render with a message.
192
+ def update_names(first, last)
193
+ names = Names.normalize(first, last)
194
+ return false if names.nil?
195
+
196
+ update(**names)
197
+ end
198
+
199
+ # Fill first/last from the display name where we only have one string, e.g.
200
+ # a reader who arrived through OmniAuth.
201
+ def suggested_names
202
+ return { first_name: first_name, last_name: last_name } if full_name
203
+
204
+ Names.split(display_name) || { first_name: display_name, last_name: nil }
205
+ end
206
+
130
207
  # ---- administrative adjustments --------------------------------------
131
208
 
132
209
  # Tick every track that has content on this day.
@@ -248,15 +325,24 @@ module Bible270
248
325
  started_on
249
326
  end
250
327
 
251
- def update_start_date!(value)
252
- return false unless Bible270.config.allow_reader_start_date
253
-
328
+ # Set the start date regardless of whether readers are allowed to set their
329
+ # own. For administrative use: config.allow_reader_start_date governs what a
330
+ # reader may do to themselves, not what an admin may do on their behalf.
331
+ # Returns false only when the value isn't a date.
332
+ def set_start_date!(value)
254
333
  date = Plan.to_date(value)
255
334
  return false if date.nil?
256
335
 
257
336
  update!(started_on: date)
258
337
  end
259
338
 
339
+ # The reader-facing version, which does respect that permission.
340
+ def update_start_date!(value)
341
+ return false unless Bible270.config.allow_reader_start_date
342
+
343
+ set_start_date!(value)
344
+ end
345
+
260
346
  def clear_start_date!
261
347
  update!(started_on: nil)
262
348
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Runtime state an admin can change without a deploy. Currently just whether
5
+ # this run of the plan is open to new readers.
6
+ class Setting < ApplicationRecord
7
+ self.table_name = 'bible270_settings'
8
+
9
+ ENROLLMENT_CLOSED_AT = 'enrollment_closed_at'
10
+
11
+ validates :key, presence: true, uniqueness: true
12
+
13
+ def self.read(key)
14
+ find_by(key: key.to_s)&.value
15
+ end
16
+
17
+ def self.write(key, value)
18
+ record = find_or_initialize_by(key: key.to_s)
19
+ record.value = value
20
+ record.save!
21
+ value
22
+ end
23
+
24
+ def self.delete_key(key)
25
+ where(key: key.to_s).delete_all
26
+ end
27
+
28
+ # ---- enrolment --------------------------------------------------------
29
+
30
+ # Closed either because an admin closed it, or because the app was configured
31
+ # to launch closed.
32
+ def self.enrollment_closed?
33
+ return true if read(ENROLLMENT_CLOSED_AT).present?
34
+
35
+ Bible270.config.respond_to?(:enrollment_open) && Bible270.config.enrollment_open == false
36
+ end
37
+
38
+ def self.enrollment_closed_at
39
+ raw = read(ENROLLMENT_CLOSED_AT)
40
+ return nil if raw.blank?
41
+
42
+ Time.zone ? Time.zone.parse(raw) : Time.parse(raw)
43
+ rescue ArgumentError
44
+ nil
45
+ end
46
+
47
+ def self.close_enrollment!(at: Time.current)
48
+ write(ENROLLMENT_CLOSED_AT, at.iso8601)
49
+ true
50
+ end
51
+
52
+ def self.open_enrollment!
53
+ delete_key(ENROLLMENT_CLOSED_AT)
54
+ true
55
+ end
56
+ end
57
+ end
@@ -4,6 +4,24 @@
4
4
  <p class="lede"><%= @readers.size %> reader<%= 's' unless @readers.size == 1 %> taking part.
5
5
  <%= link_to 'Moderate reflections →', admin_comments_path %></p>
6
6
 
7
+ <div class="b270-panel">
8
+ <h2 class="b270-subhead">This run of the plan</h2>
9
+ <% if @enrollment_closed %>
10
+ <p class="b270-startline"><strong>Closed to new readers</strong><% if @enrollment_closed_at %>
11
+ since <%= @enrollment_closed_at.strftime('%B %-d, %Y') %><% end %>.
12
+ The <%= @readers.size %> reader<%= 's' unless @readers.size == 1 %> already taking part can
13
+ still sign in and carry on.</p>
14
+ <%= button_to 'Open to new readers', admin_enrollment_path, method: :patch,
15
+ params: { state: 'open' }, class: 'b270-btn' %>
16
+ <% else %>
17
+ <p class="b270-startline"><strong>Open to new readers.</strong> Anyone signing in for the first
18
+ time joins this run.</p>
19
+ <%= button_to 'Close to new readers', admin_enrollment_path, method: :patch,
20
+ params: { state: 'closed' }, class: 'b270-btn',
21
+ form: { data: { turbo_confirm: 'Close this run? Existing readers keep their access.' } } %>
22
+ <% end %>
23
+ </div>
24
+
7
25
  <% if @readers.any? %>
8
26
  <div class="b270-list">
9
27
  <% @readers.each do |reader| %>
@@ -8,6 +8,21 @@
8
8
  <% if @reader.dated? %>· day <%= @reader.calendar_day %> today<% end %>
9
9
  </p>
10
10
 
11
+ <div class="b270-panel">
12
+ <h2 class="b270-subhead">Name</h2>
13
+ <%= form_with url: admin_reader_name_path(@reader), method: :patch, class: "b270-emailform" do %>
14
+ <label class="b270-field">
15
+ <span>First name</span>
16
+ <%= text_field_tag :first_name, @reader.suggested_names[:first_name], required: true %>
17
+ </label>
18
+ <label class="b270-field">
19
+ <span>Last name</span>
20
+ <%= text_field_tag :last_name, @reader.suggested_names[:last_name], required: true %>
21
+ </label>
22
+ <%= submit_tag 'Save name', class: 'b270-btn' %>
23
+ <% end %>
24
+ </div>
25
+
11
26
  <div class="b270-panel">
12
27
  <h2 class="b270-subhead">Where they are in the plan</h2>
13
28
  <%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
@@ -4,7 +4,7 @@
4
4
  <% if reader.own_start_date? %>
5
5
  Started <strong><%= reader.start_date.strftime("%B %-d, %Y") %></strong>
6
6
  <% else %>
7
- Community plan began <strong><%= Bible270.config.start_date.strftime("%B %-d, %Y") %></strong>
7
+ Community plan start date: <strong><%= Bible270.config.start_date.strftime("%B %-d, %Y") %></strong>
8
8
  <% end %>
9
9
  · finishes <%= reader.plan_end_date.strftime("%B %-d, %Y") %>
10
10
  <% if reader.not_started_yet? %>
@@ -33,9 +33,8 @@
33
33
  <p class="b270-signedout"><%= link_to "Sign in", sign_in_path %> to track your progress and join
34
34
  the conversation. You can browse the whole plan and read others' reflections without an account.</p>
35
35
  <% if @community_start_date %>
36
- <p class="b270-startline">The community began this plan on
37
- <strong><%= @community_start_date.strftime("%B %-d, %Y") %></strong>
38
- and finishes <%= Bible270::Plan.end_date_for(@community_start_date).strftime("%B %-d, %Y") %>.</p>
36
+ <p class="b270-startline">This community plan runs from <strong><%= @community_start_date.strftime("%B %-d, %Y") %></strong> to
37
+ <strong><%= Bible270::Plan.end_date_for(@community_start_date).strftime("%B %-d, %Y") %></strong>.</p>
39
38
  <% end %>
40
39
  <p style="margin:14px 0 0"><%= link_to "Start at Day 1 →", day_path(1), class: "b270-btn" %></p>
41
40
  <% end %>
@@ -59,11 +58,3 @@
59
58
  <p style="margin:12px 0 0"><%= link_to "See everyone →", community_path %></p>
60
59
  <% end %>
61
60
 
62
- <details class="b270-index">
63
- <summary>All <%= Bible270::Plan::DAYS %> days</summary>
64
- <div class="b270-grid">
65
- <% (1..Bible270::Plan::DAYS).each do |d| %>
66
- <%= link_to d, day_path(d), class: "b270-cell #{b270_day_status_class(current_reader, d)} #{'now' if signed_in? && d == @start_day}" %>
67
- <% end %>
68
- </div>
69
- </details>
@@ -0,0 +1,44 @@
1
+ <% content_for :title, "Your profile — #{Bible270.config.app_name}" %>
2
+ <p class="b270-eyebrow">Your details</p>
3
+ <h1>Your profile</h1>
4
+ <p class="lede">This is how you appear beside your reflections and on the community page.</p>
5
+
6
+ <div class="b270-panel">
7
+ <%= form_with url: profile_path, method: :patch, class: "b270-emailform",
8
+ html: { multipart: true } do %>
9
+ <div class="b270-avatarrow">
10
+ <%= b270_avatar(current_reader, size: 64) %>
11
+ <% if Bible270::Reader.avatar_uploads? %>
12
+ <label class="b270-field" style="flex:1">
13
+ <span>Picture</span>
14
+ <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
15
+ <span class="b270-hint" style="margin:0">
16
+ PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
17
+ </span>
18
+ </label>
19
+ <% end %>
20
+ </div>
21
+
22
+ <label class="b270-field">
23
+ <span>First name</span>
24
+ <%= text_field_tag :first_name, @names[:first_name], required: true, autocomplete: "given-name" %>
25
+ </label>
26
+ <label class="b270-field">
27
+ <span>Last name</span>
28
+ <%= text_field_tag :last_name, @names[:last_name], required: true, autocomplete: "family-name" %>
29
+ </label>
30
+ <%= submit_tag "Save", class: "b270-btn" %>
31
+ <% end %>
32
+
33
+ <% if current_reader.avatar_uploaded? %>
34
+ <%= button_to "Remove picture", profile_avatar_path, method: :delete, class: "b270-linkbtn",
35
+ form: { data: { turbo_confirm: "Remove your picture?" } } %>
36
+ <% end %>
37
+
38
+ <% unless Bible270::Reader.avatar_uploads? %>
39
+ <p class="b270-hint">Picture uploads need Active Storage in this application
40
+ (<code>bin/rails active_storage:install</code>). Until then, avatars come from your sign-in provider.</p>
41
+ <% end %>
42
+ </div>
43
+
44
+ <p style="margin-top:18px"><%= link_to "← Back to your progress", reader_path(current_reader) %></p>
@@ -5,6 +5,13 @@
5
5
  leave reflections alongside everyone else. Browsing the plan and reading others' reflections
6
6
  never requires an account.</p>
7
7
 
8
+ <% if enrollment_closed? %>
9
+ <div class="b270-flash alert" style="margin-bottom:18px">
10
+ This run of the plan is closed to new readers. If you have taken part before, sign in with the
11
+ same email or account and you'll pick up where you left off.
12
+ </div>
13
+ <% end %>
14
+
8
15
  <div class="b270-panel">
9
16
  <% if Bible270.config.email_sign_in? %>
10
17
  <h2 class="b270-subhead">Sign in with your email</h2>
@@ -13,15 +20,16 @@
13
20
  <%= form_with url: email_sign_in_link_path, method: :post, class: "b270-emailform" do |f| %>
14
21
  <%= hidden_field_tag :origin, @origin if @origin %>
15
22
  <% if Bible270.config.email_sign_in_ask_name %>
23
+ <p class="b270-hint" style="margin:0">
24
+ If you have read with us before, just your email is enough — we already know your name.
25
+ </p>
16
26
  <label class="b270-field">
17
- <span>First name</span>
18
- <%= text_field_tag :first_name, params[:first_name], autocomplete: "given-name",
19
- required: Bible270.config.email_sign_in_require_name %>
27
+ <span>First name <em>(new readers)</em></span>
28
+ <%= text_field_tag :first_name, params[:first_name], autocomplete: "given-name" %>
20
29
  </label>
21
30
  <label class="b270-field">
22
- <span>Last name</span>
23
- <%= text_field_tag :last_name, params[:last_name], autocomplete: "family-name",
24
- required: Bible270.config.email_sign_in_require_name %>
31
+ <span>Last name <em>(new readers)</em></span>
32
+ <%= text_field_tag :last_name, params[:last_name], autocomplete: "family-name" %>
25
33
  </label>
26
34
  <% end %>
27
35
  <label class="b270-field">
@@ -0,0 +1,12 @@
1
+ <% if Bible270::Plan::DAYS.positive? %>
2
+ <details class="b270-index">
3
+ <summary>All <%= Bible270::Plan::DAYS %> days</summary>
4
+ <div class="b270-grid">
5
+ <% today = signed_in? ? current_reader.current_day : nil %>
6
+ <% (1..Bible270::Plan::DAYS).each do |d| %>
7
+ <%= link_to d, day_path(d),
8
+ class: "b270-cell #{b270_day_status_class(current_reader, d)} #{'now' if d == today}" %>
9
+ <% end %>
10
+ </div>
11
+ </details>
12
+ <% end %>
@@ -9,6 +9,7 @@
9
9
  <% if signed_in? %>
10
10
  <% if Bible270.config.admin?(current_reader) %><%= link_to "Admin", admin_path %><% end %>
11
11
  <%= link_to current_reader.display_name, reader_path(current_reader), class: "b270-me" %>
12
+ <%= link_to "Profile", profile_path %>
12
13
  <%= button_to "Sign out", sign_out_path, method: :delete, class: "b270-linkbtn",
13
14
  form: { style: "display:inline" } %>
14
15
  <% elsif Bible270.config.any_sign_in_method? %>
@@ -20,9 +20,16 @@
20
20
  .b270-nav{display:flex;align-items:center;gap:16px;font-size:14px}
21
21
  .b270-nav a{color:var(--muted);text-decoration:none}
22
22
  .b270-nav a:hover{color:var(--gold)}
23
+ /* button_to renders a form; make it a flex item so the button sits on the same
24
+ line as the links, and drop the margin/underline the shared class carries. */
25
+ .b270-nav form{display:flex;align-items:center;margin:0}
26
+ .b270-nav .b270-linkbtn{margin:0;padding:0;font-size:14px;color:var(--muted);text-decoration:none;line-height:1}
27
+ .b270-nav .b270-linkbtn:hover{color:var(--gold)}
23
28
  .b270-signin,.b270-me{color:var(--ink)!important;font-weight:600}
24
29
 
25
30
  .b270-subhead{font-family:"Spectral",serif;font-size:17px;font-weight:600;margin:0 0 4px}
31
+ .b270-avatarrow{display:flex;align-items:center;gap:14px}
32
+ .b270-avatarrow input[type=file]{font:inherit;font-size:13px;max-width:100%}
26
33
  .b270-emailform{display:grid;gap:12px;max-width:340px;margin:12px 0 0}
27
34
  .b270-field{display:grid;gap:5px;font-size:12.5px;color:var(--muted)}
28
35
  .b270-field input{font:inherit;font-size:15px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);width:100%}
@@ -18,11 +18,9 @@
18
18
  <% if flash[:alert] %><div class="b270-flash alert"><%= flash[:alert] %></div><% end %>
19
19
  <% if flash[:notice] %><div class="b270-flash notice"><%= flash[:notice] %></div><% end %>
20
20
  <%= yield %>
21
+ <%= render 'bible270/shared/day_index' %>
21
22
  <footer class="b270-footer">
22
- <p>The Old Testament and New Testament are each read once across the 270 days, with a
23
- reading from all three tracks every day. Alongside them the Psalms run once and
24
- Proverbs twice; the longest chapters are divided so the readings fill each day
25
- evenly, and Psalm 119 comes in eleven sections.</p>
23
+ <p>The Old Testament, New Testament and Psalms/Proverbs are each read once across the 270 days. Long chapters are divided so the readings fill each day evenly, e.g. Psalm 119 comes in eleven sections.</p>
26
24
  </footer>
27
25
  </div>
28
26
  </body>
data/config/routes.rb CHANGED
@@ -15,15 +15,21 @@ Bible270::Engine.routes.draw do
15
15
  get 'admin', to: 'admin#index', as: :admin
16
16
  get 'admin/readers/:id', to: 'admin#show', as: :admin_reader
17
17
  delete 'admin/readers/:id', to: 'admin#destroy'
18
+ patch 'admin/readers/:id/name', to: 'admin#update_name', as: :admin_reader_name
18
19
  patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
19
20
  patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
20
21
  post 'admin/readers/:id/days/:day', to: 'admin#toggle_day', as: :admin_reader_day
21
22
 
23
+ patch 'admin/enrollment', to: 'admin#update_enrollment', as: :admin_enrollment
22
24
  get 'admin/comments', to: 'admin#comments', as: :admin_comments
23
25
  patch 'admin/comments/:id/hide', to: 'admin#hide_comment', as: :admin_hide_comment
24
26
  patch 'admin/comments/:id/unhide', to: 'admin#unhide_comment', as: :admin_unhide_comment
25
27
  delete 'admin/comments/:id', to: 'admin#destroy_comment', as: :admin_comment
26
28
 
29
+ get 'profile', to: 'profiles#edit', as: :profile
30
+ patch 'profile', to: 'profiles#update'
31
+ delete 'profile/avatar', to: 'profiles#remove_avatar', as: :profile_avatar
32
+
27
33
  get 'community', to: 'readers#index', as: :community
28
34
  get 'readers/:id', to: 'readers#show', as: :reader
29
35
 
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateBible270Settings < ActiveRecord::Migration[7.0]
4
+ def change
5
+ # A tiny key/value store for state an admin can change at runtime. Closing a
6
+ # run to new readers can't live in config, since that would need a deploy.
7
+ create_table :bible270_settings do |t|
8
+ t.string :key, null: false
9
+ t.string :value
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :bible270_settings, :key, unique: true, name: 'idx_b270_settings_key'
14
+ end
15
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # What counts as an acceptable avatar. Kept free of Rails and of Active Storage
5
+ # so the rules can be unit tested, and so the engine still loads in an app that
6
+ # has Active Storage disabled.
7
+ module Avatars
8
+ CONTENT_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
9
+
10
+ # Two megabytes. Large enough for a photo off a phone, small enough that
11
+ # nobody's profile page crawls.
12
+ DEFAULT_MAX_BYTES = 2 * 1024 * 1024
13
+
14
+ module_function
15
+
16
+ def content_types = CONTENT_TYPES
17
+
18
+ def max_bytes
19
+ configured = Bible270.config.avatar_max_bytes if Bible270.config.respond_to?(:avatar_max_bytes)
20
+ configured.to_i.positive? ? configured.to_i : DEFAULT_MAX_BYTES
21
+ end
22
+
23
+ def acceptable_type?(content_type)
24
+ CONTENT_TYPES.include?(content_type.to_s.downcase.split(';').first.to_s.strip)
25
+ end
26
+
27
+ def acceptable_size?(byte_size)
28
+ size = byte_size.to_i
29
+ size.positive? && size <= max_bytes
30
+ end
31
+
32
+ # nil when the upload is fine, otherwise the reason — phrased for a reader.
33
+ def problem_with(content_type:, byte_size:)
34
+ return 'must be a PNG, JPEG, GIF or WebP image' unless acceptable_type?(content_type)
35
+ return 'looks empty' unless byte_size.to_i.positive?
36
+ return "must be under #{human_size(max_bytes)}" unless acceptable_size?(byte_size)
37
+
38
+ nil
39
+ end
40
+
41
+ def human_size(bytes)
42
+ mb = bytes.to_f / (1024 * 1024)
43
+ mb >= 1 ? "#{format('%g', mb.round(1))}MB" : "#{(bytes.to_f / 1024).round}KB"
44
+ end
45
+ end
46
+ end
@@ -6,6 +6,7 @@
6
6
  # URI.encode_www_form_component produces byte-identical output to CGI.escape.
7
7
  require 'uri'
8
8
  require 'bible270/plan'
9
+ require 'bible270/avatars'
9
10
 
10
11
  module Bible270
11
12
  class Configuration
@@ -108,7 +109,6 @@ module Bible270
108
109
 
109
110
  # Simple abuse guard: at most N links per address per window (seconds).
110
111
  attr_accessor :email_sign_in_window
111
- attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :admin_emails, :admin_resolver
112
112
 
113
113
  # Whether a reader may type the display name shown beside their reflections
114
114
  # when signing in by email (otherwise it's derived from the address).
@@ -133,7 +133,12 @@ module Bible270
133
133
  # With neither set the panel is unreachable and its routes 404.
134
134
  # Break points set from the host app, overriding Plan::CHAPTER_BREAKS.
135
135
  # Keys may be ['Psalm', 18] or 'Psalm 18'; [] keeps a chapter whole.
136
- attr_accessor :chapter_breaks
136
+ # Largest avatar a reader may upload, in bytes. Uploading needs Active
137
+ # Storage in the host app; without it the field is hidden and readers keep
138
+ # whatever avatar their sign-in provider gave them.
139
+ # Whether new readers may join this run of the plan. An admin can close it
140
+ # from the panel at runtime; setting this to false launches closed.
141
+ attr_accessor :enrollment_open
137
142
 
138
143
  # Optional YAML file holding the same thing, re-read whenever it changes so
139
144
  # breaks can be tuned without restarting:
@@ -142,6 +147,7 @@ module Bible270
142
147
  # Psalm 35: []
143
148
  # Psalm 78: [20, 39, 55]
144
149
  attr_accessor :chapter_breaks_path
150
+ attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver
145
151
 
146
152
  def admin?(reader)
147
153
  return false if reader.nil?
@@ -160,6 +166,27 @@ module Bible270
160
166
 
161
167
  def email_sign_in? = !!@email_sign_in
162
168
 
169
+ # Reserved domains from RFC 2606 — a From: address at one of these will fail
170
+ # SPF for your real domain, and receiving servers routinely drop it without
171
+ # a bounce. The default value is one of them deliberately, so it has to be
172
+ # changed, but nothing was checking that it had been.
173
+ PLACEHOLDER_DOMAINS = %w[example.com example.org example.net example.edu invalid localhost].freeze
174
+
175
+ # Why the sign-in mail is likely to vanish, or nil if it looks fine.
176
+ def mailer_from_problem
177
+ return nil unless email_sign_in?
178
+
179
+ address = mailer_from.to_s.strip
180
+ return 'config.mailer_from is blank' if address.empty?
181
+ return "config.mailer_from (#{address}) is not a valid address" unless EmailSignIn.valid_email?(address)
182
+
183
+ domain = address.split('@').last.to_s.downcase
184
+ return nil unless PLACEHOLDER_DOMAINS.include?(domain)
185
+
186
+ "config.mailer_from is still the placeholder #{address} — mail from a reserved domain " \
187
+ 'fails SPF and is usually dropped without a bounce'
188
+ end
189
+
163
190
  # Any way at all for a new person to sign in?
164
191
  def any_sign_in_method?
165
192
  email_sign_in? || omniauth_providers.any?
@@ -224,6 +251,8 @@ module Bible270
224
251
  @email_sign_in_ask_name = true
225
252
  @email_sign_in_require_name = true
226
253
  @email_sign_in_log_link = nil
254
+ @enrollment_open = true
255
+ @avatar_max_bytes = Avatars::DEFAULT_MAX_BYTES
227
256
  @chapter_breaks = {}
228
257
  @chapter_breaks_path = nil
229
258
  @admin_emails = []
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Readers are known to each other by name beside their reflections, so a first
5
+ # and last name are both required wherever a name is set: at email sign-in, when
6
+ # a reader edits their own, and when an admin edits someone else's.
7
+ #
8
+ # Kept free of Rails so the rules can be tested on their own.
9
+ module Names
10
+ module_function
11
+
12
+ # Collapse runs of whitespace, including the non-breaking space that arrives
13
+ # from copy-paste, and trim.
14
+ def squish(value)
15
+ value.to_s.tr("\u00A0", ' ').gsub(%r{\s+}, ' ').strip
16
+ end
17
+
18
+ # A first and last name, or nil when either is missing. Returns the pieces
19
+ # plus the display name built from them, so every caller derives it the same
20
+ # way rather than assembling it locally.
21
+ def normalize(first, last)
22
+ first_name = squish(first)
23
+ last_name = squish(last)
24
+ return nil if first_name.empty? || last_name.empty?
25
+
26
+ {
27
+ first_name: first_name,
28
+ last_name: last_name,
29
+ display_name: "#{first_name} #{last_name}"
30
+ }
31
+ end
32
+
33
+ def valid?(first, last)
34
+ !normalize(first, last).nil?
35
+ end
36
+
37
+ # Best effort at splitting a single name, for readers who arrived through
38
+ # OmniAuth with only a display name. Everything after the first word is the
39
+ # surname, so "Andrew von der Luft" keeps its particles together.
40
+ def split(display_name)
41
+ parts = squish(display_name).split
42
+ return nil if parts.size < 2
43
+
44
+ { first_name: parts.first, last_name: parts[1..].join(' ') }
45
+ end
46
+ end
47
+ end
data/lib/bible270/plan.rb CHANGED
@@ -15,7 +15,7 @@ module Bible270
15
15
  # grouped so each day is ~equal in verses (~73/day)
16
16
  # * New Testament – read once, one reading every day; the 10 longest chapters
17
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.
18
+ # * Psalms/Proverbs – Psalms and Proverbs once, interleaved in one track.
19
19
  # Longer psalms are divided so the two fill exactly 270 days;
20
20
  # Psalm 119 is pinned to 11 sections of 16 verses.
21
21
  #
@@ -292,7 +292,7 @@ module Bible270
292
292
  end
293
293
 
294
294
  def parse_breaks_file(path)
295
- raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
295
+ raw = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
296
296
  raise ArgumentError, "#{path} must map \"Book Chapter\" to a list of verses" unless raw.is_a?(Hash)
297
297
 
298
298
  normalize_breaks(raw)
@@ -308,7 +308,7 @@ module Bible270
308
308
  def break_key(key)
309
309
  return [key[0].to_s, key[1].to_i] if key.is_a?(Array)
310
310
 
311
- match = key.to_s.strip.match(/\A(.+?)\s+(\d+)\z/)
311
+ match = key.to_s.strip.match(%r{\A(.+?)\s+(\d+)\z})
312
312
  raise ArgumentError, "chapter break key #{key.inspect} must look like 'Psalm 18'" if match.nil?
313
313
 
314
314
  [match[1], match[2].to_i]
@@ -406,7 +406,7 @@ module Bible270
406
406
  ['Psalm', 44] => [], ['Psalm', 49] => [], ['Psalm', 50] => [], ['Psalm', 51] => [], # Stay whole
407
407
  ['Psalm', 55] => [], ['Psalm', 59] => [], ['Psalm', 65] => [], ['Psalm', 66] => [], # Stay whole
408
408
  ['Psalm', 68] => [18], # => Psalm 68:1–18, 68:19–35
409
- ['Psalm', 69] => [18 ], # => Psalm 69:1–18, 69:19–36
409
+ ['Psalm', 69] => [18], # => Psalm 69:1–18, 69:19–36
410
410
  ['Psalm', 78] => [20, 39, 55], # => 78:1–20, 21–39, 40–55, 56–72
411
411
  ['Psalm', 37] => [20], # => Psalm 37:1–20, 37:21–40
412
412
  ['Matthew', 26] => [35], # => Matthew 26:1–35, 26:36–75
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '0.10.0'
4
+ VERSION = '0.11.0'
5
5
  end
data/lib/bible270.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require 'bible270/version'
4
4
  require 'bible270/plan'
5
5
  require 'bible270/email_sign_in'
6
+ require 'bible270/names'
7
+ require 'bible270/avatars'
6
8
  require 'bible270/configuration'
7
9
  require 'bible270/engine' if defined?(Rails::Engine)
8
10
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bible270
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -71,6 +71,7 @@ files:
71
71
  - app/controllers/bible270/checkoffs_controller.rb
72
72
  - app/controllers/bible270/comments_controller.rb
73
73
  - app/controllers/bible270/days_controller.rb
74
+ - app/controllers/bible270/profiles_controller.rb
74
75
  - app/controllers/bible270/readers_controller.rb
75
76
  - app/controllers/bible270/sessions_controller.rb
76
77
  - app/helpers/bible270/plan_helper.rb
@@ -80,6 +81,7 @@ files:
80
81
  - app/models/bible270/checkoff.rb
81
82
  - app/models/bible270/comment.rb
82
83
  - app/models/bible270/reader.rb
84
+ - app/models/bible270/setting.rb
83
85
  - app/models/bible270/sign_in_token.rb
84
86
  - app/views/bible270/admin/comments.html.erb
85
87
  - app/views/bible270/admin/index.html.erb
@@ -93,9 +95,11 @@ files:
93
95
  - app/views/bible270/days/_start_date.html.erb
94
96
  - app/views/bible270/days/index.html.erb
95
97
  - app/views/bible270/days/show.html.erb
98
+ - app/views/bible270/profiles/edit.html.erb
96
99
  - app/views/bible270/readers/index.html.erb
97
100
  - app/views/bible270/readers/show.html.erb
98
101
  - app/views/bible270/sessions/new.html.erb
102
+ - app/views/bible270/shared/_day_index.html.erb
99
103
  - app/views/bible270/shared/_header.html.erb
100
104
  - app/views/bible270/shared/_styles.html.erb
101
105
  - app/views/bible270/sign_in_mailer/magic_link.html.erb
@@ -108,10 +112,13 @@ files:
108
112
  - db/migrate/20260101000004_create_bible270_sign_in_tokens.rb
109
113
  - db/migrate/20260101000005_add_names_to_bible270_readers.rb
110
114
  - db/migrate/20260101000006_add_approval_to_bible270_comments.rb
115
+ - db/migrate/20260101000007_create_bible270_settings.rb
111
116
  - lib/bible270.rb
117
+ - lib/bible270/avatars.rb
112
118
  - lib/bible270/configuration.rb
113
119
  - lib/bible270/email_sign_in.rb
114
120
  - lib/bible270/engine.rb
121
+ - lib/bible270/names.rb
115
122
  - lib/bible270/plan.rb
116
123
  - lib/bible270/versification.rb
117
124
  - lib/bible270/version.rb