bible270 0.15.2 → 0.16.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 +4 -4
- data/CHANGELOG.md +60 -18
- data/app/controllers/bible270/admin_controller.rb +1 -1
- data/app/controllers/bible270/application_controller.rb +12 -2
- data/app/controllers/bible270/comments_controller.rb +1 -1
- data/app/controllers/bible270/days_controller.rb +24 -2
- data/app/controllers/bible270/profiles_controller.rb +4 -0
- data/app/helpers/bible270/plan_helper.rb +32 -0
- data/app/mailers/bible270/notice_mailer.rb +30 -2
- data/app/models/bible270/comment.rb +52 -2
- data/app/models/bible270/reader.rb +48 -6
- data/app/models/bible270/sign_in_token.rb +7 -1
- data/app/views/bible270/admin/comments.html.erb +1 -0
- data/app/views/bible270/comments/_comment.html.erb +21 -3
- data/app/views/bible270/comments/_form.html.erb +8 -1
- data/app/views/bible270/comments/create.turbo_stream.erb +11 -2
- data/app/views/bible270/days/_start_date.html.erb +1 -1
- data/app/views/bible270/notice_mailer/mentioned.html.erb +28 -0
- data/app/views/bible270/notice_mailer/mentioned.text.erb +12 -0
- data/app/views/bible270/profiles/edit.html.erb +5 -0
- data/app/views/bible270/shared/_styles.html.erb +12 -1
- data/db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb +9 -0
- data/db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb +10 -0
- data/db/migrate/20260101000012_add_parent_to_bible270_comments.rb +11 -0
- data/lib/bible270/configuration.rb +14 -2
- data/lib/bible270/mentions.rb +72 -0
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +17 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3190cb926559301bfff8450c5e4f3d32867d84528c795782f33786c8f49b5a9f
|
|
4
|
+
data.tar.gz: df04e7e7a97b995c86c3440a81bea7db90360617cb990bc60975aca5d11e1669
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a535dc114386b817d3e501ebb363d52f5898e8d99b10212a414c0bb760fb1f707cc09ed24298ffbeccc7e648f87ee9afadedd2bdcf0951253a3e8aa6d3348bda
|
|
7
|
+
data.tar.gz: 66b8697402e10bfd1ac99b6aa3b08728a1116521d93dbfbb41ecd2e979b7f34682f185786f27d5a23c154d892470ec679b3e596d01c220da99764fc545e361e6
|
data/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,58 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
|
|
|
6
6
|
|
|
7
7
|
See the changes since the last release:
|
|
8
8
|
|
|
9
|
-
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.
|
|
9
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.16.0...main)**
|
|
10
|
+
|
|
11
|
+
## [0.16.0] - 2026-08-05
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- threaded replies to reflections
|
|
16
|
+
- 'at' mention replies email notify writer of original reflection
|
|
17
|
+
|
|
18
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.3...v0.16.0)**
|
|
19
|
+
|
|
20
|
+
## [0.15.3] - 2026-08-04
|
|
21
|
+
|
|
22
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.2...v0.15.3)**
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `Bible270::Checkoff.reset_column_information` to migrations to prevent `UnknownAttributeError` during backfills.
|
|
27
|
+
- more tests (reader identity, sign-ins, and token management) to increase coverage to > 93%
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- 'Today' now derived from system time, not UTC
|
|
32
|
+
|
|
33
|
+
## [0.15.2] - 2026-08-04
|
|
34
|
+
|
|
35
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.1...v0.15.2)**
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- app icon to shared header partial
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- consistent reader sort order by first name in 'People' and 'Admin'
|
|
44
|
+
|
|
45
|
+
## [0.15.1] - 2026-08-03
|
|
46
|
+
|
|
47
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.0...v0.15.1)**
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- CSV file with versification by day
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- refactor 'progress' and 'reflections' into partials to be more DRY
|
|
56
|
+
- update Coverage badge to be less cached
|
|
57
|
+
|
|
58
|
+
### Removed
|
|
59
|
+
|
|
60
|
+
- old zip file of tests
|
|
10
61
|
|
|
11
62
|
## [0.15.0] - 2026-08-02
|
|
12
63
|
|
|
@@ -208,7 +259,6 @@ Nothing consequential.
|
|
|
208
259
|
- Posting a reflection about the whole day failed with "Track is not included in the list": the
|
|
209
260
|
form's blank option submits an empty string, which `allow_nil` rejects. Blank tracks are now
|
|
210
261
|
normalised to NULL before validation.
|
|
211
|
-
|
|
212
262
|
- `config.email_sign_in_log_link` — tri-state: nil logs the sign-in link in development and test
|
|
213
263
|
only, true forces it on (for smoke-testing a production build locally before mail is wired up),
|
|
214
264
|
false disables it. The log also distinguishes sent, rate-limited and failed delivery, which the
|
|
@@ -226,7 +276,7 @@ Nothing consequential.
|
|
|
226
276
|
|
|
227
277
|
### Changed
|
|
228
278
|
|
|
229
|
-
*
|
|
279
|
+
* The New Testament now has a reading every day — no days off.
|
|
230
280
|
* The daily total is now 76–175 verses (was 76–203), averaging ~119.
|
|
231
281
|
|
|
232
282
|
### Added
|
|
@@ -252,11 +302,7 @@ This is the initial public release.
|
|
|
252
302
|
* A pure, deterministic Ruby schedule; the database stores readers, check-offs, and comments rather than plan rows.
|
|
253
303
|
* `Bible270::Versification`, providing per-chapter verse counts for all 66 books, generated from a public-domain KJV text and validated against canonical anchors.
|
|
254
304
|
* Verse-balanced daily portions rather than chapter-count balancing, so daily reading time is approximately even.
|
|
255
|
-
* Old Testament days grouped to be even in verses, averaging approximately 73 verses per day.
|
|
256
305
|
* Per-track progress bars based on days read rather than chapter totals.
|
|
257
|
-
* The New Testament read twice, with each pass occupying half of the 270-day plan. Whole chapters are verse-balanced within each pass.
|
|
258
|
-
* A Psalms/Proverbs companion plan resolving to exactly 135 portions, making two complete passes across the 270-day plan.
|
|
259
|
-
* Chapters are generally kept whole; Psalm 119 is divided into two readings, while shorter chapters may be combined with a neighboring psalm.
|
|
260
306
|
* Configurable start dates through `config.start_date` and `config.allow_reader_start_date`, including per-reader date management.
|
|
261
307
|
* Calendar mapping functions on `Bible270::Plan`: `date_for`, `day_for`, `end_date_for`, `before_start?`, `after_end?`, and `to_date`.
|
|
262
308
|
* Calendar-aware day pages, including a **Today** badge, a **Go to today** link, and a pace indicator.
|
|
@@ -279,17 +325,13 @@ This is the initial public release.
|
|
|
279
325
|
* Configuration-driven engine mounting and OmniAuth path prefixes.
|
|
280
326
|
* `spec.email` in the gemspec.
|
|
281
327
|
* Gem metadata pointing to the actual repository for `source_code_uri`, `bug_tracker_uri`, and `changelog_uri`.
|
|
282
|
-
* RubyGems MFA requirement through `rubygems_mfa_required`.
|
|
283
|
-
* This changelog, packaged with the gem.
|
|
284
328
|
* Compatibility notes and tests covering Ruby 4.0 changes, including removal of the `cgi` dependency.
|
|
285
329
|
|
|
286
330
|
### Changed
|
|
287
331
|
|
|
288
332
|
* The project was renamed from `bible_reading_plan` to `bible270`, including the `Bible270` module, database tables, migrations, CSS and helper prefixes, install task, and engine name.
|
|
289
333
|
* The install generator defaults to `/daily-bread`, writes the mount path into the engine initializer, and emits configuration-driven `mount` and `path_prefix` lines.
|
|
290
|
-
* The README documents the mount point, initializer load-order requirements, OAuth callback URLs, and CMS links that may need updating when the mount path changes.
|
|
291
334
|
* The engine's sign-in paths follow the mount point at runtime through `request.script_name`.
|
|
292
|
-
* Author and copyright information now read “Andrew vonderLuft”.
|
|
293
335
|
* The display-name-from-email example uses a neutral address.
|
|
294
336
|
* `sign_out` is `DELETE` only.
|
|
295
337
|
* Guarded actions redirect to the sign-in page.
|
|
@@ -306,17 +348,17 @@ This is the initial public release.
|
|
|
306
348
|
* Ruby 4.0 compatibility issues caused by the removed `cgi` default gem.
|
|
307
349
|
* Progress bars that could not reach the correct totals because they compared checked days with chapter totals.
|
|
308
350
|
|
|
309
|
-
### Breaking Changes
|
|
310
|
-
|
|
311
|
-
* The project was renamed from `bible_reading_plan` to `bible270`; this is a fresh install rather than an in-place upgrade because database table names changed.
|
|
312
|
-
* The New Testament schedule changed from a single pass to two verse-balanced passes.
|
|
313
|
-
* The Psalms/Proverbs schedule changed to two complete 135-portion passes.
|
|
314
|
-
* Calendar and reading-plan behavior is based on day numbers rather than stored plan rows.
|
|
315
|
-
|
|
316
351
|
### Requirements
|
|
317
352
|
|
|
318
353
|
* 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.
|
|
319
354
|
|
|
355
|
+
[0.16.0]: https://github.com/avonderluft/bible270/releases/tag/v0.16.0
|
|
356
|
+
[0.15.3]: https://github.com/avonderluft/bible270/releases/tag/v0.15.3
|
|
357
|
+
[0.15.2]: https://github.com/avonderluft/bible270/releases/tag/v0.15.2
|
|
358
|
+
[0.15.1]: https://github.com/avonderluft/bible270/releases/tag/v0.15.1
|
|
359
|
+
[0.15.0]: https://github.com/avonderluft/bible270/releases/tag/v0.15.0
|
|
360
|
+
[0.14.1]: https://github.com/avonderluft/bible270/releases/tag/v0.14.1
|
|
361
|
+
[0.14.0]: https://github.com/avonderluft/bible270/releases/tag/v0.14.0
|
|
320
362
|
[0.13.1]: https://github.com/avonderluft/bible270/releases/tag/v0.13.1
|
|
321
363
|
[0.13.0]: https://github.com/avonderluft/bible270/releases/tag/v0.13.0
|
|
322
364
|
[0.12.0]: https://github.com/avonderluft/bible270/releases/tag/v0.12.0
|
|
@@ -44,7 +44,7 @@ module Bible270
|
|
|
44
44
|
# Everything is visible by default; this lists it all so anything unsuitable
|
|
45
45
|
# can be taken down.
|
|
46
46
|
def comments
|
|
47
|
-
scope = Comment.includes(:reader).order(created_at: :desc)
|
|
47
|
+
scope = Comment.includes(:reader, parent: :reader).order(created_at: :desc)
|
|
48
48
|
@filter = params[:filter].to_s
|
|
49
49
|
scope = scope.hidden if @filter == 'hidden'
|
|
50
50
|
scope = scope.approved if @filter == 'visible'
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Bible270
|
|
4
4
|
class ApplicationController < Bible270.config.parent_controller.constantize
|
|
5
|
+
# The long-lived sign-in cookie. private_constant rather than sitting below
|
|
6
|
+
# `private`, which does not apply to constants — rubocop is right that the
|
|
7
|
+
# placement said something it could not do.
|
|
8
|
+
REMEMBER_COOKIE = :bible270_remember
|
|
9
|
+
private_constant :REMEMBER_COOKIE
|
|
10
|
+
|
|
5
11
|
# Rails only auto-includes an isolated engine's helpers when the controller's
|
|
6
12
|
# superclass is *exactly* ActionController::Base:
|
|
7
13
|
#
|
|
@@ -22,8 +28,6 @@ module Bible270
|
|
|
22
28
|
|
|
23
29
|
helper_method :current_reader, :signed_in?, :b270_config, :enrollment_closed?
|
|
24
30
|
|
|
25
|
-
REMEMBER_COOKIE = :bible270_remember
|
|
26
|
-
|
|
27
31
|
private
|
|
28
32
|
|
|
29
33
|
def bible270_layout
|
|
@@ -67,6 +71,12 @@ module Bible270
|
|
|
67
71
|
# Re-establish the session so the rest of the request behaves as normal.
|
|
68
72
|
session[:bible270_reader_id] = reader.id
|
|
69
73
|
reader
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
# A cookie is attacker-supplied input: a damaged or forged one must sign
|
|
76
|
+
# nobody in, and must never take the site down. Anything unexpected here is
|
|
77
|
+
# treated as "not remembered", and the cookie is dropped.
|
|
78
|
+
Rails.logger.warn("[bible270] ignoring an unusable remember cookie: #{e.class}: #{e.message}")
|
|
79
|
+
forget_reader!
|
|
70
80
|
end
|
|
71
81
|
|
|
72
82
|
# Encrypted, so the payload can be neither read nor forged by the client.
|
|
@@ -24,8 +24,11 @@ module Bible270
|
|
|
24
24
|
redirect_to(root_path, alert: 'That day is outside the plan.') and return unless Plan.valid_day?(@day)
|
|
25
25
|
|
|
26
26
|
@readings = Plan.readings_for(@day)
|
|
27
|
-
@comments = Comment.
|
|
28
|
-
|
|
27
|
+
@comments = Comment.threads_for_day(@day)
|
|
28
|
+
# Replying prefills the form with the handle rather than needing JavaScript,
|
|
29
|
+
# so it works with Turbo and without an asset pipeline.
|
|
30
|
+
parent = reply_parent
|
|
31
|
+
@new_comment = Comment.new(day: @day, parent_id: parent&.id, body: reply_prefix(parent))
|
|
29
32
|
@reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
|
|
30
33
|
|
|
31
34
|
# Every box on the day, not every track: an Old Testament reading of three
|
|
@@ -41,5 +44,24 @@ module Bible270
|
|
|
41
44
|
@prev_day = @day > 1 ? @day - 1 : nil
|
|
42
45
|
@next_day = @day < Plan::DAYS ? @day + 1 : nil
|
|
43
46
|
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# "@handle " for the reflection being replied to, or nothing. Private, but
|
|
51
|
+
# declared with the keyword rather than trailing the file, so it is obvious
|
|
52
|
+
# this is not an action.
|
|
53
|
+
# The reflection being replied to, if the link carried one and it is a
|
|
54
|
+
# top-level reflection: a reply to a reply is not allowed.
|
|
55
|
+
def reply_parent
|
|
56
|
+
parent = Comment.approved.find_by(id: params[:reply_to])
|
|
57
|
+
parent if parent && parent.day == @day && parent.parent_id.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# "@handle " so the author is mentioned — and so gets the email — without the
|
|
61
|
+
# reader having to type it.
|
|
62
|
+
def reply_prefix(parent)
|
|
63
|
+
handle = parent&.reader&.mention_handle
|
|
64
|
+
handle ? "@#{handle} " : nil
|
|
65
|
+
end
|
|
44
66
|
end
|
|
45
67
|
end
|
|
@@ -12,6 +12,10 @@ module Bible270
|
|
|
12
12
|
|
|
13
13
|
def update
|
|
14
14
|
problems = []
|
|
15
|
+
# A checkbox sends nothing when unticked, so absence means off. Set before
|
|
16
|
+
# the name check, so the preference is saved even if a name is rejected.
|
|
17
|
+
current_reader.update_column(:notify_on_mention, params[:notify_on_mention].present?)
|
|
18
|
+
|
|
15
19
|
problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
|
|
16
20
|
params[:last_name])
|
|
17
21
|
if params[:bible_version].present? && !current_reader.update_bible_version(params[:bible_version])
|
|
@@ -53,6 +53,38 @@ module Bible270
|
|
|
53
53
|
|
|
54
54
|
# The mark beside the title in the header. Larger than the favicon and inlined
|
|
55
55
|
# as SVG, so it stays crisp at any size and needs no asset pipeline.
|
|
56
|
+
# Renders "@handle" as a link to that reader. An unresolved or ambiguous handle
|
|
57
|
+
# is left as plain text, which is how a writer discovers it found nobody.
|
|
58
|
+
#
|
|
59
|
+
# Built by walking the matches and escaping everything between them, rather
|
|
60
|
+
# than escaping afterwards: the body is reader-supplied, so the only safe
|
|
61
|
+
# construction is one where every non-link fragment is escaped by hand.
|
|
62
|
+
def b270_with_mentions(body)
|
|
63
|
+
text = body.to_s
|
|
64
|
+
readers = Bible270::Reader.mentioned_in(text)
|
|
65
|
+
|
|
66
|
+
pieces = []
|
|
67
|
+
cursor = 0
|
|
68
|
+
|
|
69
|
+
text.to_enum(:scan, Bible270::Mentions::PATTERN).each do
|
|
70
|
+
match = Regexp.last_match
|
|
71
|
+
pieces << ERB::Util.html_escape(text[cursor...match.begin(0)])
|
|
72
|
+
|
|
73
|
+
handle = Bible270::Mentions.normalize(match[1])
|
|
74
|
+
reader = readers.find { |candidate| candidate.answers_to?(handle) }
|
|
75
|
+
pieces << if reader
|
|
76
|
+
link_to(match[0], reader_path(reader), class: 'b270-mention')
|
|
77
|
+
else
|
|
78
|
+
ERB::Util.html_escape(match[0])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
cursor = match.end(0)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
pieces << ERB::Util.html_escape(text[cursor..].to_s)
|
|
85
|
+
safe_join(pieces)
|
|
86
|
+
end
|
|
87
|
+
|
|
56
88
|
def b270_mark(size: 38)
|
|
57
89
|
configured = Bible270.config.header_mark
|
|
58
90
|
return nil if configured == false
|
|
@@ -5,19 +5,47 @@ module Bible270
|
|
|
5
5
|
class NoticeMailer < ApplicationMailer
|
|
6
6
|
def new_reader(reader_id:, recipients:)
|
|
7
7
|
@reader = Reader.find_by(id: reader_id)
|
|
8
|
-
|
|
8
|
+
# Blanks dropped rather than trusting the caller: [''] is not blank — a
|
|
9
|
+
# one-element array never is — so it would otherwise have been sent to an
|
|
10
|
+
# empty address, which fails at the SMTP server rather than here.
|
|
11
|
+
addresses = Array(recipients).map { |address| address.to_s.strip }.reject(&:empty?)
|
|
12
|
+
return message.perform_deliveries = false if @reader.nil? || addresses.empty?
|
|
9
13
|
|
|
10
14
|
@app_name = Bible270.config.app_name
|
|
11
15
|
@total = Reader.count
|
|
12
16
|
@admin_url = admin_url_for(@reader)
|
|
13
17
|
|
|
14
|
-
mail to:
|
|
18
|
+
mail to: addresses, subject: "#{@app_name}: #{@reader.display_name} has joined"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def mentioned(comment_id:, reader_id:)
|
|
22
|
+
@comment = Comment.find_by(id: comment_id)
|
|
23
|
+
@reader = Reader.find_by(id: reader_id)
|
|
24
|
+
return message.perform_deliveries = false if @comment.nil? || @reader.nil? || @reader.email.blank?
|
|
25
|
+
|
|
26
|
+
@author = @comment.reader
|
|
27
|
+
@app_name = Bible270.config.app_name
|
|
28
|
+
@day_url = day_url_for(@comment.day)
|
|
29
|
+
|
|
30
|
+
mail to: @reader.email,
|
|
31
|
+
subject: "#{@app_name}: #{@author.display_name} mentioned you on day #{@comment.day}"
|
|
15
32
|
end
|
|
16
33
|
|
|
17
34
|
private
|
|
18
35
|
|
|
19
36
|
# A mailer has no request, so the host is config.mailer_host or nothing. No
|
|
20
37
|
# link is better than a broken one.
|
|
38
|
+
def day_url_for(day)
|
|
39
|
+
host = Bible270.config.mailer_host
|
|
40
|
+
return nil if host.blank?
|
|
41
|
+
|
|
42
|
+
Bible270::Engine.routes.url_helpers.day_url(
|
|
43
|
+
day, host: host, protocol: host.start_with?('localhost') ? 'http' : 'https'
|
|
44
|
+
)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
21
49
|
def admin_url_for(reader)
|
|
22
50
|
host = Bible270.config.mailer_host
|
|
23
51
|
return nil if host.blank?
|
|
@@ -7,6 +7,12 @@ module Bible270
|
|
|
7
7
|
|
|
8
8
|
belongs_to :reader, class_name: 'Bible270::Reader'
|
|
9
9
|
|
|
10
|
+
# One level of replies. dependent: :destroy, because a reply to a deleted
|
|
11
|
+
# reflection has nothing left to answer.
|
|
12
|
+
belongs_to :parent, class_name: 'Bible270::Comment', optional: true
|
|
13
|
+
has_many :replies, class_name: 'Bible270::Comment', foreign_key: :parent_id,
|
|
14
|
+
dependent: :destroy, inverse_of: :parent
|
|
15
|
+
|
|
10
16
|
validates :day, inclusion: { in: 1..Plan::DAYS }
|
|
11
17
|
validates :body, presence: true, length: { maximum: 4000 }
|
|
12
18
|
# A reflection can be about one track or about the whole day. The form's
|
|
@@ -16,16 +22,29 @@ module Bible270
|
|
|
16
22
|
before_validation :normalize_track
|
|
17
23
|
|
|
18
24
|
validates :track, inclusion: { in: Plan::TRACKS.keys }, allow_blank: true
|
|
25
|
+
validate :parent_is_a_top_level_reflection
|
|
26
|
+
validate :parent_is_on_the_same_day
|
|
27
|
+
|
|
28
|
+
# Fires once the reflection is really saved, so nothing is sent for one whose
|
|
29
|
+
# transaction rolls back.
|
|
30
|
+
after_create_commit :notify_mentioned_readers
|
|
19
31
|
|
|
20
32
|
scope :approved, -> { where(approved: true) }
|
|
21
33
|
scope :hidden, -> { where(approved: false) }
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
# Only the reflections themselves: replies are rendered under their parent, so
|
|
35
|
+
# listing them again at the top level would show each twice.
|
|
36
|
+
scope :for_day, ->(d) { approved.where(day: d, parent_id: nil).order(created_at: :asc) }
|
|
37
|
+
scope :threads_for_day, ->(d) { for_day(d).includes(:reader, replies: :reader) }
|
|
38
|
+
scope :recent, -> { approved.order(created_at: :desc) }
|
|
24
39
|
|
|
25
40
|
# Every reflection is visible when written; these are the moderation actions.
|
|
26
41
|
def hide! = update!(approved: false, moderated_at: Time.current)
|
|
27
42
|
def unhide! = update!(approved: true, moderated_at: Time.current)
|
|
28
43
|
def hidden? = !approved
|
|
44
|
+
def reply? = parent_id.present?
|
|
45
|
+
|
|
46
|
+
# Visible replies, oldest first: a conversation reads forwards.
|
|
47
|
+
def visible_replies = replies.approved.order(created_at: :asc)
|
|
29
48
|
def moderated? = moderated_at.present?
|
|
30
49
|
|
|
31
50
|
private
|
|
@@ -33,5 +52,36 @@ module Bible270
|
|
|
33
52
|
def normalize_track
|
|
34
53
|
self.track = Plan.normalize_track(track)
|
|
35
54
|
end
|
|
55
|
+
|
|
56
|
+
# A reply to a reply would make a tree, and with it the questions of how deep
|
|
57
|
+
# to indent and what hiding a middle node means. One level avoids all of that.
|
|
58
|
+
def parent_is_a_top_level_reflection
|
|
59
|
+
return if parent.nil?
|
|
60
|
+
return if parent.parent_id.nil?
|
|
61
|
+
|
|
62
|
+
errors.add(:parent, 'is itself a reply')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parent_is_on_the_same_day
|
|
66
|
+
return if parent.nil? || parent.day == day
|
|
67
|
+
|
|
68
|
+
errors.add(:parent, "is on day #{parent.day}, not #{day}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A mention emails the person named. Failures are logged rather than raised:
|
|
72
|
+
# nobody should lose a reflection because the mail server is down.
|
|
73
|
+
def notify_mentioned_readers
|
|
74
|
+
return unless Bible270.config.mention_notifications
|
|
75
|
+
|
|
76
|
+
Reader.mentioned_in(body).each do |mentioned|
|
|
77
|
+
next if mentioned.id == reader_id
|
|
78
|
+
next unless mentioned.wants_mention_notices?
|
|
79
|
+
|
|
80
|
+
notice = NoticeMailer.mentioned(comment_id: id, reader_id: mentioned.id)
|
|
81
|
+
Bible270.config.registration_notice_deliver_later ? notice.deliver_later : notice.deliver_now
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
Rails.logger.error("[bible270] could not send mention notices for comment #{id}: #{e.class}: #{e.message}")
|
|
85
|
+
end
|
|
36
86
|
end
|
|
37
87
|
end
|
|
@@ -144,6 +144,48 @@ module Bible270
|
|
|
144
144
|
display_name.to_s.strip.downcase
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
+
# ---- mentions ----------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
# The handle to write when mentioning this reader: their first name, unless
|
|
150
|
+
# someone else shares it, in which case first.last.
|
|
151
|
+
def mention_handle
|
|
152
|
+
first = first_name.presence || display_name.to_s.split.first
|
|
153
|
+
return nil if first.blank?
|
|
154
|
+
|
|
155
|
+
Mentions.preferred_handle(first, last_name, ambiguous: self.class.shared_first_name?(first, self))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.shared_first_name?(first, except = nil)
|
|
159
|
+
scope = where('LOWER(first_name) = ?', first.to_s.downcase)
|
|
160
|
+
scope = scope.where.not(id: except.id) if except&.id
|
|
161
|
+
scope.exists?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The readers a piece of text mentions. A handle matching more than one reader
|
|
165
|
+
# resolves to nobody: mailing the wrong person is worse than mailing none, and
|
|
166
|
+
# the writer sees the mention left unlinked.
|
|
167
|
+
def self.mentioned_in(text)
|
|
168
|
+
handles = Mentions.extract(text)
|
|
169
|
+
return [] if handles.empty?
|
|
170
|
+
|
|
171
|
+
candidates = where.not(first_name: [nil, '']).to_a
|
|
172
|
+
handles.filter_map do |handle|
|
|
173
|
+
matches = candidates.select { |reader| reader.answers_to?(handle) }
|
|
174
|
+
matches.first if matches.one?
|
|
175
|
+
end.uniq
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def answers_to?(handle)
|
|
179
|
+
Mentions.handles_for(first_name, last_name).include?(handle)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def wants_mention_notices?
|
|
183
|
+
return false if email.blank?
|
|
184
|
+
return true unless respond_to?(:notify_on_mention)
|
|
185
|
+
|
|
186
|
+
notify_on_mention != false
|
|
187
|
+
end
|
|
188
|
+
|
|
147
189
|
# ---- staying signed in -------------------------------------------------
|
|
148
190
|
|
|
149
191
|
# Generated on first use rather than at sign-up, so readers who never stay
|
|
@@ -348,7 +390,7 @@ module Bible270
|
|
|
348
390
|
|
|
349
391
|
# Put this reader on `day` as of `on` — i.e. back-date the start so that the
|
|
350
392
|
# given date lands on the given day of the plan.
|
|
351
|
-
def restart_on!(day:, on:
|
|
393
|
+
def restart_on!(day:, on: Bible270.today)
|
|
352
394
|
day = day.to_i
|
|
353
395
|
return false unless Plan.valid_day?(day)
|
|
354
396
|
|
|
@@ -406,7 +448,7 @@ module Bible270
|
|
|
406
448
|
|
|
407
449
|
# The plan day that today corresponds to (clamped into range), or nil when undated.
|
|
408
450
|
def calendar_day
|
|
409
|
-
Plan.day_for(
|
|
451
|
+
Plan.day_for(Bible270.today, effective_start_date)
|
|
410
452
|
end
|
|
411
453
|
|
|
412
454
|
# Raw, unclamped — lets callers distinguish "not started yet" / "finished".
|
|
@@ -425,15 +467,15 @@ module Bible270
|
|
|
425
467
|
end
|
|
426
468
|
|
|
427
469
|
def raw_calendar_day
|
|
428
|
-
Plan.day_for(
|
|
470
|
+
Plan.day_for(Bible270.today, effective_start_date, clamp: false)
|
|
429
471
|
end
|
|
430
472
|
|
|
431
473
|
def not_started_yet?
|
|
432
|
-
Plan.before_start?(
|
|
474
|
+
Plan.before_start?(Bible270.today, effective_start_date)
|
|
433
475
|
end
|
|
434
476
|
|
|
435
477
|
def past_end_date?
|
|
436
|
-
Plan.after_end?(
|
|
478
|
+
Plan.after_end?(Bible270.today, effective_start_date)
|
|
437
479
|
end
|
|
438
480
|
|
|
439
481
|
def plan_end_date
|
|
@@ -492,7 +534,7 @@ module Bible270
|
|
|
492
534
|
return if started_on.present?
|
|
493
535
|
return if config.start_date.present?
|
|
494
536
|
|
|
495
|
-
update!(started_on:
|
|
537
|
+
update!(started_on: Bible270.today)
|
|
496
538
|
end
|
|
497
539
|
end
|
|
498
540
|
end
|
|
@@ -54,9 +54,15 @@ module Bible270
|
|
|
54
54
|
|
|
55
55
|
# Too many outstanding requests for one address in the window.
|
|
56
56
|
def self.rate_limited?(address)
|
|
57
|
+
# Normalised, because issue! stores a normalised address: querying the raw
|
|
58
|
+
# one let 'Eager@example.org' and 'eager@example.org' each have their own
|
|
59
|
+
# allowance, so varying the case bypassed the limit entirely.
|
|
60
|
+
email = EmailSignIn.normalize_email(address)
|
|
61
|
+
return false if email.nil?
|
|
62
|
+
|
|
57
63
|
window = Bible270.config.email_sign_in_window
|
|
58
64
|
max = Bible270.config.email_sign_in_max_per_window
|
|
59
|
-
where(email:
|
|
65
|
+
where(email: email).where(created_at: (Time.current - window)..).count >= max
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
# Housekeeping: drop spent and stale rows. Safe to run from a cron/rake task.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<strong><%= comment.reader.display_name %></strong>
|
|
20
20
|
· <%= link_to "day #{comment.day}", day_path(comment.day) %>
|
|
21
21
|
· <%= comment.created_at.strftime('%b %-d, %Y') %>
|
|
22
|
+
<% if comment.reply? %>· reply to <strong><%= comment.parent&.reader&.display_name || 'a deleted reflection' %></strong><% end %>
|
|
22
23
|
<% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
|
|
23
24
|
</div>
|
|
24
25
|
<p class="b270-cbody"><%= comment.body %></p>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
<%# locals: (comment:, reply: false) %>
|
|
2
|
+
<div class="b270-comment <%= 'b270-reply' if reply %>" id="comment-<%= comment.id %>">
|
|
3
|
+
<%= b270_avatar(comment.reader, size: reply ? 28 : 34) %>
|
|
3
4
|
<div style="flex:1">
|
|
4
5
|
<div class="b270-cmeta">
|
|
5
6
|
<strong><%= link_to comment.reader.display_name, reader_path(comment.reader), style: "text-decoration:none" %></strong>
|
|
@@ -10,6 +11,23 @@
|
|
|
10
11
|
form: { style: "display:inline", data: { turbo_stream: true } } %>
|
|
11
12
|
<% end %>
|
|
12
13
|
</div>
|
|
13
|
-
|
|
14
|
+
|
|
15
|
+
<p class="b270-cbody"><%= b270_with_mentions(comment.body) %></p>
|
|
16
|
+
|
|
17
|
+
<%# Only a top-level reflection can be replied to: one level deep, so a reply
|
|
18
|
+
offers no Reply of its own. %>
|
|
19
|
+
<% unless reply %>
|
|
20
|
+
<% if signed_in? && comment.reader_id != current_reader.id %>
|
|
21
|
+
<div class="b270-creply">
|
|
22
|
+
<%= link_to "Reply", day_path(comment.day, reply_to: comment.id, anchor: "new_comment_form"),
|
|
23
|
+
class: "b270-linkbtn" %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<div class="b270-replies" id="replies-<%= comment.id %>">
|
|
28
|
+
<%= render partial: "bible270/comments/comment",
|
|
29
|
+
collection: comment.visible_replies, as: :comment, locals: { reply: true } %>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
14
32
|
</div>
|
|
15
33
|
</div>
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
<% if comment.errors.any? %>
|
|
4
4
|
<p style="color:var(--oxblood);font-size:13px"><%= comment.errors.full_messages.to_sentence %></p>
|
|
5
5
|
<% end %>
|
|
6
|
-
|
|
6
|
+
<% if comment.parent_id.present? && (parent = Bible270::Comment.find_by(id: comment.parent_id)) %>
|
|
7
|
+
<p class="b270-replying">
|
|
8
|
+
Replying to <strong><%= parent.reader.display_name %></strong>
|
|
9
|
+
· <%= link_to "cancel", day_path(day, anchor: "new_comment_form") %>
|
|
10
|
+
</p>
|
|
11
|
+
<% end %>
|
|
12
|
+
<%= f.hidden_field :parent_id %>
|
|
13
|
+
<%= f.text_area :body, placeholder: comment.parent_id.present? ? "Write your reply…" : "Share a reflection on today's reading…" %>
|
|
7
14
|
<div class="row">
|
|
8
15
|
<%= f.select :track,
|
|
9
16
|
options_for_select([["The whole day", ""]] + Bible270::Plan::TRACKS.map { |k, v| [v[:label], k] }),
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
<%= turbo_stream.remove "no_comments" %>
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
|
|
3
|
+
<%# A reply belongs under its parent; a new reflection goes at the end. %>
|
|
4
|
+
<% if @comment.reply? %>
|
|
5
|
+
<%= turbo_stream.append "replies-#{@comment.parent_id}" do %>
|
|
6
|
+
<%= render partial: "bible270/comments/comment", locals: { comment: @comment, reply: true } %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% else %>
|
|
9
|
+
<%= turbo_stream.append "comments" do %>
|
|
10
|
+
<%= render partial: "bible270/comments/comment", locals: { comment: @comment, reply: false } %>
|
|
11
|
+
<% end %>
|
|
4
12
|
<% end %>
|
|
13
|
+
|
|
5
14
|
<%= turbo_stream.replace "new_comment_form" do %>
|
|
6
15
|
<%= render "bible270/comments/form", day: @day, comment: Bible270::Comment.new(day: @day) %>
|
|
7
16
|
<% end %>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<% end %>
|
|
9
9
|
· finishes <%= reader.plan_end_date.strftime("%B %-d, %Y") %>
|
|
10
10
|
<% if reader.not_started_yet? %>
|
|
11
|
-
· <em>begins in <%= (reader.effective_start_date -
|
|
11
|
+
· <em>begins in <%= (reader.effective_start_date - Bible270.today).to_i %> days</em>
|
|
12
12
|
<% elsif reader.past_end_date? %>
|
|
13
13
|
· <em>the 270 days have elapsed</em>
|
|
14
14
|
<% elsif (off = reader.days_off_pace) %>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
|
|
4
|
+
<div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
|
|
5
|
+
<h1 style="margin:0 0 6px;font-family:Georgia,serif;font-size:20px;font-weight:600">
|
|
6
|
+
<%= @author.display_name %> mentioned you
|
|
7
|
+
</h1>
|
|
8
|
+
<p style="margin:0 0 18px;font-size:14px;color:#6d6558">
|
|
9
|
+
Day <%= @comment.day %> · <%= @app_name %>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<blockquote style="margin:0;padding:14px 16px;background:#ece6da;border-left:3px solid #a5812c;border-radius:8px;font-size:15px;line-height:1.55">
|
|
13
|
+
<%= simple_format(@comment.body) %>
|
|
14
|
+
</blockquote>
|
|
15
|
+
|
|
16
|
+
<% if @day_url %>
|
|
17
|
+
<p style="margin:20px 0 0">
|
|
18
|
+
<a href="<%= @day_url %>" style="display:inline-block;background:#231f18;color:#ece6da;text-decoration:none;padding:10px 18px;border-radius:10px;font-weight:600;font-size:14px">Read it and reply</a>
|
|
19
|
+
</p>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<p style="margin:22px 0 0;font-size:12px;color:#9a9082">
|
|
23
|
+
You are getting this because someone wrote your name in a reflection.
|
|
24
|
+
You can turn these off on your profile page.
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%= @author.display_name %> mentioned you in a reflection on day <%= @comment.day %>.
|
|
2
|
+
|
|
3
|
+
<%= @comment.body %>
|
|
4
|
+
|
|
5
|
+
<% if @day_url %>
|
|
6
|
+
Read it and reply: <%= @day_url %>
|
|
7
|
+
<% else %>
|
|
8
|
+
You can find it on day <%= @comment.day %> of <%= @app_name %>.
|
|
9
|
+
<% end %>
|
|
10
|
+
--
|
|
11
|
+
You are getting this because someone wrote your name in a reflection. You can
|
|
12
|
+
turn these off on your profile page.
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
<% end %>
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
|
+
<label class="b270-field" style="flex-direction:row;align-items:center;gap:10px">
|
|
23
|
+
<%= check_box_tag :notify_on_mention, "1", current_reader.notify_on_mention %>
|
|
24
|
+
<span style="margin:0">Email me when someone mentions me in a reflection</span>
|
|
25
|
+
</label>
|
|
26
|
+
|
|
22
27
|
<label class="b270-field">
|
|
23
28
|
<span>First name</span>
|
|
24
29
|
<%= text_field_tag :first_name, @names[:first_name], required: true, autocomplete: "given-name" %>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
--line:#d7cfbf; --line-soft:#e2dbcd; --teal:#2f6a67; --oxblood:#8a2f3b; --gold:#a5812c;
|
|
5
5
|
--gold-lite:#c6a24a; --shadow:0 1px 0 rgba(35,31,24,.04),0 10px 30px -18px rgba(35,31,24,.35);
|
|
6
6
|
margin:0; background:radial-gradient(120% 80% at 100% 0%,rgba(165,129,44,.06),transparent 55%),var(--paper);
|
|
7
|
-
color:var(--ink); font-family:"Inter",system-ui,sans-serif; line-height:1.
|
|
7
|
+
color:var(--ink); font-family:"Inter",system-ui,sans-serif; line-height:1.2; -webkit-font-smoothing:antialiased;
|
|
8
8
|
}
|
|
9
9
|
.b270 *{box-sizing:border-box}
|
|
10
10
|
/* Blanket anchor colour. NB: this is (0,1,1), so any single-class rule
|
|
@@ -151,6 +151,17 @@
|
|
|
151
151
|
.b270-comment:first-child{border-top:none}
|
|
152
152
|
.b270-cmeta{font-size:13px;color:var(--muted)}
|
|
153
153
|
.b270-cmeta strong{color:var(--ink)}
|
|
154
|
+
/* Replies sit indented under their parent, with a rule down the left so a
|
|
155
|
+
thread reads as one block rather than a run of separate reflections. */
|
|
156
|
+
.b270-replies{margin:10px 0 0}
|
|
157
|
+
.b270-replies:empty{margin:0}
|
|
158
|
+
.b270-comment.b270-reply{padding:10px 0 0 16px;border-top:none;border-left:2px solid var(--line-soft);gap:10px}
|
|
159
|
+
.b270-comment.b270-reply .b270-cbody{font-size:14.5px}
|
|
160
|
+
.b270-replying{margin:0 0 8px;font-size:13px;color:var(--muted)}
|
|
161
|
+
.b270-replying strong{color:var(--ink)}
|
|
162
|
+
.b270-mention{color:var(--teal);font-weight:600;text-decoration:none}
|
|
163
|
+
.b270-mention:hover{text-decoration:underline}
|
|
164
|
+
.b270-creply{margin-top:6px}
|
|
154
165
|
.b270-cbody{margin:3px 0 0;white-space:pre-wrap}
|
|
155
166
|
.b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
|
|
156
167
|
.b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
|
|
@@ -12,6 +12,11 @@ class AddPartToBible270Checkoffs < ActiveRecord::Migration[7.0]
|
|
|
12
12
|
add_index :bible270_checkoffs, %i[reader_id day track part], unique: true,
|
|
13
13
|
name: 'idx_b270_checkoffs_part_unique'
|
|
14
14
|
|
|
15
|
+
# The backfill goes through the model, which caches its columns. If Checkoff
|
|
16
|
+
# was loaded before this ran — likely, since the app boots first — it does not
|
|
17
|
+
# know about `part` yet and insert_all raises UnknownAttributeError.
|
|
18
|
+
Bible270::Checkoff.reset_column_information
|
|
19
|
+
|
|
15
20
|
expand_existing_checkoffs
|
|
16
21
|
end
|
|
17
22
|
|
|
@@ -28,6 +33,10 @@ class AddPartToBible270Checkoffs < ActiveRecord::Migration[7.0]
|
|
|
28
33
|
add_index :bible270_checkoffs, %i[reader_id day track], unique: true,
|
|
29
34
|
name: 'idx_b270_checkoffs_unique'
|
|
30
35
|
remove_column :bible270_checkoffs, :part
|
|
36
|
+
|
|
37
|
+
# Likewise on the way back down: leaving the cached column in place makes the
|
|
38
|
+
# model try to write one the table no longer has.
|
|
39
|
+
Bible270::Checkoff.reset_column_information
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
private
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddMentionNoticesToBible270Readers < ActiveRecord::Migration[7.0]
|
|
4
|
+
# Being emailed because someone typed your name is the sort of thing people
|
|
5
|
+
# want to switch off, so it is a per-reader setting rather than a site-wide one.
|
|
6
|
+
# Default true: a mention nobody hears about is not much use.
|
|
7
|
+
def change
|
|
8
|
+
add_column :bible270_readers, :notify_on_mention, :boolean, null: false, default: true
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddParentToBible270Comments < ActiveRecord::Migration[7.0]
|
|
4
|
+
# Replies. One level only: a reply cannot itself be replied to, which keeps the
|
|
5
|
+
# rendering and the moderation story simple — a thread is a reflection and the
|
|
6
|
+
# answers to it, not a tree.
|
|
7
|
+
def change
|
|
8
|
+
add_reference :bible270_comments, :parent, null: true, index: { name: 'idx_b270_comments_parent' },
|
|
9
|
+
foreign_key: { to_table: :bible270_comments }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -292,7 +292,7 @@ module Bible270
|
|
|
292
292
|
# from, so without this the notice names the reader but cannot link to them.
|
|
293
293
|
attr_accessor :mailer_host
|
|
294
294
|
|
|
295
|
-
attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :footer_html, :footer, :favicon, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver, :bible_version, :remember_for, :require_sign_in_to_participate
|
|
295
|
+
attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :footer_html, :footer, :favicon, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver, :bible_version, :header_mark, :remember_for, :require_sign_in_to_participate
|
|
296
296
|
|
|
297
297
|
# Public labels.
|
|
298
298
|
attr_accessor :app_name
|
|
@@ -321,7 +321,17 @@ module Bible270
|
|
|
321
321
|
# The mark beside the title in the header. nil uses the built-in loaf, false
|
|
322
322
|
# shows none, and a string is used as an image source — so a host can put its
|
|
323
323
|
# own logo there without overriding the partial.
|
|
324
|
-
|
|
324
|
+
# Which clock decides what day it is. nil follows the machine's own time zone,
|
|
325
|
+
# which is what a self-hosted plan usually wants — Rails defaults Time.zone to
|
|
326
|
+
# UTC, so a reader in Los Angeles saw the next day's reading from 4pm.
|
|
327
|
+
#
|
|
328
|
+
# config.time_zone = nil # the system clock (default)
|
|
329
|
+
# config.time_zone = 'America/Los_Angeles' # a named zone
|
|
330
|
+
attr_accessor :time_zone
|
|
331
|
+
|
|
332
|
+
# Email a reader when someone writes "@them" in a reflection. Readers can opt
|
|
333
|
+
# out individually on their profile; this switches the whole feature off.
|
|
334
|
+
attr_accessor :mention_notifications
|
|
325
335
|
|
|
326
336
|
def remember_signed_in?
|
|
327
337
|
remember_for.to_i.positive?
|
|
@@ -343,6 +353,8 @@ module Bible270
|
|
|
343
353
|
end
|
|
344
354
|
@app_name = 'Daily Bread'
|
|
345
355
|
@tagline = 'Journeying through Scripture in 9 months'
|
|
356
|
+
@time_zone = nil
|
|
357
|
+
@mention_notifications = true
|
|
346
358
|
@header_mark = nil
|
|
347
359
|
@remember_for = 365 * 24 * 60 * 60
|
|
348
360
|
@require_sign_in_to_participate = true
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bible270
|
|
4
|
+
# Finding "@someone" in a reflection, and deciding who that is.
|
|
5
|
+
#
|
|
6
|
+
# There is no username field, so mentions match against the names readers
|
|
7
|
+
# already have. Two forms are accepted, both case-insensitively:
|
|
8
|
+
#
|
|
9
|
+
# @andrew a first name
|
|
10
|
+
# @andrew.vonderluft first and last, joined by a dot
|
|
11
|
+
#
|
|
12
|
+
# A mention that matches more than one reader resolves to nobody. Guessing
|
|
13
|
+
# would mean mailing the wrong person, and silence is easier to notice than a
|
|
14
|
+
# misdelivered message: the writer sees their mention unlinked.
|
|
15
|
+
module Mentions
|
|
16
|
+
# Deliberately narrow: letters, digits, dots, hyphens and apostrophes, so
|
|
17
|
+
# "@andrew," and "@andrew." at the end of a sentence still work, and an email
|
|
18
|
+
# address in the body is not mistaken for a mention.
|
|
19
|
+
PATTERN = %r{(?<![\w@])@([\p{L}\p{N}][\p{L}\p{N}.'-]{0,60})}
|
|
20
|
+
|
|
21
|
+
MAX_PER_COMMENT = 10
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The handles written in a piece of text, normalised and de-duplicated.
|
|
26
|
+
def extract(text)
|
|
27
|
+
return [] if text.nil?
|
|
28
|
+
|
|
29
|
+
text.to_s.scan(PATTERN).flatten
|
|
30
|
+
.map { |handle| normalize(handle) }
|
|
31
|
+
.reject(&:empty?)
|
|
32
|
+
.uniq
|
|
33
|
+
.first(MAX_PER_COMMENT)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Trailing punctuation is dropped so "@andrew." and "@andrew" agree.
|
|
37
|
+
def normalize(handle)
|
|
38
|
+
handle.to_s.downcase.gsub(%r{[^\p{L}\p{N}.'-]}, '').sub(%r{[.'-]+\z}, '')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Every handle a reader answers to, given their names.
|
|
42
|
+
def handles_for(first_name, last_name)
|
|
43
|
+
first = normalize(first_name)
|
|
44
|
+
last = normalize(last_name)
|
|
45
|
+
return [] if first.empty?
|
|
46
|
+
|
|
47
|
+
handles = [first]
|
|
48
|
+
handles << "#{first}.#{last}" unless last.empty?
|
|
49
|
+
handles.uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The form to write when there is more than one reader with the same first
|
|
53
|
+
# name: the longer handle is unambiguous.
|
|
54
|
+
def preferred_handle(first_name, last_name, ambiguous: false)
|
|
55
|
+
handles = handles_for(first_name, last_name)
|
|
56
|
+
return nil if handles.empty?
|
|
57
|
+
|
|
58
|
+
ambiguous ? handles.last : handles.first
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Wraps each mention so a view can style it, leaving the text otherwise
|
|
62
|
+
# untouched. The block receives the handle and returns the replacement.
|
|
63
|
+
def highlight(text)
|
|
64
|
+
return text.to_s if text.nil?
|
|
65
|
+
|
|
66
|
+
text.to_s.gsub(PATTERN) do
|
|
67
|
+
handle = Regexp.last_match(1)
|
|
68
|
+
yield(normalize(handle), "@#{handle}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/bible270/version.rb
CHANGED
data/lib/bible270.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
3
5
|
require 'bible270/version'
|
|
4
6
|
require 'bible270/plan'
|
|
5
7
|
require 'bible270/email_sign_in'
|
|
6
8
|
require 'bible270/names'
|
|
9
|
+
require 'bible270/mentions'
|
|
7
10
|
require 'bible270/avatars'
|
|
8
11
|
require 'bible270/translations'
|
|
9
12
|
require 'bible270/favicon'
|
|
@@ -11,4 +14,18 @@ require 'bible270/configuration'
|
|
|
11
14
|
require 'bible270/engine' if defined?(Rails::Engine)
|
|
12
15
|
|
|
13
16
|
module Bible270
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# The plan's idea of today.
|
|
20
|
+
#
|
|
21
|
+
# Date.current follows Rails' Time.zone, which is UTC unless the host app sets
|
|
22
|
+
# it; Date.today follows the machine. A self-hosted reading plan wants the
|
|
23
|
+
# latter, so that is the default, with config.time_zone to override.
|
|
24
|
+
def today
|
|
25
|
+
zone = config.time_zone
|
|
26
|
+
return Date.today if zone.nil? || zone.to_s.empty?
|
|
27
|
+
|
|
28
|
+
found = Time.respond_to?(:find_zone) ? Time.find_zone(zone) : nil
|
|
29
|
+
found ? found.today : Date.today
|
|
30
|
+
end
|
|
14
31
|
end
|
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.
|
|
4
|
+
version: 0.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -110,6 +110,8 @@ files:
|
|
|
110
110
|
- app/views/bible270/days/_start_date.html.erb
|
|
111
111
|
- app/views/bible270/days/index.html.erb
|
|
112
112
|
- app/views/bible270/days/show.html.erb
|
|
113
|
+
- app/views/bible270/notice_mailer/mentioned.html.erb
|
|
114
|
+
- app/views/bible270/notice_mailer/mentioned.text.erb
|
|
113
115
|
- app/views/bible270/notice_mailer/new_reader.html.erb
|
|
114
116
|
- app/views/bible270/notice_mailer/new_reader.text.erb
|
|
115
117
|
- app/views/bible270/profiles/edit.html.erb
|
|
@@ -138,12 +140,15 @@ files:
|
|
|
138
140
|
- db/migrate/20260101000008_add_bible_version_to_bible270_readers.rb
|
|
139
141
|
- db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb
|
|
140
142
|
- db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb
|
|
143
|
+
- db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb
|
|
144
|
+
- db/migrate/20260101000012_add_parent_to_bible270_comments.rb
|
|
141
145
|
- lib/bible270.rb
|
|
142
146
|
- lib/bible270/avatars.rb
|
|
143
147
|
- lib/bible270/configuration.rb
|
|
144
148
|
- lib/bible270/email_sign_in.rb
|
|
145
149
|
- lib/bible270/engine.rb
|
|
146
150
|
- lib/bible270/favicon.rb
|
|
151
|
+
- lib/bible270/mentions.rb
|
|
147
152
|
- lib/bible270/names.rb
|
|
148
153
|
- lib/bible270/plan.rb
|
|
149
154
|
- lib/bible270/translations.rb
|