bible270 1.4.2 → 1.5.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -6
  3. data/README.md +34 -4
  4. data/app/controllers/bible270/comments_controller.rb +11 -1
  5. data/app/controllers/bible270/days_controller.rb +1 -1
  6. data/app/helpers/bible270/plan_helper.rb +33 -27
  7. data/app/mailers/bible270/application_mailer.rb +1 -0
  8. data/app/models/bible270/comment.rb +20 -0
  9. data/app/views/bible270/admin/comments.html.erb +1 -1
  10. data/app/views/bible270/comments/_body.html.erb +6 -0
  11. data/app/views/bible270/comments/_comment.html.erb +1 -1
  12. data/app/views/bible270/comments/_edit_fields.html.erb +2 -1
  13. data/app/views/bible270/comments/_editor.html.erb +57 -0
  14. data/app/views/bible270/comments/_form.html.erb +3 -12
  15. data/app/views/bible270/comments/create.turbo_stream.erb +2 -1
  16. data/app/views/bible270/days/show.html.erb +1 -0
  17. data/app/views/bible270/notice_mailer/comment_notice.html.erb +1 -1
  18. data/app/views/bible270/notice_mailer/comment_notice.text.erb +1 -1
  19. data/app/views/bible270/shared/_comment_drafts.html.erb +9 -1
  20. data/app/views/bible270/shared/_markdown_toolbar.html.erb +239 -0
  21. data/app/views/bible270/shared/_reflections.html.erb +1 -1
  22. data/app/views/bible270/shared/_styles.html.erb +30 -1
  23. data/config/routes.rb +1 -0
  24. data/db/migrate/20260101000021_add_body_format_to_bible270_comments.rb +11 -0
  25. data/db/migrate/20260101000022_change_all_comment_notices_default_to_true.rb +11 -0
  26. data/lib/bible270/comment_formatter.rb +135 -0
  27. data/lib/bible270/migration_reconciler.rb +21 -0
  28. data/lib/bible270/reader_progress_report.rb +52 -0
  29. data/lib/bible270/version.rb +1 -1
  30. data/lib/bible270.rb +2 -0
  31. data/lib/tasks/bible270_notifications.rake +38 -0
  32. data/lib/tasks/bible270_readers.rake +17 -0
  33. metadata +44 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8792007d09956f4b53cbe9842007baea209a5eb0010758ef35e7d6f1977928f0
4
- data.tar.gz: 0c5d16299136af62a6e1466210e962ef6e8d48065d759d9b5b57450215471c31
3
+ metadata.gz: f8c170757c087fab1698cb43ef5251e28c4e7c4f08187cb33b0ac3962f41fc28
4
+ data.tar.gz: 5e803ddf4f8d77b56c2c7c91859b7ea0f8222341162790f39fb2cc7abbc1f517
5
5
  SHA512:
6
- metadata.gz: 89cbba3ff6adfc107efacddb9b2d80903452488384983b488ecf733aa6e16d55451a0137626cda5a446591b2c28585201f49b612927a6ba80ad0b54a5889c377
7
- data.tar.gz: d2d501c3f9935405bf0b850124f2329b3c5355ccfdf5d7403688329a65f1bfdc4a779fc3d65d957eba242363393e6a057519ef509b337986f6426bb2a3c6ffdc
6
+ metadata.gz: 3ff3dcde674bf0be28d1baec700a39d8db3a26a199c72fc467dd6864020d1608a1667e9feecb2e530706cf1a92b3a653cd7b904dd883cc4f495dc9027145c6c8
7
+ data.tar.gz: 4e3566d9c90076d1a9c4c46ee99e9d2415320fcfeb8be0d2acc1a03ac9168278362a8cfe1dd93b108e4db312c292f3436a4061e24b5cb211ec217445a942a254
data/CHANGELOG.md CHANGED
@@ -2,22 +2,58 @@
2
2
 
3
3
  All notable changes to bible270. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html), treating pre-1.0 minor bumps as the place breaking changes may land.
4
4
 
5
- ## Unreleased Changes
5
+ ## [Unreleased]
6
6
 
7
7
  ### Added
8
8
 
9
- - let administrators disable the day-completion dove animation for individual readers
9
+ - 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
10
12
 
11
13
  ### Changed
12
14
 
13
- - simplify day completion to one continuous server-confirmed dove flight from the lower center, eliminating the stagger between a pre-submit animation and the Turbo or HTML response
14
- - keep the dove bright in dark mode, grow it to an 80%-of-viewport fit, and finish with a quick fade instead of veering offscreen
15
+ - default new readers to emails for every new reflection and reply; existing reader preferences remain unchanged by the migration
15
16
 
16
17
  ### Fixed
17
18
 
18
- - show the dove only once for each persisted day-completion event, even when Turbo replays a response or retries a stale-session request
19
+ - 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
19
20
 
20
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...main)**
21
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.2...main)**
22
+
23
+ ## [1.4.2] - 2026-09-03
24
+
25
+ ### Added
26
+
27
+ - let administrators disable the day-completion dove animation for individual readers without affecting progress tracking; upgrading hosts must copy and run the new `completion_dove_disabled` reader migration before changing this setting
28
+
29
+ ### Changed
30
+
31
+ - keep the completion dove bright in dark mode, grow it to an 80%-of-viewport fit with a smoother rounded silhouette, and finish with a centered fade instead of veering offscreen
32
+ - show every reader who finished a day after the Reflections section, using compact 56px avatars ordered from earliest to latest completion
33
+
34
+ ### Fixed
35
+
36
+ - show the dove only once for each persisted day completion, including during concurrent final checkoffs, Turbo response replays, and stale-session retries
37
+
38
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.1...v1.4.2)**
39
+
40
+ ## [1.4.1] - 2026-09-02
41
+
42
+ ### Changed
43
+
44
+ - run the day-completion dove as one continuous, server-confirmed animation from the lower center, avoiding a stagger between submission and the Turbo or HTML response
45
+ - improve installer guidance for OmniAuth providers, deferred bundling, Active Storage, pending migrations, and OAuth callback setup
46
+ - detect existing engine mounts more reliably and move them above catch-all routes when necessary
47
+
48
+ ### Fixed
49
+
50
+ - reject invalid enrollment states and malformed or out-of-range admin day values instead of silently interpreting them
51
+ - apply admin profile name and avatar changes atomically, preventing partial updates when validation fails
52
+ - report broadcast queueing, delivery failures, and partial success accurately without recording an entirely failed broadcast as sent
53
+ - restrict reflection return redirects to safe application URLs
54
+ - honor the install generator's `--mailer-from` option, reject unsafe provider identifiers, and safely quote generated configuration values
55
+
56
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...v1.4.1)**
21
57
 
22
58
  ## [1.4.0] - 2026-09-02
23
59
 
@@ -631,6 +667,9 @@ This is the initial public release.
631
667
 
632
668
  * 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.
633
669
 
670
+ [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.4.2...HEAD
671
+ [1.4.2]: https://github.com/avonderluft/bible270/releases/tag/v1.4.2
672
+ [1.4.1]: https://github.com/avonderluft/bible270/releases/tag/v1.4.1
634
673
  [1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
635
674
  [1.3.2]: https://github.com/avonderluft/bible270/releases/tag/v1.3.2
636
675
  [1.3.1]: https://github.com/avonderluft/bible270/releases/tag/v1.3.1
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Bible 270
2
2
 
3
- [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![Coverage Status](https://coveralls.io/repos/github/avonderluft/bible270/badge.svg?branch=main&cache=20260812-1)](https://coveralls.io/github/avonderluft/bible270?branch=main) [![Downloads](https://img.shields.io/gem/dt/bible270)](https://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
3
+ [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/avonderluft/bible270?branch=main&cacheSeconds=300)](https://coveralls.io/github/avonderluft/bible270?branch=main) [![Downloads](https://img.shields.io/gem/dt/bible270)](https://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
4
4
 
5
5
  A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-media-surfer) CMS in mind. Readers tick off each day's readings, post reflections, and see how everyone else is getting on, much like the social plans on Bible.com.
6
6
 
@@ -283,12 +283,20 @@ filters, and paginates older threads. Signed-in readers see which conversations
283
283
  their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
284
284
  cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
285
285
  Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
286
- work without JavaScript.
286
+ work without JavaScript. A quiet **Formatting** disclosure opens a toolbar for bold, italic,
287
+ quotations, bulleted and numbered lists, and links without requiring readers to know Markdown syntax.
288
+ A separate **Preview** control checks the current draft through the same server-side sanitizer used
289
+ after posting; formatting tools stay out of sight until requested. The source remains
290
+ editable in the ordinary textarea, drafts preserve its format, and all rendered HTML is restricted to that small safe
291
+ set. Existing reflections remain literal plain text until a reader intentionally formats or converts
292
+ them. Upgrading hosts must copy and run migration `20260101000021`, which records whether each body is
293
+ plain text or Markdown; until then, the composer safely falls back to plain text.
287
294
 
288
295
  When `config.mention_notifications` is enabled, each reader can request email for every new reflection
289
296
  and reply, only replies to their reflections and mentions of them, or none. Administrators can change
290
297
  the same preference from the reader's Admin page. Migration
291
- `20260101000018` adds the broad opt-in without changing any existing reader's choice. Migration
298
+ `20260101000018` adds the broad opt-in without changing any existing reader's choice, while migration
299
+ `20260101000022` makes that broad option the default for readers created afterward. Migration
292
300
  `20260101000017` stores each reader's last visit to the Reflections page. After upgrading, install and
293
301
  run the current migrations:
294
302
 
@@ -297,6 +305,27 @@ bin/rails bible270:install:migrations
297
305
  bin/rails db:migrate
298
306
  ```
299
307
 
308
+ To change every existing reader to **Every new reflection and reply**, run:
309
+
310
+ ```bash
311
+ bin/rails bible270:notifications:enable_all
312
+ ```
313
+
314
+ With Docker Compose:
315
+
316
+ ```bash
317
+ docker compose exec -e RAILS_ENV=production rails bin/rails bible270:notifications:enable_all
318
+ ```
319
+
320
+ To print an aligned progress report ordered by completed days, run:
321
+
322
+ ```bash
323
+ docker compose exec -e RAILS_ENV=production rails bin/rails bible270:readers:list
324
+ ```
325
+
326
+ The report compares completed days with the calendar day implied by each reader's effective start date
327
+ and shows `on track`, days ahead or behind, or `undated` when no calendar applies.
328
+
300
329
  Break points can also live in your app rather than the gem, and be changed without a restart:
301
330
 
302
331
  ```ruby
@@ -483,7 +512,7 @@ The CMS serves your content; this is a separate mounted app. Either **just link
483
512
  |-------|-------|
484
513
  | `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation, passage-reader, and reminder preferences |
485
514
  | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`), unique-indexed |
486
- | `bible270_comments` | a reflection on a day, optionally scoped to a track, public to all |
515
+ | `bible270_comments` | a plain-text or safely rendered Markdown reflection on a day, optionally scoped to a track, public to all |
487
516
  | `bible270_sign_in_tokens` | magic-link tokens: email, **digest only**, expiry, consumed-at |
488
517
 
489
518
  A day is "complete" once all three tracks are ticked. Progress and comments are **public** by
@@ -496,6 +525,7 @@ GET / days#index overview + community + calendar
496
525
  GET /day/:day days#show readings, who's finished, reflections
497
526
  POST /day/:day/toggle/:track checkoffs#toggle
498
527
  POST /day/:day/comments comments#create
528
+ POST /comments/preview comments#preview
499
529
  DELETE /comments/:id comments#destroy
500
530
  GET /community readers#index reader directory
501
531
  GET /mention-suggestions readers#mention_suggestions (signed-in JSON)
@@ -11,6 +11,14 @@ module Bible270
11
11
  current_reader&.mark_reflections_seen!(visited_at)
12
12
  end
13
13
 
14
+ def preview
15
+ return unless require_reader!
16
+
17
+ attributes = { body: params.dig(:comment, :body).to_s }
18
+ attributes[:body_format] = CommentFormatter::MARKDOWN if Comment.body_format_column?
19
+ render partial: 'bible270/comments/body', locals: { comment: Comment.new(attributes) }
20
+ end
21
+
14
22
  def create
15
23
  return unless require_reader!
16
24
 
@@ -134,7 +142,9 @@ module Bible270
134
142
  end
135
143
 
136
144
  def comment_params
137
- params.require(:comment).permit(:body, :track, :parent_id)
145
+ permitted = %i[body track parent_id]
146
+ permitted << :body_format if Comment.body_format_column?
147
+ params.require(:comment).permit(*permitted)
138
148
  end
139
149
 
140
150
  # Scoped to the reader, so someone else's reflection is simply not found —
@@ -18,7 +18,7 @@ module Bible270
18
18
  @readings = Plan.readings_for(@day)
19
19
  @comments = Comment.threads_for_day(@day)
20
20
  @editing_comment_id = editing_comment_id
21
- @new_comment = Comment.new(day: @day, parent_id: reply_parent&.id)
21
+ @new_comment = Comment.new_for_composer(day: @day, parent_id: reply_parent&.id)
22
22
  @reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
23
23
  @day_just_completed = flash[:b270_day_just_completed].to_i == @day
24
24
  @completion_event_id = flash[:b270_completion_event_id] if @day_just_completed
@@ -53,40 +53,46 @@ module Bible270
53
53
  end
54
54
  end
55
55
 
56
- # The mark beside the title in the header. Larger than the favicon and inlined
57
- # as SVG, so it stays crisp at any size and needs no asset pipeline.
58
- # Renders "@handle" as a link to that reader. An unresolved or ambiguous handle
59
- # is left as plain text, which is how a writer discovers it found nobody.
60
- #
61
- # Built by walking the matches and escaping everything between them, rather
62
- # than escaping afterwards: the body is reader-supplied, so the only safe
63
- # construction is one where every non-link fragment is escaped by hand.
64
- def b270_with_mentions(body)
65
- text = body.to_s
66
- readers = Bible270::Reader.mentioned_in(text)
56
+ # Formats every reflection surface through the same sanitizer. Web views link
57
+ # resolved mentions; email can opt out because it has no mounted request path.
58
+ def b270_comment_body(comment, link_mentions: true)
59
+ format = comment.rendered_body_format
60
+ html = if link_mentions
61
+ readers = Bible270::Reader.mentioned_in(comment.body)
62
+ Bible270::CommentFormatter.html(comment.body, format: format) do |handle|
63
+ reader = readers.find { |candidate| candidate.answers_to?(handle) }
64
+ reader_path(reader) if reader
65
+ end
66
+ else
67
+ Bible270::CommentFormatter.html(comment.body, format: format)
68
+ end
69
+ html.html_safe
70
+ end
67
71
 
68
- pieces = []
69
- cursor = 0
72
+ def b270_comment_text(comment)
73
+ Bible270::CommentFormatter.text(comment.body, format: comment.rendered_body_format)
74
+ end
70
75
 
71
- text.to_enum(:scan, Bible270::Mentions::PATTERN).each do
72
- match = Regexp.last_match
73
- pieces << ERB::Util.html_escape(text[cursor...match.begin(0)])
76
+ def b270_editor_rows(body, columns: 48, minimum: 4)
77
+ rows = body.to_s.split("\n", -1).sum do |line|
78
+ [1, (line.length.to_f / columns).ceil].max
79
+ end
80
+ [minimum, rows].max
81
+ end
74
82
 
75
- handle = Bible270::Mentions.normalize(match[1])
83
+ # Backward-compatible plain-text entry point for host views that called the old
84
+ # mention helper directly.
85
+ def b270_with_mentions(body)
86
+ readers = Bible270::Reader.mentioned_in(body)
87
+ html = Bible270::CommentFormatter.html(body, format: Bible270::CommentFormatter::PLAIN) do |handle|
76
88
  reader = readers.find { |candidate| candidate.answers_to?(handle) }
77
- pieces << if reader
78
- link_to(match[0], reader_path(reader), class: 'b270-mention')
79
- else
80
- ERB::Util.html_escape(match[0])
81
- end
82
-
83
- cursor = match.end(0)
89
+ reader_path(reader) if reader
84
90
  end
85
-
86
- pieces << ERB::Util.html_escape(text[cursor..].to_s)
87
- safe_join(pieces)
91
+ html.html_safe
88
92
  end
89
93
 
94
+ # The mark beside the title in the header. Larger than the favicon and inlined
95
+ # as SVG, so it stays crisp at any size and needs no asset pipeline.
90
96
  def b270_mark(size: 38)
91
97
  configured = Bible270.config.header_mark
92
98
  return nil if configured == false
@@ -3,6 +3,7 @@
3
3
  module Bible270
4
4
  class ApplicationMailer < ActionMailer::Base
5
5
  default from: -> { Bible270.config.mailer_from }
6
+ helper Bible270::PlanHelper
6
7
  # `layout nil` means "normal lookup", which finds the host app's mailer layout and
7
8
  # nests our complete HTML document inside theirs. Only `false` disables it.
8
9
  layout false
@@ -11,6 +11,8 @@ module Bible270
11
11
  # the paper background; an outline glyph like U+2661 is drawn by the font
12
12
  # rather than the emoji set and needs hand-tuning to match.
13
13
  HEART = '❤️'
14
+ BODY_FORMAT_COLUMN = 'body_format'
15
+ BODY_FORMATS = CommentFormatter::FORMATS
14
16
  ThreadPage = Struct.new(:threads, :activity_by_id, :page, :pages, :total, keyword_init: true)
15
17
 
16
18
  self.table_name = 'bible270_comments'
@@ -26,6 +28,7 @@ module Bible270
26
28
 
27
29
  validates :day, inclusion: { in: 1..Plan::DAYS }
28
30
  validates :body, presence: true, length: { maximum: 4000 }
31
+ validates :body_format, inclusion: { in: BODY_FORMATS }, if: :body_format_column?
29
32
  # A reflection can be about one track or about the whole day. The form's
30
33
  # "The whole day" option submits an empty string, so normalise it to NULL
31
34
  # before validating — allow_nil alone rejects "" with
@@ -54,6 +57,23 @@ module Bible270
54
57
  }
55
58
  scope :recent, -> { approved.order(created_at: :desc) }
56
59
 
60
+ def self.body_format_column?
61
+ column_names.include?(BODY_FORMAT_COLUMN)
62
+ rescue ActiveRecord::StatementInvalid
63
+ false
64
+ end
65
+
66
+ def self.new_for_composer(attributes = {})
67
+ attributes = attributes.merge(BODY_FORMAT_COLUMN => CommentFormatter::MARKDOWN) if body_format_column?
68
+ new(attributes)
69
+ end
70
+
71
+ def body_format_column? = self.class.body_format_column?
72
+
73
+ def rendered_body_format
74
+ body_format_column? ? body_format.presence || CommentFormatter::PLAIN : CommentFormatter::PLAIN
75
+ end
76
+
57
77
  # One page of top-level reflections ordered by the newest activity in each
58
78
  # conversation. Loading only IDs and timestamps keeps older archives cheap to
59
79
  # sort, while the displayed page still eager-loads its readers, replies and likes.
@@ -22,7 +22,7 @@
22
22
  <% if comment.reply? %>· reply to <strong><%= comment.parent&.reader&.display_name || 'a deleted reflection' %></strong><% end %>
23
23
  <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
24
24
  </div>
25
- <p class="b270-cbody"><%= comment.body %></p>
25
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
26
26
  <div style="display:flex;gap:10px;align-items:center;margin-top:6px">
27
27
  <% if comment.hidden? %>
28
28
  <%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
@@ -0,0 +1,6 @@
1
+ <%# locals: (comment:) %>
2
+ <% if comment.body.to_s.strip.empty? %>
3
+ <p class="b270-hint">Nothing to preview yet.</p>
4
+ <% else %>
5
+ <%= b270_comment_body(comment) %>
6
+ <% end %>
@@ -32,7 +32,7 @@
32
32
  <% if editing %>
33
33
  <%= render partial: "bible270/comments/edit_fields", locals: { comment: comment } %>
34
34
  <% else %>
35
- <p class="b270-cbody"><%= b270_with_mentions(comment.body) %></p>
35
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
36
36
  <% end %>
37
37
 
38
38
  <%# Only a top-level reflection can be replied to: one level deep, so a reply
@@ -13,7 +13,8 @@
13
13
  <p class="b270-editerror"><%= comment.errors.full_messages.to_sentence %></p>
14
14
  <% end %>
15
15
 
16
- <%= f.text_area :body %>
16
+ <%= render "bible270/comments/editor", form: f, comment: comment,
17
+ label_text: (comment.reply? ? "Reply" : "Reflection") %>
17
18
  <div class="row">
18
19
  <% unless comment.reply? %>
19
20
  <%= f.select :track,
@@ -0,0 +1,57 @@
1
+ <%# locals: (form:, comment:, label_text:, placeholder: nil) %>
2
+ <% editor_key = comment.persisted? ? "edit-#{comment.id}" : "new" %>
3
+ <% textarea_id = "b270-comment-body-#{editor_key}" %>
4
+ <% options_id = "b270-mention-options-#{editor_key}" %>
5
+ <% write_id = "b270-comment-write-#{editor_key}" %>
6
+ <% preview_id = "b270-comment-preview-#{editor_key}" %>
7
+ <% drawer_id = "b270-comment-formatting-#{editor_key}" %>
8
+ <% markdown_available = Bible270::Comment.body_format_column? %>
9
+
10
+ <div class="b270-mention-composer" data-b270-mention-composer
11
+ data-suggestions-url="<%= mention_suggestions_path %>">
12
+ <div class="b270-markdown-editor" data-b270-markdown-editor data-b270-editor-key="<%= editor_key %>"
13
+ data-b270-preview-url="<%= comment_preview_path if markdown_available %>"
14
+ data-b270-formatting-open="false" data-b270-editor-mode="write">
15
+ <% if markdown_available %>
16
+ <div class="b270-editor-utilities" data-b270-editor-utilities hidden>
17
+ <button type="button" class="b270-editor-utility" aria-expanded="false"
18
+ aria-controls="<%= drawer_id %>" data-b270-formatting-toggle>
19
+ Formatting <span class="b270-format-chevron" aria-hidden="true">▾</span>
20
+ </button>
21
+ <button type="button" class="b270-editor-utility" aria-pressed="false"
22
+ aria-controls="<%= write_id %> <%= preview_id %>" data-b270-preview-toggle>Preview</button>
23
+ </div>
24
+ <div id="<%= drawer_id %>" class="b270-formatting-drawer" aria-hidden="true"
25
+ data-b270-formatting-drawer inert>
26
+ <div class="b270-formatting-drawer-inner">
27
+ <div class="b270-markdown-toolbar" role="toolbar" aria-label="Reflection formatting"
28
+ data-b270-markdown-toolbar>
29
+ <button type="button" data-b270-markdown-action="bold" aria-label="Bold selected text" title="Bold (Ctrl+B)"><span class="b270-format-glyph b270-format-bold" aria-hidden="true">B</span></button>
30
+ <button type="button" data-b270-markdown-action="italic" aria-label="Italicize selected text" title="Italic (Ctrl+I)"><span class="b270-format-glyph b270-format-italic" aria-hidden="true">I</span></button>
31
+ <button type="button" data-b270-markdown-action="quote" aria-label="Format as a quotation" title="Quotation">“ ”</button>
32
+ <button type="button" data-b270-markdown-action="bullet-list" aria-label="Format as a bulleted list" title="Bulleted list">• List</button>
33
+ <button type="button" data-b270-markdown-action="number-list" aria-label="Format as a numbered list" title="Numbered list">1. List</button>
34
+ <button type="button" data-b270-markdown-action="link" aria-label="Add a link" title="Link">Link</button>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <%= form.hidden_field :body_format, data: { b270_body_format: true } %>
39
+ <% end %>
40
+
41
+ <div id="<%= write_id %>" data-b270-write-panel>
42
+ <%= form.label :body, label_text, for: textarea_id, class: "b270-sr-only" %>
43
+ <%= form.text_area :body, id: textarea_id, placeholder: placeholder,
44
+ rows: (b270_editor_rows(comment.body) if comment.persisted?),
45
+ role: "combobox", autocomplete: "off", data: { b270_autosize: comment.persisted? },
46
+ aria: { autocomplete: "list", expanded: "false", haspopup: "listbox", controls: options_id } %>
47
+ </div>
48
+ <% if markdown_available %>
49
+ <div id="<%= preview_id %>" class="b270-markdown-preview b270-cbody"
50
+ aria-label="Reflection preview" aria-live="polite" data-b270-preview-panel hidden></div>
51
+ <% end %>
52
+ </div>
53
+
54
+ <div class="b270-mention-options" id="<%= options_id %>" role="listbox"
55
+ data-b270-mention-options hidden></div>
56
+ <p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
57
+ </div>
@@ -18,18 +18,9 @@
18
18
  </p>
19
19
  <% end %>
20
20
  <%= f.hidden_field :parent_id %>
21
- <div class="b270-mention-composer" data-b270-mention-composer
22
- data-suggestions-url="<%= mention_suggestions_path %>">
23
- <%= f.label :body, (posting_reply ? "Reply" : "Reflection"), class: "b270-sr-only" %>
24
- <%= f.text_area :body,
25
- placeholder: posting_reply ? "Write your reply…" : "Share a reflection on today's reading…",
26
- role: "combobox", autocomplete: "off",
27
- aria: { autocomplete: "list", expanded: "false", haspopup: "listbox",
28
- controls: "b270-mention-options" } %>
29
- <div class="b270-mention-options" id="b270-mention-options" role="listbox"
30
- data-b270-mention-options hidden></div>
31
- <p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
32
- </div>
21
+ <%= render "bible270/comments/editor", form: f, comment: comment,
22
+ label_text: (posting_reply ? "Reply" : "Reflection"),
23
+ placeholder: (posting_reply ? "Write your reply…" : "Share a reflection on today's reading…") %>
33
24
  <p class="b270-draft-status" data-b270-draft-status aria-live="polite"></p>
34
25
  <div class="row">
35
26
  <%= f.select :track,
@@ -10,5 +10,6 @@
10
10
  <% end %>
11
11
 
12
12
  <%= turbo_stream.replace "new_comment_form" do %>
13
- <%= render "bible270/comments/form", day: @day, comment: Bible270::Comment.new(day: @day), posted: true %>
13
+ <%= render "bible270/comments/form", day: @day,
14
+ comment: Bible270::Comment.new_for_composer(day: @day), posted: true %>
14
15
  <% end %>
@@ -27,6 +27,7 @@
27
27
  </div>
28
28
  <%= render "bible270/shared/comment_drafts" %>
29
29
  <%= render "bible270/shared/mention_typeahead" %>
30
+ <%= render "bible270/shared/markdown_toolbar" %>
30
31
  <% end %>
31
32
 
32
33
  <%= render "bible270/days/completers", day: @day, completers: @completers %>
@@ -10,7 +10,7 @@
10
10
  </p>
11
11
 
12
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) %>
13
+ <%= b270_comment_body(@comment, link_mentions: false) %>
14
14
  </blockquote>
15
15
 
16
16
  <% if @day_url %>
@@ -1,6 +1,6 @@
1
1
  <%= @introduction %>
2
2
 
3
- <%= @comment.body %>
3
+ <%= b270_comment_text(@comment) %>
4
4
 
5
5
  <% if @day_url %>
6
6
  Read it and reply: <%= @day_url %>
@@ -56,6 +56,7 @@
56
56
  const key = form.dataset.b270DraftKey;
57
57
  const body = form.querySelector("textarea[name='comment[body]']");
58
58
  const track = form.querySelector("select[name='comment[track]']");
59
+ const format = form.querySelector("[data-b270-body-format]");
59
60
  if (!key || !body) return;
60
61
 
61
62
  if (!body.value.trim()) {
@@ -65,8 +66,10 @@
65
66
  }
66
67
 
67
68
  const trackValue = track ? track.value : "";
69
+ const bodyFormat = format ? format.value : null;
68
70
  const current = readDraft(key);
69
- if (current && current.body === body.value && current.track === trackValue) {
71
+ if (current && current.body === body.value && current.track === trackValue &&
72
+ current.bodyFormat === bodyFormat) {
70
73
  setStatus(form, "Draft saved in this browser.");
71
74
  return;
72
75
  }
@@ -74,6 +77,7 @@
74
77
  const draft = {
75
78
  body: body.value,
76
79
  track: trackValue,
80
+ bodyFormat,
77
81
  savedAt: Date.now(),
78
82
  revision: `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`
79
83
  };
@@ -115,10 +119,14 @@
115
119
 
116
120
  const body = form.querySelector("textarea[name='comment[body]']");
117
121
  const track = form.querySelector("select[name='comment[track]']");
122
+ const format = form.querySelector("[data-b270-body-format]");
118
123
  if (body) body.value = draft.body;
119
124
  if (track && Array.from(track.options).some((option) => option.value === draft.track)) {
120
125
  track.value = draft.track;
121
126
  }
127
+ if (format) {
128
+ format.value = ["plain", "markdown"].includes(draft.bodyFormat) ? draft.bodyFormat : "plain";
129
+ }
122
130
  setStatus(form, "Draft restored from this browser.");
123
131
  }
124
132
 
@@ -0,0 +1,239 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270MarkdownToolbar) {
4
+ window.Bible270MarkdownToolbar.scan();
5
+ return;
6
+ }
7
+
8
+ const openEditors = new Set();
9
+
10
+ function editorKey(editor) {
11
+ return editor.dataset.b270EditorKey;
12
+ }
13
+
14
+ function select(textarea, start, finish) {
15
+ textarea.focus();
16
+ textarea.setSelectionRange(start, finish);
17
+ }
18
+
19
+ function resizeTextarea(textarea) {
20
+ if (!textarea || textarea.dataset.b270Autosize !== "true") return;
21
+
22
+ textarea.style.height = "auto";
23
+ const borders = textarea.offsetHeight - textarea.clientHeight;
24
+ textarea.style.height = `${textarea.scrollHeight + borders}px`;
25
+ }
26
+
27
+ function initializeAutosize(textarea) {
28
+ if (!textarea || textarea.dataset.b270Autosize !== "true" ||
29
+ textarea.dataset.b270AutosizeReady === "true") return;
30
+
31
+ textarea.dataset.b270AutosizeReady = "true";
32
+ textarea.addEventListener("input", () => resizeTextarea(textarea));
33
+ window.requestAnimationFrame(() => resizeTextarea(textarea));
34
+ }
35
+
36
+ function resizeEditors() {
37
+ document.querySelectorAll("textarea[data-b270-autosize='true']").forEach(resizeTextarea);
38
+ }
39
+
40
+ function update(editor, textarea, replacement, start, finish, selectedStart, selectedEnd) {
41
+ textarea.value = `${textarea.value.slice(0, start)}${replacement}${textarea.value.slice(finish)}`;
42
+ const format = editor.querySelector("[data-b270-body-format]");
43
+ if (format) format.value = "markdown";
44
+ select(textarea, start + selectedStart, start + selectedEnd);
45
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
46
+ }
47
+
48
+ function wrap(editor, textarea, before, after, placeholder) {
49
+ const start = textarea.selectionStart;
50
+ const finish = textarea.selectionEnd;
51
+ const selected = textarea.value.slice(start, finish) || placeholder;
52
+ const replacement = `${before}${selected}${after}`;
53
+ update(editor, textarea, replacement, start, finish, before.length, before.length + selected.length);
54
+ }
55
+
56
+ function prefixLines(editor, textarea, action) {
57
+ const selectionStart = textarea.selectionStart;
58
+ const selectionEnd = textarea.selectionEnd;
59
+ const start = textarea.value.lastIndexOf("\n", Math.max(selectionStart - 1, 0)) + 1;
60
+ const nextNewline = textarea.value.indexOf("\n", selectionEnd);
61
+ const finish = nextNewline === -1 ? textarea.value.length : nextNewline;
62
+ const source = textarea.value.slice(start, finish) || (action === "quote" ? "quoted text" : "list item");
63
+ const lines = source.split("\n");
64
+ const replacement = lines.map((line, index) => {
65
+ if (action === "quote") return `> ${line}`;
66
+ if (action === "number-list") return `${index + 1}. ${line}`;
67
+ return `- ${line}`;
68
+ }).join("\n");
69
+ update(editor, textarea, replacement, start, finish, 0, replacement.length);
70
+ }
71
+
72
+ function addLink(editor, textarea) {
73
+ const href = window.prompt("Link address (https://…)");
74
+ if (!href) return;
75
+
76
+ const start = textarea.selectionStart;
77
+ const finish = textarea.selectionEnd;
78
+ const selected = textarea.value.slice(start, finish) || "link text";
79
+ const replacement = `[${selected}](${href.trim()})`;
80
+ update(editor, textarea, replacement, start, finish, 1, selected.length + 1);
81
+ }
82
+
83
+ function requestPreview(editor, state) {
84
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
85
+ const preview = editor.querySelector("[data-b270-preview-panel]");
86
+ const url = editor.dataset.b270PreviewUrl;
87
+ if (!textarea || !preview || !url) return;
88
+
89
+ if (!textarea.value.trim()) {
90
+ preview.innerHTML = '<p class="b270-hint">Nothing to preview yet.</p>';
91
+ return;
92
+ }
93
+
94
+ if (state.abort) state.abort.abort();
95
+ state.abort = new AbortController();
96
+ preview.textContent = "Loading preview…";
97
+ const body = new URLSearchParams({ "comment[body]": textarea.value });
98
+ const token = document.querySelector("meta[name='csrf-token']")?.content;
99
+ const headers = {
100
+ Accept: "text/html",
101
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
102
+ };
103
+ if (token) headers["X-CSRF-Token"] = token;
104
+
105
+ fetch(url, {
106
+ method: "POST",
107
+ credentials: "same-origin",
108
+ cache: "no-store",
109
+ redirect: "error",
110
+ headers,
111
+ body: body.toString(),
112
+ signal: state.abort.signal
113
+ }).then((response) => {
114
+ if (!response.ok) throw new Error("Preview unavailable");
115
+ return response.text();
116
+ }).then((html) => {
117
+ preview.innerHTML = html;
118
+ }).catch((error) => {
119
+ if (error.name !== "AbortError") preview.textContent = "Preview unavailable. Return to Write and try again.";
120
+ });
121
+ }
122
+
123
+ function setFormattingOpen(editor, open, remember = true) {
124
+ const drawer = editor.querySelector("[data-b270-formatting-drawer]");
125
+ const toggle = editor.querySelector("[data-b270-formatting-toggle]");
126
+ if (!drawer || !toggle) return;
127
+
128
+ editor.dataset.b270FormattingOpen = open ? "true" : "false";
129
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
130
+ drawer.setAttribute("aria-hidden", open ? "false" : "true");
131
+ drawer.inert = !open;
132
+ if (!remember) return;
133
+
134
+ if (open) openEditors.add(editorKey(editor));
135
+ else openEditors.delete(editorKey(editor));
136
+ }
137
+
138
+ function showMode(editor, state, mode) {
139
+ const previewing = mode === "preview";
140
+ const writePanel = editor.querySelector("[data-b270-write-panel]");
141
+ const previewPanel = editor.querySelector("[data-b270-preview-panel]");
142
+ const previewToggle = editor.querySelector("[data-b270-preview-toggle]");
143
+ const formattingToggle = editor.querySelector("[data-b270-formatting-toggle]");
144
+ if (!writePanel || !previewPanel || !previewToggle || !formattingToggle) return;
145
+
146
+ editor.dataset.b270EditorMode = mode;
147
+ writePanel.hidden = previewing;
148
+ previewPanel.hidden = !previewing;
149
+ previewToggle.textContent = previewing ? "Write" : "Preview";
150
+ previewToggle.setAttribute("aria-pressed", previewing ? "true" : "false");
151
+ previewToggle.setAttribute("aria-label", previewing ? "Return to writing" : "Preview reflection");
152
+ formattingToggle.disabled = previewing;
153
+ if (previewing) {
154
+ setFormattingOpen(editor, false, false);
155
+ requestPreview(editor, state);
156
+ } else {
157
+ setFormattingOpen(editor, openEditors.has(editorKey(editor)), false);
158
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
159
+ resizeTextarea(textarea);
160
+ textarea?.focus();
161
+ }
162
+ }
163
+
164
+ function apply(editor, action) {
165
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
166
+ if (!textarea) return;
167
+
168
+ if (action === "bold") {
169
+ wrap(editor, textarea, "**", "**", "bold text");
170
+ } else if (action === "italic") {
171
+ wrap(editor, textarea, "*", "*", "italic text");
172
+ } else if (action === "link") {
173
+ addLink(editor, textarea);
174
+ } else {
175
+ prefixLines(editor, textarea, action);
176
+ }
177
+ }
178
+
179
+ function initialize(editor) {
180
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
181
+ if (!textarea) return;
182
+ initializeAutosize(textarea);
183
+ if (editor.dataset.b270MarkdownReady === "true") return;
184
+
185
+ const utilities = editor.querySelector("[data-b270-editor-utilities]");
186
+ const toolbar = editor.querySelector("[data-b270-markdown-toolbar]");
187
+ const drawer = editor.querySelector("[data-b270-formatting-drawer]");
188
+ const formattingToggle = editor.querySelector("[data-b270-formatting-toggle]");
189
+ const previewToggle = editor.querySelector("[data-b270-preview-toggle]");
190
+ if (!utilities || !toolbar || !drawer || !formattingToggle || !previewToggle) return;
191
+
192
+ editor.dataset.b270MarkdownReady = "true";
193
+ if (editor.closest("form")?.dataset.b270DraftPosted === "true") openEditors.delete(editorKey(editor));
194
+ const state = { abort: null };
195
+ utilities.hidden = false;
196
+ setFormattingOpen(editor, openEditors.has(editorKey(editor)), false);
197
+ formattingToggle.addEventListener("click", () => {
198
+ setFormattingOpen(editor, editor.dataset.b270FormattingOpen !== "true");
199
+ });
200
+ previewToggle.addEventListener("click", () => {
201
+ const mode = editor.dataset.b270EditorMode === "preview" ? "write" : "preview";
202
+ showMode(editor, state, mode);
203
+ });
204
+ toolbar.querySelectorAll("[data-b270-markdown-action]").forEach((button) => {
205
+ button.addEventListener("click", () => apply(editor, button.dataset.b270MarkdownAction));
206
+ });
207
+ toolbar.addEventListener("keydown", (event) => {
208
+ if (event.key !== "Escape") return;
209
+
210
+ event.preventDefault();
211
+ setFormattingOpen(editor, false);
212
+ formattingToggle.focus();
213
+ });
214
+ textarea.addEventListener("keydown", (event) => {
215
+ if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
216
+ const action = event.key.toLocaleLowerCase() === "b" ? "bold" :
217
+ event.key.toLocaleLowerCase() === "i" ? "italic" : null;
218
+ if (!action) return;
219
+
220
+ event.preventDefault();
221
+ apply(editor, action);
222
+ });
223
+ }
224
+
225
+ function scan() {
226
+ document.querySelectorAll("[data-b270-markdown-editor]").forEach(initialize);
227
+ }
228
+
229
+ scan();
230
+ new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
231
+ window.addEventListener("pageshow", () => {
232
+ scan();
233
+ resizeEditors();
234
+ });
235
+ window.addEventListener("resize", resizeEditors);
236
+ document.addEventListener("turbo:load", scan);
237
+ window.Bible270MarkdownToolbar = { scan };
238
+ })();
239
+ </script>
@@ -23,7 +23,7 @@
23
23
  <% if moderate && comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
24
24
  </div>
25
25
 
26
- <p class="b270-cbody"><%= comment.body %></p>
26
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
27
27
 
28
28
  <% if moderate %>
29
29
  <div style="display:flex;gap:10px;margin-top:6px">
@@ -256,7 +256,15 @@
256
256
  .b270-likes:active .b270-liketooltip{display:block}
257
257
  .b270-liker{display:block;white-space:nowrap}
258
258
  .b270-creply{margin-top:6px}
259
- .b270-cbody{margin:3px 0 0;white-space:pre-wrap}
259
+ .b270-cbody{margin:3px 0 0;line-height:1.2}
260
+ .b270-cbody p{margin:0;white-space:pre-wrap}
261
+ .b270-cbody p+p,.b270-cbody p+ul,.b270-cbody p+ol,.b270-cbody p+blockquote,
262
+ .b270-cbody ul+p,.b270-cbody ol+p,.b270-cbody blockquote+p{margin-top:8px}
263
+ .b270-cbody ul,.b270-cbody ol{margin:6px 0 0;padding-left:24px}
264
+ .b270-cbody li{margin:2px 0;white-space:pre-wrap}
265
+ .b270-cbody blockquote{margin:7px 0 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)}
266
+ .b270-cbody a{color:var(--teal);text-decoration:underline;text-decoration-color:var(--line)}
267
+ .b270-cbody a:hover{text-decoration-color:currentColor}
260
268
  .b270-editform{margin-top:6px}
261
269
  .b270-editform textarea{min-height:64px}
262
270
  .b270-editerror{margin:0 0 8px;font-size:13px;color:var(--oxblood)}
@@ -268,6 +276,27 @@
268
276
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
269
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}
270
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
+ .b270-form textarea[data-b270-autosize=false]{min-height:114px}
280
+ .b270-editor-utilities[hidden]{display:none}
281
+ .b270-editor-utilities{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 2px 5px}
282
+ .b270 .b270-editor-utility{padding:2px 0;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
283
+ .b270 .b270-editor-utility:hover,.b270 .b270-editor-utility:focus-visible{color:var(--gold)}
284
+ .b270 .b270-editor-utility:disabled{color:var(--faint);opacity:.55;cursor:default}
285
+ .b270-format-chevron{display:inline-block;margin-left:2px;transition:transform .14s ease}
286
+ .b270-markdown-editor[data-b270-formatting-open=true] .b270-format-chevron{transform:rotate(180deg)}
287
+ .b270-formatting-drawer{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .14s ease,opacity .1s ease}
288
+ .b270-markdown-editor[data-b270-formatting-open=true] .b270-formatting-drawer{grid-template-rows:1fr;opacity:1}
289
+ .b270-formatting-drawer-inner{min-height:0;overflow:hidden}
290
+ .b270-markdown-toolbar{display:flex;align-items:center;gap:2px;flex-wrap:wrap;padding:5px;border:1px solid var(--line);border-bottom:0;border-radius:10px 10px 0 0;background:var(--paper)}
291
+ .b270 .b270-markdown-toolbar button{min-height:32px;padding:5px 9px;border:0;border-radius:6px;background:transparent;color:var(--muted);font:inherit;font-size:12.5px;cursor:pointer}
292
+ .b270 .b270-markdown-toolbar button:hover,.b270 .b270-markdown-toolbar button:focus-visible{background:var(--card);color:var(--ink)}
293
+ .b270-format-glyph{display:inline-block;min-width:13px;font-family:Georgia,"Times New Roman",serif;font-size:18px;line-height:1}
294
+ .b270-format-bold{font-weight:700}
295
+ .b270-format-italic{font-style:italic;font-weight:600}
296
+ .b270-markdown-editor[data-b270-formatting-open=true] textarea{border-radius:0 0 10px 10px}
297
+ .b270-markdown-preview{min-height:76px;margin:0;padding:11px 13px;border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink)}
298
+ .b270-markdown-preview[hidden]{display:none}
299
+ .b270-markdown-preview>.b270-hint{margin:0}
271
300
  .b270-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
272
301
  .b270-mention-composer{position:relative}
273
302
  .b270-mention-options{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 4px);overflow:hidden;
data/config/routes.rb CHANGED
@@ -13,6 +13,7 @@ Bible270::Engine.routes.draw do
13
13
  constraints: { day: %r{\d+} }
14
14
  post 'day/:day/comments', to: 'comments#create', as: :day_comments,
15
15
  constraints: { day: %r{\d+} }
16
+ post 'comments/preview', to: 'comments#preview', as: :comment_preview
16
17
  post 'comments/:id/like', to: 'likes#toggle', as: :like_comment
17
18
  patch 'comments/:id', to: 'comments#update'
18
19
  delete 'comments/:id', to: 'comments#destroy', as: :comment
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddBodyFormatToBible270Comments < ActiveRecord::Migration[7.0]
4
+ def up
5
+ add_column :bible270_comments, :body_format, :string, null: false, default: 'plain'
6
+ end
7
+
8
+ def down
9
+ remove_column :bible270_comments, :body_format
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ChangeAllCommentNoticesDefaultToTrue < ActiveRecord::Migration[7.0]
4
+ def up
5
+ change_column_default :bible270_readers, :notify_on_all_comments, from: false, to: true
6
+ end
7
+
8
+ def down
9
+ change_column_default :bible270_readers, :notify_on_all_comments, from: true, to: false
10
+ end
11
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'kramdown'
5
+ require 'nokogiri'
6
+ require 'rails-html-sanitizer'
7
+ require 'bible270/mentions'
8
+
9
+ module Bible270
10
+ # Converts stored reflection source into a deliberately small, safe subset of
11
+ # HTML. Plain bodies remain literal; Markdown bodies gain only the formatting
12
+ # exposed by the reflection toolbar.
13
+ module CommentFormatter
14
+ PLAIN = 'plain'
15
+ MARKDOWN = 'markdown'
16
+ FORMATS = [PLAIN, MARKDOWN].freeze
17
+ ALLOWED_TAGS = %w[p br strong em blockquote ul ol li a].freeze
18
+ ALLOWED_ATTRIBUTES = %w[href rel].freeze
19
+ LINK_REL = 'nofollow ugc noopener'
20
+ SKIP_MENTIONS_IN = %w[a code pre].freeze
21
+
22
+ module_function
23
+
24
+ def html(body, format: PLAIN, &mention_path)
25
+ source = body.to_s
26
+ rendered = format == MARKDOWN ? markdown_html(source) : plain_html(source)
27
+ fragment = fragment_for(sanitize(rendered))
28
+ hard_wrap(fragment) if format == MARKDOWN
29
+ decorate_links(fragment)
30
+ link_mentions(fragment, &mention_path) if mention_path
31
+ fragment.to_html
32
+ end
33
+
34
+ def text(body, format: PLAIN)
35
+ return body.to_s if format != MARKDOWN
36
+
37
+ fragment = fragment_for(html(body, format: format))
38
+ fragment.css('a[href]').each do |link|
39
+ href = link['href'].to_s
40
+ next if href.empty? || link.text == href
41
+
42
+ link.add_next_sibling(Nokogiri::XML::Text.new(" (#{href})", fragment.document))
43
+ end
44
+ fragment.css('br').each do |break_node|
45
+ break_node.replace(Nokogiri::XML::Text.new("\n", fragment.document))
46
+ end
47
+ fragment.css('p, blockquote, li').each do |block|
48
+ block.add_next_sibling(Nokogiri::XML::Text.new("\n", fragment.document))
49
+ end
50
+ fragment.text.lines.map(&:strip).join("\n").gsub(%r{\n{3,}}, "\n\n").strip
51
+ end
52
+
53
+ def markdown_html(source)
54
+ Kramdown::Document.new(source).to_html
55
+ end
56
+ private_class_method :markdown_html
57
+
58
+ def plain_html(source)
59
+ paragraphs = source.split(%r{(?:\r?\n){2,}}, -1).filter_map do |paragraph|
60
+ next if paragraph.empty?
61
+
62
+ escaped = ERB::Util.html_escape(paragraph).gsub(%r{\r?\n}, "<br>\n")
63
+ "<p>#{escaped}</p>"
64
+ end
65
+ paragraphs.join("\n")
66
+ end
67
+ private_class_method :plain_html
68
+
69
+ def sanitize(rendered)
70
+ Rails::Html::SafeListSanitizer.new.sanitize(
71
+ rendered,
72
+ tags: ALLOWED_TAGS,
73
+ attributes: ALLOWED_ATTRIBUTES
74
+ )
75
+ end
76
+ private_class_method :sanitize
77
+
78
+ def fragment_for(rendered)
79
+ Nokogiri::HTML::DocumentFragment.parse(rendered)
80
+ end
81
+ private_class_method :fragment_for
82
+
83
+ def hard_wrap(fragment)
84
+ fragment.xpath('.//p//text() | .//li//text()').to_a.each do |node|
85
+ next unless node.text.include?("\n")
86
+
87
+ replacement = Nokogiri::HTML::DocumentFragment.parse('')
88
+ node.text.split("\n", -1).each_with_index do |line, index|
89
+ replacement.add_child(Nokogiri::XML::Node.new('br', fragment.document)) if index.positive?
90
+ replacement.add_child(Nokogiri::XML::Text.new(line, fragment.document))
91
+ end
92
+ node.replace(replacement)
93
+ end
94
+ end
95
+ private_class_method :hard_wrap
96
+
97
+ def decorate_links(fragment)
98
+ fragment.css('a:not([href])').each do |link|
99
+ link.replace(Nokogiri::XML::Text.new(link.text, fragment.document))
100
+ end
101
+ fragment.css('a[href]').each { |link| link['rel'] = LINK_REL }
102
+ end
103
+ private_class_method :decorate_links
104
+
105
+ def link_mentions(fragment)
106
+ text_nodes = fragment.xpath('.//text()').to_a
107
+ text_nodes.each do |node|
108
+ next if node.ancestors.any? { |ancestor| SKIP_MENTIONS_IN.include?(ancestor.name) }
109
+ next unless node.text.match?(Mentions::PATTERN)
110
+
111
+ replacement = Nokogiri::HTML::DocumentFragment.parse('')
112
+ cursor = 0
113
+ node.text.to_enum(:scan, Mentions::PATTERN).each do
114
+ match = Regexp.last_match
115
+ replacement.add_child(Nokogiri::XML::Text.new(node.text[cursor...match.begin(0)], fragment.document))
116
+ href = yield Mentions.normalize(match[1])
117
+ if href
118
+ link = Nokogiri::XML::Node.new('a', fragment.document)
119
+ link['href'] = href
120
+ link['class'] = 'b270-mention'
121
+ link['rel'] = LINK_REL
122
+ link.content = match[0]
123
+ replacement.add_child(link)
124
+ else
125
+ replacement.add_child(Nokogiri::XML::Text.new(match[0], fragment.document))
126
+ end
127
+ cursor = match.end(0)
128
+ end
129
+ replacement.add_child(Nokogiri::XML::Text.new(node.text[cursor..].to_s, fragment.document))
130
+ node.replace(replacement)
131
+ end
132
+ end
133
+ private_class_method :link_mentions
134
+ end
135
+ end
@@ -30,6 +30,8 @@ module Bible270
30
30
  'AddAllCommentNoticesToBible270Readers' => :check_all_comment_notices,
31
31
  'ChangePassageSourceDefaultToBibleCom' => :check_passage_source_default,
32
32
  'AddCompletionDoveDisabledToBible270Readers' => :check_completion_dove_disabled,
33
+ 'AddBodyFormatToBible270Comments' => :check_comment_body_format,
34
+ 'ChangeAllCommentNoticesDefaultToTrue' => :check_all_comment_notices_default,
33
35
  'CreateActiveStorageTables' => :check_active_storage
34
36
  }.freeze
35
37
 
@@ -209,6 +211,25 @@ module Bible270
209
211
  table_missing(:bible270_readers, columns: %i[completion_dove_disabled])
210
212
  end
211
213
 
214
+ def check_comment_body_format
215
+ missing = table_missing(:bible270_comments, columns: %i[body_format])
216
+ return missing if missing.any?
217
+
218
+ column = connection.columns(:bible270_comments).find { |candidate| candidate.name == 'body_format' }
219
+ missing = []
220
+ missing << 'column bible270_comments.body_format default plain' unless column&.default == 'plain'
221
+ missing << 'column bible270_comments.body_format null false' unless column && !column.null
222
+ missing
223
+ end
224
+
225
+ def check_all_comment_notices_default
226
+ missing = table_missing(:bible270_readers, columns: %i[notify_on_all_comments])
227
+ return missing if missing.any?
228
+
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']
231
+ end
232
+
212
233
  # The Bible270 installer can install Active Storage for reader avatars. A
213
234
  # copied database can therefore have the same timestamp mismatch for Rails'
214
235
  # generated Active Storage migration as it has for the engine migrations.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Builds the plain-text reader progress table used by administrative tasks.
5
+ class ReaderProgressReport
6
+ HEADERS = ['Name', 'Days Read', 'Status'].freeze
7
+ Row = Struct.new(:name, :days_read, :status, keyword_init: true)
8
+
9
+ def initialize(readers: Reader.all, completed_days: Reader.completed_days_by_id)
10
+ @readers = readers
11
+ @completed_days = completed_days
12
+ end
13
+
14
+ 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] }
21
+ end
22
+ end
23
+
24
+ def empty? = rows.empty?
25
+
26
+ def to_table
27
+ values = rows.map { |row| [row.name, row.days_read.to_s, row.status] }
28
+ widths = HEADERS.each_index.map do |index|
29
+ ([HEADERS[index].length] + values.map { |value| value[index].length }).max
30
+ end
31
+ format = "%-#{widths[0]}s %#{widths[1]}s %-#{widths[2]}s"
32
+ lines = [format % HEADERS, format % widths.map { |width| '-' * width }].map(&:rstrip)
33
+ lines.concat(values.map { |value| (format % value).rstrip })
34
+ lines.join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :readers, :completed_days
40
+
41
+ def status(reader, days_read)
42
+ calendar_day = reader.calendar_day
43
+ return 'undated' unless calendar_day
44
+
45
+ difference = days_read - calendar_day
46
+ return 'on track' if difference.zero?
47
+
48
+ amount = difference.abs
49
+ "#{amount} #{amount == 1 ? 'day' : 'days'} #{difference.positive? ? 'ahead' : 'behind'}"
50
+ end
51
+ end
52
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.4.2'
4
+ VERSION = '1.5.1'
5
5
  end
data/lib/bible270.rb CHANGED
@@ -7,11 +7,13 @@ require 'bible270/plan'
7
7
  require 'bible270/email_sign_in'
8
8
  require 'bible270/names'
9
9
  require 'bible270/mentions'
10
+ require 'bible270/comment_formatter'
10
11
  require 'bible270/avatars'
11
12
  require 'bible270/translations'
12
13
  require 'bible270/favicon'
13
14
  require 'bible270/configuration'
14
15
  require 'bible270/daily_reminders'
16
+ require 'bible270/reader_progress_report'
15
17
  require 'bible270/engine' if defined?(Rails::Engine)
16
18
 
17
19
  module Bible270
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :bible270 do
4
+ namespace :notifications do
5
+ desc 'Email every existing reader about each new reflection and reply'
6
+ task enable_all: :environment do
7
+ unless Bible270::Reader.comment_notification_columns?
8
+ abort 'Reflection email columns are missing. Run bin/rails bible270:install:migrations and bin/rails db:migrate.'
9
+ end
10
+
11
+ total = Bible270::Reader.count
12
+ puts "Found #{total} Bible270 #{'reader'.pluralize(total)} in the #{Rails.env} environment."
13
+ if total.zero?
14
+ puts 'No reader settings were changed.'
15
+ puts 'Rerun with RAILS_ENV=production if that is where your readers are stored.' unless Rails.env.production?
16
+ next
17
+ end
18
+
19
+ readers = Bible270::Reader.where(notify_on_mention: false)
20
+ .or(Bible270::Reader.where(notify_on_all_comments: false))
21
+ updated = 0
22
+
23
+ Bible270::Reader.transaction do
24
+ readers.find_each do |reader|
25
+ reader.update_comment_notification_level!('all')
26
+ updated += 1
27
+ end
28
+ end
29
+
30
+ if updated.zero?
31
+ puts "All #{total} #{'reader'.pluralize(total)} already receive every new reflection and reply."
32
+ else
33
+ puts "Updated #{updated} #{'reader'.pluralize(updated)}. " \
34
+ "All #{total} #{'reader'.pluralize(total)} now receive every new reflection and reply."
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bible270/reader_progress_report'
4
+
5
+ namespace :bible270 do
6
+ namespace :readers do
7
+ desc 'List readers by completed days with their schedule status'
8
+ task list: :environment do
9
+ report = Bible270::ReaderProgressReport.new
10
+ if report.empty?
11
+ puts "No Bible270 readers found in the #{Rails.env} environment."
12
+ else
13
+ puts report.to_table
14
+ end
15
+ end
16
+ end
17
+ 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.4.2
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -9,6 +9,40 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "<"
17
+ - !ruby/object:Gem::Version
18
+ version: '3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "<"
24
+ - !ruby/object:Gem::Version
25
+ version: '3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: kramdown
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.5'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '3'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '2.5'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '3'
12
46
  - !ruby/object:Gem::Dependency
13
47
  name: rails
14
48
  requirement: !ruby/object:Gem::Requirement
@@ -106,9 +140,11 @@ files:
106
140
  - app/views/bible270/admin/index.html.erb
107
141
  - app/views/bible270/admin/show.html.erb
108
142
  - app/views/bible270/checkoffs/toggle.turbo_stream.erb
143
+ - app/views/bible270/comments/_body.html.erb
109
144
  - app/views/bible270/comments/_comment.html.erb
110
145
  - app/views/bible270/comments/_edit_fields.html.erb
111
146
  - app/views/bible270/comments/_edit_form.html.erb
147
+ - app/views/bible270/comments/_editor.html.erb
112
148
  - app/views/bible270/comments/_form.html.erb
113
149
  - app/views/bible270/comments/create.turbo_stream.erb
114
150
  - app/views/bible270/comments/destroy.turbo_stream.erb
@@ -145,6 +181,7 @@ files:
145
181
  - app/views/bible270/shared/_header.html.erb
146
182
  - app/views/bible270/shared/_interaction_status.html.erb
147
183
  - app/views/bible270/shared/_interaction_ui.html.erb
184
+ - app/views/bible270/shared/_markdown_toolbar.html.erb
148
185
  - app/views/bible270/shared/_mention_typeahead.html.erb
149
186
  - app/views/bible270/shared/_nav_links.html.erb
150
187
  - app/views/bible270/shared/_passage_source.html.erb
@@ -176,8 +213,11 @@ files:
176
213
  - db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb
177
214
  - db/migrate/20260101000019_change_passage_source_default_to_bible_com.rb
178
215
  - db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb
216
+ - db/migrate/20260101000021_add_body_format_to_bible270_comments.rb
217
+ - db/migrate/20260101000022_change_all_comment_notices_default_to_true.rb
179
218
  - lib/bible270.rb
180
219
  - lib/bible270/avatars.rb
220
+ - lib/bible270/comment_formatter.rb
181
221
  - lib/bible270/configuration.rb
182
222
  - lib/bible270/daily_reminders.rb
183
223
  - lib/bible270/email_sign_in.rb
@@ -187,6 +227,7 @@ files:
187
227
  - lib/bible270/migration_reconciler.rb
188
228
  - lib/bible270/names.rb
189
229
  - lib/bible270/plan.rb
230
+ - lib/bible270/reader_progress_report.rb
190
231
  - lib/bible270/translations.rb
191
232
  - lib/bible270/versification.rb
192
233
  - lib/bible270/version.rb
@@ -194,6 +235,8 @@ files:
194
235
  - lib/generators/bible270/install/templates/bible270.rb.tt
195
236
  - lib/generators/bible270/install/templates/omniauth.rb.tt
196
237
  - lib/tasks/bible270_migrations.rake
238
+ - lib/tasks/bible270_notifications.rake
239
+ - lib/tasks/bible270_readers.rake
197
240
  - lib/tasks/bible270_reminders.rake
198
241
  homepage: https://github.com/avonderluft/bible270
199
242
  licenses: