bible270 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e566c5e5c2097bfcb768ef08108d115814096e552c768636b67f009a6c31532
4
- data.tar.gz: d11ec18971a487ef039492849da05543062b88841b80f79f3d2fa545ff674cbb
3
+ metadata.gz: f214ecf16725944cbd5ffd021f9ed2a6b17ba97ddc84d8ca0e8a8cc2e45b351c
4
+ data.tar.gz: 75746ab729d9f9f18681d0c1a65b7bca5474f141720a34e1de2a40494fc549be
5
5
  SHA512:
6
- metadata.gz: 067e9c103eb5688c04c4e2c352a225768d9d9d98e04a4b046c16a285b736adf313c9687e825a781f615199bac580a94104107b3f273947b2faeb378712a53b87
7
- data.tar.gz: f9c658c9e118f25c880ece18f7128b931fef51ddd6cc7d83042c3b18ede120f84398a09c1d34b028ec913ab6a19ad683f8824b28ed9174fecc61e9ac6bdc950e
6
+ metadata.gz: 44e3360ef501a381938a002b3bdf5d40c948e8783688374aeffb2df2bcc2248c683f16eec25c94f5b5278a6b059643d511a756dea53865be1dbc6f02420c394d
7
+ data.tar.gz: 10ec4cc9154f7d388c9adfdccb5906261436aab786d73fa676536a664733474587a6d0e53e10e7d01827768855e25d69fd0b680be936211dcd8d6cf844090403
data/CHANGELOG.md CHANGED
@@ -6,12 +6,14 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
6
6
 
7
7
  ### Added
8
8
 
9
+ - add a YouTube Video formatting button for reflections and replies, with optional video names, validated click-to-load players in previews and web views, privacy tooltips, new-tab YouTube links, no autoplay or remote thumbnails, and ordinary video links in notification emails
9
10
  - show Blue Letter Bible Scripture popups when readers hover over references in rendered reflections, replies, and Markdown previews; popups follow each reader's selected translation, handle original-language choices by testament, and rescan content added by Turbo without forcing a page reload
10
11
  - let administrators sort the reader list and Reader Stats report by most or least recent reading activity, while keeping readers with no activity at the end
11
12
 
12
13
  ### Changed
13
14
 
14
15
  - link reader email addresses directly to email composition and link recent-activity day labels to their Day pages with passage tooltips in Admin
16
+ - include each reader's last reading-activity date in the `bible270:readers:list` and Admin Reader Stats reports
15
17
 
16
18
  **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.6.2...main)**
17
19
 
data/README.md CHANGED
@@ -1,6 +1,5 @@
1
1
  # Bible 270
2
-
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)
2
+ [![Rails CI](https://github.com/avonderluft/bible270/actions/workflows/railsci.yml/badge.svg)](https://github.com/avonderluft/comfortable-media-surfer/actions/workflows/rubyonrails.yml) [![Test Coverage](https://img.shields.io/coverallsCoverage/github/avonderluft/bible270?branch=main&cacheSeconds=300)](https://coveralls.io/github/avonderluft/bible270?branch=main) [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![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
3
 
5
4
  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
5
 
@@ -288,7 +287,7 @@ their previous visit. Reflection and reply drafts are retained for up to 30 days
288
287
  cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
289
288
  Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
290
289
  work without JavaScript. A quiet **Formatting** disclosure opens a toolbar for bold, italic,
291
- quotations, bulleted and numbered lists, and links without requiring readers to know Markdown syntax.
290
+ quotations, bulleted and numbered lists, links, and YouTube videos without requiring readers to know Markdown syntax.
292
291
  A separate **Preview** control checks the current draft through the same server-side sanitizer used
293
292
  after posting; formatting tools stay out of sight until requested. The source remains
294
293
  editable in the ordinary textarea, drafts preserve its format, and all rendered HTML is restricted to that small safe
@@ -296,6 +295,31 @@ set. Existing reflections remain literal plain text until a reader intentionally
296
295
  them. Upgrading hosts must copy and run migration `20260101000021`, which records whether each body is
297
296
  plain text or Markdown; until then, the composer safely falls back to plain text.
298
297
 
298
+ The **Video** button accepts YouTube watch, share (`youtu.be`), Shorts, live, and embed URLs,
299
+ then asks for an optional video name. Leave it blank for **View video**, or name it to show
300
+ **View video ‘Name’**. The name is stored as the link text and is also used in notification emails.
301
+ It inserts an explicit Markdown link on its own paragraph, for example:
302
+
303
+ ```markdown
304
+ [YouTube video](https://www.youtube.com/watch?v=aB3_dE-fG12 "bible270-video")
305
+ ```
306
+
307
+ Preview and posted reflections/replies show a small **View video** card. No YouTube player or remote
308
+ thumbnail is requested until the reader loads the video; the player uses `www.youtube-nocookie.com`
309
+ and does not autoplay. Loading it shares the reader's IP address and request metadata with YouTube;
310
+ privacy-enhanced mode does not make playback anonymous. The button tooltip reads “Loads player and
311
+ shares data with YouTube”; the link tooltip reads “Opens YouTube in a new tab.” An **Open on YouTube** link opens in a new
312
+ tab/window with `rel="nofollow ugc noopener"`, and remains available when JavaScript is disabled or
313
+ the provider disallows embedding. Notification emails retain ordinary
314
+ links, and unmarked links never become players automatically. Only validated YouTube URLs become
315
+ cards; unsupported marked links remain links. No uploads, API keys, extra gems, or database migrations
316
+ are needed for this feature. Shared links' start times and playlist parameters are not retained.
317
+
318
+ Hosts using Content Security Policy must add `https://www.youtube-nocookie.com` to their existing
319
+ `frame-src` directive. Do not replace the rest of the host's policy or broadly allow arbitrary frames.
320
+ Hosts with a custom engine layout should also render `bible270/shared/video_embeds` once, alongside the
321
+ other shared scripts, to enable click-to-load playback.
322
+
299
323
  When `config.mention_notifications` is enabled, each reader can request email for every new reflection
300
324
  and reply, only replies to their reflections and mentions of them, or none. Broad notifications are sent
301
325
  once with eligible subscribers in BCC; direct reply and mention notices remain individually addressed.
@@ -633,6 +657,7 @@ The engine's CSS rides in a partial its own layout renders. Using your layout? A
633
657
  ```bash
634
658
  bundle install
635
659
  bundle exec rake test
660
+ node --test test/javascript/*.cjs
636
661
  bundle exec rubocop --cache false
637
662
  ```
638
663
 
@@ -644,6 +669,11 @@ subsequent balancing. Set `PARALLEL_WORKERS` to tune the worker count. Use
644
669
  `bundle exec rake test:serial` when a serial coverage run is specifically needed; it writes to the same
645
670
  project-local `coverage/` directory.
646
671
 
672
+ The video UI tests use Node.js 22+ and its built-in test runner, with no npm dependencies. They run the
673
+ actual toolbar/player scripts against a small mocked DOM and also run as a separate CI job. They do
674
+ not contact YouTube; live playback should be checked in a browser on the deployed host, especially
675
+ when a host supplies a Content Security Policy.
676
+
647
677
  ### Get an email link for local testing
648
678
 
649
679
  in the rails console: `rails c`
@@ -53,18 +53,18 @@ module Bible270
53
53
  end
54
54
  end
55
55
 
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)
56
+ # Formats every reflection surface through the same sanitizer. Emails opt out
57
+ # of interactive video cards and mention links requiring a mounted request path.
58
+ def b270_comment_body(comment, link_mentions: true, embed_videos: true)
59
59
  format = comment.rendered_body_format
60
60
  html = if link_mentions
61
61
  readers = Bible270::Reader.mentioned_in(comment.body)
62
- Bible270::CommentFormatter.html(comment.body, format: format) do |handle|
62
+ Bible270::CommentFormatter.html(comment.body, format: format, embed_videos: embed_videos) do |handle|
63
63
  reader = readers.find { |candidate| candidate.answers_to?(handle) }
64
64
  reader_path(reader) if reader
65
65
  end
66
66
  else
67
- Bible270::CommentFormatter.html(comment.body, format: format)
67
+ Bible270::CommentFormatter.html(comment.body, format: format, embed_videos: embed_videos)
68
68
  end
69
69
  html.html_safe
70
70
  end
@@ -32,6 +32,7 @@
32
32
  <button type="button" data-b270-markdown-action="bullet-list" aria-label="Format as a bulleted list" title="Bulleted list">• List</button>
33
33
  <button type="button" data-b270-markdown-action="number-list" aria-label="Format as a numbered list" title="Numbered list">1. List</button>
34
34
  <button type="button" data-b270-markdown-action="link" aria-label="Add a link" title="Link">Link</button>
35
+ <button type="button" data-b270-markdown-action="video" aria-label="Add a YouTube video" title="YouTube video">Video</button>
35
36
  </div>
36
37
  </div>
37
38
  </div>
@@ -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
- <%= b270_comment_body(@comment, link_mentions: false) %>
13
+ <%= b270_comment_body(@comment, link_mentions: false, embed_videos: false) %>
14
14
  </blockquote>
15
15
 
16
16
  <% if @day_url %>
@@ -105,6 +105,44 @@
105
105
  update(editor, textarea, replacement, start, finish, 1, selected.length + 1);
106
106
  }
107
107
 
108
+ function youtubeVideoId(href) {
109
+ if (/[\s\x00-\x1f\x7f]/.test(href) || /%(?![0-9a-f]{2})/i.test(href)) return null;
110
+ let url;
111
+ try { url = new URL(href); } catch { return null; }
112
+ const hosts = ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"];
113
+ if (!/^https?:\/\//i.test(href) || !hosts.includes(url.hostname) || url.username || url.password || url.port) return null;
114
+
115
+ let id;
116
+ if (url.hostname === "youtu.be" || url.hostname === "www.youtu.be") id = url.pathname.slice(1);
117
+ else if (url.pathname === "/watch") {
118
+ const ids = url.searchParams.getAll("v");
119
+ if (ids.length === 1) id = ids[0];
120
+ } else id = url.pathname.match(/^\/(?:shorts|live|embed)\/([A-Za-z0-9_-]{11})$/)?.[1];
121
+ return /^[A-Za-z0-9_-]{11}$/.test(id || "") ? id : null;
122
+ }
123
+
124
+ function addVideo(editor, textarea) {
125
+ const href = window.prompt("YouTube video address (https://…)");
126
+ if (!href) return;
127
+ const id = youtubeVideoId(href.trim());
128
+ if (!id) {
129
+ window.alert("Enter a valid YouTube video URL. Use Link for other video sites.");
130
+ return;
131
+ }
132
+
133
+ const name = window.prompt("Video name (optional — leave blank for View video)");
134
+ if (name === null) return;
135
+ // Entity-encode punctuation so a name stays text, not Markdown or HTML.
136
+ const label = (name.trim().replace(/\s+/g, " ") || "YouTube video")
137
+ .replace(/[!-/:-@\[-`{-~]/g, (character) => `&#${character.codePointAt(0)};`);
138
+ const start = textarea.selectionStart;
139
+ const finish = textarea.selectionEnd;
140
+ const marker = `[${label}](https://www.youtube.com/watch?v=${id} "bible270-video")`;
141
+ const before = start > 0 ? "\n\n" : "";
142
+ const replacement = `${before}${marker}\n\n`;
143
+ update(editor, textarea, replacement, start, finish, replacement.length, replacement.length);
144
+ }
145
+
108
146
  function requestPreview(editor, state) {
109
147
  const textarea = editor.querySelector("textarea[name='comment[body]']");
110
148
  const preview = editor.querySelector("[data-b270-preview-panel]");
@@ -181,6 +219,7 @@
181
219
  setFormattingOpen(editor, false, false);
182
220
  requestPreview(editor, state);
183
221
  } else {
222
+ window.Bible270Videos?.reset(previewPanel);
184
223
  setFormattingOpen(editor, openEditors.has(editorKey(editor)), false);
185
224
  const textarea = editor.querySelector("textarea[name='comment[body]']");
186
225
  resizeTextarea(textarea);
@@ -198,6 +237,8 @@
198
237
  wrap(editor, textarea, "*", "*", "italic text");
199
238
  } else if (action === "link") {
200
239
  addLink(editor, textarea);
240
+ } else if (action === "video") {
241
+ addVideo(editor, textarea);
201
242
  } else {
202
243
  prefixLines(editor, textarea, action);
203
244
  }
@@ -265,6 +265,12 @@
265
265
  .b270-cbody blockquote{margin:7px 0 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)}
266
266
  .b270-cbody a{color:var(--teal);text-decoration:underline;text-decoration-color:var(--line)}
267
267
  .b270-cbody a:hover{text-decoration-color:currentColor}
268
+ .b270-video{margin:10px 0;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--paper);width:100%;box-sizing:border-box;white-space:normal}
269
+
270
+ .b270 .b270-video button{padding:7px 12px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--ink);font:inherit;cursor:pointer}
271
+ .b270-video button[hidden]{display:none}
272
+ .b270-video iframe{display:block;width:100%;aspect-ratio:16/9;border:0;border-radius:6px}
273
+ .b270-video a{display:block;margin-top:8px;font-size:13px}
268
274
  .b270-editform{margin-top:6px}
269
275
  .b270-editform textarea{min-height:64px}
270
276
  .b270-editerror{margin:0 0 8px;font-size:13px;color:var(--oxblood)}
@@ -0,0 +1,46 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270Videos) {
4
+ window.Bible270Videos.scan();
5
+ return;
6
+ }
7
+
8
+ function scan() {
9
+ document.querySelectorAll("[data-b270-video-load]").forEach((button) => {
10
+ button.hidden = !!button.closest(".b270-video")?.querySelector("iframe");
11
+ });
12
+ }
13
+
14
+ function reset(root = document) {
15
+ root.querySelectorAll(".b270-video iframe").forEach((frame) => frame.remove());
16
+ root.querySelectorAll("[data-b270-video-load]").forEach((button) => {
17
+ button.hidden = false;
18
+ });
19
+ }
20
+
21
+ document.addEventListener("click", (event) => {
22
+ const button = event.target.closest?.("[data-b270-video-load]");
23
+ if (!button) return;
24
+ const card = button.closest("[data-b270-video-id]");
25
+ const id = card?.dataset.b270VideoId;
26
+ if (!/^[A-Za-z0-9_-]{11}$/.test(id || "") || card.querySelector("iframe")) return;
27
+
28
+ const frame = document.createElement("iframe");
29
+ frame.src = `https://www.youtube-nocookie.com/embed/${id}?autoplay=0`;
30
+ frame.title = "YouTube video player";
31
+ frame.allow = "encrypted-media; fullscreen; picture-in-picture";
32
+ frame.allowFullscreen = true;
33
+ // YouTube requires an origin referrer to identify the embedding site.
34
+ frame.referrerPolicy = "strict-origin-when-cross-origin";
35
+ card.insertBefore(frame, button);
36
+ button.hidden = true;
37
+ frame.focus();
38
+ });
39
+
40
+ scan();
41
+ new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
42
+ document.addEventListener("turbo:load", scan);
43
+ document.addEventListener("turbo:before-cache", () => reset());
44
+ window.Bible270Videos = { scan, reset };
45
+ })();
46
+ </script>
@@ -24,5 +24,6 @@
24
24
  <%= b270_footer %>
25
25
  </div>
26
26
  <%= render "bible270/shared/scripture_popups" %>
27
+ <%= render "bible270/shared/video_embeds" %>
27
28
  </body>
28
29
  </html>
@@ -5,6 +5,7 @@ require 'kramdown'
5
5
  require 'nokogiri'
6
6
  require 'rails-html-sanitizer'
7
7
  require 'bible270/mentions'
8
+ require 'bible270/youtube_video'
8
9
 
9
10
  module Bible270
10
11
  # Converts stored reflection source into a deliberately small, safe subset of
@@ -15,22 +16,28 @@ module Bible270
15
16
  MARKDOWN = 'markdown'
16
17
  FORMATS = [PLAIN, MARKDOWN].freeze
17
18
  ALLOWED_TAGS = %w[p br strong em blockquote ul ol li a].freeze
18
- ALLOWED_ATTRIBUTES = %w[href rel].freeze
19
+ ALLOWED_ATTRIBUTES = %w[href rel title].freeze
20
+ VIDEO_MARKER = 'bible270-video'
21
+ VIDEO_PRIVACY = 'Loads player and shares data with YouTube'
19
22
  LINK_REL = 'nofollow ugc noopener'
20
23
  URL_PATTERN = %r{https?://[^\s<>"]*[^\s<>".,;:!?)\]\}]}i
21
- SKIP_MENTIONS_IN = %w[a code pre].freeze
24
+ SKIP_MENTIONS_IN = %w[a button code pre].freeze
22
25
 
23
26
  module_function
24
27
 
25
- def html(body, format: PLAIN, &mention_path)
28
+ def html(body, format: PLAIN, embed_videos: false, &mention_path)
26
29
  source = body.to_s
27
30
  rendered = format == MARKDOWN ? markdown_html(source) : plain_html(source)
28
31
  fragment = fragment_for(sanitize(rendered))
29
32
  if format == MARKDOWN
33
+ replace_video_markers(fragment) if embed_videos
30
34
  normalize_lists(fragment)
31
35
  hard_wrap(fragment)
32
36
  link_urls(fragment)
33
37
  end
38
+ fragment.css('[title]').each { |node| node.remove_attribute('title') }
39
+ fragment.css('.b270-video button').each { |node| node['title'] = VIDEO_PRIVACY }
40
+ fragment.css('.b270-video a').each { |node| node['title'] = 'Opens YouTube in a new tab.' }
34
41
  decorate_links(fragment)
35
42
  link_mentions(fragment, &mention_path) if mention_path
36
43
  fragment.to_html
@@ -135,6 +142,43 @@ module Bible270
135
142
  end
136
143
  private_class_method :link_urls
137
144
 
145
+ def replace_video_markers(fragment)
146
+ fragment.css('p').each do |paragraph|
147
+ children = paragraph.children.reject { |node| node.text? && node.text.strip.empty? }
148
+ next unless children.one?
149
+
150
+ link = children.first
151
+ next unless link.name == 'a' && link['title'] == VIDEO_MARKER
152
+
153
+ id = YouTubeVideo.id_from_url(link['href'].to_s)
154
+ paragraph.replace(video_card(id, fragment.document, name: link.text.strip)) if id
155
+ end
156
+ end
157
+ private_class_method :replace_video_markers
158
+
159
+ # These elements are generated only after sanitization; they are deliberately
160
+ # not part of the allowlist for user-supplied HTML.
161
+ def video_card(id, document, name:)
162
+ card = Nokogiri::XML::Node.new('div', document)
163
+ card['class'] = 'b270-video'
164
+ card['data-b270-video-id'] = id
165
+
166
+ button = Nokogiri::XML::Node.new('button', document)
167
+ button['type'] = 'button'
168
+ button['data-b270-video-load'] = ''
169
+ button['hidden'] = 'hidden'
170
+ button.content = name.empty? || name == 'YouTube video' ? 'View video' : "View video ‘#{name}’"
171
+ card.add_child(button)
172
+
173
+ link = Nokogiri::XML::Node.new('a', document)
174
+ link['href'] = "https://www.youtube.com/watch?v=#{id}"
175
+ link['target'] = '_blank'
176
+ link.content = 'Open on YouTube'
177
+ card.add_child(link)
178
+ card
179
+ end
180
+ private_class_method :video_card
181
+
138
182
  def decorate_links(fragment)
139
183
  fragment.css('a:not([href])').each do |link|
140
184
  link.replace(Nokogiri::XML::Text.new(link.text, fragment.document))
@@ -3,18 +3,18 @@
3
3
  module Bible270
4
4
  # Builds the plain-text reader progress table used by administrative tasks.
5
5
  class ReaderProgressReport
6
- HEADERS = ['Name', 'Days Read', 'Status'].freeze
6
+ HEADERS = ['Name', 'Days Read', 'Status', 'Last Activity'].freeze
7
7
  SORTS = %w[
8
8
  first_name last_name most_completed least_completed most_recent_activity least_recent_activity
9
9
  ].freeze
10
10
  DEFAULT_SORT = 'most_completed'
11
- Row = Struct.new(:name, :days_read, :status, keyword_init: true)
11
+ Row = Struct.new(:name, :days_read, :status, :last_activity, keyword_init: true)
12
12
 
13
- def initialize(readers: Reader.all, completed_days: Reader.completed_days_by_id, recent_activities: {},
13
+ def initialize(readers: Reader.all, completed_days: Reader.completed_days_by_id, recent_activities: nil,
14
14
  sort: DEFAULT_SORT)
15
- @readers = readers
15
+ @readers = readers.to_a
16
16
  @completed_days = completed_days
17
- @recent_activities = recent_activities
17
+ @recent_activities = recent_activities || Checkoff.recent_activity_by_reader(@readers.map(&:id))
18
18
  @sort = SORTS.include?(sort.to_s) ? sort.to_s : DEFAULT_SORT
19
19
  end
20
20
 
@@ -25,18 +25,23 @@ module Bible270
25
25
  def rows
26
26
  @rows ||= sorted_readers.map do |reader|
27
27
  days_read = completed_days.fetch(reader.id, 0)
28
- Row.new(name: reader.display_name, days_read: days_read, status: status(reader, days_read))
28
+ Row.new(
29
+ name: reader.display_name,
30
+ days_read: days_read,
31
+ status: status(reader, days_read),
32
+ last_activity: last_activity_date(reader)
33
+ )
29
34
  end
30
35
  end
31
36
 
32
37
  def empty? = rows.empty?
33
38
 
34
39
  def to_table
35
- values = rows.map { |row| [row.name, row.days_read.to_s, row.status] }
40
+ values = rows.map { |row| [row.name, row.days_read.to_s, row.status, row.last_activity] }
36
41
  widths = HEADERS.each_index.map do |index|
37
42
  ([HEADERS[index].length] + values.map { |value| value[index].length }).max
38
43
  end
39
- format = "%-#{widths[0]}s %#{widths[1]}s %-#{widths[2]}s"
44
+ format = "%-#{widths[0]}s %#{widths[1]}s %-#{widths[2]}s %-#{widths[3]}s"
40
45
  lines = [format % HEADERS, format % widths.map { |width| '-' * width }].map(&:rstrip)
41
46
  lines.concat(values.map { |value| (format % value).rstrip })
42
47
  lines.join("\n")
@@ -70,6 +75,11 @@ module Bible270
70
75
  [activity.nil? ? 1 : 0, descending && timestamp ? -timestamp : timestamp.to_f, reader.sort_name, reader.id]
71
76
  end
72
77
 
78
+ def last_activity_date(reader)
79
+ occurred_at = recent_activities[reader.id]&.occurred_at
80
+ occurred_at ? Bible270.local_time(occurred_at).strftime('%b %-d, %Y') : '—'
81
+ end
82
+
73
83
  def status(reader, days_read)
74
84
  calendar_day = reader.calendar_day
75
85
  return 'undated' unless calendar_day
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.7.2'
4
+ VERSION = '1.8.0'
5
5
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Bible270
6
+ module YouTubeVideo
7
+ HOSTS = %w[youtube.com www.youtube.com m.youtube.com youtu.be www.youtu.be].freeze
8
+ SHORT_HOSTS = %w[youtu.be www.youtu.be].freeze
9
+ ID_PATTERN = %r{\A[A-Za-z0-9_-]{11}\z}
10
+
11
+ module_function
12
+
13
+ def id_from_url(url)
14
+ return if url.match?(%r{[[:space:][:cntrl:]]})
15
+
16
+ uri = URI.parse(url)
17
+ return unless uri.is_a?(URI::HTTP) && HOSTS.include?(uri.host&.downcase)
18
+ return if uri.userinfo || url.match?(%r{%(?![0-9a-f]{2})}i)
19
+ # Match the authority literally too: URI accepts empty ports and normalizes
20
+ # explicit default ports, which would otherwise conceal malformed input.
21
+ return unless url.match?(%r{\Ahttps?://#{Regexp.escape(uri.host)}(?::#{uri.default_port})?/}i)
22
+
23
+ id = candidate_id(uri)
24
+ id if id&.match?(ID_PATTERN)
25
+ rescue URI::InvalidURIError, ArgumentError
26
+ nil
27
+ end
28
+
29
+ def candidate_id(uri)
30
+ if SHORT_HOSTS.include?(uri.host.downcase)
31
+ uri.path.delete_prefix('/')
32
+ elsif uri.path == '/watch'
33
+ ids = URI.decode_www_form(uri.query.to_s).filter_map { |key, value| value if key == 'v' }
34
+ ids.first if ids.one?
35
+ else
36
+ uri.path.match(%r{\A/(?:shorts|live|embed)/([A-Za-z0-9_-]{11})\z})&.[](1)
37
+ end
38
+ end
39
+ private_class_method :candidate_id
40
+ end
41
+ end
@@ -4,7 +4,7 @@ require 'bible270/reader_progress_report'
4
4
 
5
5
  namespace :bible270 do
6
6
  namespace :readers do
7
- desc 'List readers by completed days with their schedule status'
7
+ desc 'List readers by completed days with their schedule status and last activity'
8
8
  task list: :environment do
9
9
  report = Bible270::ReaderProgressReport.new
10
10
  if report.empty?
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.7.2
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -190,6 +190,7 @@ files:
190
190
  - app/views/bible270/shared/_scripture_popups.html.erb
191
191
  - app/views/bible270/shared/_styles.html.erb
192
192
  - app/views/bible270/shared/_track_bars.html.erb
193
+ - app/views/bible270/shared/_video_embeds.html.erb
193
194
  - app/views/bible270/sign_in_mailer/magic_link.html.erb
194
195
  - app/views/bible270/sign_in_mailer/magic_link.text.erb
195
196
  - app/views/layouts/bible270/application.html.erb
@@ -232,6 +233,7 @@ files:
232
233
  - lib/bible270/translations.rb
233
234
  - lib/bible270/versification.rb
234
235
  - lib/bible270/version.rb
236
+ - lib/bible270/youtube_video.rb
235
237
  - lib/generators/bible270/install/install_generator.rb
236
238
  - lib/generators/bible270/install/templates/bible270.rb.tt
237
239
  - lib/generators/bible270/install/templates/omniauth.rb.tt