Class: Bible270::Reader

Inherits:
ApplicationRecord show all
Defined in:
app/models/bible270/reader.rb

Overview

A reader identity. Either self-contained (created via OmniAuth) or bridged to one of the host application's users through the polymorphic :owner.

Constant Summary collapse

AVATAR_UPLOADS =

Active Storage is optional: an app may have it disabled, and the engine has to keep working there — readers simply can't upload, and any avatar from their sign-in provider is used instead.

respond_to?(:has_one_attached)
PASSAGE_SOURCES =
%w[bible_gateway blue_letter_bible].freeze
DEFAULT_PASSAGE_SOURCE =
'bible_gateway'

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.avatar_uploads?Boolean

Returns:

  • (Boolean)


20
# File 'app/models/bible270/reader.rb', line 20

def self.avatar_uploads? = AVATAR_UPLOADS

.email_reader_exists?(email) ⇒ Boolean

Find or create the reader behind a verified email address. Uses the same provider/uid identity columns as OmniAuth, with provider "email", so an email reader is indistinguishable from any other downstream. Used when enrolment is closed: an existing reader may still sign in, a new one may not be created.

Returns:

  • (Boolean)


88
89
90
91
92
93
# File 'app/models/bible270/reader.rb', line 88

def self.email_reader_exists?(email)
  address = EmailSignIn.normalize_email(email)
  return false if address.nil?

  exists?(provider: 'email', uid: address)
end

.for_owner(owner, display_name:, email: nil, avatar_url: nil) ⇒ Object

Find or create a reader bridged to a host user (or any model).



134
135
136
137
138
139
140
141
# File 'app/models/bible270/reader.rb', line 134

def self.for_owner(owner, display_name:, email: nil, avatar_url: nil)
  reader = find_or_initialize_by(owner: owner)
  reader.display_name = display_name.presence || reader.display_name || 'Reader'
  reader.email      ||= email
  reader.avatar_url ||= avatar_url
  reader.save!
  reader
end

.from_email(email, first_name: nil, last_name: nil, display_name: nil) ⇒ Object



101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'app/models/bible270/reader.rb', line 101

def self.from_email(email, first_name: nil, last_name: nil, display_name: nil)
  address = EmailSignIn.normalize_email(email)
  return nil if address.nil?

  reader = find_or_initialize_by(provider: 'email', uid: address)
  reader.first_name = first_name.to_s.strip if first_name.present?
  reader.last_name  = last_name.to_s.strip  if last_name.present?
  reader.display_name = first_present(reader.full_name, display_name, reader.display_name,
                                      EmailSignIn.display_name_from(address), 'Reader')
  reader.email = address
  reader.save
  reader
end

.from_omniauth(auth) ⇒ Object

Build/refresh a reader from an OmniAuth auth hash. Tolerant of the various shapes strategies return (OmniAuth::AuthHash, plain Hash, missing info).



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'app/models/bible270/reader.rb', line 37

def self.from_omniauth(auth)
  provider = dig_auth(auth, :provider).to_s
  uid      = dig_auth(auth, :uid).to_s
  return nil if provider.empty? || uid.empty?

  info = dig_auth(auth, :info) || {}
  name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
                       dig_auth(info, :first_name), dig_auth(info, :email))

  reader = find_or_initialize_by(provider: provider, uid: uid)
  reader.display_name = first_present(name, reader.display_name, 'Reader')
  email = dig_auth(info, :email)
  image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
  reader.email      = email if email.present?
  reader.avatar_url = image if image.present?
  assign_names_from(reader, info)
  reader.save
  reader
end


249
250
251
252
253
254
255
256
257
258
259
# File 'app/models/bible270/reader.rb', line 249

def self.from_remember_cookie(reader_id, token)
  return nil if reader_id.blank? || token.blank?

  reader = find_by(id: reader_id)
  return nil if reader.nil? || reader.remember_token.blank?

  # Constant-time comparison: the token is a credential.
  return nil unless ActiveSupport::SecurityUtils.secure_compare(reader.remember_token, token.to_s)

  reader
end

.mentioned_in(text) ⇒ Object

The readers a piece of text mentions. A handle matching more than one reader resolves to nobody: mailing the wrong person is worse than mailing none, and the writer sees the mention left unlinked.



209
210
211
212
213
214
215
216
217
218
# File 'app/models/bible270/reader.rb', line 209

def self.mentioned_in(text)
  handles = Mentions.extract(text)
  return [] if handles.empty?

  candidates = where.not(first_name: [nil, '']).to_a
  handles.filter_map do |handle|
    matches = candidates.select { |reader| reader.answers_to?(handle) }
    matches.first if matches.one?
  end.uniq
end

.omniauth_reader_exists?(provider, uid) ⇒ Boolean

Returns:

  • (Boolean)


95
96
97
98
99
# File 'app/models/bible270/reader.rb', line 95

def self.omniauth_reader_exists?(provider, uid)
  return false if provider.blank? || uid.blank?

  exists?(provider: provider.to_s, uid: uid.to_s)
end

.shared_first_name?(first, except = nil) ⇒ Boolean

Returns:

  • (Boolean)


200
201
202
203
204
# File 'app/models/bible270/reader.rb', line 200

def self.shared_first_name?(first, except = nil)
  scope = where('LOWER(first_name) = ?', first.to_s.downcase)
  scope = scope.where.not(id: except.id) if except&.id
  scope.exists?
end

Instance Method Details

#answers_to?(handle) ⇒ Boolean

Returns:

  • (Boolean)


220
221
222
# File 'app/models/bible270/reader.rb', line 220

def answers_to?(handle)
  Mentions.handles_for(first_name, last_name).include?(handle)
end

#attach_avatar(upload) ⇒ Object

Returns false and sets an error when the upload isn't acceptable.



266
267
268
269
270
271
272
273
274
275
276
277
# File 'app/models/bible270/reader.rb', line 266

def attach_avatar(upload)
  return false unless self.class.avatar_uploads?

  problem = Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size)
  if problem
    errors.add(:avatar, problem)
    return false
  end

  avatar.attach(upload)
  true
end

#avatar_uploaded?Boolean

Returns:

  • (Boolean)


261
262
263
# File 'app/models/bible270/reader.rb', line 261

def avatar_uploaded?
  self.class.avatar_uploads? && avatar.attached?
end

#bible_gateway?Boolean

Returns:

  • (Boolean)


178
# File 'app/models/bible270/reader.rb', line 178

def bible_gateway? = passage_source == 'bible_gateway'

#bible_version_labelObject



161
162
163
# File 'app/models/bible270/reader.rb', line 161

def bible_version_label
  Translations.label(effective_bible_version)
end

#blue_letter_bible?Boolean

Returns:

  • (Boolean)


179
# File 'app/models/bible270/reader.rb', line 179

def blue_letter_bible? = passage_source == 'blue_letter_bible'

#calendar_dayObject

The plan day that today corresponds to (clamped into range), or nil when undated.



492
493
494
# File 'app/models/bible270/reader.rb', line 492

def calendar_day
  Plan.day_for(Bible270.today, effective_start_date)
end

#checked_count(day) ⇒ Object

Tracks ticked on a day, counted from the single grouped query in checked_counts. Use this rather than read_tracks_for when rendering a grid: read_tracks_for costs a query per day, which is 270 of them per page.



296
297
298
# File 'app/models/bible270/reader.rb', line 296

def checked_count(day)
  checked_counts[day].to_i
end

#checked_countsObject

=> number of tracks checked off



289
290
291
# File 'app/models/bible270/reader.rb', line 289

def checked_counts
  @checked_counts ||= checkoffs.group(:day).count
end

#clear_day!(day) ⇒ Object



408
409
410
411
412
413
414
# File 'app/models/bible270/reader.rb', line 408

def clear_day!(day)
  return false unless Plan.valid_day?(day)

  checkoffs.where(day: day).destroy_all
  reload_progress
  true
end

#clear_start_date!Object



567
568
569
# File 'app/models/bible270/reader.rb', line 567

def clear_start_date!
  update!(started_on: nil)
end

#completion_percentObject



344
345
346
# File 'app/models/bible270/reader.rb', line 344

def completion_percent
  (days_completed.to_f / Plan::DAYS * 100).round
end

#current_dayObject

First day not yet fully complete (where the reader "is").



349
350
351
# File 'app/models/bible270/reader.rb', line 349

def current_day
  (1..Plan::DAYS).find { |d| !day_complete?(d) } || Plan::DAYS
end

#date_for_day(day) ⇒ Object



527
528
529
# File 'app/models/bible270/reader.rb', line 527

def date_for_day(day)
  Plan.date_for(day, effective_start_date)
end

#dated?Boolean

Returns:

  • (Boolean)


482
483
484
# File 'app/models/bible270/reader.rb', line 482

def dated?
  effective_start_date.present?
end

#day_complete?(day) ⇒ Boolean

Returns:

  • (Boolean)


328
329
330
331
# File 'app/models/bible270/reader.rb', line 328

def day_complete?(day)
  n = checked_counts[day].to_i
  n.positive? && n >= Plan.total_parts(day)
end

#day_status(day) ⇒ Object



300
301
302
303
304
305
# File 'app/models/bible270/reader.rb', line 300

def day_status(day)
  done = checked_count(day)
  return :none if done.zero?

  done >= Plan.total_parts(day) ? :complete : :partial
end

#days_completedObject



333
334
335
# File 'app/models/bible270/reader.rb', line 333

def days_completed
  checked_counts.count { |day, n| n >= Plan.total_parts(day) }
end

#days_off_paceObject

How many days behind (positive) or ahead (negative) of the calendar the reader's actual progress is. Nil when undated.



533
534
535
536
537
538
# File 'app/models/bible270/reader.rb', line 533

def days_off_pace
  today = calendar_day
  return nil unless today

  today - days_completed
end

#days_read_in(track) ⇒ Object

Days on which this track is finished. Counting rows would count chapters now that an Old Testament reading has one per chapter.



339
340
341
342
# File 'app/models/bible270/reader.rb', line 339

def days_read_in(track)
  checkoffs.where(track: track.to_s).group(:day).count
    .count { |day, done| done >= Plan.part_count(day, track) }
end

#effective_bible_versionObject

The translation this reader reads in. Null means "whatever the site default is", so changing config.bible_version moves everyone who hasn't chosen.



157
158
159
# File 'app/models/bible270/reader.rb', line 157

def effective_bible_version
  Translations.resolve(bible_version)
end

#effective_start_dateObject

The start date that actually governs this reader. A reader's own started_on wins when per-reader dates are allowed; otherwise (or if they haven't got one) the community-wide config.start_date applies. Nil means the plan is undated for this reader and no calendar mapping exists.



473
474
475
476
477
478
479
480
# File 'app/models/bible270/reader.rb', line 473

def effective_start_date
  config = Bible270.config
  if config.allow_reader_start_date && started_on
    started_on
  else
    config.start_date
  end
end

#ensure_started!Object

Called when a reader first participates. Only stamps a personal start date when per-reader dates are enabled and a shared date isn't already in force.



573
574
575
576
577
578
579
580
# File 'app/models/bible270/reader.rb', line 573

def ensure_started!
  config = Bible270.config
  return unless config.allow_reader_start_date
  return if started_on.present?
  return if config.start_date.present?

  update!(started_on: Bible270.today)
end

#first_with_last_initialObject

Shorter than the full name, for lists where a surname is more than needed. Falls back to the display name for readers who arrived with only one.



149
150
151
152
153
# File 'app/models/bible270/reader.rb', line 149

def first_with_last_initial
  return display_name if first_name.blank?

  Names.first_with_last_initial(first_name, last_name).presence || display_name
end

#forget!Object

Rotating the token invalidates every device at once.



244
245
246
247
# File 'app/models/bible270/reader.rb', line 244

def forget!
  update_column(:remember_token, nil)
  true
end

#full_nameObject



143
144
145
# File 'app/models/bible270/reader.rb', line 143

def full_name
  [first_name, last_name].map { |n| n.to_s.strip }.reject(&:empty?).join(' ').presence
end

#initialsObject



284
285
286
# File 'app/models/bible270/reader.rb', line 284

def initials
  display_name.to_s.split(%r{\s+}).first(2).map { |w| w[0] }.join.upcase.presence || '?'
end

#mark_day_complete!(day) ⇒ Object

Tick every track that has content on this day.



374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'app/models/bible270/reader.rb', line 374

def mark_day_complete!(day)
  return false unless Plan.valid_day?(day)

  # Deliberately not find_or_create_by!: since Rails 8.1 that creates first
  # and rescues RecordNotUnique, but Checkoff validates uniqueness, so a
  # duplicate raises RecordInvalid before the database is reached and is
  # never rescued. Reading what exists first is also one query per day rather
  # than one per chapter.
  missing = missing_parts_on(day)
  insert_checkoffs(day, missing) if missing.any?

  reload_progress
  true
end

#mark_through!(day) ⇒ Object

Mark everything up to and including day complete, and clear anything after.



421
422
423
424
425
426
427
428
429
430
431
# File 'app/models/bible270/reader.rb', line 421

def mark_through!(day)
  day = day.to_i
  return false unless day.between?(0, Plan::DAYS)

  transaction do
    checkoffs.where(day: (day + 1)..).delete_all
    (1..day).each { |d| mark_day_complete!(d) }
  end
  reload_progress
  true
end

#mention_handleObject

The handle to write when mentioning this reader: their first name, unless someone else shares it, in which case first.last.



193
194
195
196
197
198
# File 'app/models/bible270/reader.rb', line 193

def mention_handle
  first = first_name.presence || display_name.to_s.split.first
  return nil if first.blank?

  Mentions.preferred_handle(first, last_name, ambiguous: self.class.shared_first_name?(first, self))
end

#not_started_yet?Boolean

Returns:

  • (Boolean)


515
516
517
# File 'app/models/bible270/reader.rb', line 515

def not_started_yet?
  Plan.before_start?(Bible270.today, effective_start_date)
end

#own_start_date?Boolean

Whether this reader is following a personal date or the shared cohort one.

Returns:

  • (Boolean)


487
488
489
# File 'app/models/bible270/reader.rb', line 487

def own_start_date?
  Bible270.config.allow_reader_start_date && started_on.present?
end

#past_end_date?Boolean

Returns:

  • (Boolean)


519
520
521
# File 'app/models/bible270/reader.rb', line 519

def past_end_date?
  Plan.after_end?(Bible270.today, effective_start_date)
end

#plan_end_dateObject



523
524
525
# File 'app/models/bible270/reader.rb', line 523

def plan_end_date
  Plan.end_date_for(effective_start_date)
end

#raw_calendar_dayObject



511
512
513
# File 'app/models/bible270/reader.rb', line 511

def raw_calendar_day
  Plan.day_for(Bible270.today, effective_start_date, clamp: false)
end

#read?(day, track, part = nil) ⇒ Boolean

Returns:

  • (Boolean)


316
317
318
319
320
321
# File 'app/models/bible270/reader.rb', line 316

def read?(day, track, part = nil)
  return read_parts_for(day, track).include?(part) if part

  # No part given: the track counts as read only when every chapter is.
  read_parts_for(day, track).size >= Plan.part_count(day, track)
end

#read_parts_for(day, track) ⇒ Object

Which chapters of a track the reader has ticked on this day.



312
313
314
# File 'app/models/bible270/reader.rb', line 312

def read_parts_for(day, track)
  checkoffs.where(day: day, track: track.to_s).pluck(:part)
end

#read_tracks_for(day) ⇒ Object



307
308
309
# File 'app/models/bible270/reader.rb', line 307

def read_tracks_for(day)
  checkoffs.where(day: day).pluck(:track).uniq
end

#reload_progressObject



462
463
464
465
# File 'app/models/bible270/reader.rb', line 462

def reload_progress
  @checked_counts = nil
  self
end

#remember_token!Object

Generated on first use rather than at sign-up, so readers who never stay signed in never carry one.



235
236
237
238
239
240
241
# File 'app/models/bible270/reader.rb', line 235

def remember_token!
  return remember_token if remember_token.present?

  token = SecureRandom.urlsafe_base64(32)
  update_column(:remember_token, token)
  token
end

#remove_avatar!Object



279
280
281
282
# File 'app/models/bible270/reader.rb', line 279

def remove_avatar!
  avatar.purge if avatar_uploaded?
  true
end

#restart_on!(day:, on: Bible270.today) ⇒ Object

Put this reader on day as of on — i.e. back-date the start so that the given date lands on the given day of the plan.



435
436
437
438
439
440
# File 'app/models/bible270/reader.rb', line 435

def restart_on!(day:, on: Bible270.today)
  day = day.to_i
  return false unless Plan.valid_day?(day)

  update!(started_on: Plan.to_date(on) - (day - 1))
end

#set_start_date!(value) ⇒ Object

Set the start date regardless of whether readers are allowed to set their own. For administrative use: config.allow_reader_start_date governs what a reader may do to themselves, not what an admin may do on their behalf. Returns false only when the value isn't a date.



553
554
555
556
557
558
# File 'app/models/bible270/reader.rb', line 553

def set_start_date!(value)
  date = Plan.to_date(value)
  return false if date.nil?

  update!(started_on: date)
end

#sort_nameObject

Readers are listed by first name, matching how they are shown — the display name is "First Last", so sorting on it needs only case folding. Surname order was inconsistent with the community page and read oddly next to names displayed first-name-first.



185
186
187
# File 'app/models/bible270/reader.rb', line 185

def sort_name
  display_name.to_s.strip.downcase
end

#start_dateObject



545
546
547
# File 'app/models/bible270/reader.rb', line 545

def start_date
  started_on
end

#start_date=(value) ⇒ Object

Set or change this reader's own start date. Accepts a Date or a string.



541
542
543
# File 'app/models/bible270/reader.rb', line 541

def start_date=(value)
  self.started_on = Plan.to_date(value)
end

#suggested_namesObject

Fill first/last from the display name where we only have one string, e.g. a reader who arrived through OmniAuth.



365
366
367
368
369
# File 'app/models/bible270/reader.rb', line 365

def suggested_names
  return { first_name: first_name, last_name: last_name } if full_name

  Names.split_display_name(display_name) || { first_name: display_name, last_name: nil }
end

#today?(day) ⇒ Boolean

Returns:

  • (Boolean)


507
508
509
# File 'app/models/bible270/reader.rb', line 507

def today?(day)
  today_day == day
end

#today_dayObject

Raw, unclamped — lets callers distinguish "not started yet" / "finished". The plan day that today actually is, or nil when today falls outside the plan's window. calendar_day clamps, so before the start date it reports day 1 — which made day 1 claim to be "today" for anyone whose plan hadn't begun.



500
501
502
503
504
505
# File 'app/models/bible270/reader.rb', line 500

def today_day
  raw = raw_calendar_day
  return nil if raw.nil?

  Plan.valid_day?(raw) ? raw : nil
end

#toggle_day!(day) ⇒ Object



416
417
418
# File 'app/models/bible270/reader.rb', line 416

def toggle_day!(day)
  day_complete?(day) ? clear_day!(day) : mark_day_complete!(day)
end

#track_partially_read?(day, track) ⇒ Boolean

Returns:

  • (Boolean)


323
324
325
326
# File 'app/models/bible270/reader.rb', line 323

def track_partially_read?(day, track)
  done = read_parts_for(day, track).size
  done.positive? && done < Plan.part_count(day, track)
end

#update_bible_version(code) ⇒ Object



165
166
167
168
169
# File 'app/models/bible270/reader.rb', line 165

def update_bible_version(code)
  return false unless Translations.valid?(code)

  update(bible_version: Translations.normalize(code))
end

#update_names(first, last) ⇒ Object

Day number implied by the calendar, if a start date is set. Set the name shown beside this reader's reflections. Returns false when either half is missing, so callers can re-render with a message.



356
357
358
359
360
361
# File 'app/models/bible270/reader.rb', line 356

def update_names(first, last)
  names = Names.normalize(first, last)
  return false if names.nil?

  update(**names)
end

#update_passage_source(source) ⇒ Object



171
172
173
174
175
176
# File 'app/models/bible270/reader.rb', line 171

def update_passage_source(source)
  source = source.to_s
  return false unless PASSAGE_SOURCES.include?(source)

  update(passage_source: source)
end

#update_start_date!(value) ⇒ Object

The reader-facing version, which does respect that permission.



561
562
563
564
565
# File 'app/models/bible270/reader.rb', line 561

def update_start_date!(value)
  return false unless Bible270.config.allow_reader_start_date

  set_start_date!(value)
end

#wants_mention_notices?Boolean

Returns:

  • (Boolean)


224
225
226
227
228
229
# File 'app/models/bible270/reader.rb', line 224

def wants_mention_notices?
  return false if email.blank?
  return true unless respond_to?(:notify_on_mention)

  notify_on_mention != false
end