bible270 1.5.1 → 1.6.1

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: f8c170757c087fab1698cb43ef5251e28c4e7c4f08187cb33b0ac3962f41fc28
4
- data.tar.gz: 5e803ddf4f8d77b56c2c7c91859b7ea0f8222341162790f39fb2cc7abbc1f517
3
+ metadata.gz: c39318054ec3245ba579a3c7f14ece0d8749cfd6bcf4209e90338ac48fe0294c
4
+ data.tar.gz: 05cfa41ce39280c39c6a278503a1f89248a2a057b96cda3c9e994dd831840864
5
5
  SHA512:
6
- metadata.gz: 3ff3dcde674bf0be28d1baec700a39d8db3a26a199c72fc467dd6864020d1608a1667e9feecb2e530706cf1a92b3a653cd7b904dd883cc4f495dc9027145c6c8
7
- data.tar.gz: 4e3566d9c90076d1a9c4c46ee99e9d2415320fcfeb8be0d2acc1a03ac9168278362a8cfe1dd93b108e4db312c292f3436a4061e24b5cb211ec217445a942a254
6
+ metadata.gz: 759bbd980b0f1994719f0323b487bd86270fc3b0ae6489899c849a807f5cef9e0c528da104881a3602b68ab8d734aa9ff8b507019ebbd427641128bff4f55db6
7
+ data.tar.gz: 7c2c28f53b206dba13947676da4b7c9d2468fcfa7a1e202f2c0850602732fa11a0185462b37a6ee1965000d60dab5b7463b8f8e58a1552bf9ec0ea825c282470
data/CHANGELOG.md CHANGED
@@ -4,21 +4,55 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.6.0...main)**
8
+
9
+ ## [1.6.0] - 2026-09-14
10
+
11
+ ### Added
12
+
13
+ - let administrators sort the reader list by first name, last name, most completed days, or least completed days, and open the Rake task's progress report in a dismissable Reader Stats dialog that follows the current sort selection
14
+ - let admin broadcasts target either every reader with an email address or only readers recognized as administrators
15
+
16
+ ### Changed
17
+
18
+ - send each broad reflection/reply notification once with all eligible subscribers in BCC, while keeping direct reply and mention notices individually addressed
19
+ - expand regression coverage for registration delivery and failure isolation, legacy reader names, custom footer composition, mention compatibility, and stale completion-animation schema caches
20
+
21
+ ### Fixed
22
+
23
+ - normalize boolean column defaults reported by Rails adapters so migration reconciliation and tests work consistently across Rails 7.1 through 8.1
24
+
25
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.5.1...v1.6.0)**
26
+
27
+ ## [1.5.1] - 2026-09-11
28
+
29
+ ### Added
30
+
31
+ - add `bible270:notifications:enable_all` to opt every existing reader into emails for each new reflection and reply, with environment-aware counts and guidance when the selected database has no readers
32
+ - add `bible270:readers:list` to print aligned Name, Days Read, and schedule Status columns, ordered by completed days
33
+
34
+ ### Changed
35
+
36
+ - default new readers to emails for every new reflection and reply; migration `20260101000022` changes only the default and preserves existing reader preferences
37
+
38
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.5.0...v1.5.1)**
39
+
40
+ ## [1.5.0] - 2026-09-07
41
+
7
42
  ### Added
8
43
 
9
44
  - add an on-demand Markdown formatting drawer and separate server-rendered Preview control for reflections and replies, with safe rendering, formatted notification emails, mention and browser-draft compatibility, and literal display of existing plain-text reflections; upgrading hosts must copy and run the new `body_format` comment migration
10
- - add `bible270:notifications:enable_all` to opt every existing reader into emails for each new reflection and reply
11
- - add `bible270:readers:list` to print an aligned, progress-sorted reader report with schedule status
12
45
 
13
46
  ### Changed
14
47
 
15
- - default new readers to emails for every new reflection and reply; existing reader preferences remain unchanged by the migration
48
+ - update RuboCop and SimpleCov development dependencies and replace deprecated SimpleCov configuration methods
49
+ - use the current Coveralls-backed Shields.io coverage badge endpoint for the `main` branch
16
50
 
17
51
  ### Fixed
18
52
 
19
53
  - keep `json` below 3.0 while supported Rails releases still pass positional parse options, preventing session-cookie decoding from crashing every request under `json` 3.0
20
54
 
21
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.2...main)**
55
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.2...v1.5.0)**
22
56
 
23
57
  ## [1.4.2] - 2026-09-03
24
58
 
@@ -667,7 +701,10 @@ This is the initial public release.
667
701
 
668
702
  * 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.
669
703
 
670
- [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.4.2...HEAD
704
+ [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.6.0...HEAD
705
+ [1.6.0]: https://github.com/avonderluft/bible270/releases/tag/v1.6.0
706
+ [1.5.1]: https://github.com/avonderluft/bible270/releases/tag/v1.5.1
707
+ [1.5.0]: https://github.com/avonderluft/bible270/releases/tag/v1.5.0
671
708
  [1.4.2]: https://github.com/avonderluft/bible270/releases/tag/v1.4.2
672
709
  [1.4.1]: https://github.com/avonderluft/bible270/releases/tag/v1.4.1
673
710
  [1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
data/README.md CHANGED
@@ -258,8 +258,10 @@ end
258
258
 
259
259
  ## Admin panel
260
260
 
261
- At `<mount>/admin`: remove readers, adjust their completions, move them to a given day. Unreachable
262
- every route 404s rather than 403s unless you say who may use it:
261
+ At `<mount>/admin`: sort readers by first name, last name, or completed days; open **Reader Stats** for
262
+ the same progress report as `bible270:readers:list`, ordered by the current admin sort selection; remove
263
+ readers; adjust their completions; and move them to a given day. The panel is unreachable — every route
264
+ 404s rather than 403s — unless you say who may use it:
263
265
 
264
266
  ```ruby
265
267
  config.admin_emails = %w[andrew@example.org]
@@ -267,7 +269,9 @@ config.admin_emails = %w[andrew@example.org]
267
269
  config.admin_resolver = ->(reader) { reader.email.to_s.end_with?('@example.org') }
268
270
  ```
269
271
 
270
- Admins get an extra "Admin" link in the header. From a reader's page you can:
272
+ Admins get an extra "Admin" link in the header. The broadcast form can send an individually addressed
273
+ message either to every reader with an email address or only to readers recognized as administrators.
274
+ From a reader's page you can:
271
275
 
272
276
  - **move them to a day** — "put them on day 42 as of today" back-dates the start date; check-offs are
273
277
  keyed to day numbers, so nothing in their history moves;
@@ -293,8 +297,9 @@ them. Upgrading hosts must copy and run migration `20260101000021`, which record
293
297
  plain text or Markdown; until then, the composer safely falls back to plain text.
294
298
 
295
299
  When `config.mention_notifications` is enabled, each reader can request email for every new reflection
296
- and reply, only replies to their reflections and mentions of them, or none. Administrators can change
297
- the same preference from the reader's Admin page. Migration
300
+ and reply, only replies to their reflections and mentions of them, or none. Broad notifications are sent
301
+ once with eligible subscribers in BCC; direct reply and mention notices remain individually addressed.
302
+ Administrators can change the same preference from the reader's Admin page. Migration
298
303
  `20260101000018` adds the broad opt-in without changing any existing reader's choice, while migration
299
304
  `20260101000022` makes that broad option the default for readers created afterward. Migration
300
305
  `20260101000017` stores each reader's last visit to the Reflections page. After upgrading, install and
@@ -10,6 +10,13 @@ module Bible270
10
10
  class AdminController < ApplicationController
11
11
  LAST_BROADCAST_AT = 'last_broadcast_at'
12
12
  LAST_BROADCAST_SUBJECT = 'last_broadcast_subject'
13
+ BROADCAST_AUDIENCES = %w[everyone admins].freeze
14
+ READER_SORT_OPTIONS = {
15
+ 'first_name' => 'First Name',
16
+ 'last_name' => 'Last Name',
17
+ 'most_completed' => 'Most days completed',
18
+ 'least_completed' => 'Least days completed'
19
+ }.freeze
13
20
 
14
21
  before_action :require_admin!
15
22
  before_action :load_reader,
@@ -55,7 +62,11 @@ module Bible270
55
62
  def index
56
63
  @enrollment_closed = Setting.enrollment_closed?
57
64
  @enrollment_closed_at = Setting.enrollment_closed_at
58
- @readers = Reader.all.to_a.sort_by(&:sort_name)
65
+ @days_completed = Reader.completed_days_by_id
66
+ @reader_sort_options = READER_SORT_OPTIONS
67
+ @reader_sort = READER_SORT_OPTIONS.key?(params[:sort]) ? params[:sort] : 'first_name'
68
+ @reader_report = ReaderProgressReport.new(completed_days: @days_completed, sort: @reader_sort)
69
+ @readers = @reader_report.sorted_readers
59
70
  @run_start_date = Setting.run_start_date
60
71
  @configured_start_date = Bible270.config.start_date
61
72
  @run_start_date_overridden = Setting.run_start_date_overridden?
@@ -66,8 +77,9 @@ module Bible270
66
77
  @shared_calendar_readers = @readers.size
67
78
  @personal_calendar_readers = 0
68
79
  end
69
- @days_completed = Reader.completed_days_by_id
70
- @reachable = Reader.where.not(email: [nil, '']).count
80
+ reachable_readers = @readers.select { |reader| reader.email.present? }
81
+ @reachable = reachable_readers.size
82
+ @reachable_admins = reachable_readers.count { |reader| Bible270.config.admin?(reader) }
71
83
  @last_broadcast_at = parsed_time(Setting.read(LAST_BROADCAST_AT))
72
84
  @last_broadcast_subject = Setting.read(LAST_BROADCAST_SUBJECT)
73
85
  end
@@ -118,13 +130,21 @@ module Bible270
118
130
  def broadcast
119
131
  subject = params[:subject].to_s.strip
120
132
  body = params[:body].to_s.strip
133
+ audience = params[:audience].presence || 'everyone'
121
134
 
135
+ unless BROADCAST_AUDIENCES.include?(audience)
136
+ return redirect_to admin_path, alert: 'Choose who should receive the message. Nothing was sent.'
137
+ end
122
138
  if subject.empty? || body.empty?
123
139
  return redirect_to(admin_path, alert: 'A message needs both a subject and something to say.')
124
140
  end
125
141
 
126
- recipients = Reader.where.not(email: [nil, '']).order(:id)
127
- return redirect_to(admin_path, alert: 'Nobody has an email address.') if recipients.empty?
142
+ recipients = broadcast_recipients(audience)
143
+ noun = audience == 'admins' ? 'admin' : 'reader'
144
+ if recipients.empty?
145
+ alert = audience == 'admins' ? 'No admins have an email address.' : 'Nobody has an email address.'
146
+ return redirect_to admin_path, alert: alert
147
+ end
128
148
 
129
149
  recipient_count = recipients.count
130
150
  sent = deliver_broadcast(recipients, subject, body)
@@ -132,7 +152,7 @@ module Bible270
132
152
 
133
153
  if sent.zero?
134
154
  action = later ? 'queued' : 'sent'
135
- return redirect_to admin_path, alert: "The message could not be #{action} to any readers."
155
+ return redirect_to admin_path, alert: "The message could not be #{action} to any #{noun.pluralize}."
136
156
  end
137
157
 
138
158
  Setting.write(LAST_BROADCAST_AT, Time.current.iso8601)
@@ -140,8 +160,8 @@ module Bible270
140
160
 
141
161
  action = later ? 'Queued for' : 'Sent to'
142
162
  failures = recipient_count - sent
143
- notice = "#{action} #{sent} #{'reader'.pluralize(sent)}."
144
- notice += " #{failures} #{'reader'.pluralize(failures)} could not be #{later ? 'queued' : 'sent'}." if failures.positive?
163
+ notice = "#{action} #{sent} #{noun.pluralize(sent)}."
164
+ notice += " #{failures} #{noun.pluralize(failures)} could not be #{later ? 'queued' : 'sent'}." if failures.positive?
145
165
  redirect_to admin_path, notice: notice
146
166
  end
147
167
 
@@ -285,6 +305,14 @@ module Bible270
285
305
  nil
286
306
  end
287
307
 
308
+ def broadcast_recipients(audience)
309
+ recipients = Reader.where.not(email: [nil, '']).order(:id)
310
+ return recipients if audience == 'everyone'
311
+
312
+ admin_ids = recipients.select { |reader| Bible270.config.admin?(reader) }.map(&:id)
313
+ recipients.where(id: admin_ids)
314
+ end
315
+
288
316
  def deliver_broadcast(recipients, subject, body)
289
317
  later = Bible270.config.registration_notice_deliver_later
290
318
  sent = 0
@@ -52,7 +52,7 @@ module Bible270
52
52
  def update
53
53
  return unless require_reader!
54
54
 
55
- @comment = own_comment
55
+ @comment = editable_comment
56
56
  head :not_found and return unless @comment
57
57
 
58
58
  @day = @comment.day
@@ -147,18 +147,16 @@ module Bible270
147
147
  params.require(:comment).permit(*permitted)
148
148
  end
149
149
 
150
- # Scoped to the reader, so someone else's reflection is simply not found
151
- # it reveals nothing about what exists.
152
- def own_comment
153
- current_reader.comments.find_by(id: params[:id])
150
+ # Scoped to the reader unless they are an administrator, so an ordinary reader
151
+ # cannot use editing or deletion to discover someone else's reflection.
152
+ def editable_comment
153
+ return current_reader.comments.find_by(id: params[:id]) unless Bible270.config.admin?(current_reader)
154
+
155
+ Comment.find_by(id: params[:id])
154
156
  end
155
157
 
156
- # An admin may remove any reflection; everyone else only their own. Editing
157
- # stays with the writer either way.
158
158
  def deletable_comment
159
- return own_comment unless Bible270.config.admin?(current_reader)
160
-
161
- Comment.find_by(id: params[:id])
159
+ editable_comment
162
160
  end
163
161
  end
164
162
  end
@@ -28,12 +28,13 @@ module Bible270
28
28
 
29
29
  private
30
30
 
31
- # The reflection the reader has asked to edit: their own, on this day. Anything
32
- # else is ignored rather than refused — the page still renders.
31
+ # The reflection the reader has asked to edit: their own, or any reflection
32
+ # when they are an administrator. Anything else is ignored rather than refused.
33
33
  def editing_comment_id
34
34
  return nil if params[:edit].blank? || current_reader.nil?
35
35
 
36
- current_reader.comments.where(day: @day, id: params[:edit]).pick(:id)
36
+ comments = Bible270.config.admin?(current_reader) ? Comment.all : current_reader.comments
37
+ comments.where(day: @day, id: params[:edit]).pick(:id)
37
38
  end
38
39
 
39
40
  # The reflection being replied to, if the link carried one and it is a
@@ -181,11 +181,12 @@ module Bible270
181
181
  local&.strftime('%b %-d, %Y at %-I:%M %p')
182
182
  end
183
183
 
184
- # Only the writer may change their own words. An admin can remove a reflection
185
- # but not rewrite it: putting words in someone's mouth is worse than taking
186
- # them away, and taking them away is already the moderator's job.
184
+ # Writers may revise their own words; administrators may also correct or
185
+ # moderate any reflection without changing its recorded author.
187
186
  def b270_can_edit?(comment)
188
- signed_in? && comment.reader_id == current_reader.id
187
+ return false unless signed_in?
188
+
189
+ comment.reader_id == current_reader.id || Bible270.config.admin?(current_reader)
189
190
  end
190
191
 
191
192
  def b270_can_delete?(comment)
@@ -36,16 +36,19 @@ module Bible270
36
36
  deliver_comment_notice("#{@app_name}: #{@author.display_name} replied on day #{@comment.day}")
37
37
  end
38
38
 
39
- def comment_posted(comment_id:, reader_id:)
40
- return message.perform_deliveries = false unless prepare_comment_notice(comment_id, reader_id)
41
- return message.perform_deliveries = false unless @reader.wants_all_comment_notifications?
42
- return message.perform_deliveries = false if @comment.reader_id == @reader.id
39
+ def comment_posted(comment_id:, reader_ids:)
40
+ return message.perform_deliveries = false unless prepare_comment(comment_id)
41
+
42
+ addresses = all_comment_addresses(reader_ids)
43
+ return message.perform_deliveries = false if addresses.empty?
43
44
 
44
45
  kind = @comment.reply? ? 'reply' : 'reflection'
45
46
  @heading = "#{@author.display_name} posted a #{kind}"
46
47
  @introduction = "#{@author.display_name} posted a #{kind} on day #{@comment.day}."
47
48
  @reason = 'You are getting this because you asked to be emailed about every new reflection and reply.'
48
- deliver_comment_notice("#{@app_name}: #{@author.display_name} posted a #{kind} on day #{@comment.day}")
49
+ mail bcc: addresses,
50
+ subject: "#{@app_name}: #{@author.display_name} posted a #{kind} on day #{@comment.day}",
51
+ template_name: 'comment_notice'
49
52
  end
50
53
 
51
54
  def daily_reminder(reader_id:, day:, on: Bible270.today)
@@ -108,13 +111,21 @@ module Bible270
108
111
  end
109
112
 
110
113
  def prepare_comment_notice(comment_id, reader_id)
111
- return false unless Bible270.config.mention_notifications
114
+ return false unless prepare_comment(comment_id)
112
115
 
113
- @comment = Comment.find_by(id: comment_id)
114
116
  @reader = Reader.find_by(id: reader_id)
115
- return false if @comment.nil? || @reader.nil? || @reader.email.blank?
117
+ return false if @reader.nil? || @reader.email.blank?
116
118
  return false unless @reader.wants_comment_notifications?
117
119
 
120
+ true
121
+ end
122
+
123
+ def prepare_comment(comment_id)
124
+ return false unless Bible270.config.mention_notifications
125
+
126
+ @comment = Comment.find_by(id: comment_id)
127
+ return false if @comment.nil?
128
+
118
129
  @author = @comment.reader
119
130
  @app_name = Bible270.config.app_name
120
131
  @day_url = day_url_for(@comment.day)
@@ -122,6 +133,15 @@ module Bible270
122
133
  true
123
134
  end
124
135
 
136
+ def all_comment_addresses(reader_ids)
137
+ Reader.all_comment_notification_recipients
138
+ .where(id: Array(reader_ids))
139
+ .where.not(id: @comment.reader_id)
140
+ .pluck(:email)
141
+ .map(&:strip)
142
+ .uniq(&:downcase)
143
+ end
144
+
125
145
  def deliver_comment_notice(subject)
126
146
  mail to: @reader.email, subject: subject, template_name: 'comment_notice'
127
147
  end
@@ -205,9 +205,20 @@ module Bible270
205
205
  end
206
206
 
207
207
  def notify_all_comment_readers(except: [])
208
- Reader.all_comment_notification_recipients.where.not(id: except).find_each do |recipient|
209
- deliver_comment_notice(recipient, :comment_posted)
208
+ recipient_ids = Reader.all_comment_notification_recipients.where.not(id: except).pluck(:id)
209
+ return if recipient_ids.empty?
210
+
211
+ notice = NoticeMailer.comment_posted(comment_id: id, reader_ids: recipient_ids)
212
+ if Bible270.config.registration_notice_deliver_later
213
+ notice.deliver_later
214
+ else
215
+ notice.deliver_now
210
216
  end
217
+ rescue StandardError => e
218
+ Rails.logger.error(
219
+ "[bible270] could not send comment_posted notice for comment #{id} " \
220
+ "to #{recipient_ids&.size || 0} readers: #{e.class}: #{e.message}"
221
+ )
211
222
  end
212
223
 
213
224
  def reply_notification_reader
@@ -63,6 +63,18 @@
63
63
  </div>
64
64
 
65
65
  <% if @readers.any? %>
66
+ <div class="b270-admin-reader-tools">
67
+ <%= form_with url: admin_path, method: :get, class: "b270-admin-reader-sort" do %>
68
+ <label>
69
+ <span>Sort readers by</span>
70
+ <%= select_tag :sort, options_for_select(@reader_sort_options.map { |value, label| [label, value] }, @reader_sort) %>
71
+ </label>
72
+ <%= submit_tag "Sort", class: "b270-btn" %>
73
+ <% end %>
74
+ <button type="button" class="b270-btn b270-reader-stats-open" aria-haspopup="dialog"
75
+ aria-controls="b270-reader-stats-dialog" data-b270-reader-stats-open hidden>Reader Stats</button>
76
+ </div>
77
+
66
78
  <div class="b270-list">
67
79
  <% @readers.each do |reader| %>
68
80
  <div class="b270-li">
@@ -84,27 +96,73 @@
84
96
  </div>
85
97
  <% end %>
86
98
  </div>
99
+
100
+ <dialog id="b270-reader-stats-dialog" class="b270-reader-stats-dialog"
101
+ aria-labelledby="b270-reader-stats-title" data-b270-reader-stats-dialog>
102
+ <div class="b270-reader-stats-content">
103
+ <div class="b270-reader-stats-head">
104
+ <h2 id="b270-reader-stats-title" class="b270-subhead">Reader Stats</h2>
105
+ <form method="dialog"><button type="submit" class="b270-linkbtn">Close</button></form>
106
+ </div>
107
+ <pre><%= @reader_report.to_table %></pre>
108
+ </div>
109
+ </dialog>
110
+
111
+ <script nonce="<%= content_security_policy_nonce %>">
112
+ (() => {
113
+ function scan() {
114
+ document.querySelectorAll("[data-b270-reader-stats-open]").forEach((button) => {
115
+ const dialog = document.getElementById(button.getAttribute("aria-controls"));
116
+ if (dialog && typeof dialog.showModal === "function") button.hidden = false;
117
+ });
118
+ }
119
+
120
+ if (window.Bible270ReaderStats) {
121
+ scan();
122
+ return;
123
+ }
124
+
125
+ document.addEventListener("click", (event) => {
126
+ const target = event.target;
127
+ const openButton = target.closest && target.closest("[data-b270-reader-stats-open]");
128
+ if (openButton) {
129
+ const dialog = document.getElementById(openButton.getAttribute("aria-controls"));
130
+ if (dialog && typeof dialog.showModal === "function") dialog.showModal();
131
+ return;
132
+ }
133
+
134
+ if (target.matches && target.matches("[data-b270-reader-stats-dialog]")) target.close();
135
+ });
136
+ document.addEventListener("turbo:load", scan);
137
+ window.Bible270ReaderStats = { scan };
138
+ scan();
139
+ })();
140
+ </script>
87
141
  <% else %>
88
142
  <p style="color:var(--faint)">No readers yet.</p>
89
143
  <% end %>
90
144
 
91
145
  <div class="b270-panel">
92
- <h2 class="b270-subhead">Write to everyone</h2>
146
+ <h2 class="b270-subhead">Write a message</h2>
93
147
 
94
148
  <% if @reachable.zero? %>
95
149
  <p class="b270-hint" style="margin-top:0">No reader has an email address yet.</p>
96
150
  <% else %>
97
151
  <p class="b270-hint" style="margin-top:0">
98
- Goes to <strong><%= @reachable %></strong>
99
- <%= @reachable == 1 ? 'reader' : 'readers' %> individually, not as one message with
100
- everyone in bcc so each is greeted by name.
152
+ <strong><%= pluralize(@reachable, 'reader') %></strong> can receive email,
153
+ including <strong><%= pluralize(@reachable_admins, 'admin') %></strong>.
154
+ Messages are sent individually so each recipient can be greeted by name.
101
155
  <% if @last_broadcast_at.present? %>
102
156
  <br>Last sent <%= b270_datetime(@last_broadcast_at) %><% if @last_broadcast_subject.present? %>: <em><%= @last_broadcast_subject %></em><% end %>.
103
157
  <% end %>
104
158
  </p>
105
159
 
106
160
  <%= form_with url: admin_broadcast_path, method: :post, class: "b270-form",
107
- data: { turbo_confirm: "Send this to #{@reachable} #{@reachable == 1 ? 'reader' : 'readers'}?" } do %>
161
+ data: { turbo_confirm: "Send this message to the selected recipients?" } do %>
162
+ <div class="b270-broadcast-audience">
163
+ <label for="b270-broadcast-audience" class="b270-sr-only">Recipients</label>
164
+ <%= select_tag :audience, options_for_select([['Write to everyone', 'everyone'], ['Write to all admins', 'admins']], 'everyone'), id: 'b270-broadcast-audience', class: 'b270-select' %>
165
+ </div>
108
166
  <%= text_field_tag :subject, params[:subject], placeholder: "Subject", class: "b270-input" %>
109
167
  <%= text_area_tag :body, params[:body], placeholder: "What would you like to say?", rows: 6 %>
110
168
  <div class="row">
@@ -25,7 +25,7 @@
25
25
  <%= button_to "delete", comment_path(comment), method: :delete, class: "b270-cdel",
26
26
  form: { style: "display:inline",
27
27
  data: { turbo_stream: true,
28
- turbo_confirm: (b270_can_edit?(comment) ? nil : "Delete #{comment.reader.display_name}'s reflection?") } } %>
28
+ turbo_confirm: (comment.reader_id == current_reader.id ? nil : "Delete #{comment.reader.display_name}'s reflection?") } } %>
29
29
  <% end %>
30
30
  </div>
31
31
 
@@ -59,14 +59,39 @@
59
59
  const start = textarea.value.lastIndexOf("\n", Math.max(selectionStart - 1, 0)) + 1;
60
60
  const nextNewline = textarea.value.indexOf("\n", selectionEnd);
61
61
  const finish = nextNewline === -1 ? textarea.value.length : nextNewline;
62
- const source = textarea.value.slice(start, finish) || (action === "quote" ? "quoted text" : "list item");
62
+ const existing = textarea.value.slice(start, finish);
63
+ const placeholder = action === "quote" ? "quoted text" : "list item";
64
+ const source = existing || placeholder;
63
65
  const lines = source.split("\n");
64
66
  const replacement = lines.map((line, index) => {
65
67
  if (action === "quote") return `> ${line}`;
66
68
  if (action === "number-list") return `${index + 1}. ${line}`;
67
69
  return `- ${line}`;
68
70
  }).join("\n");
69
- update(editor, textarea, replacement, start, finish, 0, replacement.length);
71
+ const selectListPlaceholder = !existing && action.endsWith("-list");
72
+ const selectedStart = selectListPlaceholder ? replacement.indexOf(placeholder) : 0;
73
+ const selectedEnd = selectListPlaceholder ? selectedStart + placeholder.length : replacement.length;
74
+ update(editor, textarea, replacement, start, finish, selectedStart, selectedEnd);
75
+ }
76
+
77
+ function continueList(editor, textarea, event) {
78
+ if (event.key !== "Enter" || event.isComposing || event.shiftKey || event.ctrlKey ||
79
+ event.metaKey || event.altKey || textarea.selectionStart !== textarea.selectionEnd) return false;
80
+
81
+ const caret = textarea.selectionStart;
82
+ const lineStart = textarea.value.lastIndexOf("\n", Math.max(caret - 1, 0)) + 1;
83
+ const beforeCaret = textarea.value.slice(lineStart, caret);
84
+ const bullet = beforeCaret.match(/^(\s*)([-+*])\s+/);
85
+ const numbered = beforeCaret.match(/^(\s*)(\d+)([.)])\s+/);
86
+ let marker;
87
+ if (bullet) marker = `${bullet[1]}${bullet[2]} `;
88
+ else if (numbered) marker = `${numbered[1]}${Number(numbered[2]) + 1}${numbered[3]} `;
89
+ else return false;
90
+
91
+ event.preventDefault();
92
+ const insertion = `\n${marker}`;
93
+ update(editor, textarea, insertion, caret, caret, insertion.length, insertion.length);
94
+ return true;
70
95
  }
71
96
 
72
97
  function addLink(editor, textarea) {
@@ -212,6 +237,7 @@
212
237
  formattingToggle.focus();
213
238
  });
214
239
  textarea.addEventListener("keydown", (event) => {
240
+ if (continueList(editor, textarea, event)) return;
215
241
  if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
216
242
  const action = event.key.toLocaleLowerCase() === "b" ? "bold" :
217
243
  event.key.toLocaleLowerCase() === "i" ? "italic" : null;
@@ -277,6 +277,7 @@
277
277
  .b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
278
278
  .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}
279
279
  .b270-form textarea[data-b270-autosize=false]{min-height:114px}
280
+ .b270-broadcast-audience{margin:8px 0}
280
281
  .b270-editor-utilities[hidden]{display:none}
281
282
  .b270-editor-utilities{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 2px 5px}
282
283
  .b270 .b270-editor-utility{padding:2px 0;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
@@ -313,6 +314,19 @@
313
314
  .b270-btn:hover{background:#3a352b}
314
315
 
315
316
  /* community */
317
+ .b270-admin-reader-tools{display:flex;align-items:flex-end;justify-content:flex-end;gap:8px;margin:0 0 10px;flex-wrap:wrap}
318
+ .b270-admin-reader-sort{display:flex;align-items:flex-end;gap:8px}
319
+ .b270-admin-reader-sort label{display:grid;gap:3px;font-size:11.5px;color:var(--muted)}
320
+ .b270-admin-reader-sort select{min-width:170px;padding:7px 25px 7px 9px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);font:inherit;font-size:12.5px}
321
+ .b270 .b270-admin-reader-sort .b270-btn,.b270 .b270-reader-stats-open{margin:0;padding:8px 13px;font-size:12.5px}
322
+ .b270-reader-stats-dialog{width:min(680px,calc(100vw - 28px));max-width:none;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--card);color:var(--ink);box-shadow:0 18px 60px rgba(35,31,24,.35)}
323
+ .b270-reader-stats-dialog::backdrop{background:rgba(35,31,24,.55)}
324
+ .b270-reader-stats-content{padding:20px}
325
+ .b270-reader-stats-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:12px}
326
+ .b270-reader-stats-head .b270-subhead{margin:0}
327
+ .b270-reader-stats-head form{margin:0}
328
+ .b270-reader-stats-head .b270-linkbtn{margin:0;color:var(--muted)}
329
+ .b270-reader-stats-dialog pre{max-height:65vh;overflow:auto;margin:0;padding:12px;border:1px solid var(--line-soft);border-radius:9px;background:#fff;color:var(--ink);font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
316
330
  .b270-list{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
317
331
  .b270-li{display:flex;align-items:center;gap:14px;padding:14px 18px}
318
332
  .b270-li+.b270-li{border-top:1px solid var(--line-soft)}
@@ -17,6 +17,7 @@ module Bible270
17
17
  ALLOWED_TAGS = %w[p br strong em blockquote ul ol li a].freeze
18
18
  ALLOWED_ATTRIBUTES = %w[href rel].freeze
19
19
  LINK_REL = 'nofollow ugc noopener'
20
+ URL_PATTERN = %r{https?://[^\s<>"]*[^\s<>".,;:!?)\]\}]}i
20
21
  SKIP_MENTIONS_IN = %w[a code pre].freeze
21
22
 
22
23
  module_function
@@ -26,6 +27,7 @@ module Bible270
26
27
  rendered = format == MARKDOWN ? markdown_html(source) : plain_html(source)
27
28
  fragment = fragment_for(sanitize(rendered))
28
29
  hard_wrap(fragment) if format == MARKDOWN
30
+ link_urls(fragment) if format == MARKDOWN
29
31
  decorate_links(fragment)
30
32
  link_mentions(fragment, &mention_path) if mention_path
31
33
  fragment.to_html
@@ -94,6 +96,28 @@ module Bible270
94
96
  end
95
97
  private_class_method :hard_wrap
96
98
 
99
+ def link_urls(fragment)
100
+ fragment.xpath('.//text()').to_a.each do |node|
101
+ next if node.ancestors.any? { |ancestor| SKIP_MENTIONS_IN.include?(ancestor.name) }
102
+ next unless node.text.match?(URL_PATTERN)
103
+
104
+ replacement = Nokogiri::HTML::DocumentFragment.parse('')
105
+ cursor = 0
106
+ node.text.to_enum(:scan, URL_PATTERN).each do
107
+ match = Regexp.last_match
108
+ replacement.add_child(Nokogiri::XML::Text.new(node.text[cursor...match.begin(0)], fragment.document))
109
+ link = Nokogiri::XML::Node.new('a', fragment.document)
110
+ link['href'] = match[0]
111
+ link.content = match[0]
112
+ replacement.add_child(link)
113
+ cursor = match.end(0)
114
+ end
115
+ replacement.add_child(Nokogiri::XML::Text.new(node.text[cursor..].to_s, fragment.document))
116
+ node.replace(replacement)
117
+ end
118
+ end
119
+ private_class_method :link_urls
120
+
97
121
  def decorate_links(fragment)
98
122
  fragment.css('a:not([href])').each do |link|
99
123
  link.replace(Nokogiri::XML::Text.new(link.text, fragment.document))
@@ -227,7 +227,8 @@ module Bible270
227
227
  return missing if missing.any?
228
228
 
229
229
  column = connection.columns(:bible270_readers).find { |candidate| candidate.name == 'notify_on_all_comments' }
230
- column&.default == true ? [] : ['column bible270_readers.notify_on_all_comments default true']
230
+ default = ActiveModel::Type::Boolean.new.cast(column&.default)
231
+ default == true ? [] : ['column bible270_readers.notify_on_all_comments default true']
231
232
  end
232
233
 
233
234
  # The Bible270 installer can install Active Storage for reader avatars. A
@@ -4,20 +4,24 @@ module Bible270
4
4
  # Builds the plain-text reader progress table used by administrative tasks.
5
5
  class ReaderProgressReport
6
6
  HEADERS = ['Name', 'Days Read', 'Status'].freeze
7
+ SORTS = %w[first_name last_name most_completed least_completed].freeze
8
+ DEFAULT_SORT = 'most_completed'
7
9
  Row = Struct.new(:name, :days_read, :status, keyword_init: true)
8
10
 
9
- def initialize(readers: Reader.all, completed_days: Reader.completed_days_by_id)
11
+ def initialize(readers: Reader.all, completed_days: Reader.completed_days_by_id, sort: DEFAULT_SORT)
10
12
  @readers = readers
11
13
  @completed_days = completed_days
14
+ @sort = SORTS.include?(sort.to_s) ? sort.to_s : DEFAULT_SORT
15
+ end
16
+
17
+ def sorted_readers
18
+ @sorted_readers ||= readers.to_a.sort_by { |reader| reader_sort_key(reader) }
12
19
  end
13
20
 
14
21
  def rows
15
- @rows ||= begin
16
- report_rows = readers.map do |reader|
17
- days_read = completed_days.fetch(reader.id, 0)
18
- Row.new(name: reader.display_name, days_read: days_read, status: status(reader, days_read))
19
- end
20
- report_rows.sort_by { |row| [-row.days_read, row.name.downcase, row.name] }
22
+ @rows ||= sorted_readers.map do |reader|
23
+ days_read = completed_days.fetch(reader.id, 0)
24
+ Row.new(name: reader.display_name, days_read: days_read, status: status(reader, days_read))
21
25
  end
22
26
  end
23
27
 
@@ -36,7 +40,21 @@ module Bible270
36
40
 
37
41
  private
38
42
 
39
- attr_reader :readers, :completed_days
43
+ attr_reader :readers, :completed_days, :sort
44
+
45
+ def reader_sort_key(reader)
46
+ case sort
47
+ when 'first_name'
48
+ [reader.sort_name, reader.id]
49
+ when 'last_name'
50
+ last_name = reader.last_name.presence || reader.display_name.to_s.split.last
51
+ [last_name.to_s.downcase, reader.sort_name, reader.id]
52
+ when 'least_completed'
53
+ [completed_days[reader.id].to_i, reader.sort_name, reader.id]
54
+ else
55
+ [-completed_days[reader.id].to_i, reader.sort_name, reader.id]
56
+ end
57
+ end
40
58
 
41
59
  def status(reader, days_read)
42
60
  calendar_day = reader.calendar_day
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.5.1'
4
+ VERSION = '1.6.1'
5
5
  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: 1.5.1
4
+ version: 1.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft