diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index b806582..2463ff0 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run tests run: | @@ -42,24 +42,24 @@ jobs: status: FAILED color: danger - # notify_slack: - # name: Notify slack of build - # runs-on: ubuntu-latest - # needs: - # - test - # outputs: - # slack_message_id: ${{ steps.slack.outputs.message_id }} - # steps: - # - name: Notify slack - build starting - # uses: zuplo/github-action-slack-notify-build@v2 - # if: success() - # id: slack - # env: - # SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - # with: - # channel: build - # status: STARTING - # color: warning + notify_slack: + name: Notify slack of build + runs-on: ubuntu-latest + needs: + - test + outputs: + slack_message_id: ${{ steps.slack.outputs.message_id }} + steps: + - name: Notify slack - build starting + uses: zuplo/github-action-slack-notify-build@v2 + if: success() + id: slack + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + with: + channel: build + status: STARTING + color: warning determine_environment: name: Determine environment @@ -68,7 +68,7 @@ jobs: environment: ${{ steps.set_environment.outputs.environment }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set environment id: set_environment @@ -120,7 +120,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref_name == 'master' }} type=raw,value=staging,enable=${{ needs.determine_environment.outputs.environment == 'staging' }} - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Docker Buildx id: buildx @@ -158,16 +158,16 @@ jobs: if: ${{ always() }} run: docker logout - # - name: Notify slack - build result failed - # uses: zuplo/github-action-slack-notify-build@v2 - # if: failure() - # env: - # SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - # with: - # message_id: ${{ needs.notify_slack.outputs.slack_message_id }} - # channel: build - # status: FAILED - # color: danger + - name: Notify slack - build result failed + uses: zuplo/github-action-slack-notify-build@v2 + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + with: + message_id: ${{ needs.notify_slack.outputs.slack_message_id }} + channel: build + status: FAILED + color: danger # Join the two layers back together merge: @@ -218,24 +218,24 @@ jobs: if: ${{ always() }} run: docker logout - # - name: Notify slack - build result successful - # uses: zuplo/github-action-slack-notify-build@v2 - # if: success() - # env: - # SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - # with: - # message_id: ${{ needs.notify_slack.outputs.slack_message_id }} - # channel: build - # status: SUCCESS - # color: good - - # - name: Notify slack - build result failed - # uses: zuplo/github-action-slack-notify-build@v2 - # if: failure() - # env: - # SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} - # with: - # message_id: ${{ needs.notify_slack.outputs.slack_message_id }} - # channel: build - # status: FAILED - # color: danger + - name: Notify slack - build result successful + uses: zuplo/github-action-slack-notify-build@v2 + if: success() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + with: + message_id: ${{ needs.notify_slack.outputs.slack_message_id }} + channel: build + status: SUCCESS + color: good + + - name: Notify slack - build result failed + uses: zuplo/github-action-slack-notify-build@v2 + if: failure() + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + with: + message_id: ${{ needs.notify_slack.outputs.slack_message_id }} + channel: build + status: FAILED + color: danger diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2e1cac..75ca797 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Run tests run: | @@ -23,7 +23,7 @@ jobs: fi - name: Notify slack - test results - uses: voxmedia/github-action-slack-notify-build@v1 + uses: voxmedia/github-action-slack-notify-build@v2 if: failure() env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/Gemfile.lock b/Gemfile.lock index 580dfb6..96f74a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -27,8 +27,8 @@ GEM public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) aws-eventstream (1.4.0) - aws-partitions (1.1255.0) - aws-sdk-core (3.250.0) + aws-partitions (1.1262.0) + aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -36,10 +36,10 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.128.0) + aws-sdk-kms (1.129.0) aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.224.0) + aws-sdk-s3 (1.226.0) aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -51,8 +51,10 @@ GEM bigdecimal (4.1.2) cli-ui (2.7.0) coderay (1.1.3) + commander (5.0.0) + highline (~> 3.0.0) commonmarker (0.23.12) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.7) connection_pool (3.0.2) diff-lcs (1.6.2) drb (2.2.3) @@ -60,11 +62,11 @@ GEM eventmachine (>= 0.12.9) http_parser.rb (~> 0) eventmachine (1.2.7) - faraday (2.14.2) + faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-net_http (3.4.3) + faraday-net_http (3.4.4) net-http (~> 0.5) faraday-retry (2.4.0) faraday (~> 2.0) @@ -82,7 +84,7 @@ GEM addressable (~> 2.8) process_executer (~> 4.0) rchardet (~> 1.9) - google-protobuf (4.35.0) + google-protobuf (4.35.1) bigdecimal rake (~> 13.3) guard (2.20.1) @@ -101,13 +103,14 @@ GEM guard (~> 2.8) guard-compat (~> 1.0) multi_json (~> 1.8) + highline (3.0.1) http_parser.rb (0.8.1) - i18n (1.14.8) + i18n (1.15.2) concurrent-ruby (~> 1.0) io-console (0.8.2) jaro_winkler (1.7.0) jmespath (1.6.2) - json (2.19.7) + json (2.20.0) kramdown (2.5.2) rexml (>= 3.4.4) kramdown-parser-gfm (1.1.0) @@ -133,7 +136,7 @@ GEM net-http (0.9.1) uri (>= 0.11.1) nio4r (2.7.5) - nokogiri (1.19.3) + nokogiri (1.19.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) notiffany (0.1.3) @@ -146,6 +149,11 @@ GEM open3 (0.2.1) ostruct (0.6.3) parallel (2.1.0) + parlour (9.1.2) + commander (~> 5.0) + parser + rainbow (~> 3.0) + sorbet-runtime (>= 0.5) parser (3.3.11.1) ast (~> 2.4.1) racc @@ -181,7 +189,7 @@ GEM ffi (~> 1.0) rbnacl (7.1.2) ffi (~> 1) - rbs (4.0.2) + rbs (4.0.3) logger prism (>= 1.6.0) tsort @@ -192,7 +200,7 @@ GEM reverse_markdown (3.0.2) nokogiri rexml (3.4.4) - rubocop (1.87.0) + rubocop (1.88.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -207,7 +215,7 @@ GEM parser (>= 3.3.7.2) prism (~> 1.7) ruby-progressbar (1.13.0) - sass-embedded (1.100.0) + sass-embedded (1.101.0) google-protobuf (~> 4.31) rake (>= 13) sawyer (0.9.3) @@ -223,7 +231,7 @@ GEM rack-session (>= 2.0.0, < 3) tilt (~> 2.0) slack-notifier (2.4.0) - solargraph (0.59.2) + solargraph (0.60.1) ast (~> 2.4.3) backport (~> 1.2) benchmark (~> 0.4) @@ -241,11 +249,20 @@ GEM rbs (>= 3.10.0) reverse_markdown (~> 3.0) rubocop (~> 1.76) + sord (~> 7.0) thor (~> 1.0) tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) yard-activesupport-concern (~> 0.0) yard-solargraph (~> 0.1) + sorbet-runtime (0.6.13310) + sord (7.1.0) + commander (~> 5.0) + parlour (~> 9.1) + parser + rbs (>= 3.0, < 5) + sorbet-runtime + yard thor (1.5.0) tilt (2.7.0) track_open_instances (0.1.15) @@ -257,7 +274,7 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) webrick (1.9.2) - websocket-driver (0.8.0) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -305,4 +322,4 @@ DEPENDENCIES zeitwerk (~> 2.3) BUNDLED WITH - 4.0.12 + 4.0.14 diff --git a/app/lib/renderer/markdown_timestamper.rb b/app/lib/renderer/markdown_timestamper.rb index b487580..c5308f9 100644 --- a/app/lib/renderer/markdown_timestamper.rb +++ b/app/lib/renderer/markdown_timestamper.rb @@ -19,6 +19,15 @@ def apply! # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/Cycl # We only care about paragraph nodes next unless node.type == :paragraph + # Blockquotes are callouts, not spoken-at-a-timestamp content. Skip every + # paragraph (and nested list item) inside one, so we don't render a + # timestamp in the gutter outside the quote or alongside its contents. + next if inside_blockquote?(node) + + # Lists get a single timestamp for the whole list rather than one per + # item, so only stamp the first paragraph of the first item. + next if list_item_paragraph?(node) && !first_list_paragraph?(node) + # Get the plain text for this paragraph and tidy it up paragraph = node.to_plaintext.gsub("\n", ' ').strip @@ -57,5 +66,32 @@ def apply! # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/Cycl def vtt @vtt ||= WebVTT.read(vtt_path) if vtt_path.present? end + + private + + # True if any ancestor of the node is a blockquote (handles paragraphs and + # list items nested at any depth inside the quote). + def inside_blockquote?(node) + ancestor = node.parent + while ancestor + return true if ancestor.type == :blockquote + + ancestor = ancestor.parent + end + false + end + + def list_item_paragraph?(node) + node.parent&.type == :list_item + end + + # The very first paragraph of the very first item of its list. Loose list + # items can hold several paragraphs, so we check both the item and the + # paragraph position to guarantee exactly one timestamp per list. + def first_list_paragraph?(node) + list_item = node.parent + list = list_item.parent + list_item == list.first_child && list_item.first_child == node + end end end diff --git a/app/models/choices_validator.rb b/app/models/choices_validator.rb index 1ad2d9e..65430c4 100644 --- a/app/models/choices_validator.rb +++ b/app/models/choices_validator.rb @@ -19,7 +19,7 @@ def check_correct_class(record, attribute, value) def check_unique_refs(record, attribute, value) return unless value.is_a?(Array) - ref_counts = value.map(&:ref).each_with_object(Hash.new(0)) { |ref, counts| counts[ref] += 1 } + ref_counts = value.map(&:ref).tally ref_counts.each do |ref, count| next if count == 1 diff --git a/app/models/parts_validator.rb b/app/models/parts_validator.rb index 13b7419..a9548a4 100644 --- a/app/models/parts_validator.rb +++ b/app/models/parts_validator.rb @@ -20,7 +20,7 @@ def check_unique_refs(record, attribute, value) episodes = value.flat_map(&:episodes) - ref_counts = episodes.map(&:ref).each_with_object(Hash.new(0)) { |ref, counts| counts[ref] += 1 } + ref_counts = episodes.map(&:ref).tally ref_counts.each do |ref, count| next if count == 1 diff --git a/app/server/robles_book_server.rb b/app/server/robles_book_server.rb index f0758d0..a1aaee3 100644 --- a/app/server/robles_book_server.rb +++ b/app/server/robles_book_server.rb @@ -9,7 +9,7 @@ class RoblesBookServer < Sinatra::Application set :bind, '0.0.0.0' set :views, "#{__dir__}/views" set :public_folder, "#{__dir__}/public" - set :static_cache_control, [max_age: 0] + set :static_cache_control, [{ max_age: 0 }] use Rack::LiveReload, host: 'localhost', source: :vendored diff --git a/app/server/robles_content_module_server.rb b/app/server/robles_content_module_server.rb index d3adb21..d051276 100644 --- a/app/server/robles_content_module_server.rb +++ b/app/server/robles_content_module_server.rb @@ -7,7 +7,7 @@ class RoblesContentModuleServer < Sinatra::Application # rubocop:disable Metrics set :bind, '0.0.0.0' set :views, "#{__dir__}/views" set :public_folder, "#{__dir__}/public" - set :static_cache_control, [max_age: 0] + set :static_cache_control, [{ max_age: 0 }] use Rack::LiveReload, host: 'localhost', source: :vendored diff --git a/app/server/robles_pablo_server.rb b/app/server/robles_pablo_server.rb index be481f1..57cc423 100644 --- a/app/server/robles_pablo_server.rb +++ b/app/server/robles_pablo_server.rb @@ -7,7 +7,7 @@ class RoblesPabloServer < Sinatra::Application set :bind, '0.0.0.0' set :views, "#{__dir__}/views" set :public_folder, "#{__dir__}/public" - set :static_cache_control, [max_age: 0] + set :static_cache_control, [{ max_age: 0 }] set :image_extractor, nil set :local, true set :host_authorization, { permitted_hosts: [] } diff --git a/app/server/robles_video_server.rb b/app/server/robles_video_server.rb index f5e9e59..af04914 100644 --- a/app/server/robles_video_server.rb +++ b/app/server/robles_video_server.rb @@ -7,7 +7,7 @@ class RoblesVideoServer < Sinatra::Application set :bind, '0.0.0.0' set :views, "#{__dir__}/views" set :public_folder, "#{__dir__}/public" - set :static_cache_control, [max_age: 0] + set :static_cache_control, [{ max_age: 0 }] use Rack::LiveReload, host: 'localhost', source: :vendored diff --git a/test/fixtures/transcript.vtt b/test/fixtures/transcript.vtt new file mode 100644 index 0000000..0b1b272 --- /dev/null +++ b/test/fixtures/transcript.vtt @@ -0,0 +1,10 @@ +WEBVTT + +00:00:01.000 --> 00:00:04.000 +Alright at this point we have created the bones of our user interface + +00:00:05.000 --> 00:00:09.000 +A normal top level list the instructor reads aloud one two three + +00:00:10.000 --> 00:00:14.000 +And finally a closing thought to round things out nicely diff --git a/test/renderer/markdown_timestamper_test.rb b/test/renderer/markdown_timestamper_test.rb new file mode 100644 index 0000000..39b3c86 --- /dev/null +++ b/test/renderer/markdown_timestamper_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +module Renderer + # Exercises the real timestamper against a real WebVTT fixture (no stubbing). + # The fixture only needs to contain a few cues — the Levenshtein matcher will + # assign *some* timestamp to every paragraph we don't deliberately skip, so the + # assertions here are about which nodes get a `$[t=...]` marker, not which one. + class MarkdownTimestamperTest < Minitest::Test + include TestHelpers + + TIMESTAMP = /\$\[t=[\d:.]+\]/ + + def render(markdown) + doc = CommonMarker.render_doc(markdown, %i[SMART], %i[table strikethrough autolink]) + MarkdownTimestamper.new(doc, fixture_path('transcript.vtt')).apply! + doc.to_html + end + + def test_spoken_paragraph_is_timestamped + html = render("Alright, at this point we have created the bones.\n") + + assert_match TIMESTAMP, html + end + + def test_blockquote_paragraphs_are_not_timestamped + html = render(<<~MD) + > **Update Notes:** a callout the instructor reads aloud. + > + > Apple's Human Interface Guidelines are worth a read. + MD + + refute_match TIMESTAMP, html, 'blockquote paragraphs should never be timestamped' + end + + def test_nested_list_inside_a_blockquote_is_not_timestamped + html = render(<<~MD) + > To open the Modifiers Library: + > + > - Click View - Show Library and click the second button, or + > - Press Shift-Command-L and click the second button. + MD + + refute_match TIMESTAMP, html, 'a list nested in a blockquote should never be timestamped' + end + + def test_a_list_gets_a_single_timestamp_rather_than_one_per_item + html = render(<<~MD) + A normal top level list the instructor reads: + + - One + - Two + - Three + MD + + list = html[%r{