From 07957681f63f7fff24d5ef2f66711c748f0da336 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Thu, 31 Aug 2023 16:25:45 +0530 Subject: [PATCH 01/44] WEB-6415: Add initial support for MMLP modules - Added cli, runner, parser, render and models for module, lesson, and text objects --- .env.sample | 1 - .ruby-version | 1 + Gemfile.lock | 2 +- app/commands/content_module_cli.rb | 39 ++++++ app/commands/robles_cli.rb | 32 +---- app/commands/video_cli.rb | 1 - .../content_module_extractor.rb | 27 +++++ app/lib/parser/circulate.rb | 25 ++++ app/lib/parser/content_module.rb | 112 ++++++++++++++++++ app/lib/parser/lesson_metadata.rb | 21 ++++ app/lib/parser/text_metadata.rb | 29 +++++ app/lib/parser/video_metadata.rb | 2 +- app/lib/renderer/content_module.rb | 24 ++++ app/lib/renderer/lesson.rb | 23 ++++ app/lib/renderer/segment.rb | 27 +++++ app/lib/runner/base.rb | 18 +++ app/lib/runner/interactive.rb | 4 + app/models/content_module.rb | 55 +++++++++ app/models/lesson.rb | 28 +++++ app/models/lessons_validator.rb | 30 +++++ app/models/text.rb | 46 +++++++ 21 files changed, 517 insertions(+), 30 deletions(-) create mode 100644 .ruby-version create mode 100644 app/commands/content_module_cli.rb create mode 100644 app/lib/image_provider/content_module_extractor.rb create mode 100644 app/lib/parser/circulate.rb create mode 100644 app/lib/parser/content_module.rb create mode 100644 app/lib/parser/lesson_metadata.rb create mode 100644 app/lib/parser/text_metadata.rb create mode 100644 app/lib/renderer/content_module.rb create mode 100644 app/lib/renderer/lesson.rb create mode 100644 app/lib/renderer/segment.rb create mode 100644 app/models/content_module.rb create mode 100644 app/models/lesson.rb create mode 100644 app/models/lessons_validator.rb create mode 100644 app/models/text.rb diff --git a/.env.sample b/.env.sample index 2376572..1f599ba 100644 --- a/.env.sample +++ b/.env.sample @@ -33,4 +33,3 @@ REPO_AWS_SECRET_ACCESS_KEY_PRODUCTION= REPO_AWS_SECRET_ACCESS_KEY_STAGING= REPO_SLACK_BOT_TOKEN= REPO_SLACK_WEBHOOK_URL= - diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..944880f --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.2.0 diff --git a/Gemfile.lock b/Gemfile.lock index 317f7c0..cbe791c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -236,4 +236,4 @@ DEPENDENCIES zeitwerk (~> 2.3) BUNDLED WITH - 2.4.7 + 2.4.19 diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb new file mode 100644 index 0000000..944faf1 --- /dev/null +++ b/app/commands/content_module_cli.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# CLI for managing content module of a multi model learning path + +class ContentModuleCli < Thor + desc 'render', 'renders content modules' + option :'module_file', type: :string, desc: 'Location of the module.yaml file' + option :local, type: :boolean + def render + content_module = runner.render_content_module(module_file: options['module_file'], local: options['local']) + end + + desc 'serve', 'starts local preview server' + option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)' + def serve + fork do + if options[:dev] + Guard.start(no_interactions: true) + else + Guard.start(guardfile_contents: content_module_guardfile, watchdir: '/data/src', no_interactions: true) + end + end + RoblesModuleServer.run! + end + + private + + def runner + Runner::Base.runner + end + + def video_guardfile + <<~GUARDFILE + guard 'livereload' do + watch(%r{[a-zA-Z0-9-_]+.yaml$}) + end + GUARDFILE + end +end diff --git a/app/commands/robles_cli.rb b/app/commands/robles_cli.rb index db79ee1..f4ed7ec 100644 --- a/app/commands/robles_cli.rb +++ b/app/commands/robles_cli.rb @@ -7,37 +7,17 @@ def self.exit_on_failure? true end - desc 'book SUBCOMMAND ...ARGS', 'manage publication of books' + desc 'book [SUBCOMMAND] ...ARGS', 'manage publication of books' subcommand 'book', BookCli - desc 'video SUBCOMMAND ...ARGS', 'manage publication of videos' + desc 'video [SUBCOMMAND] ...ARGS', 'manage publication of videos' subcommand 'video', VideoCli - desc 'pablo SUBCOMMAND ...ARGS', 'manage publication of pablo' - subcommand 'pablo', PabloCli - - ## We leave these in for now--they're deprecated. They should be removed later - desc 'serve', '[DEPRECATED: use `robles book serve` instead] starts local preview server' - option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)' - def serve - fork do - if options[:dev] - Guard.start(no_interactions: true) - else - Guard.start(guardfile_contents: book_guardfile, watchdir: '/data/src', no_interactions: true) - end - end - RoblesBookServer.run! - end + desc 'module [SUBCOMMAND] ...ARGS', 'manage publication of content modules' + subcommand 'lo', ContentModuleCli - desc 'lint [PUBLISH_FILE]', '[DEPRECATED: use `robles book lint` instead] runs a selection of linters on the book' - option :'publish-file', type: :string, desc: 'Location of the publish.yaml file' - method_options 'without-edition': :boolean, aliases: '-e', default: false, desc: 'Run linting without git branch naming check' - method_options silent: :boolean, aliases: '-s', default: false, desc: 'Hide all output' - def lint - output = runner.lint_book(publish_file: options['publish_file'], options:) - exit 1 unless output.validated || ENVIRONMENT == 'staging' - end + desc 'pablo [SUBCOMMAND] ...ARGS', 'manage publication of pablo' + subcommand 'pablo', PabloCli private diff --git a/app/commands/video_cli.rb b/app/commands/video_cli.rb index bec04f0..213b089 100644 --- a/app/commands/video_cli.rb +++ b/app/commands/video_cli.rb @@ -7,7 +7,6 @@ class VideoCli < Thor option :local, type: :boolean def render video_course = runner.render_video_course(release_file: options['release_file'], local: options['local']) - puts video_course.to_json end desc 'serve', 'starts local preview server' diff --git a/app/lib/image_provider/content_module_extractor.rb b/app/lib/image_provider/content_module_extractor.rb new file mode 100644 index 0000000..54513fc --- /dev/null +++ b/app/lib/image_provider/content_module_extractor.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ImageProvider + # Extract all images from a video course + class ContentModuleExtractor + attr_reader :video_course, :images + + def initialize(video_course) + @video_course = video_course + @images = [] + end + + def extract + @images = image_paths.map do |path| + Image.with_representations({ local_url: path[:absolute_path], uploaded_image_root_path: }, variants: path[:variants]) + end + end + + def uploaded_image_root_path + "videos/#{Digest::SHA2.hexdigest(video_course.shortcode)}/images" + end + + def image_paths + video_course.image_attachment_paths + end + end +end diff --git a/app/lib/parser/circulate.rb b/app/lib/parser/circulate.rb new file mode 100644 index 0000000..6828017 --- /dev/null +++ b/app/lib/parser/circulate.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Parser + # Parses a release.yaml file, and returns a VideoCourse model object + class Circulate + include Util::PathExtraction + include Util::GitHashable + + attr_reader :video_course + + def parse + load_course + apply_additional_metadata + video_course + end + + def load_course + @video_course = Parser::ContentModule.new(file:).parse + end + + def apply_additional_metadata + video_course.root_path = root_directory + end + end +end diff --git a/app/lib/parser/content_module.rb b/app/lib/parser/content_module.rb new file mode 100644 index 0000000..77daa7a --- /dev/null +++ b/app/lib/parser/content_module.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Parser + # Parse a symbolised hash into a ContentModule + class ContentModule + include Parser::SimpleAttributes + include Util::PathExtraction + include Util::GitHashable + + VALID_SIMPLE_ATTRIBUTES = %i[shortcode version version_description title course_type + description_md short_description released_at materials_url + professional difficulty platform language editor domains + categories who_is_this_for_md covered_concepts_md git_commit_hash + card_artwork_image featured_banner_image twitter_card_image + access_personal access_team].freeze + + attr_accessor :content_module + + def parse + lessons = metadata[:lessons].map.with_index do |lesson, idx| + parse_lesson(lesson, idx) + end + + @content_module = ::ContentModule.new(lessons:) + apply_segment_ordinals + load_authors + apply_additional_metadata + content_module + end + + def metadata + @metadata = load_yaml_file(file).merge(git_commit_hash: git_hash) + end + + def parse_lesson(metadata, index) + segments = parse_segments(metadata[:segments_path]) + + puts segments + + Lesson.new(ordinal: index + 1, segments:).tap do |lesson| + LessonMetadata.new(lesson, metadata).apply! + end + end + + def parse_segments(segment_yaml_file) + lesson_path = segment_yaml_file.split('/').first + lesson = load_yaml_file(apply_path(segment_yaml_file)) + + lesson[:segments].map do |segment| + segment_path = "#{lesson_path}/#{segment[:path]}" + send("parse_#{segment[:type]}".to_sym, segment_path) + end + end + + def parse_text(text) + markdown_file = apply_path(text) + + Text.new(markdown_file:, root_path: Pathname.new(markdown_file).dirname.to_s).tap do |text| + TextMetadata.new(text).apply! + end + end + + def parse_video(file) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + script_file = apply_path(file) if file.present? + raise Parser::Error.new(file:, msg: "Script file (#{file}}) not found") if script_file.present? && !File.file?(script_file) + + root_path = Pathname.new(script_file).dirname.to_s if script_file.present? + captions_file = apply_path(metadata[:captions_file]) if metadata[:captions_file].present? + raise Parser::Error.new(file:, msg: "Captions file (#{metadata[:captions_file]}) not found") if captions_file.present? && !File.file?(captions_file) + + metadata[:captions_file] = captions_file if captions_file.present? + Video.new(script_file:, root_path:).tap do |video| + VideoMetadata.new(video, metadata).apply! + end + end + + def parse_assessment(file) + assessment_file = apply_path(file) if file.present? + raise Parser::Error.new(file:, msg: "Assessment file (#{file}}) not found") if assessment_file.present? && !File.file?(assessment_file) + + assessment_metadata = load_yaml_file(assessment_file) + + Assessment.create(assessment_metadata).tap do |assessment| + AssessmentMetadata.new(assessment, assessment_metadata).apply! + end + end + + def apply_segment_ordinals + content_module.lessons.flat_map(&:segments).each_with_index do |episode, index| + episode.ordinal = index + 1 + episode.ref ||= episode.ordinal.to_s.rjust(2, '0') + end + end + + def load_authors + content_module.authors = Array.wrap(metadata[:authors]).map do |author| + Author.new(author) + end + end + + def apply_additional_metadata + content_module.assign_attributes(simple_attributes) + end + + private + + def load_yaml_file(file) + Psych.load_file(file, permitted_classes: [Date]) + .deep_symbolize_keys + end + end +end diff --git a/app/lib/parser/lesson_metadata.rb b/app/lib/parser/lesson_metadata.rb new file mode 100644 index 0000000..0e18358 --- /dev/null +++ b/app/lib/parser/lesson_metadata.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Parser + # Parse a symbolised hash into a Part, with nested Episodes + class LessonMetadata + include Parser::SimpleAttributes + + VALID_SIMPLE_ATTRIBUTES = %i[title description ordinal].freeze + + attr_accessor :lesson, :metadata + + def initialize(lesson, metadata) + @lesson = lesson + @metadata = metadata + end + + def apply! + lesson.assign_attributes(simple_attributes) + end + end +end diff --git a/app/lib/parser/text_metadata.rb b/app/lib/parser/text_metadata.rb new file mode 100644 index 0000000..68fccc5 --- /dev/null +++ b/app/lib/parser/text_metadata.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Parser + # Parses the metadata at the top of a text markdown file + class TextMetadata + include MarkdownMetadata + + VALID_SIMPLE_ATTRIBUTES = %i[number title description free].freeze + + attr_reader :text + + def initialize(text) + @text = text + @path = text.markdown_file + end + + def apply! + text.assign_attributes(simple_attributes) + text.cleanse_title! + text.authors += authors if authors.present? + end + + def authors + @authors ||= metadata[:authors]&.map do |author| + Author.new(author) + end + end + end +end diff --git a/app/lib/parser/video_metadata.rb b/app/lib/parser/video_metadata.rb index 929d24c..ec6fd08 100644 --- a/app/lib/parser/video_metadata.rb +++ b/app/lib/parser/video_metadata.rb @@ -29,7 +29,7 @@ def authors # If we've read the captions path from the script file, it needs adjusting to be absolute def check_captions_path - return unless markdown_metadata&.include?(:captions_file) + return unless markdown_metadata&.include?(:captions_file) && markdown_metadata[:captions_file].present? @markdown_metadata[:captions_file] = apply_path(@markdown_metadata[:captions_file]) end diff --git a/app/lib/renderer/content_module.rb b/app/lib/renderer/content_module.rb new file mode 100644 index 0000000..1ee3e2c --- /dev/null +++ b/app/lib/renderer/content_module.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Renderer + # Takes a sparse ContentModule object (i.e. parsed) and renders markdown / attaches images + class ContentModule + include ImageAttachable + include MarkdownRenderable + include Util::Logging + + attr_accessor :disable_transcripts + + def render + logger.info "Beginning module render: #{object.title}" + attach_images + render_markdown + object.lessons.each do |lesson| + lesson_renderer = Renderer::Lesson.new(lesson, image_provider:) + lesson_renderer.disable_transcripts = disable_transcripts + lesson_renderer.render + end + logger.info 'Completed module render' + end + end +end diff --git a/app/lib/renderer/lesson.rb b/app/lib/renderer/lesson.rb new file mode 100644 index 0000000..c72fea6 --- /dev/null +++ b/app/lib/renderer/lesson.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Renderer + # Takes a Lesson model, and updates it with markdown + class Lesson + include MarkdownRenderable + include ImageAttachable + include Util::Logging + + attr_accessor :disable_transcripts + + def render + logger.info "Beginning lesson render: #{object.title}" + attach_images + render_markdown + object.segments.each do |segment| + segment_renderer = Renderer::Segment.create(segment, image_provider:) + segment_renderer.disable_transcripts = disable_transcripts if segment_renderer.respond_to?(:disable_transcripts=) + segment_renderer.render + end + end + end +end diff --git a/app/lib/renderer/segment.rb b/app/lib/renderer/segment.rb new file mode 100644 index 0000000..b66d304 --- /dev/null +++ b/app/lib/renderer/segment.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Renderer + # Base renderer for episodes + class Segment + include MarkdownRenderable + include Util::Logging + + def self.create(model, image_provider:) + case model + when ::Assessment + Renderer::Assessment.new(model, image_provider:) + when ::Video + Renderer::Video.new(model, image_provider:) + when ::Text + Renderer::Chapter.new(model, image_provider:) + else + raise "Unknown model type: #{model.class}" + end + end + + def render + logger.info "Beginning episode render: #{object.ordinal}: #{object.title}" + render_markdown + end + end +end diff --git a/app/lib/runner/base.rb b/app/lib/runner/base.rb index 36a757f..9c6b042 100644 --- a/app/lib/runner/base.rb +++ b/app/lib/runner/base.rb @@ -123,10 +123,28 @@ def publish_pablo(source:, output:) }) end + # module + def render_content_module(module_file:, local: false) + module_file ||= default_module_file + + parser = Parser::Circulate.new(file: module_file) + content_modules = parser.parse + image_extractor = ImageProvider::ContentModuleExtractor.new(content_modules) + image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor) + image_provider&.process + renderer = Renderer::ContentModule.new(content_modules, image_provider:) + renderer.render + content_modules + end + def default_publish_file raise 'Override this in a subclass please' end + def default_module_file + raise 'Override this in a subclass please' + end + def default_release_file raise 'Override this in a subclass please' end diff --git a/app/lib/runner/interactive.rb b/app/lib/runner/interactive.rb index ed6f316..50d9095 100644 --- a/app/lib/runner/interactive.rb +++ b/app/lib/runner/interactive.rb @@ -18,5 +18,9 @@ def default_pablo_source def default_pablo_output '/data/src/dist' end + + def default_module_file + '/data/src/module.yaml' + end end end diff --git a/app/models/content_module.rb b/app/models/content_module.rb new file mode 100644 index 0000000..b35f50c --- /dev/null +++ b/app/models/content_module.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# The top-level model object for a MMLP module +class ContentModule + include ActiveModel::Model + include ActiveModel::Serializers::JSON + include Concerns::ImageAttachable + include Concerns::MarkdownRenderable + + attr_accessor :shortcode, :version, :version_description, :title, :course_type, :description_md, + :short_description, :released_at, :materials_url, :professional, :difficulty, + :platform, :language, :editor, :domains, :categories, :who_is_this_for_md, + :covered_concepts_md, :authors, :lessons, :git_commit_hash, :card_artwork_image, + :featured_banner_image, :twitter_card_image, :root_path, :access_personal, + :access_team + + attr_markdown :who_is_this_for, source: :who_is_this_for_md, file: false + attr_markdown :covered_concepts, source: :covered_concepts_md, file: false + attr_markdown :description, source: :description_md, file: false + attr_image :card_artwork_image_url, source: :card_artwork_image, variants: %i[original w560 w240] + attr_image :featured_banner_image_url, source: :featured_banner_image, variants: %i[original w750 w225 w90] + attr_image :twitter_card_image_url, source: :twitter_card_image, variants: %i[original w1800] + + validates :shortcode, :version, :title, :version_description, :description_md, :domains, + :categories, presence: true + validates_inclusion_of :difficulty, in: %w[beginner intermediate advanced] + validates_inclusion_of :course_type, in: %w[core spotlight] + validates_inclusion_of :professional, :access_personal, :access_team, in: [true, false] + validates :lessons, length: { minimum: 1 }, allow_blank: false, lessons: true + validates_each :domains do |record, attr, value| + value.each do |domain| + record.errors.add(attr, "(#{domain}) not included in the list") unless %w[ios android flutter server-side-swift unity macos professional-growth].include?(domain) + end + end + + def initialize(attributes = {}) + super + @lessons ||= [] + end + + # Used for serialisation + def attributes + { shortcode: nil, version: nil, version_description: nil, title: nil, course_type: nil, + description: nil, short_description: nil, released_at: nil, materials_url: nil, + professional: nil, difficulty: nil, platform: nil, language: nil, editor: nil, domains: [], + categories: [], who_is_this_for: nil, covered_concepts: nil, authors: [], lessons: [], + git_commit_hash: nil, card_artwork_image_url: [], featured_banner_image_url: [], + twitter_card_image_url: [], access_personal: nil, access_team: nil }.stringify_keys + end + + # Used for linting + def validation_name + title + end +end diff --git a/app/models/lesson.rb b/app/models/lesson.rb new file mode 100644 index 0000000..2f2d0b5 --- /dev/null +++ b/app/models/lesson.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# A MMLP module has multiple lesson +class Lesson + include ActiveModel::Model + include ActiveModel::Serializers::JSON + include Concerns::ImageAttachable + include Concerns::MarkdownRenderable + + attr_accessor :title, :description, :ordinal, :segments + + validates :title, :ordinal, presence: true + + def initialize(attributes = {}) + super + @segments ||= [] + end + + # Used for serialisation + def attributes + { title: nil, description: nil, ordinal: nil, segments: [] }.stringify_keys + end + + # Used for linting + def validation_name + title + end +end diff --git a/app/models/lessons_validator.rb b/app/models/lessons_validator.rb new file mode 100644 index 0000000..6b20bb2 --- /dev/null +++ b/app/models/lessons_validator.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# A validator that will check an array of choices for uniqueness of ref +class LessonsValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return unless value.is_a?(Array) + + check_correct_class(record, attribute, value) + check_unique_refs(record, attribute, value) + end + + def check_correct_class(record, attribute, value) + value.each do |part| + record.errors.add(attribute, "part #{choice} is not a Part") unless part.is_a?(Part) + end + end + + def check_unique_refs(record, attribute, value) + return unless value.is_a?(Array) + + episodes = value.flat_map(&:episodes) + + ref_counts = episodes.map(&:ref).each_with_object(Hash.new(0)) { |ref, counts| counts[ref] += 1 } + ref_counts.each do |ref, count| + next if count == 1 + + record.errors.add(attribute, "episode ref #{ref} is not unique") + end + end +end diff --git a/app/models/text.rb b/app/models/text.rb new file mode 100644 index 0000000..47bb567 --- /dev/null +++ b/app/models/text.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# The smallest quantity of content +class Text + include ActiveModel::Model + include ActiveModel::Serializers::JSON + include Concerns::AutoNumberable + include Concerns::ImageAttachable + include Concerns::MarkdownRenderable + include Concerns::TitleCleanser + + attr_accessor :title, :number, :ordinal, :ref, :description, :authors, :markdown_file, :root_path, :free, :kind + + attr_markdown :body, source: :markdown_file, file: true, wrapper_class: :wrapper_class + validates :title, :number, :ordinal, :markdown_file, presence: true + + def initialize(attributes = {}) + super + @authors ||= [] + @free ||= false + @kind ||= 'chapter' + end + + def slug + "#{number}-#{title.parameterize}" + end + + # Used for serialisation + def attributes + { title: nil, number: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false }.stringify_keys + end + + # Used for linting + def validation_name + title + end + + # For wrapping content + def wrapper_class + { + chapter: nil, + dedications: 'c-book-chapter__dedications', + 'team-bios': 'c-book-chapter__team' + }[kind&.to_sym] + end +end From 4d955fe508e909d80982ee9b226402419b0550cd Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Sat, 9 Sep 2023 19:14:43 +0530 Subject: [PATCH 02/44] WEB-6415: Add initial linting support for content modules --- Gemfile.lock | 91 ++++++------ app/commands/content_module_cli.rb | 17 ++- app/lib/linting/content_module_linter.rb | 125 +++++++++++++++++ .../linting/content_module_metadata_linter.rb | 22 +++ app/lib/linting/metadata/assessment_file.rb | 31 ++++- app/lib/linting/metadata/captions_file.rb | 41 +++++- .../linting/metadata/circulate_attributes.rb | 36 +++++ app/lib/linting/metadata/module_file.rb | 51 +++++++ app/lib/linting/validations/content_module.rb | 64 +++++++++ app/lib/parser/markdown_metadata.rb | 6 +- app/lib/runner/base.rb | 20 ++- app/models/lessons_validator.rb | 8 +- app/server/robles_content_module_server.rb | 131 ++++++++++++++++++ 13 files changed, 575 insertions(+), 68 deletions(-) create mode 100644 app/lib/linting/content_module_linter.rb create mode 100644 app/lib/linting/content_module_metadata_linter.rb create mode 100644 app/lib/linting/metadata/circulate_attributes.rb create mode 100644 app/lib/linting/metadata/module_file.rb create mode 100644 app/lib/linting/validations/content_module.rb create mode 100644 app/server/robles_content_module_server.rb diff --git a/Gemfile.lock b/Gemfile.lock index cbe791c..0b71f0c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,35 +14,36 @@ GIT GEM remote: https://rubygems.org/ specs: - activemodel (7.0.7.1) - activesupport (= 7.0.7.1) - activesupport (7.0.7.1) + activemodel (7.0.7.2) + activesupport (= 7.0.7.2) + activesupport (7.0.7.2) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - addressable (2.8.4) + addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.747.0) - aws-sdk-core (3.171.0) + aws-partitions (1.814.0) + aws-sdk-core (3.181.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.63.0) - aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-kms (1.71.0) + aws-sdk-core (~> 3, >= 3.177.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.120.1) - aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-s3 (1.134.0) + aws-sdk-core (~> 3, >= 3.181.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.0) aws-eventstream (~> 1, >= 1.0.2) backport (1.2.0) + base64 (0.1.1) benchmark (0.2.1) - cli-ui (2.1.0) + cli-ui (2.2.3) coderay (1.1.3) commonmarker (0.23.10) concurrent-ruby (1.2.2) @@ -53,11 +54,11 @@ GEM eventmachine (>= 0.12.9) http_parser.rb (~> 0) eventmachine (1.2.7) - faraday (2.7.4) + faraday (2.7.10) faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) - faraday-retry (2.1.0) + faraday-retry (2.2.0) faraday (~> 2.0) ferrum (0.13) addressable (~> 2.5) @@ -69,7 +70,7 @@ GEM git (1.18.0) addressable (~> 2.8) rchardet (~> 1.8) - google-protobuf (3.22.0) + google-protobuf (3.24.2) guard (2.18.0) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) @@ -88,27 +89,28 @@ GEM http_parser.rb (0.8.0) i18n (1.14.1) concurrent-ruby (~> 1.0) - jaro_winkler (1.5.4) + jaro_winkler (1.5.6) jmespath (1.6.2) json (2.6.3) kramdown (2.4.0) rexml kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) + language_server-protocol (3.17.0.3) listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - lumberjack (1.2.8) + lumberjack (1.2.9) method_source (1.0.0) mini_magick (4.12.0) - mini_portile2 (2.8.1) + mini_portile2 (2.8.4) minitest (5.19.0) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) nenv (0.3.0) - nokogiri (1.14.3) - mini_portile2 (~> 2.8.0) + nokogiri (1.15.4) + mini_portile2 (~> 2.8.2) racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) @@ -116,19 +118,20 @@ GEM octokit (6.1.1) faraday (>= 1, < 3) sawyer (~> 0.9) - parallel (1.22.1) - parser (3.2.2.0) + parallel (1.23.0) + parser (3.2.2.3) ast (~> 2.4.1) + racc pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) - public_suffix (5.0.1) - racc (1.6.2) - rack (2.2.6.4) + public_suffix (5.0.3) + racc (1.7.1) + rack (2.2.8) rack-livereload (0.5.1) rack - rack-protection (3.0.6) - rack + rack-protection (3.1.0) + rack (~> 2.2, >= 2.2.4) rack-test (2.1.0) rack (>= 1.3) rainbow (3.1.1) @@ -140,35 +143,37 @@ GEM ffi rbs (2.8.4) rchardet (1.8.0) - regexp_parser (2.7.0) + regexp_parser (2.8.1) reverse_markdown (2.1.1) nokogiri - rexml (3.2.5) - rubocop (1.50.1) + rexml (3.2.6) + rubocop (1.56.2) + base64 (~> 0.1.1) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.2.2.3) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + rubocop-ast (>= 1.28.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.0) + rubocop-ast (1.29.0) parser (>= 3.2.1.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - sass-embedded (1.62.0) - google-protobuf (~> 3.21) - rake (>= 10.0.0) + sass-embedded (1.66.1) + google-protobuf (~> 3.23) + rake (>= 13.0.0) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) shellany (0.0.1) - sinatra (3.0.6) + sinatra (3.1.0) mustermann (~> 3.0) rack (~> 2.2, >= 2.2.4) - rack-protection (= 3.0.6) + rack-protection (= 3.1.0) tilt (~> 2.0) slack-notifier (2.4.0) solargraph (0.49.0) @@ -191,17 +196,17 @@ GEM daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) - thor (1.2.1) - tilt (2.1.0) + thor (1.2.2) + tilt (2.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.4.2) webrick (1.8.1) - websocket-driver (0.7.5) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) yard (0.9.34) - zeitwerk (2.6.7) + zeitwerk (2.6.11) PLATFORMS ruby diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb index 944faf1..a68c46a 100644 --- a/app/commands/content_module_cli.rb +++ b/app/commands/content_module_cli.rb @@ -7,7 +7,7 @@ class ContentModuleCli < Thor option :'module_file', type: :string, desc: 'Location of the module.yaml file' option :local, type: :boolean def render - content_module = runner.render_content_module(module_file: options['module_file'], local: options['local']) + runner.render_content_module(module_file: options['module_file'], local: options['local']) end desc 'serve', 'starts local preview server' @@ -17,10 +17,19 @@ def serve if options[:dev] Guard.start(no_interactions: true) else - Guard.start(guardfile_contents: content_module_guardfile, watchdir: '/data/src', no_interactions: true) + Guard.start(guardfile_contents: content_module_guardfile, watchdir: '../m3-devtest', no_interactions: true) end end - RoblesModuleServer.run! + RoblesContentModuleServer.run! + end + + desc 'lint [MODULE_FILE]', 'runs a selection of linters on the module' + option :'module_file', type: :string, desc: 'Location of the module.yaml file' + method_options 'without-version': :boolean, aliases: '-e', default: false, desc: 'Run linting without git branch naming check' + method_options silent: :boolean, aliases: '-s', default: false, desc: 'Hide all output' + def lint + output = runner.lint_content_module(module_file: options['module_file'], options:) + exit 1 unless output.validated || ENVIRONMENT == 'staging' end private @@ -29,7 +38,7 @@ def runner Runner::Base.runner end - def video_guardfile + def content_module_guardfile <<~GUARDFILE guard 'livereload' do watch(%r{[a-zA-Z0-9-_]+.yaml$}) diff --git a/app/lib/linting/content_module_linter.rb b/app/lib/linting/content_module_linter.rb new file mode 100644 index 0000000..8c96176 --- /dev/null +++ b/app/lib/linting/content_module_linter.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Linting + # Overall linter that combines all other linters + class ContentModuleLinter # rubocop:disable Metrics/ClassLength + include Util::PathExtraction + include Util::Logging + include Linting::FileExistenceChecker + include Linting::YamlSyntaxChecker + + attr_reader :annotations, :output_details + + def initialize(file:) + super + Linting::Annotation.root_directory = Pathname.new(file).dirname + end + + def lint(options: {}) + @annotations = [] + lint_with_ui(options:, show_ui: !options['silent']) + output + end + + def lint_with_ui(options:, show_ui: true) # rubocop:disable Metrics/AbcSize + with_spinner(title: 'Checking {{bold:module.yaml}} exists', show: show_ui) do + check_module_file_exists + end + return if output_details.present? + + with_spinner(title: 'Checking {{bold:module.yaml}} is valid YAML', show: show_ui) do + check_module_file_valid_yaml + end + return if output_details.present? + + with_spinner(title: 'Validating metadata in {{bold:module.yaml}}', show: show_ui) do + annotations.concat(Linting::ContentModuleMetadataLinter.new(file:).lint(options:)) + end + return unless annotations.blank? + + with_spinner(title: 'Attempting to parse content module', show: show_ui) do + content_module + end + return unless annotations.blank? + + with_spinner(title: 'Validating data models', show: show_ui) do + annotations.concat(Linting::Validations::ContentModule.new(content_module:, file:).lint) + end + end + + def with_spinner(title:, show: true, &block) + if show + CLI::UI::Spinner.spin(title, &block) + else + yield + end + end + + def output + return Linting::Output.new(output_details.merge(annotations:)) if output_details.present? + + if annotations.present? + Linting::Output.new( + title: 'robles Linting Failure', + summary: 'There was a problem with your content module repository', + text: 'Please check the individual file annotations for details', + annotations:, + validated: false + ) + else + Linting::Output.new( + title: 'robles Linting Success', + summary: 'Your content module repo looks great', + text: 'I have nothing else to say here...', + validated: true + ) + end + end + + def check_module_file_exists + logger.debug "Checking for existence of #{file}" + return true if file_exists?(file) + + @output_details = { + title: 'robles Linting Failure', + summary: 'Unable to locate the `module.yaml` file', + text: "There should be a `module.yaml` file in the root of your book repository. Looking here: #{file}", + validated: false + } + false + end + + def check_module_file_valid_yaml + logger.debug "Checking for validity of #{file}" + error = valid_yaml?(file) + return true unless error + + @output_details = { + title: 'robles Linting Failure', + summary: 'Unable to parse `module.yaml`', + text: "There was a problem parsing the `module.yaml` file. Check the indentation. Details:\n\n > #{error}", + validated: false + } + false + end + + def content_module + @content_module ||= begin + parser = Parser::Circulate.new(file:) + parser.parse + end + rescue Parser::Error => e + line_number = (e.message.match(/at line (\d+)/)&.captures&.first&.to_i || 0) + 1 + annotations.push( + Annotation.new( + absolute_path: e.file, + annotation_level: 'failure', + start_line: line_number, + end_line: line_number, + message: e.message, + title: 'Unable to parse content module.' + ) + ) + end + end +end diff --git a/app/lib/linting/content_module_metadata_linter.rb b/app/lib/linting/content_module_metadata_linter.rb new file mode 100644 index 0000000..57deee2 --- /dev/null +++ b/app/lib/linting/content_module_metadata_linter.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Linting + # Run through various metadata checks + class ContentModuleMetadataLinter + include Util::PathExtraction + + def lint(options: {}) # rubocop:disable Metrics/AbcSize + [].tap do |annotations| + annotations.concat Linting::Metadata::CirculateAttributes.lint(file:, attributes: module_attributes) + annotations.concat Linting::Metadata::ModuleFile.lint(file:, attributes: module_attributes) + annotations.concat Linting::Metadata::CaptionsFile.lint(file:, attributes: module_attributes) + annotations.concat Linting::Metadata::AssessmentFile.lint(file:, attributes: module_attributes) + annotations.concat Linting::Metadata::BranchName.lint(file:, attributes: module_attributes, version_attribute: :version) unless options['without-version'] + end + end + + def module_attributes + @module_attributes ||= Psych.load_file(file, permitted_classes: [Date]).deep_symbolize_keys + end + end +end diff --git a/app/lib/linting/metadata/assessment_file.rb b/app/lib/linting/metadata/assessment_file.rb index a66753f..ce8137f 100644 --- a/app/lib/linting/metadata/assessment_file.rb +++ b/app/lib/linting/metadata/assessment_file.rb @@ -23,12 +23,31 @@ def lint # Find the script file references in each episode in the release.yaml def file_path_list - @file_path_list ||= - attributes[:parts].flat_map do |part| - part[:episodes].map do |episode| - episode[:assessment_file] - end - end.compact + @file_path_list ||= content_module? ? module_assessments : video_course_assessments + end + + def video_course_assessments + attributes[:parts].flat_map do |part| + part[:episodes].map do |episode| + episode[:assessment_file] + end + end.compact + end + + def module_assessments + attributes[:lessons].flat_map do |lesson| + ModuleFile.new(file:, attributes:).segments(lesson).flat_map do |segment| + segment[:relative_path] if assessment?(segment) + end + end.compact + end + + def content_module? + attributes.key?(:lessons) + end + + def assessment?(segment) + segment[:type] == 'assessment' end # What kind of files are we looking for? diff --git a/app/lib/linting/metadata/captions_file.rb b/app/lib/linting/metadata/captions_file.rb index 4b9acb0..8a84911 100644 --- a/app/lib/linting/metadata/captions_file.rb +++ b/app/lib/linting/metadata/captions_file.rb @@ -5,6 +5,7 @@ module Metadata # Check that captions file references point to actual files class CaptionsFile include Linting::Metadata::FileAttributeExistenceChecker + include Parser::MarkdownMetadata attr_reader :file, :attributes @@ -23,18 +24,46 @@ def lint # Find the script file references in each episode in the release.yaml def file_path_list - @file_path_list ||= - attributes[:parts].flat_map do |part| - part[:episodes].map do |episode| - episode[:captions_file] - end - end.compact + @file_path_list ||= content_module? ? module_captions : video_course_captions + end + + def video_course_captions + attributes[:parts].flat_map do |part| + part[:episodes].map do |episode| + episode[:captions_file] + end + end.compact + end + + # Caption file is part of markdown metadata for video and text + # And quiz being a yaml file is handled + def module_captions + caption_files = ModuleFile.new(file:, attributes:).file_path_list + caption_files.map do |file| + if yaml?(file) + load_yaml(File.read(file)).deep_symbolize_keys[:captions_file] + else + @markdown_metadata = nil + @path = file + markdown_metadata[:captions_file] + end + end.compact end # What kind of files are we looking for? def file_description 'captions' end + + private + + def yaml?(file) + ['.yaml', '.yml'].include?(file.extname) + end + + def content_module? + attributes[:lessons].present? + end end end end diff --git a/app/lib/linting/metadata/circulate_attributes.rb b/app/lib/linting/metadata/circulate_attributes.rb new file mode 100644 index 0000000..7b594c4 --- /dev/null +++ b/app/lib/linting/metadata/circulate_attributes.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Linting + module Metadata + # Check for the required attributes in the publish.yaml file + class CirculateAttributes + REQUIRED_ATTRIBUTES = %i[shortcode version title description_md released_at authors lessons materials_url + version_description difficulty platform language editor professional + short_description domains categories ].freeze + + attr_reader :file, :attributes + + def self.lint(file:, attributes:) + new(file:, attributes:).lint + end + + def initialize(file:, attributes:) + @file = file + @attributes = attributes + end + + def lint + (REQUIRED_ATTRIBUTES - attributes.keys).map do |missing_key| + Annotation.new( + start_line: 1, + end_line: 1, + absolute_path: file, + annotation_level: 'failure', + message: "`module.yaml` should include a top-level `#{missing_key}` attribute.`", + title: 'Missing required attribute' + ) + end + end + end + end +end diff --git a/app/lib/linting/metadata/module_file.rb b/app/lib/linting/metadata/module_file.rb new file mode 100644 index 0000000..298eb4f --- /dev/null +++ b/app/lib/linting/metadata/module_file.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Linting + module Metadata + # Check that script file references point to actual files + class ModuleFile + include Linting::Metadata::FileAttributeExistenceChecker + + attr_reader :file, :attributes + + def self.lint(file:, attributes:) + new(file:, attributes:).lint + end + + def initialize(file:, attributes:) + @file = file + @attributes = attributes + end + + def lint + file_attribute_annotations + end + + # Find the script file references in each episode in the release.yaml + def file_path_list + @file_path_list ||= + attributes[:lessons].flat_map do |lesson| + segment_files(lesson) + end.compact + end + + def segments(lesson) + @file_path = Pathname.new(file).dirname.join(lesson[:segments_path]) + lesson_data = Psych.load_file(@file_path, permitted_classes: [Date]).deep_symbolize_keys + lesson_data[:segments].map do |segment| + segment[:relative_path] = Pathname.new(@file_path).dirname.join(segment[:path]) + segment + end + end + + def segment_files(lesson) + segments(lesson).map { |segment| segment[:relative_path] } + end + + # What kind of files are we looking for? + def file_description + 'script' + end + end + end +end diff --git a/app/lib/linting/validations/content_module.rb b/app/lib/linting/validations/content_module.rb new file mode 100644 index 0000000..a5b2d20 --- /dev/null +++ b/app/lib/linting/validations/content_module.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Linting + module Validations + # Run the model validations for a VideoCourse and child objects + class ContentModule + attr_accessor :content_module, :annotations, :file + + def initialize(content_module:, file:) + @content_module = content_module + @file = file + end + + def lint # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + [].tap do |annotations| + content_module.lessons.each do |lesson| + lesson.segments.each do |segment| + if segment.is_a?(Video) + annotations.concat(validate_children(segment, :authors)) + elsif segment.is_a?(Assessment::Quiz) + segment.questions.each do |question| + context = "Assessment::Quiz (#{segment&.validation_name || 'unknown'})" + annotations.concat(validate_children(question, :choices, context:)) + end + + annotations.concat(validate_children(segment, :questions)) + end + end + + annotations.concat(validate_children(lesson, :segments)) + end + + annotations.concat(validate_children(content_module, :authors)) + annotations.concat(validate_children(content_module, :lessons)) + + annotations.concat(annotations_from_errors(content_module)) unless content_module.valid? + end.compact.reverse + end + + def validate_children(object, attribute, context: nil) + Array.wrap(object.send(attribute)).flat_map do |child| + next if child.valid? + + context = "#{context}\n#{object.class} (#{object&.validation_name || 'unknown'})" + annotations_from_errors(child, context:) + end.compact + end + + def annotations_from_errors(object, context: nil) + title = "#{object.class} (#{object&.validation_name || 'unknown'})" + object.errors.full_messages.map do |error| + Linting::Annotation.new( + start_line: 0, + end_line: 0, + absolute_path: file, + annotation_level: 'failure', + message: "#{context}\n#{title}: #{error}", + title: "#{title} Validation Error" + ) + end + end + end + end +end diff --git a/app/lib/parser/markdown_metadata.rb b/app/lib/parser/markdown_metadata.rb index dc6bb3b..588b486 100644 --- a/app/lib/parser/markdown_metadata.rb +++ b/app/lib/parser/markdown_metadata.rb @@ -20,12 +20,16 @@ def metadata def markdown_metadata return {} if path.blank? - @markdown_metadata ||= Psych.load(extract_metadata, symbolize_names: true, permitted_classes: [Date]).tap do |header_metadata| + @markdown_metadata ||= load_yaml(extract_metadata).tap do |header_metadata| # If can't metadata, and also don't have a provided hash, then raise error raise Parser::Error.new(file: path, msg: 'Unable to locate metadata at the top of the markdown') if header_metadata.blank? && @metadata.blank? end || {} rescue Psych::SyntaxError => e raise Parser::Error.new(file: path, error: e) end + + def load_yaml(file) + Psych.load(file, symbolize_names: true, permitted_classes: [Date]) + end end end diff --git a/app/lib/runner/base.rb b/app/lib/runner/base.rb index 9c6b042..fd4ed3c 100644 --- a/app/lib/runner/base.rb +++ b/app/lib/runner/base.rb @@ -128,13 +128,25 @@ def render_content_module(module_file:, local: false) module_file ||= default_module_file parser = Parser::Circulate.new(file: module_file) - content_modules = parser.parse - image_extractor = ImageProvider::ContentModuleExtractor.new(content_modules) + content_module = parser.parse + image_extractor = ImageProvider::ContentModuleExtractor.new(content_module) image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor) image_provider&.process - renderer = Renderer::ContentModule.new(content_modules, image_provider:) + renderer = Renderer::ContentModule.new(content_module, image_provider:) renderer.render - content_modules + content_module + end + + def lint_content_module(module_file:, options: {}) + module_file ||= default_module_file + logger.info("Attempting to lint using release file at #{module_file}") + + CLI::UI::StdoutRouter.enable unless options['silent'] + + linter = Linting::ContentModuleLinter.new(file: module_file) + output = linter.lint(options:) + Cli::OutputFormatter.render(output) unless options['silent'] + output end def default_publish_file diff --git a/app/models/lessons_validator.rb b/app/models/lessons_validator.rb index 6b20bb2..9f22450 100644 --- a/app/models/lessons_validator.rb +++ b/app/models/lessons_validator.rb @@ -10,17 +10,17 @@ def validate_each(record, attribute, value) end def check_correct_class(record, attribute, value) - value.each do |part| - record.errors.add(attribute, "part #{choice} is not a Part") unless part.is_a?(Part) + value.each do |choice| + record.errors.add(attribute, "lesson #{choice} is not a Lesson") unless choice.is_a?(Lesson) end end def check_unique_refs(record, attribute, value) return unless value.is_a?(Array) - episodes = value.flat_map(&:episodes) + segments = value.flat_map(&:segments) - ref_counts = episodes.map(&:ref).each_with_object(Hash.new(0)) { |ref, counts| counts[ref] += 1 } + ref_counts = segments.map(&:ref).each_with_object(Hash.new(0)) { |ref, counts| counts[ref] += 1 } ref_counts.each do |ref, count| next if count == 1 diff --git a/app/server/robles_content_module_server.rb b/app/server/robles_content_module_server.rb new file mode 100644 index 0000000..187183f --- /dev/null +++ b/app/server/robles_content_module_server.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rack-livereload' + +# A local preview server for robles +class RoblesContentModuleServer < Sinatra::Application + set :bind, '0.0.0.0' + set :views, "#{__dir__}/views" + set :public_folder, "#{__dir__}/public" + set :static_cache_control, [max_age: 0] + + use Rack::LiveReload, host: 'localhost', source: :vendored + + helpers do + def slide_path(episode) + "/slides/#{episode.slug}" + end + + def transcript_path(episode) + "/transcripts/#{episode.slug}" + end + + def assessment_path(episode) + "/assessments/#{episode.slug}" + end + + def class_for_domain(course) + if course.domains.count > 1 + 'multi-domain' + else + course.domains.first + end + end + + # scss is no longer a built-in helper in sinatra + # However, we can almost just proxy it to Tilt + def scss(template, options = {}, locals = {}) + options.merge!(layout: false, exclude_outvar: true) + # Set the content type to css + render(:scss, template, options, locals).dup.tap do |css| + css.extend(ContentTyped).content_type = :css + end + end + end + + before do + cache_control max_age: 0 + end + + get '/' do + @content_module = content_module(with_transcript: false) + erb :'videos/index.html', locals: { content_module: @content_module, title: "robles Preview: #{@content_module.title}" }, layout: :'videos/layout.html' + end + + get '/slides/:slug' do + @content_module = content_module(with_transcript: false) + episode = episode_for_slug(params[:slug]) + raise Sinatra::NotFound unless episode.present? + + part = @content_module.parts.find { |p| p.episodes.include?(episode) } + + erb :'videos/episode_slide.html', + locals: { episode:, part:, content_module: @content_module, title: "robles Preview: #{episode.title}" }, + layout: :'videos/layout.html' + end + + get '/transcripts/:slug' do + @content_module = content_module(with_transcript: true) + episode = episode_for_slug(params[:slug]) + raise Sinatra::NotFound unless episode.present? + + part = @content_module.parts.find { |p| p.episodes.include?(episode) } + + erb :'videos/episode_transcript.html', + locals: { episode:, part:, content_module: @content_module, title: "robles Preview: #{episode.title}" }, + layout: :'videos/layout.html' + end + + get '/assessments/:slug' do + @content_module = content_module(with_transcript: false) + episode = episode_for_slug(params[:slug]) + raise Sinatra::NotFound unless episode.present? + + part = @content_module.parts.find { |p| p.episodes.include?(episode) } + + erb :'videos/assessment.html', + locals: { episode:, part:, content_module: @content_module, title: "robles Preview: #{episode.title}" }, + layout: :'videos/layout.html' + end + + get '/assets/*' do + local_url = File.join('/data/src/', params[:splat]) + raise Sinatra::NotFound unless acceptable_image_extension(File.extname(local_url)) && File.exist?(local_url) + + send_file(local_url) + end + + get '/styles.css' do + scss :'styles/application', style: :expanded + end + + def content_module(with_transcript: true) + parser = Parser::Circulate.new(file: module_file) + content_module = parser.parse + renderer = Renderer::ContentModule.new(content_module, image_provider: nil) + renderer.disable_transcripts = !with_transcript + renderer.render + content_module.image_attachment_loop { |local_url| servable_image_url(local_url) } + content_module + end + + def render_string(content) + Renderer::MarkdownStringRenderer.new(content:).render + end + + def episode_for_slug(slug) + @content_module.parts.flat_map(&:episodes).find { |episode| episode.slug == slug } + end + + def module_file + '/data/src/module.yaml' + end + + def servable_image_url(local_url) + [OpenStruct.new(url: local_url&.gsub(%r{/data/src}, '/assets'), variant: :original)] # rubocop:disable Style/OpenStructUse + end + + def acceptable_image_extension(extension) + %w[jpg jpeg png gif].include?(extension.sub('.', '').downcase) + end +end From b72fe3d8160035be588758893f9daf51d4e2cb2c Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Fri, 15 Sep 2023 19:48:36 +0530 Subject: [PATCH 03/44] WEB-6415: Update linting for text and lesson --- Gemfile.lock | 27 ++++++++++++++------------- app/lib/parser/content_module.rb | 26 +++++++++++++++----------- app/lib/parser/lesson_metadata.rb | 2 +- app/lib/parser/text_metadata.rb | 2 +- app/models/content_module.rb | 5 ++--- app/models/lessons_validator.rb | 11 +++++------ app/models/text.rb | 8 ++++---- 7 files changed, 42 insertions(+), 39 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0b71f0c..1189875 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,9 +14,9 @@ GIT GEM remote: https://rubygems.org/ specs: - activemodel (7.0.7.2) - activesupport (= 7.0.7.2) - activesupport (7.0.7.2) + activemodel (7.0.8) + activesupport (= 7.0.8) + activesupport (7.0.8) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -25,8 +25,8 @@ GEM public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.814.0) - aws-sdk-core (3.181.0) + aws-partitions (1.823.0) + aws-sdk-core (3.181.1) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) @@ -54,13 +54,14 @@ GEM eventmachine (>= 0.12.9) http_parser.rb (~> 0) eventmachine (1.2.7) - faraday (2.7.10) + faraday (2.7.11) + base64 faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) faraday-retry (2.2.0) faraday (~> 2.0) - ferrum (0.13) + ferrum (0.14) addressable (~> 2.5) concurrent-ruby (~> 1.1) webrick (~> 1.7) @@ -70,8 +71,8 @@ GEM git (1.18.0) addressable (~> 2.8) rchardet (~> 1.8) - google-protobuf (3.24.2) - guard (2.18.0) + google-protobuf (3.24.3) + guard (2.18.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) lumberjack (>= 1.0.12, < 2.0) @@ -104,7 +105,7 @@ GEM method_source (1.0.0) mini_magick (4.12.0) mini_portile2 (2.8.4) - minitest (5.19.0) + minitest (5.20.0) multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) @@ -147,7 +148,7 @@ GEM reverse_markdown (2.1.1) nokogiri rexml (3.2.6) - rubocop (1.56.2) + rubocop (1.56.3) base64 (~> 0.1.1) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -163,7 +164,7 @@ GEM parser (>= 3.2.1.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - sass-embedded (1.66.1) + sass-embedded (1.67.0) google-protobuf (~> 3.23) rake (>= 13.0.0) sawyer (0.9.2) @@ -197,7 +198,7 @@ GEM eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) thor (1.2.2) - tilt (2.2.0) + tilt (2.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.4.2) diff --git a/app/lib/parser/content_module.rb b/app/lib/parser/content_module.rb index 77daa7a..1341f31 100644 --- a/app/lib/parser/content_module.rb +++ b/app/lib/parser/content_module.rb @@ -7,7 +7,7 @@ class ContentModule include Util::PathExtraction include Util::GitHashable - VALID_SIMPLE_ATTRIBUTES = %i[shortcode version version_description title course_type + VALID_SIMPLE_ATTRIBUTES = %i[shortcode version version_description title description_md short_description released_at materials_url professional difficulty platform language editor domains categories who_is_this_for_md covered_concepts_md git_commit_hash @@ -35,10 +35,9 @@ def metadata def parse_lesson(metadata, index) segments = parse_segments(metadata[:segments_path]) - puts segments - Lesson.new(ordinal: index + 1, segments:).tap do |lesson| - LessonMetadata.new(lesson, metadata).apply! + lesson_metadata = load_yaml_file(apply_path((metadata[:segments_path]))) + LessonMetadata.new(lesson, lesson_metadata).apply! end end @@ -52,8 +51,8 @@ def parse_segments(segment_yaml_file) end end - def parse_text(text) - markdown_file = apply_path(text) + def parse_text(file) + markdown_file = apply_path(file) Text.new(markdown_file:, root_path: Pathname.new(markdown_file).dirname.to_s).tap do |text| TextMetadata.new(text).apply! @@ -86,9 +85,15 @@ def parse_assessment(file) end def apply_segment_ordinals - content_module.lessons.flat_map(&:segments).each_with_index do |episode, index| - episode.ordinal = index + 1 - episode.ref ||= episode.ordinal.to_s.rjust(2, '0') + content_module.lessons.flat_map(&:segments).each_with_index do |segment, index| + segment.ordinal = index + 1 + end + + # ref is reset for each lesson + content_module.lessons.each do |lesson| + lesson.segments.each_with_index do |segment, index| + segment.ref ||= (index + 1).to_s.rjust(2, '0') + end end end @@ -105,8 +110,7 @@ def apply_additional_metadata private def load_yaml_file(file) - Psych.load_file(file, permitted_classes: [Date]) - .deep_symbolize_keys + Psych.load_file(file, permitted_classes: [Date]).deep_symbolize_keys end end end diff --git a/app/lib/parser/lesson_metadata.rb b/app/lib/parser/lesson_metadata.rb index 0e18358..a24ddd5 100644 --- a/app/lib/parser/lesson_metadata.rb +++ b/app/lib/parser/lesson_metadata.rb @@ -3,7 +3,7 @@ module Parser # Parse a symbolised hash into a Part, with nested Episodes class LessonMetadata - include Parser::SimpleAttributes + include SimpleAttributes VALID_SIMPLE_ATTRIBUTES = %i[title description ordinal].freeze diff --git a/app/lib/parser/text_metadata.rb b/app/lib/parser/text_metadata.rb index 68fccc5..5462a5f 100644 --- a/app/lib/parser/text_metadata.rb +++ b/app/lib/parser/text_metadata.rb @@ -5,7 +5,7 @@ module Parser class TextMetadata include MarkdownMetadata - VALID_SIMPLE_ATTRIBUTES = %i[number title description free].freeze + VALID_SIMPLE_ATTRIBUTES = %i[title description short_description free ref authors_notes_md].freeze attr_reader :text diff --git a/app/models/content_module.rb b/app/models/content_module.rb index b35f50c..d597036 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -7,7 +7,7 @@ class ContentModule include Concerns::ImageAttachable include Concerns::MarkdownRenderable - attr_accessor :shortcode, :version, :version_description, :title, :course_type, :description_md, + attr_accessor :shortcode, :version, :version_description, :title, :description_md, :short_description, :released_at, :materials_url, :professional, :difficulty, :platform, :language, :editor, :domains, :categories, :who_is_this_for_md, :covered_concepts_md, :authors, :lessons, :git_commit_hash, :card_artwork_image, @@ -24,7 +24,6 @@ class ContentModule validates :shortcode, :version, :title, :version_description, :description_md, :domains, :categories, presence: true validates_inclusion_of :difficulty, in: %w[beginner intermediate advanced] - validates_inclusion_of :course_type, in: %w[core spotlight] validates_inclusion_of :professional, :access_personal, :access_team, in: [true, false] validates :lessons, length: { minimum: 1 }, allow_blank: false, lessons: true validates_each :domains do |record, attr, value| @@ -40,7 +39,7 @@ def initialize(attributes = {}) # Used for serialisation def attributes - { shortcode: nil, version: nil, version_description: nil, title: nil, course_type: nil, + { shortcode: nil, version: nil, version_description: nil, title: nil, description: nil, short_description: nil, released_at: nil, materials_url: nil, professional: nil, difficulty: nil, platform: nil, language: nil, editor: nil, domains: [], categories: [], who_is_this_for: nil, covered_concepts: nil, authors: [], lessons: [], diff --git a/app/models/lessons_validator.rb b/app/models/lessons_validator.rb index 9f22450..f7ed239 100644 --- a/app/models/lessons_validator.rb +++ b/app/models/lessons_validator.rb @@ -18,13 +18,12 @@ def check_correct_class(record, attribute, value) def check_unique_refs(record, attribute, value) return unless value.is_a?(Array) - segments = value.flat_map(&:segments) + value.each do |lesson| + ref_counts = Hash.new(0) + lesson.segments.each { |segment| ref_counts[segment.ref] += 1 } + non_unique_refs = ref_counts.select { |_, count| count > 1 }.keys - ref_counts = segments.map(&:ref).each_with_object(Hash.new(0)) { |ref, counts| counts[ref] += 1 } - ref_counts.each do |ref, count| - next if count == 1 - - record.errors.add(attribute, "episode ref #{ref} is not unique") + non_unique_refs.each { |ref| record.errors.add(attribute, "segment ref #{ref} is not unique") } end end end diff --git a/app/models/text.rb b/app/models/text.rb index 47bb567..d59a20c 100644 --- a/app/models/text.rb +++ b/app/models/text.rb @@ -9,10 +9,10 @@ class Text include Concerns::MarkdownRenderable include Concerns::TitleCleanser - attr_accessor :title, :number, :ordinal, :ref, :description, :authors, :markdown_file, :root_path, :free, :kind + attr_accessor :title, :ordinal, :ref, :description, :authors, :markdown_file, :root_path, :free, :kind attr_markdown :body, source: :markdown_file, file: true, wrapper_class: :wrapper_class - validates :title, :number, :ordinal, :markdown_file, presence: true + validates :title, :ordinal, :markdown_file, presence: true def initialize(attributes = {}) super @@ -22,12 +22,12 @@ def initialize(attributes = {}) end def slug - "#{number}-#{title.parameterize}" + "#{ref}-#{title.parameterize}" end # Used for serialisation def attributes - { title: nil, number: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false }.stringify_keys + { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false }.stringify_keys end # Used for linting From e18e9e956a9bdcf0a0afb0bfb5d9cb8eb98c52e4 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Sun, 17 Sep 2023 18:48:26 +0530 Subject: [PATCH 04/44] WEB-6415: Upload content module to alexandria --- app/commands/content_module_cli.rb | 10 +++- .../api/alexandria/content_module_uploader.rb | 56 +++++++++++++++++++ .../content_module_extractor.rb | 22 ++++++-- app/lib/runner/base.rb | 17 ++++++ app/lib/util/slack_notifiable.rb | 46 +++++++++++++++ app/models/video.rb | 5 ++ 6 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 app/lib/api/alexandria/content_module_uploader.rb diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb index a68c46a..f327a89 100644 --- a/app/commands/content_module_cli.rb +++ b/app/commands/content_module_cli.rb @@ -4,7 +4,7 @@ class ContentModuleCli < Thor desc 'render', 'renders content modules' - option :'module_file', type: :string, desc: 'Location of the module.yaml file' + option :module_file, type: :string, desc: 'Location of the module.yaml file' option :local, type: :boolean def render runner.render_content_module(module_file: options['module_file'], local: options['local']) @@ -24,7 +24,7 @@ def serve end desc 'lint [MODULE_FILE]', 'runs a selection of linters on the module' - option :'module_file', type: :string, desc: 'Location of the module.yaml file' + option :module_file, type: :string, desc: 'Location of the module.yaml file' method_options 'without-version': :boolean, aliases: '-e', default: false, desc: 'Run linting without git branch naming check' method_options silent: :boolean, aliases: '-s', default: false, desc: 'Hide all output' def lint @@ -32,6 +32,12 @@ def lint exit 1 unless output.validated || ENVIRONMENT == 'staging' end + desc 'circulate [MODULE_FILE]', 'renders and circulates a content module' + option :module_file, type: :string, desc: 'Location of the module.yaml file' + def circulate + runner.circulate_content_module(module_file: options['module_file']) + end + private def runner diff --git a/app/lib/api/alexandria/content_module_uploader.rb b/app/lib/api/alexandria/content_module_uploader.rb new file mode 100644 index 0000000..3ebf085 --- /dev/null +++ b/app/lib/api/alexandria/content_module_uploader.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Api + module Alexandria + # Allow uploading of content_modules to alexandria + class ContentModuleUploader + include Util::Logging + + attr_reader :content_module + + PUBLISH_URL = '/api/content_modules/publish' + + def self.upload(content_module) + new(content_module).upload + end + + def initialize(content_module) + @content_module = content_module + end + + def upload + conn.post do |req| + req.url api_uri + req.body = payload + end + end + + private + + def api_uri + path = [ + ALEXANDRIA_BASE_URL, + PUBLISH_URL + ].join + + URI(path) + end + + def conn + @conn ||= Faraday.new(headers: { 'Content-Type' => 'application/json' }) do |faraday| + faraday.response(:logger, logger) do |logger| + logger.filter(/(Token token=\\")(\w+)/, '\1[REMOVED]') + end + faraday.response(:raise_error) + faraday.adapter(Faraday.default_adapter) + faraday.request(:authorization, 'Bearer', ALEXANDRIA_SERVICE_API_TOKEN) + faraday.request(:retry) + end + end + + def payload + { content_module: }.to_json + end + end + end +end diff --git a/app/lib/image_provider/content_module_extractor.rb b/app/lib/image_provider/content_module_extractor.rb index 54513fc..71313e5 100644 --- a/app/lib/image_provider/content_module_extractor.rb +++ b/app/lib/image_provider/content_module_extractor.rb @@ -3,10 +3,10 @@ module ImageProvider # Extract all images from a video course class ContentModuleExtractor - attr_reader :video_course, :images + attr_reader :content_module, :images - def initialize(video_course) - @video_course = video_course + def initialize(content_module) + @content_module = content_module @images = [] end @@ -16,12 +16,24 @@ def extract end end + def extract_images_from_markdown(file) + MarkdownImageExtractor.images_from(file) if file && File.exist?(file) + end + def uploaded_image_root_path - "videos/#{Digest::SHA2.hexdigest(video_course.shortcode)}/images" + "content_module/#{Digest::SHA2.hexdigest(content_module.shortcode)}/images" end def image_paths - video_course.image_attachment_paths + content_module.lessons.map do |lesson| + from_segments = lesson.segments.map do |segment| + next unless segment.respond_to?(:markdown_file) + + extract_images_from_markdown(segment.markdown_file) + segment.image_attachment_paths + end + + from_segments + lesson.image_attachment_paths + end.flatten.compact.uniq + content_module.image_attachment_paths end end end diff --git a/app/lib/runner/base.rb b/app/lib/runner/base.rb index fd4ed3c..f6ccaed 100644 --- a/app/lib/runner/base.rb +++ b/app/lib/runner/base.rb @@ -149,6 +149,23 @@ def lint_content_module(module_file:, options: {}) output end + def circulate_content_module(module_file:) + module_file ||= default_module_file + + parser = Parser::Circulate.new(file: module_file) + content_module = parser.parse + + image_extractor = ImageProvider::ContentModuleExtractor.new(content_module) + image_provider = ImageProvider::Provider.new(extractor: image_extractor) + image_provider.process + Renderer::ContentModule.new(content_module, image_provider:).render + Api::Alexandria::ContentModuleUploader.upload(content_module) + notify_content_module_success(content_module:) + rescue StandardError => e + notify_content_module_failure(content_module: defined?(content_module) ? content_module : nil, details: e.full_message) + raise e + end + def default_publish_file raise 'Override this in a subclass please' end diff --git a/app/lib/util/slack_notifiable.rb b/app/lib/util/slack_notifiable.rb index 58fd6cd..e0c8b59 100644 --- a/app/lib/util/slack_notifiable.rb +++ b/app/lib/util/slack_notifiable.rb @@ -10,6 +10,7 @@ module SlackNotifiable # rubocop:disable Metrics/ModuleLength FAILURE_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object_errors.png' BOOK_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object_box-of-books.png' VIDEO_COURSE_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object-box-videos.png' + CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL = 'https://wolverine.raywenderlich.com/v3-resources/razebot/images/object-box-content-module.png' def notify_book_success(book:) return unless notifiable? @@ -35,6 +36,18 @@ def notify_video_course_failure(video_course:, details: nil) notifier.post(blocks: video_course_failure_blocks(video_course:, details: details || 'N/A')) end + def notify_content_module_success(content_module:) + return unless notifiable? + + notifier.post(blocks: content_module_success_blocks(content_module:)) + end + + def notify_content_module_failure(content_module:, details: nil) + return unless notifiable? + + notifier.post(blocks: content_module_failure_blocks(content_module:, details: details || 'N/A')) + end + def notifiable? SLACK_WEBHOOK_URL.present? end @@ -109,6 +122,39 @@ def video_course_failure_blocks(video_course:, details:) ] end + def content_module_success_blocks(content_module:) + [ + intro_section(fields: standard_content_module_fields(content_module:), + message: ':white_check_mark: Content module upload successful!', + image_url: CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL, + alt_text: 'Upload successful'), + { + type: 'divider' + }, + context(CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL) + ] + end + + def content_module_failure_blocks(content_module:, details:) + [ + intro_section(fields: standard_content_module_fields(content_module:), + message: ':x: Content module upload failed!', + image_url: FAILURE_IMAGE_URL, + alt_text: 'Upload failed'), + { + type: 'section', + text: { + type: 'mrkdwn', + text: "```#{details}```" + } + }, + { + type: 'divider' + }, + context(CONTENT_MODULE_ROBLES_CONTEXT_IMAGE_URL) + ] + end + def standard_book_fields(book:) [ { diff --git a/app/models/video.rb b/app/models/video.rb index c61f1a0..d4d1a5a 100644 --- a/app/models/video.rb +++ b/app/models/video.rb @@ -43,4 +43,9 @@ def attributes def validation_name title end + + # Content module videos are markdown files containing metadata + def markdown_file + script_file + end end From 6f9a09826ac2c857e5019abcbd9f273b04976227 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Mon, 18 Sep 2023 23:26:07 +0530 Subject: [PATCH 05/44] WEB-6415: Add slide and serve command - Uses existing video course format for now - Updated slide snapshotter to account for segments as well along with episodes --- app/commands/content_module_cli.rb | 31 ++++++++ app/commands/video_cli.rb | 2 +- app/lib/snapshotter/slides.rb | 8 +- app/server/robles_content_module_server.rb | 56 ++++++------- .../_index_table_of_contents.html.erb | 29 +++++++ .../_table_of_contents.html.erb | 41 ++++++++++ .../views/content_modules/assessment.html.erb | 75 ++++++++++++++++++ .../episode_transcript.html.erb | 65 ++++++++++++++++ .../views/content_modules/index.html.erb | 78 +++++++++++++++++++ .../views/content_modules/layout.html.erb | 23 ++++++ .../content_modules/segment_slide.html.erb | 24 ++++++ 11 files changed, 399 insertions(+), 33 deletions(-) create mode 100644 app/server/views/content_modules/_index_table_of_contents.html.erb create mode 100644 app/server/views/content_modules/_table_of_contents.html.erb create mode 100644 app/server/views/content_modules/assessment.html.erb create mode 100644 app/server/views/content_modules/episode_transcript.html.erb create mode 100644 app/server/views/content_modules/index.html.erb create mode 100644 app/server/views/content_modules/layout.html.erb create mode 100644 app/server/views/content_modules/segment_slide.html.erb diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb index f327a89..88b05f1 100644 --- a/app/commands/content_module_cli.rb +++ b/app/commands/content_module_cli.rb @@ -38,6 +38,37 @@ def circulate runner.circulate_content_module(module_file: options['module_file']) end + desc 'slides [MODULE_FILE]', 'generates slides to be inserted at beginning of video' + option :module_file, type: :string, desc: 'Location of the module.yaml file' + option :app_host, type: :string, default: 'app', desc: 'Hostname of host running robles app server' + option :app_port, type: :string, default: '4567', desc: 'Port of host running robles app server' + option :snapshot_host, type: :string, default: 'snapshot', desc: 'Hostname of host running headless chrome' + option :snapshot_port, type: :string, default: '3000', desc: 'Port of host running headless chrome' + option :out_dir, type: :string, default: '/data/src/artwork/slides', desc: 'Location to save the output slides' + def slides + module_file = options.fetch('module_file', runner.default_module_file) + parser = Parser::Circulate.new(file: module_file) + content_module = parser.parse + options.delete('module_file') + args = options.merge(data: content_module.lessons.flat_map(&:segments), snapshot_host: 'localhost').symbolize_keys + snapshotter = Snapshotter::Slides.new(**args) + snapshotter.generate + end + + + desc 'serve', 'starts local preview server' + option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)' + def serve + fork do + if options[:dev] + Guard.start(no_interactions: true) + else + Guard.start(guardfile_contents: content_module_guardfile, watchdir: './', no_interactions: true) + end + end + RoblesContentModuleServer.run! + end + private def runner diff --git a/app/commands/video_cli.rb b/app/commands/video_cli.rb index 213b089..9f79e11 100644 --- a/app/commands/video_cli.rb +++ b/app/commands/video_cli.rb @@ -57,7 +57,7 @@ def slides release_file = options.fetch('release_file', runner.default_release_file) parser = Parser::Release.new(file: release_file) video_course = parser.parse - args = options.merge(video_course:).symbolize_keys + args = options.merge(data: video_course.parts.flat_map(&:episodes)).symbolize_keys snapshotter = Snapshotter::Slides.new(**args) snapshotter.generate end diff --git a/app/lib/snapshotter/slides.rb b/app/lib/snapshotter/slides.rb index d695ddb..31d1e4c 100644 --- a/app/lib/snapshotter/slides.rb +++ b/app/lib/snapshotter/slides.rb @@ -5,10 +5,10 @@ module Snapshotter # Creates snapshots of all the slides in a video course class Slides - attr_reader :video_course, :app_host, :app_port, :snapshot_host, :snapshot_port, :out_dir + attr_reader :app_host, :app_port, :snapshot_host, :snapshot_port, :out_dir - def initialize(video_course:, app_host: 'app', app_port: 4567, snapshot_host: 'snapshot', snapshot_port: 3000, out_dir: '/data/src/slides') # rubocop:disable Metrics/ParameterLists - @video_course = video_course + def initialize(data:, app_host: 'app', app_port: 4567, snapshot_host: 'snapshot', snapshot_port: 3000, out_dir: '/data/src/slides') # rubocop:disable Metrics/ParameterLists + @data = data @app_host = app_host @app_port = app_port @snapshot_host = snapshot_host @@ -18,7 +18,7 @@ def initialize(video_course:, app_host: 'app', app_port: 4567, snapshot_host: 's def generate browser = Ferrum::Browser.new(url: "http://#{snapshot_host}:#{snapshot_port}", window_size: [1920, 1080], timeout: 15) - video_course.parts.flat_map(&:episodes).each do |episode| + data.each do |episode| next unless episode.is_a?(Video) browser.goto("#{app_base}/slides/#{episode.slug}") diff --git a/app/server/robles_content_module_server.rb b/app/server/robles_content_module_server.rb index 187183f..ed030bc 100644 --- a/app/server/robles_content_module_server.rb +++ b/app/server/robles_content_module_server.rb @@ -12,16 +12,16 @@ class RoblesContentModuleServer < Sinatra::Application use Rack::LiveReload, host: 'localhost', source: :vendored helpers do - def slide_path(episode) - "/slides/#{episode.slug}" + def slide_path(segment) + "/slides/#{segment.slug}" end - def transcript_path(episode) - "/transcripts/#{episode.slug}" + def transcript_path(segment) + "/transcripts/#{segment.slug}" end - def assessment_path(episode) - "/assessments/#{episode.slug}" + def assessment_path(segment) + "/assessments/#{segment.slug}" end def class_for_domain(course) @@ -49,43 +49,43 @@ def scss(template, options = {}, locals = {}) get '/' do @content_module = content_module(with_transcript: false) - erb :'videos/index.html', locals: { content_module: @content_module, title: "robles Preview: #{@content_module.title}" }, layout: :'videos/layout.html' + erb :'content_modules/index.html', locals: { content_module: @content_module, title: "robles Preview: #{@content_module.title}" }, layout: :'content_modules/layout.html' end get '/slides/:slug' do @content_module = content_module(with_transcript: false) - episode = episode_for_slug(params[:slug]) - raise Sinatra::NotFound unless episode.present? + segment = segment_for_slug(params[:slug]) + raise Sinatra::NotFound unless segment.present? - part = @content_module.parts.find { |p| p.episodes.include?(episode) } + lesson = @content_module.lessons.find { |p| p.segments.include?(segment) } - erb :'videos/episode_slide.html', - locals: { episode:, part:, content_module: @content_module, title: "robles Preview: #{episode.title}" }, - layout: :'videos/layout.html' + erb :'content_modules/segment_slide.html', + locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, + layout: :'content_modules/layout.html' end get '/transcripts/:slug' do @content_module = content_module(with_transcript: true) - episode = episode_for_slug(params[:slug]) - raise Sinatra::NotFound unless episode.present? + segment = segment_for_slug(params[:slug]) + raise Sinatra::NotFound unless segment.present? - part = @content_module.parts.find { |p| p.episodes.include?(episode) } + lesson = @content_module.lessons.find { |p| p.segments.include?(segment) } - erb :'videos/episode_transcript.html', - locals: { episode:, part:, content_module: @content_module, title: "robles Preview: #{episode.title}" }, - layout: :'videos/layout.html' + erb :'content_modules/segment_transcript.html', + locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, + layout: :'content_modules/layout.html' end get '/assessments/:slug' do @content_module = content_module(with_transcript: false) - episode = episode_for_slug(params[:slug]) - raise Sinatra::NotFound unless episode.present? + segment = segment_for_slug(params[:slug]) + raise Sinatra::NotFound unless segment.present? - part = @content_module.parts.find { |p| p.episodes.include?(episode) } + lesson = @content_module.lessons.find { |p| p.segments.include?(segment) } - erb :'videos/assessment.html', - locals: { episode:, part:, content_module: @content_module, title: "robles Preview: #{episode.title}" }, - layout: :'videos/layout.html' + erb :'content_modules/assessment.html', + locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, + layout: :'content_modules/layout.html' end get '/assets/*' do @@ -113,12 +113,12 @@ def render_string(content) Renderer::MarkdownStringRenderer.new(content:).render end - def episode_for_slug(slug) - @content_module.parts.flat_map(&:episodes).find { |episode| episode.slug == slug } + def segment_for_slug(slug) + @content_module.lessons.flat_map(&:segments).find { |segment| segment.slug == slug } end def module_file - '/data/src/module.yaml' + '../m3-devtest/module.yaml' end def servable_image_url(local_url) diff --git a/app/server/views/content_modules/_index_table_of_contents.html.erb b/app/server/views/content_modules/_index_table_of_contents.html.erb new file mode 100644 index 0000000..23223aa --- /dev/null +++ b/app/server/views/content_modules/_index_table_of_contents.html.erb @@ -0,0 +1,29 @@ +
+ <% content_module.lessons.each do |lesson| %> +

Lesson <%= lesson.ordinal %>: <%= lesson.title %>

+
+

+ <%= lesson.description %> +

+
+ + + <% lesson.segments.each do |segment| %> +
+
+ <% if segment.is_a?(Video) %> +

<%= segment.title %>

+ <% if segment.free %>Free<% end %> + slide + <% elsif segment.is_a?(Assessment) %> +

<%= segment.title %>

+ <% end %> +
+

<%= segment.description %>

+ + <%= segment.ref %> + +
+ <% end %> + <% end %> +
diff --git a/app/server/views/content_modules/_table_of_contents.html.erb b/app/server/views/content_modules/_table_of_contents.html.erb new file mode 100644 index 0000000..2c1708d --- /dev/null +++ b/app/server/views/content_modules/_table_of_contents.html.erb @@ -0,0 +1,41 @@ +
+
+
+ +
+
+
diff --git a/app/server/views/content_modules/assessment.html.erb b/app/server/views/content_modules/assessment.html.erb new file mode 100644 index 0000000..2afdcd0 --- /dev/null +++ b/app/server/views/content_modules/assessment.html.erb @@ -0,0 +1,75 @@ + + +
+ <%= erb :'content_modules/_table_of_contents.html', locals: { content_module: content_module, current_segment: segment } %> + +
+
+
+
+ + <%= content_module.title %> + + + + <%= segment.title %> + + +

+ <%= segment.ref %> +
+ <%= segment.title %> + + It's a quiz! + +
+

+
+
+ +
+ <% segment.questions.each do |question| %> +
+

<%= question.question %>

+
    + <% question.choices.each do |choice| %> +
  • + <%= choice.ref %> + <%= choice.option %> + <% if choice.correct %>correct<% end %> +
  • + <% end %> +
+ <%= question.explanation %> +
+ <% end %> +
+ +
+
+
diff --git a/app/server/views/content_modules/episode_transcript.html.erb b/app/server/views/content_modules/episode_transcript.html.erb new file mode 100644 index 0000000..5fcd44d --- /dev/null +++ b/app/server/views/content_modules/episode_transcript.html.erb @@ -0,0 +1,65 @@ + + +
+ <%= erb :'content_modules/_table_of_contents.html', locals: { content_module: content_module, current_segment: segment } %> + +
+
+
+
+ + <%= content_module.title %> + + + + <%= segment.title %> + + + + +

+ <%= segment.ref %> +
+ <%= segment.title %> + + <% if segment.authors.filter { |author| author.role == 'author' }.present? %> + Written by <%= segment.authors.filter { |author| author.role == 'author' }.map(&:username).to_sentence %> + <% end %> + +
+

+
+
+ +
+ <%= segment.transcript %> +
+ +
+
+
diff --git a/app/server/views/content_modules/index.html.erb b/app/server/views/content_modules/index.html.erb new file mode 100644 index 0000000..c9f7365 --- /dev/null +++ b/app/server/views/content_modules/index.html.erb @@ -0,0 +1,78 @@ +
+ +
+ +
+
+ +
+
+ Version + <%= content_module.version_description %> +
+
+ Platform + <%= content_module.platform %> +
+
+ Language + <%= content_module.language %> +
+
+ Editor + <%= content_module.editor %> +
+
+
+
+ +
+
+
+ +
+ <%= content_module.description %> +
+ + <%= erb :'content_modules/_index_table_of_contents.html', locals: { content_module: content_module } %> + +
+
+

Who is this course for

+
+ <%= content_module.who_is_this_for %> +
+
+
+

Concepts covered in this course

+
+ <%= content_module.covered_concepts %> +
+
+
+
+ +
+
+ +
+ + <% if content_module.professional %> +
+ Pro +
+ <% end %> +
+ +

<%= content_module.title %>

+

+ By* <%= content_module.authors.map(&:username).to_sentence(two_words_connector: ' & ', last_word_connector: ' and ') %> +

+ +

+ <%= content_module.short_description %> +

+
+
+
+
diff --git a/app/server/views/content_modules/layout.html.erb b/app/server/views/content_modules/layout.html.erb new file mode 100644 index 0000000..30fc796 --- /dev/null +++ b/app/server/views/content_modules/layout.html.erb @@ -0,0 +1,23 @@ + + + + + + + <%= title || 'robles Preview' %> + + + + + + + + + <%= erb :'shared/_svg_icons.html' %> + + +
+ <%= yield %> +
+ + diff --git a/app/server/views/content_modules/segment_slide.html.erb b/app/server/views/content_modules/segment_slide.html.erb new file mode 100644 index 0000000..703dd23 --- /dev/null +++ b/app/server/views/content_modules/segment_slide.html.erb @@ -0,0 +1,24 @@ +
+

+ <%= content_module.title %> +

+
+ <% if content_module.lessons.count> 1 %> +

Lesson <%= lesson.ordinal %>: <%= lesson.title %> +

+ <% end %> +

+ <%=segment.ref%>. <%= segment.title %> +

+
+ +
+ +
+ + <% if content_module.featured_banner_image_url&.first&.url.present? %> +
+ +
+ <% end %> +
From c538cee496efd2b319f733d4bbf657536414e542 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Mon, 18 Sep 2023 23:45:55 +0530 Subject: [PATCH 06/44] WEB-6415: Serve text content of modules - Fix nil transcript and author for text and assessment segment. --- app/server/robles_content_module_server.rb | 26 ++++++ .../_index_table_of_contents.html.erb | 2 + ...t.html.erb => segment_transcript.html.erb} | 14 +-- .../views/content_modules/text.html.erb | 85 +++++++++++++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) rename app/server/views/content_modules/{episode_transcript.html.erb => segment_transcript.html.erb} (77%) create mode 100644 app/server/views/content_modules/text.html.erb diff --git a/app/server/robles_content_module_server.rb b/app/server/robles_content_module_server.rb index ed030bc..2aea166 100644 --- a/app/server/robles_content_module_server.rb +++ b/app/server/robles_content_module_server.rb @@ -24,6 +24,10 @@ def assessment_path(segment) "/assessments/#{segment.slug}" end + def text_path(segment) + "/texts/#{segment.slug}" + end + def class_for_domain(course) if course.domains.count > 1 'multi-domain' @@ -88,6 +92,23 @@ def scss(template, options = {}, locals = {}) layout: :'content_modules/layout.html' end + get '/texts/:slug' do + @content_module = content_module(with_transcript: false) + segment = segment_for_slug(params[:slug]) + raise Sinatra::NotFound unless segment.present? + + lesson = @content_module.lessons.find { |p| p.segments.include?(segment) } + + erb :'content_modules/text.html', + locals: { + segment:, + content_module: @content_module, + title: "robles Preview: #{segment.title}", + word_counter: word_counter_for_segment(segment) + }, + layout: :'content_modules/layout.html' + end + get '/assets/*' do local_url = File.join('/data/src/', params[:splat]) raise Sinatra::NotFound unless acceptable_image_extension(File.extname(local_url)) && File.exist?(local_url) @@ -113,6 +134,11 @@ def render_string(content) Renderer::MarkdownStringRenderer.new(content:).render end + def word_counter_for_segment(segment) + markdown = File.read(segment.markdown_file) + Linting::Markdown::WordCounter.new(markdown) + end + def segment_for_slug(slug) @content_module.lessons.flat_map(&:segments).find { |segment| segment.slug == slug } end diff --git a/app/server/views/content_modules/_index_table_of_contents.html.erb b/app/server/views/content_modules/_index_table_of_contents.html.erb index 23223aa..ee20012 100644 --- a/app/server/views/content_modules/_index_table_of_contents.html.erb +++ b/app/server/views/content_modules/_index_table_of_contents.html.erb @@ -17,6 +17,8 @@ slide <% elsif segment.is_a?(Assessment) %>

<%= segment.title %>

+ <% elsif segment.is_a?(Text) %> +

<%= segment.title %>

<% end %>

<%= segment.description %>

diff --git a/app/server/views/content_modules/episode_transcript.html.erb b/app/server/views/content_modules/segment_transcript.html.erb similarity index 77% rename from app/server/views/content_modules/episode_transcript.html.erb rename to app/server/views/content_modules/segment_transcript.html.erb index 5fcd44d..e5e29a8 100644 --- a/app/server/views/content_modules/episode_transcript.html.erb +++ b/app/server/views/content_modules/segment_transcript.html.erb @@ -46,18 +46,20 @@ <%= segment.ref %>
<%= segment.title %> - - <% if segment.authors.filter { |author| author.role == 'author' }.present? %> - Written by <%= segment.authors.filter { |author| author.role == 'author' }.map(&:username).to_sentence %> - <% end %> - + <% if segment.respond_to?(:authors) %> + + <% if segment.authors.filter { |author| author.role == 'author' }.present? %> + Written by <%= segment.authors.filter { |author| author.role == 'author' }.map(&:username).to_sentence %> + <% end %> + + <% end %>
- <%= segment.transcript %> + <%= segment.transcript if segment.respond_to?(:transcript) %>
diff --git a/app/server/views/content_modules/text.html.erb b/app/server/views/content_modules/text.html.erb new file mode 100644 index 0000000..96dc17b --- /dev/null +++ b/app/server/views/content_modules/text.html.erb @@ -0,0 +1,85 @@ +
+ Word count: <%= "#{word_counter.count} / #{word_counter.word_limit}" %>. + <%= "This chapter exceeds the word limit." if word_counter.exceeds_word_limit? %> +
+ + + + +
+ +
+
+
+
+ + <%= content_module.title %> + + + + <%= segment.title %> + + + + +

+
+ <%= segment.title %> + + <% if segment.authors.filter { |author| author.role == 'author' }.present? %> + Written by <%= segment.authors.filter { |author| author.role == 'author' }.map(&:username).to_sentence %> + <% end %> + +
+

+
+
+ +
+ <%= segment.body %> +
+ +
+
+
From 269b1810ce1b378d12d94be8b9c4f080a7a0081f Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Mon, 18 Sep 2023 23:47:57 +0530 Subject: [PATCH 07/44] WEB-6415: Remove hardcoded path --- app/commands/content_module_cli.rb | 2 +- app/server/robles_content_module_server.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb index 88b05f1..dc503e5 100644 --- a/app/commands/content_module_cli.rb +++ b/app/commands/content_module_cli.rb @@ -17,7 +17,7 @@ def serve if options[:dev] Guard.start(no_interactions: true) else - Guard.start(guardfile_contents: content_module_guardfile, watchdir: '../m3-devtest', no_interactions: true) + Guard.start(guardfile_contents: content_module_guardfile, watchdir: '/data/src', no_interactions: true) end end RoblesContentModuleServer.run! diff --git a/app/server/robles_content_module_server.rb b/app/server/robles_content_module_server.rb index 2aea166..1fca1dd 100644 --- a/app/server/robles_content_module_server.rb +++ b/app/server/robles_content_module_server.rb @@ -144,7 +144,7 @@ def segment_for_slug(slug) end def module_file - '../m3-devtest/module.yaml' + '/data/src/module.yaml' end def servable_image_url(local_url) From 80710336c2171f0e652d0689f3a0cac62c6e0795 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Fri, 22 Sep 2023 14:27:29 +0530 Subject: [PATCH 08/44] WEB-6415: Add episode_type for content --- app/models/text.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/text.rb b/app/models/text.rb index d59a20c..e3c88ca 100644 --- a/app/models/text.rb +++ b/app/models/text.rb @@ -27,7 +27,7 @@ def slug # Used for serialisation def attributes - { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false }.stringify_keys + { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false, episode_type: nil }.stringify_keys end # Used for linting @@ -43,4 +43,8 @@ def wrapper_class 'team-bios': 'c-book-chapter__team' }[kind&.to_sym] end + + def episode_type + 'text' + end end From 2cedcad2a7d4fb29b2581df364f75ff1b0114bf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 11:25:17 +0000 Subject: [PATCH 09/44] Bump actions/checkout from 3.6.0 to 4.1.0 (#214) --- .github/workflows/build-docker.yml | 4 ++-- .github/workflows/test.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 0f25eae..1abce8e 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@v3.6.0 + - uses: actions/checkout@v4.1.0 - name: Run tests run: | @@ -62,7 +62,7 @@ jobs: status: STARTING color: warning - - uses: actions/checkout@v3.6.0 + - uses: actions/checkout@v4.1.0 - name: Set up QEMU uses: docker/setup-qemu-action@v2.2.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 725f703..8dd8b06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3.6.0 + - uses: actions/checkout@v4.1.0 - name: Run tests run: | From 04e36d84bc9d0c85d9f98b72950ce62c1bdf77b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 11:25:41 +0000 Subject: [PATCH 10/44] Bump docker/setup-buildx-action from 2.10.0 to 3.0.0 (#213) --- .github/workflows/build-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 1abce8e..b5af551 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -69,7 +69,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2.10.0 + uses: docker/setup-buildx-action@v3.0.0 with: buildkitd-flags: --debug From f0b11458a207b14b679ce17ff67be58993876c56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 11:26:52 +0000 Subject: [PATCH 11/44] Bump docker/build-push-action from 4.1.1 to 5.0.0 (#211) --- .github/workflows/build-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index b5af551..f293a42 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -108,7 +108,7 @@ jobs: echo "::set-output name=tag_list::${TAG_LIST}" - name: Build and push - uses: docker/build-push-action@v4.1.1 + uses: docker/build-push-action@v5.0.0 with: context: . platforms: linux/amd64,linux/arm64 From e7e96ec3307b75baac4e1c7cb403b19bfbeb7441 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 11:27:29 +0000 Subject: [PATCH 12/44] Bump docker/setup-qemu-action from 2.2.0 to 3.0.0 (#212) --- .github/workflows/build-docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index f293a42..1512058 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@v4.1.0 - name: Set up QEMU - uses: docker/setup-qemu-action@v2.2.0 + uses: docker/setup-qemu-action@v3.0.0 - name: Set up Docker Buildx id: buildx From e4f3f70e14e72adaf6bf3e67ac3d042d76edde56 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 25 Sep 2023 13:09:59 +0100 Subject: [PATCH 13/44] WEB-6415: Updating gems so that docker builds And also updated dc file so we can easily preview. Also pins google protobuf so that it works on ARM musl. --- Gemfile | 5 ++++- Gemfile.lock | 21 +++++++++++---------- docker-compose.yml | 8 +++++--- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/Gemfile b/Gemfile index c2e7e2c..33cf0fd 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ gem 'aws-sdk-s3', '~> 1.64' gem 'concurrent-ruby', '~> 1.1' # Interacting with github -gem 'octokit', '~> 6' +gem 'octokit', '~> 7' # Interface with libsodium gem 'rbnacl' @@ -45,6 +45,9 @@ gem 'sass-embedded', '~> 1.58' gem 'sinatra', '~> 3' gem 'thin' +# Pinning google-protobuf so that it continues to build on ARM devices +gem 'google-protobuf', '=3.22.0' + # Controlling Chrome to create snapshots gem 'ferrum' diff --git a/Gemfile.lock b/Gemfile.lock index 1189875..6f161c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,8 +25,8 @@ GEM public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.823.0) - aws-sdk-core (3.181.1) + aws-partitions (1.827.0) + aws-sdk-core (3.183.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) @@ -34,7 +34,7 @@ GEM aws-sdk-kms (1.71.0) aws-sdk-core (~> 3, >= 3.177.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.134.0) + aws-sdk-s3 (1.135.0) aws-sdk-core (~> 3, >= 3.181.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) @@ -66,12 +66,12 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (>= 0.6, < 0.8) - ffi (1.15.5) + ffi (1.16.1) formatador (1.1.0) git (1.18.0) addressable (~> 2.8) rchardet (~> 1.8) - google-protobuf (3.24.3) + google-protobuf (3.22.0) guard (2.18.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) @@ -116,7 +116,7 @@ GEM notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) - octokit (6.1.1) + octokit (7.1.0) faraday (>= 1, < 3) sawyer (~> 0.9) parallel (1.23.0) @@ -164,9 +164,9 @@ GEM parser (>= 3.2.1.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) - sass-embedded (1.67.0) - google-protobuf (~> 3.23) - rake (>= 13.0.0) + sass-embedded (1.62.1) + google-protobuf (~> 3.21) + rake (>= 10.0.0) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) @@ -223,11 +223,12 @@ DEPENDENCIES faraday-retry ferrum git + google-protobuf (= 3.22.0) guard (~> 2, >= 2.16.2) guard-livereload levenshtein-ffi! mini_magick - octokit (~> 6) + octokit (~> 7) rack-livereload rack-test rbnacl diff --git a/docker-compose.yml b/docker-compose.yml index 549ec93..eddded9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,15 @@ services: # When working on videos #- ../videos/video-aiar:/data/src # When working on books - - ../../books/alg:/data/src + #- ../../books/alg:/data/src # When working on pablo #- ../../sites/pablo:/data/src - command: bin/robles video serve + # When working on m3 + - ../../m3/m3-devtest:/data/src + command: bin/robles module serve env_file: .env environment: - - IMAGES_CDN_HOST=assets.robles.raywenderlich.com + - IMAGES_CDN_HOST=assets.robles.kodeco.com ports: - "4567:4567" - "35729:35729" From e7278df7fcd5eb9f2b16b67218b81892e9334177 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Wed, 27 Sep 2023 19:28:04 +0530 Subject: [PATCH 14/44] WEB-6415: Use module for content module command - lo was learning objective acronym --- app/commands/robles_cli.rb | 2 +- app/lib/image_provider/content_module_extractor.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/commands/robles_cli.rb b/app/commands/robles_cli.rb index f4ed7ec..86863ac 100644 --- a/app/commands/robles_cli.rb +++ b/app/commands/robles_cli.rb @@ -14,7 +14,7 @@ def self.exit_on_failure? subcommand 'video', VideoCli desc 'module [SUBCOMMAND] ...ARGS', 'manage publication of content modules' - subcommand 'lo', ContentModuleCli + subcommand 'module', ContentModuleCli desc 'pablo [SUBCOMMAND] ...ARGS', 'manage publication of pablo' subcommand 'pablo', PabloCli diff --git a/app/lib/image_provider/content_module_extractor.rb b/app/lib/image_provider/content_module_extractor.rb index 71313e5..b2cc19a 100644 --- a/app/lib/image_provider/content_module_extractor.rb +++ b/app/lib/image_provider/content_module_extractor.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ImageProvider - # Extract all images from a video course + # Extract all images from a content module class ContentModuleExtractor attr_reader :content_module, :images From dd419ea0d66af60909cb806cfd9601e12d80d1a6 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Thu, 28 Sep 2023 18:57:44 +0530 Subject: [PATCH 15/44] WEB-6415: Accept module secrets --- app/commands/content_module_cli.rb | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb index dc503e5..b198da8 100644 --- a/app/commands/content_module_cli.rb +++ b/app/commands/content_module_cli.rb @@ -11,7 +11,7 @@ def render end desc 'serve', 'starts local preview server' - option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)' + option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not module files)' def serve fork do if options[:dev] @@ -57,7 +57,7 @@ def slides desc 'serve', 'starts local preview server' - option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not book files)' + option :dev, type: :boolean, desc: 'Run in development mode (watch robles files, not module files)' def serve fork do if options[:dev] @@ -69,6 +69,29 @@ def serve RoblesContentModuleServer.run! end + desc 'secrets [REPO]', 'configures a module repo with the necessary secrets' + long_desc <<-LONGDESC + `robles module secrets [REPO]` will upload the secrets requires to run robles on a + git repository containing a module. + + You must ensure that the required secrets are provided as environment variables + before running this command: + + GITHUB_TOKEN= + REPO_ALEXANDRIA_SERVICE_API_TOKEN_PRODUCTION= + REPO_ALEXANDRIA_SERVICE_API_TOKEN_STAGING= + REPO_AWS_ACCESS_KEY_ID_PRODUCTION= + REPO_AWS_ACCESS_KEY_ID_STAGING= + REPO_AWS_SECRET_ACCESS_KEY_PRODUCTION= + REPO_AWS_SECRET_ACCESS_KEY_STAGING= + REPO_SLACK_BOT_TOKEN= + REPO_SLACK_WEBHOOK_URL= + LONGDESC + def secrets(repo) + secrets_manager = RepoManagement::Secrets.new(repo:, mode: :content_module) + secrets_manager.apply_secrets + end + private def runner From 33625f54ebe1167714ccb806954ad4de24fee032 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Thu, 28 Sep 2023 16:24:46 +0100 Subject: [PATCH 16/44] Attempting to handle update in octokit.rb --- app/lib/repo_management/secrets.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/repo_management/secrets.rb b/app/lib/repo_management/secrets.rb index b236c4d..8127738 100644 --- a/app/lib/repo_management/secrets.rb +++ b/app/lib/repo_management/secrets.rb @@ -44,7 +44,7 @@ def apply_secrets end def apply_secret(name, value) - client.create_or_update_secret(repo, name.upcase, options_for_secret(value)) + client.create_or_update_actions_secret(repo, name.upcase, options_for_secret(value)) end def options_for_secret(value) @@ -65,7 +65,7 @@ def key end def public_key - @public_key ||= client.get_public_key(repo) + @public_key ||= client.get_actions_public_key(repo) end def client From ca826575fc81f5f539ba6b0062a76146f0717fb1 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Thu, 28 Sep 2023 21:07:38 +0530 Subject: [PATCH 17/44] WEB-6415: Add default module file location --- app/lib/runner/ci.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/lib/runner/ci.rb b/app/lib/runner/ci.rb index e4fe0c2..89bd559 100644 --- a/app/lib/runner/ci.rb +++ b/app/lib/runner/ci.rb @@ -19,6 +19,10 @@ def default_release_file Pathname.new(GITHUB_WORKSPACE).join('release.yaml').to_s end + def default_module_file + Pathname.new(GITHUB_WORKSPACE).join('module.yaml').to_s + end + def default_pablo_source Pathname.new(GITHUB_WORKSPACE).join('images').to_s end From 0ff20692cf7dac0f7b11b13623871c6d19bcacbf Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Mon, 2 Oct 2023 15:42:53 +0530 Subject: [PATCH 18/44] WEB-6415: Add module outcomes details - Add text content ref details --- app/lib/parser/content_module.rb | 2 +- app/lib/renderer/segment.rb | 2 +- app/models/content_module.rb | 5 +++-- app/models/lesson.rb | 4 ++-- app/models/text.rb | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/lib/parser/content_module.rb b/app/lib/parser/content_module.rb index 1341f31..6ecba3c 100644 --- a/app/lib/parser/content_module.rb +++ b/app/lib/parser/content_module.rb @@ -10,7 +10,7 @@ class ContentModule VALID_SIMPLE_ATTRIBUTES = %i[shortcode version version_description title description_md short_description released_at materials_url professional difficulty platform language editor domains - categories who_is_this_for_md covered_concepts_md git_commit_hash + categories who_is_this_for_md module_outcomes_md covered_concepts_md git_commit_hash card_artwork_image featured_banner_image twitter_card_image access_personal access_team].freeze diff --git a/app/lib/renderer/segment.rb b/app/lib/renderer/segment.rb index b66d304..9278ba7 100644 --- a/app/lib/renderer/segment.rb +++ b/app/lib/renderer/segment.rb @@ -20,7 +20,7 @@ def self.create(model, image_provider:) end def render - logger.info "Beginning episode render: #{object.ordinal}: #{object.title}" + logger.info "Beginning segment render: #{object.ref}: #{object.title}" render_markdown end end diff --git a/app/models/content_module.rb b/app/models/content_module.rb index d597036..a49ade7 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -9,13 +9,14 @@ class ContentModule attr_accessor :shortcode, :version, :version_description, :title, :description_md, :short_description, :released_at, :materials_url, :professional, :difficulty, - :platform, :language, :editor, :domains, :categories, :who_is_this_for_md, + :platform, :language, :editor, :domains, :categories, :who_is_this_for_md, :module_outcomes_md, :covered_concepts_md, :authors, :lessons, :git_commit_hash, :card_artwork_image, :featured_banner_image, :twitter_card_image, :root_path, :access_personal, :access_team attr_markdown :who_is_this_for, source: :who_is_this_for_md, file: false attr_markdown :covered_concepts, source: :covered_concepts_md, file: false + attr_markdown :outcomes, source: :module_outcomes_md, file: false attr_markdown :description, source: :description_md, file: false attr_image :card_artwork_image_url, source: :card_artwork_image, variants: %i[original w560 w240] attr_image :featured_banner_image_url, source: :featured_banner_image, variants: %i[original w750 w225 w90] @@ -42,7 +43,7 @@ def attributes { shortcode: nil, version: nil, version_description: nil, title: nil, description: nil, short_description: nil, released_at: nil, materials_url: nil, professional: nil, difficulty: nil, platform: nil, language: nil, editor: nil, domains: [], - categories: [], who_is_this_for: nil, covered_concepts: nil, authors: [], lessons: [], + categories: [], who_is_this_for: nil, covered_concepts: nil, outcomes: nil, authors: [], lessons: [], git_commit_hash: nil, card_artwork_image_url: [], featured_banner_image_url: [], twitter_card_image_url: [], access_personal: nil, access_team: nil }.stringify_keys end diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 2f2d0b5..9933667 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -7,7 +7,7 @@ class Lesson include Concerns::ImageAttachable include Concerns::MarkdownRenderable - attr_accessor :title, :description, :ordinal, :segments + attr_accessor :title, :description, :ordinal, :ref, :segments validates :title, :ordinal, presence: true @@ -18,7 +18,7 @@ def initialize(attributes = {}) # Used for serialisation def attributes - { title: nil, description: nil, ordinal: nil, segments: [] }.stringify_keys + { title: nil, description: nil, ordinal: nil, segments: [], ref: nil }.stringify_keys end # Used for linting diff --git a/app/models/text.rb b/app/models/text.rb index e3c88ca..46717df 100644 --- a/app/models/text.rb +++ b/app/models/text.rb @@ -27,7 +27,7 @@ def slug # Used for serialisation def attributes - { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false, episode_type: nil }.stringify_keys + { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false, episode_type: nil, ref: nil }.stringify_keys end # Used for linting From 9a04b6b71f5fa4de0ab2bcdc8506fa9ba75136e0 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Mon, 2 Oct 2023 18:56:41 +0530 Subject: [PATCH 19/44] WEB-6415: Add segment type to content model wherever necessary --- app/models/assessment.rb | 6 +++++- app/models/text.rb | 6 +++++- app/models/video.rb | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/models/assessment.rb b/app/models/assessment.rb index b64a95f..02acee8 100644 --- a/app/models/assessment.rb +++ b/app/models/assessment.rb @@ -35,9 +35,13 @@ def episode_type 'assessment' end + def segment_type + 'assessment' + end + # Used for serialisation def attributes - { title: nil, ordinal: nil, description: nil, short_description: nil, ref: nil, episode_type: nil, assessment_type: nil }.stringify_keys + { title: nil, ordinal: nil, description: nil, short_description: nil, ref: nil, episode_type:, segment_type:, assessment_type: nil }.stringify_keys end # Used for linting diff --git a/app/models/text.rb b/app/models/text.rb index 46717df..0a79177 100644 --- a/app/models/text.rb +++ b/app/models/text.rb @@ -27,7 +27,7 @@ def slug # Used for serialisation def attributes - { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false, episode_type: nil, ref: nil }.stringify_keys + { title: nil, ordinal: nil, description: nil, body: nil, authors: [], free: false, ref: nil, episode_type:, segment_type: }.stringify_keys end # Used for linting @@ -47,4 +47,8 @@ def wrapper_class def episode_type 'text' end + + def segment_type + 'text' + end end diff --git a/app/models/video.rb b/app/models/video.rb index d4d1a5a..42053fa 100644 --- a/app/models/video.rb +++ b/app/models/video.rb @@ -33,10 +33,14 @@ def episode_type 'video' end + def segment_type + 'video' + end + # Used for serialisation def attributes { title: nil, ordinal: nil, free: false, description: nil, short_description: nil, authors_notes: nil, - authors: [], transcript: nil, ref: nil, episode_type: }.stringify_keys + authors: [], transcript: nil, ref: nil, episode_type:, segment_type: }.stringify_keys end # Used for linting From 5540664995cc61f043e36a4d0a5a414dc1d390d7 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Mon, 2 Oct 2023 18:59:45 +0530 Subject: [PATCH 20/44] WEB-6415: card_artwork_image is not needed since rebrading - Identical with feature_banner --- app/lib/parser/content_module.rb | 2 +- app/models/content_module.rb | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/lib/parser/content_module.rb b/app/lib/parser/content_module.rb index 6ecba3c..c9166df 100644 --- a/app/lib/parser/content_module.rb +++ b/app/lib/parser/content_module.rb @@ -11,7 +11,7 @@ class ContentModule description_md short_description released_at materials_url professional difficulty platform language editor domains categories who_is_this_for_md module_outcomes_md covered_concepts_md git_commit_hash - card_artwork_image featured_banner_image twitter_card_image + featured_banner_image twitter_card_image access_personal access_team].freeze attr_accessor :content_module diff --git a/app/models/content_module.rb b/app/models/content_module.rb index a49ade7..f0635c6 100644 --- a/app/models/content_module.rb +++ b/app/models/content_module.rb @@ -10,7 +10,7 @@ class ContentModule attr_accessor :shortcode, :version, :version_description, :title, :description_md, :short_description, :released_at, :materials_url, :professional, :difficulty, :platform, :language, :editor, :domains, :categories, :who_is_this_for_md, :module_outcomes_md, - :covered_concepts_md, :authors, :lessons, :git_commit_hash, :card_artwork_image, + :covered_concepts_md, :authors, :lessons, :git_commit_hash, :featured_banner_image, :twitter_card_image, :root_path, :access_personal, :access_team @@ -18,7 +18,6 @@ class ContentModule attr_markdown :covered_concepts, source: :covered_concepts_md, file: false attr_markdown :outcomes, source: :module_outcomes_md, file: false attr_markdown :description, source: :description_md, file: false - attr_image :card_artwork_image_url, source: :card_artwork_image, variants: %i[original w560 w240] attr_image :featured_banner_image_url, source: :featured_banner_image, variants: %i[original w750 w225 w90] attr_image :twitter_card_image_url, source: :twitter_card_image, variants: %i[original w1800] @@ -44,7 +43,7 @@ def attributes description: nil, short_description: nil, released_at: nil, materials_url: nil, professional: nil, difficulty: nil, platform: nil, language: nil, editor: nil, domains: [], categories: [], who_is_this_for: nil, covered_concepts: nil, outcomes: nil, authors: [], lessons: [], - git_commit_hash: nil, card_artwork_image_url: [], featured_banner_image_url: [], + git_commit_hash: nil, featured_banner_image_url: [], twitter_card_image_url: [], access_personal: nil, access_team: nil }.stringify_keys end From 07d63d362f311c4e0c9386c5549461a0b2492003 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Mon, 2 Oct 2023 19:31:56 +0530 Subject: [PATCH 21/44] WEB-6415: Missing method error fix --- app/lib/util/slack_notifiable.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/lib/util/slack_notifiable.rb b/app/lib/util/slack_notifiable.rb index e0c8b59..88b8f87 100644 --- a/app/lib/util/slack_notifiable.rb +++ b/app/lib/util/slack_notifiable.rb @@ -197,6 +197,27 @@ def standard_video_course_fields(video_course:) ] end + def standard_content_module_fields(content_module:) + [ + { + type: 'mrkdwn', + text: "*Content Module*\n#{content_module&.title || '_unknown_'}" + }, + { + type: 'mrkdwn', + text: "*Short Code*\n`#{content_module&.shortcode || 'unknown'}`" + }, + { + type: 'mrkdwn', + text: "*Version*\n#{content_module&.version || '_unknown_'}" + }, + { + type: 'mrkdwn', + text: "*Environment*\n`#{ENVIRONMENT}`" + } + ] + end + def intro_section(fields:, message:, image_url:, alt_text:) { type: 'section', From f3116c96cbc75f41d587e1e410a9fbffede61d3e Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Wed, 4 Oct 2023 22:45:45 +0530 Subject: [PATCH 22/44] WEB-6508: Validate presence of ref in text lesson --- app/models/lessons_validator.rb | 2 +- app/models/text.rb | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models/lessons_validator.rb b/app/models/lessons_validator.rb index f7ed239..1c068c4 100644 --- a/app/models/lessons_validator.rb +++ b/app/models/lessons_validator.rb @@ -23,7 +23,7 @@ def check_unique_refs(record, attribute, value) lesson.segments.each { |segment| ref_counts[segment.ref] += 1 } non_unique_refs = ref_counts.select { |_, count| count > 1 }.keys - non_unique_refs.each { |ref| record.errors.add(attribute, "segment ref #{ref} is not unique") } + non_unique_refs.each { |ref| record.errors.add(attribute, "(#{lesson.title}) segment ref #{ref} is not unique") } end end end diff --git a/app/models/text.rb b/app/models/text.rb index 0a79177..edc9e98 100644 --- a/app/models/text.rb +++ b/app/models/text.rb @@ -12,7 +12,11 @@ class Text attr_accessor :title, :ordinal, :ref, :description, :authors, :markdown_file, :root_path, :free, :kind attr_markdown :body, source: :markdown_file, file: true, wrapper_class: :wrapper_class - validates :title, :ordinal, :markdown_file, presence: true + validates :title, :ordinal, :ref, :markdown_file, presence: true + validate do |text| + # Check the ref is a string + errors.add(:ref, 'must be a string') unless text.ref.is_a?(String) + end def initialize(attributes = {}) super From f98949c30c61dda93be45b6e693682fbbe1a85fc Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 9 Oct 2023 20:25:46 +0100 Subject: [PATCH 23/44] WEB-6519: Fixing the paths for segments in the m3 server They need to be scoped within the lessons, otherwise they aren't unique --- app/lib/parser/lesson_metadata.rb | 2 +- app/models/lesson.rb | 4 ++ app/server/robles_content_module_server.rb | 52 +++++++++---------- .../_index_table_of_contents.html.erb | 8 +-- .../_table_of_contents.html.erb | 2 +- 5 files changed, 36 insertions(+), 32 deletions(-) diff --git a/app/lib/parser/lesson_metadata.rb b/app/lib/parser/lesson_metadata.rb index a24ddd5..df9c88b 100644 --- a/app/lib/parser/lesson_metadata.rb +++ b/app/lib/parser/lesson_metadata.rb @@ -5,7 +5,7 @@ module Parser class LessonMetadata include SimpleAttributes - VALID_SIMPLE_ATTRIBUTES = %i[title description ordinal].freeze + VALID_SIMPLE_ATTRIBUTES = %i[title description ordinal ref].freeze attr_accessor :lesson, :metadata diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 9933667..99fdfd7 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -16,6 +16,10 @@ def initialize(attributes = {}) @segments ||= [] end + def slug + "#{ref}-#{title.parameterize}" + end + # Used for serialisation def attributes { title: nil, description: nil, ordinal: nil, segments: [], ref: nil }.stringify_keys diff --git a/app/server/robles_content_module_server.rb b/app/server/robles_content_module_server.rb index 1fca1dd..df9c8e3 100644 --- a/app/server/robles_content_module_server.rb +++ b/app/server/robles_content_module_server.rb @@ -12,20 +12,20 @@ class RoblesContentModuleServer < Sinatra::Application use Rack::LiveReload, host: 'localhost', source: :vendored helpers do - def slide_path(segment) - "/slides/#{segment.slug}" + def slide_path(lesson, segment) + "/slides/#{lesson.slug}/#{segment.slug}" end - def transcript_path(segment) - "/transcripts/#{segment.slug}" + def transcript_path(lesson, segment) + "/transcripts/#{lesson.slug}/#{segment.slug}" end - def assessment_path(segment) - "/assessments/#{segment.slug}" + def assessment_path(lesson, segment) + "/assessments/#{lesson.slug}/#{segment.slug}" end - def text_path(segment) - "/texts/#{segment.slug}" + def text_path(lesson, segment) + "/texts/#{lesson.slug}/#{segment.slug}" end def class_for_domain(course) @@ -56,49 +56,45 @@ def scss(template, options = {}, locals = {}) erb :'content_modules/index.html', locals: { content_module: @content_module, title: "robles Preview: #{@content_module.title}" }, layout: :'content_modules/layout.html' end - get '/slides/:slug' do + get '/slides/:lesson_slug/:slug' do @content_module = content_module(with_transcript: false) - segment = segment_for_slug(params[:slug]) + lesson = lesson_for_slug(params[:lesson_slug]) + segment = segment_for_slug(lesson, params[:slug]) raise Sinatra::NotFound unless segment.present? - lesson = @content_module.lessons.find { |p| p.segments.include?(segment) } - erb :'content_modules/segment_slide.html', locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, layout: :'content_modules/layout.html' end - get '/transcripts/:slug' do + get '/transcripts/:lesson_slug/:slug' do @content_module = content_module(with_transcript: true) - segment = segment_for_slug(params[:slug]) + lesson = lesson_for_slug(params[:lesson_slug]) + segment = segment_for_slug(lesson, params[:slug]) raise Sinatra::NotFound unless segment.present? - lesson = @content_module.lessons.find { |p| p.segments.include?(segment) } - erb :'content_modules/segment_transcript.html', locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, layout: :'content_modules/layout.html' end - get '/assessments/:slug' do + get '/assessments/:lesson_slug/:slug' do @content_module = content_module(with_transcript: false) - segment = segment_for_slug(params[:slug]) + lesson = lesson_for_slug(params[:lesson_slug]) + segment = segment_for_slug(lesson, params[:slug]) raise Sinatra::NotFound unless segment.present? - lesson = @content_module.lessons.find { |p| p.segments.include?(segment) } - erb :'content_modules/assessment.html', locals: { segment:, lesson:, content_module: @content_module, title: "robles Preview: #{segment.title}" }, layout: :'content_modules/layout.html' end - get '/texts/:slug' do + get '/texts/:lesson_slug/:slug' do @content_module = content_module(with_transcript: false) - segment = segment_for_slug(params[:slug]) + lesson = lesson_for_slug(params[:lesson_slug]) + segment = segment_for_slug(lesson, params[:slug]) raise Sinatra::NotFound unless segment.present? - lesson = @content_module.lessons.find { |p| p.segments.include?(segment) } - erb :'content_modules/text.html', locals: { segment:, @@ -139,8 +135,12 @@ def word_counter_for_segment(segment) Linting::Markdown::WordCounter.new(markdown) end - def segment_for_slug(slug) - @content_module.lessons.flat_map(&:segments).find { |segment| segment.slug == slug } + def lesson_for_slug(slug) + @content_module.lessons.find { |lesson| lesson.slug == slug } + end + + def segment_for_slug(lesson, slug) + lesson.segments.find { |segment| segment.slug == slug } end def module_file diff --git a/app/server/views/content_modules/_index_table_of_contents.html.erb b/app/server/views/content_modules/_index_table_of_contents.html.erb index ee20012..e8bf9d3 100644 --- a/app/server/views/content_modules/_index_table_of_contents.html.erb +++ b/app/server/views/content_modules/_index_table_of_contents.html.erb @@ -12,13 +12,13 @@
<% if segment.is_a?(Video) %> -

<%= segment.title %>

+

<%= segment.title %>

<% if segment.free %>Free<% end %> - slide + slide <% elsif segment.is_a?(Assessment) %> -

<%= segment.title %>

+

<%= segment.title %>

<% elsif segment.is_a?(Text) %> -

<%= segment.title %>

+

<%= segment.title %>

<% end %>

<%= segment.description %>

diff --git a/app/server/views/content_modules/_table_of_contents.html.erb b/app/server/views/content_modules/_table_of_contents.html.erb index 2c1708d..a9d59ae 100644 --- a/app/server/views/content_modules/_table_of_contents.html.erb +++ b/app/server/views/content_modules/_table_of_contents.html.erb @@ -26,7 +26,7 @@
    <% lesson.segments.each do |segment| %>
  • - + <%= segment.ref %>. <%= segment.title %> From 20adcf8f134f40c116277f49c32afa9172b64b9c Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 9 Oct 2023 20:51:01 +0100 Subject: [PATCH 24/44] WEB-6519: Refactoring the slide generator to support content modules --- app/commands/content_module_cli.rb | 4 ++-- app/commands/video_cli.rb | 2 +- app/lib/snapshotter/content_module_slides.rb | 17 +++++++++++++++++ app/lib/snapshotter/slides.rb | 10 ++++------ app/lib/snapshotter/video_course_slides.rb | 15 +++++++++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 app/lib/snapshotter/content_module_slides.rb create mode 100644 app/lib/snapshotter/video_course_slides.rb diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb index b198da8..e7d5031 100644 --- a/app/commands/content_module_cli.rb +++ b/app/commands/content_module_cli.rb @@ -44,14 +44,14 @@ def circulate option :app_port, type: :string, default: '4567', desc: 'Port of host running robles app server' option :snapshot_host, type: :string, default: 'snapshot', desc: 'Hostname of host running headless chrome' option :snapshot_port, type: :string, default: '3000', desc: 'Port of host running headless chrome' - option :out_dir, type: :string, default: '/data/src/artwork/slides', desc: 'Location to save the output slides' + option :out_dir, type: :string, default: '/data/src/artwork/video-title-slides', desc: 'Location to save the output slides' def slides module_file = options.fetch('module_file', runner.default_module_file) parser = Parser::Circulate.new(file: module_file) content_module = parser.parse options.delete('module_file') args = options.merge(data: content_module.lessons.flat_map(&:segments), snapshot_host: 'localhost').symbolize_keys - snapshotter = Snapshotter::Slides.new(**args) + snapshotter = Snapshotter::ContentModuleSlides.new(**args) snapshotter.generate end diff --git a/app/commands/video_cli.rb b/app/commands/video_cli.rb index 9f79e11..495e6d1 100644 --- a/app/commands/video_cli.rb +++ b/app/commands/video_cli.rb @@ -58,7 +58,7 @@ def slides parser = Parser::Release.new(file: release_file) video_course = parser.parse args = options.merge(data: video_course.parts.flat_map(&:episodes)).symbolize_keys - snapshotter = Snapshotter::Slides.new(**args) + snapshotter = Snapshotter::VideoCourseSlides.new(**args) snapshotter.generate end diff --git a/app/lib/snapshotter/content_module_slides.rb b/app/lib/snapshotter/content_module_slides.rb new file mode 100644 index 0000000..280b1fc --- /dev/null +++ b/app/lib/snapshotter/content_module_slides.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Snapshotter + # Creates snapshots of all the slides in a content module + class ContentModule < Slides + def generate + data.each do |lesson| + lesson.segments.each do |segment| + next unless segment.is_a?(Video) + + browser.goto("#{app_base}/slides/#{lesson.slug}/#{episode.slug}") + browser.screenshot(path: "#{out_dir}/#{lesson-ref}-#{episode.slug}.png", selector: '#slide-to-snapshot') + end + end + end + end +end diff --git a/app/lib/snapshotter/slides.rb b/app/lib/snapshotter/slides.rb index 31d1e4c..dcd8af7 100644 --- a/app/lib/snapshotter/slides.rb +++ b/app/lib/snapshotter/slides.rb @@ -17,13 +17,11 @@ def initialize(data:, app_host: 'app', app_port: 4567, snapshot_host: 'snapshot' end def generate - browser = Ferrum::Browser.new(url: "http://#{snapshot_host}:#{snapshot_port}", window_size: [1920, 1080], timeout: 15) - data.each do |episode| - next unless episode.is_a?(Video) + raise 'Override this in a subclass please' + end - browser.goto("#{app_base}/slides/#{episode.slug}") - browser.screenshot(path: "#{out_dir}/#{episode.slug}.png", selector: '#slide-to-snapshot') - end + def browser + @browser ||= Ferrum::Browser.new(url: "http://#{snapshot_host}:#{snapshot_port}", window_size: [1920, 1080], timeout: 15) end def app_base diff --git a/app/lib/snapshotter/video_course_slides.rb b/app/lib/snapshotter/video_course_slides.rb new file mode 100644 index 0000000..24740b1 --- /dev/null +++ b/app/lib/snapshotter/video_course_slides.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Snapshotter + # Creates snapshots of all the slides in a video course + class ContentModule < Slides + def generate + data.each do |episode| + next unless episode.is_a?(Video) + + browser.goto("#{app_base}/slides/#{episode.slug}") + browser.screenshot(path: "#{out_dir}/#{episode.slug}.png", selector: '#slide-to-snapshot') + end + end + end +end From 325f58d50d2d44e0f17d20bfdf616a38138b81bc Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 9 Oct 2023 21:17:02 +0100 Subject: [PATCH 25/44] WEB-6519: Attempting to get the slide design to align with design --- .../views/content_modules/segment_slide.html.erb | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/server/views/content_modules/segment_slide.html.erb b/app/server/views/content_modules/segment_slide.html.erb index 703dd23..41000c8 100644 --- a/app/server/views/content_modules/segment_slide.html.erb +++ b/app/server/views/content_modules/segment_slide.html.erb @@ -4,21 +4,16 @@
    <% if content_module.lessons.count> 1 %> -

    Lesson <%= lesson.ordinal %>: <%= lesson.title %> -

    - <% end %> -

    - <%=segment.ref%>. <%= segment.title %> -

    +

    Lesson <%= lesson.ref %>: <%= lesson.title %>

    + <% end %> +

    <%= segment.title %>

    -
    - -
    +
    <% if content_module.featured_banner_image_url&.first&.url.present? %>
    - <% end %> + <% end %>
From ccfacc5f92437596ed32b6bcccb45a526eab3197 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 9 Oct 2023 21:46:56 +0100 Subject: [PATCH 26/44] WEB-6520: Missing description and learning objective fields from lesson It's possible that it's because we don't use LOs yet? Not sure. But we defo need description. --- app/lib/parser/lesson_metadata.rb | 2 +- app/models/lesson.rb | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/lib/parser/lesson_metadata.rb b/app/lib/parser/lesson_metadata.rb index df9c88b..76f8550 100644 --- a/app/lib/parser/lesson_metadata.rb +++ b/app/lib/parser/lesson_metadata.rb @@ -5,7 +5,7 @@ module Parser class LessonMetadata include SimpleAttributes - VALID_SIMPLE_ATTRIBUTES = %i[title description ordinal ref].freeze + VALID_SIMPLE_ATTRIBUTES = %i[title description_md learning_objectives_md ordinal ref].freeze attr_accessor :lesson, :metadata diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 99fdfd7..3c39c50 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -7,8 +7,10 @@ class Lesson include Concerns::ImageAttachable include Concerns::MarkdownRenderable - attr_accessor :title, :description, :ordinal, :ref, :segments + attr_accessor :title, :description_md, :ordinal, :ref, :segments, :learning_objectives_md + attr_markdown :description, source: :description_md, file: false + attr_markdown :learning_objectives, source: :learning_objectives_md, file: false validates :title, :ordinal, presence: true def initialize(attributes = {}) @@ -22,7 +24,7 @@ def slug # Used for serialisation def attributes - { title: nil, description: nil, ordinal: nil, segments: [], ref: nil }.stringify_keys + { title: nil, description: nil, learning_objectives: nil, ordinal: nil, segments: [], ref: nil }.stringify_keys end # Used for linting From c87ce8a91ff610e02c0ee0ac4abf1f92f3838671 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 9 Oct 2023 22:13:29 +0100 Subject: [PATCH 27/44] WEB-6519: Typo in the slide generator --- app/lib/snapshotter/content_module_slides.rb | 2 +- app/lib/snapshotter/video_course_slides.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/snapshotter/content_module_slides.rb b/app/lib/snapshotter/content_module_slides.rb index 280b1fc..651f4d6 100644 --- a/app/lib/snapshotter/content_module_slides.rb +++ b/app/lib/snapshotter/content_module_slides.rb @@ -2,7 +2,7 @@ module Snapshotter # Creates snapshots of all the slides in a content module - class ContentModule < Slides + class ContentModuleSlides < Slides def generate data.each do |lesson| lesson.segments.each do |segment| diff --git a/app/lib/snapshotter/video_course_slides.rb b/app/lib/snapshotter/video_course_slides.rb index 24740b1..0227f68 100644 --- a/app/lib/snapshotter/video_course_slides.rb +++ b/app/lib/snapshotter/video_course_slides.rb @@ -2,7 +2,7 @@ module Snapshotter # Creates snapshots of all the slides in a video course - class ContentModule < Slides + class VideoCourseSlides < Slides def generate data.each do |episode| next unless episode.is_a?(Video) From df4ab2a94186bc2e182b4cb7275f77316208e96b Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 9 Oct 2023 22:40:36 +0100 Subject: [PATCH 28/44] Ci bugfizing... --- app/commands/content_module_cli.rb | 2 +- app/lib/snapshotter/slides.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb index e7d5031..2f3fbe3 100644 --- a/app/commands/content_module_cli.rb +++ b/app/commands/content_module_cli.rb @@ -50,7 +50,7 @@ def slides parser = Parser::Circulate.new(file: module_file) content_module = parser.parse options.delete('module_file') - args = options.merge(data: content_module.lessons.flat_map(&:segments), snapshot_host: 'localhost').symbolize_keys + args = options.merge(data: content_module.lessons, snapshot_host: 'localhost').symbolize_keys snapshotter = Snapshotter::ContentModuleSlides.new(**args) snapshotter.generate end diff --git a/app/lib/snapshotter/slides.rb b/app/lib/snapshotter/slides.rb index dcd8af7..84bd7ad 100644 --- a/app/lib/snapshotter/slides.rb +++ b/app/lib/snapshotter/slides.rb @@ -5,7 +5,7 @@ module Snapshotter # Creates snapshots of all the slides in a video course class Slides - attr_reader :app_host, :app_port, :snapshot_host, :snapshot_port, :out_dir + attr_reader :data, :app_host, :app_port, :snapshot_host, :snapshot_port, :out_dir def initialize(data:, app_host: 'app', app_port: 4567, snapshot_host: 'snapshot', snapshot_port: 3000, out_dir: '/data/src/slides') # rubocop:disable Metrics/ParameterLists @data = data From b6bb3ff6725e1b5e3e94775d0d670c0acde9400e Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 10 Oct 2023 08:29:17 +0100 Subject: [PATCH 29/44] snapshot_host should not have been overridden for CI --- app/commands/content_module_cli.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/commands/content_module_cli.rb b/app/commands/content_module_cli.rb index 2f3fbe3..75926e8 100644 --- a/app/commands/content_module_cli.rb +++ b/app/commands/content_module_cli.rb @@ -50,7 +50,7 @@ def slides parser = Parser::Circulate.new(file: module_file) content_module = parser.parse options.delete('module_file') - args = options.merge(data: content_module.lessons, snapshot_host: 'localhost').symbolize_keys + args = options.merge(data: content_module.lessons).symbolize_keys snapshotter = Snapshotter::ContentModuleSlides.new(**args) snapshotter.generate end From 0eabe744e610b0569b4ee14570d988917290e0b4 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 10 Oct 2023 08:47:32 +0100 Subject: [PATCH 30/44] Incorrect variable naming --- app/lib/snapshotter/content_module_slides.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/snapshotter/content_module_slides.rb b/app/lib/snapshotter/content_module_slides.rb index 651f4d6..051cca3 100644 --- a/app/lib/snapshotter/content_module_slides.rb +++ b/app/lib/snapshotter/content_module_slides.rb @@ -8,8 +8,8 @@ def generate lesson.segments.each do |segment| next unless segment.is_a?(Video) - browser.goto("#{app_base}/slides/#{lesson.slug}/#{episode.slug}") - browser.screenshot(path: "#{out_dir}/#{lesson-ref}-#{episode.slug}.png", selector: '#slide-to-snapshot') + browser.goto("#{app_base}/slides/#{lesson.slug}/#{segment.slug}") + browser.screenshot(path: "#{out_dir}/#{lesson-ref}-#{segment.slug}.png", selector: '#slide-to-snapshot') end end end From a8114daf47359db1c8facb9ec5db13a3bd5e721d Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 10 Oct 2023 09:05:10 +0100 Subject: [PATCH 31/44] Another typo --- app/lib/snapshotter/content_module_slides.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/snapshotter/content_module_slides.rb b/app/lib/snapshotter/content_module_slides.rb index 051cca3..3eead9d 100644 --- a/app/lib/snapshotter/content_module_slides.rb +++ b/app/lib/snapshotter/content_module_slides.rb @@ -9,7 +9,7 @@ def generate next unless segment.is_a?(Video) browser.goto("#{app_base}/slides/#{lesson.slug}/#{segment.slug}") - browser.screenshot(path: "#{out_dir}/#{lesson-ref}-#{segment.slug}.png", selector: '#slide-to-snapshot') + browser.screenshot(path: "#{out_dir}/#{lesson.ref}-#{segment.slug}.png", selector: '#slide-to-snapshot') end end end From 75ce6c8736ad6e9c7995623d5767af6c09b25f16 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Wed, 11 Oct 2023 12:49:19 +0100 Subject: [PATCH 32/44] WEB-6543: Updating linter file existence checker to work with subfiles Paths in markdown metadata should always be relative to the markdown file itself. This updates the file existence checker so that that is the case. --- app/lib/linting/metadata/captions_file.rb | 5 ++++- .../file_attribute_existence_checker.rb | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/lib/linting/metadata/captions_file.rb b/app/lib/linting/metadata/captions_file.rb index 8a84911..a527aa2 100644 --- a/app/lib/linting/metadata/captions_file.rb +++ b/app/lib/linting/metadata/captions_file.rb @@ -40,13 +40,16 @@ def video_course_captions def module_captions caption_files = ModuleFile.new(file:, attributes:).file_path_list caption_files.map do |file| - if yaml?(file) + captions_file = if yaml?(file) load_yaml(File.read(file)).deep_symbolize_keys[:captions_file] else @markdown_metadata = nil @path = file markdown_metadata[:captions_file] end + next unless captions_file.present? + + [captions_file, file] end.compact end diff --git a/app/lib/linting/metadata/file_attribute_existence_checker.rb b/app/lib/linting/metadata/file_attribute_existence_checker.rb index 8e469b6..ae66738 100644 --- a/app/lib/linting/metadata/file_attribute_existence_checker.rb +++ b/app/lib/linting/metadata/file_attribute_existence_checker.rb @@ -8,15 +8,21 @@ module FileAttributeExistenceChecker def file_attribute_annotations file_path_list.map do |path| - next if relative_file_exists?(path) + if path.is_a?(Array) + relative_to = path.second + path = path.first + else + relative_to = file + end + next if relative_file_exists?(path, relative_to:) line = missing_file_line(path) Annotation.new( start_line: line, end_line: line, - absolute_path: file, + absolute_path: relative_to, annotation_level: 'failure', - message: "`release.yaml` includes references to unknown #{file_description} file: `#{path}", + message: "`#{relative_to}` includes references to unknown #{file_description} file: `#{path}", title: "Missing #{file_description} file" ) end.compact @@ -33,9 +39,9 @@ def file_description private # Check whether script file exists - def relative_file_exists?(path) + def relative_file_exists?(path, relative_to: nil) # Find path relative to the release.yaml file - file_path = Pathname.new(file).dirname.join(path) + file_path = Pathname.new(relative_to || file).dirname.join(path) # Check whether this exists file_exists?(file_path) end @@ -43,7 +49,7 @@ def relative_file_exists?(path) # Search release.yaml line by line to find the file references def missing_file_line(path) File.readlines(file).each_with_index do |line, index| - return index + 1 if line.include?(path) + return index + 1 if line.include?(path.to_s) end end end From 89eb0fa7ffd564ce41e27734d07fedf5cc235369 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Thu, 12 Oct 2023 11:59:39 +0100 Subject: [PATCH 33/44] WEB-6559: Extract the vimeo_id from metadata, add to model, and push --- app/lib/parser/video_metadata.rb | 2 ++ app/models/video.rb | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/lib/parser/video_metadata.rb b/app/lib/parser/video_metadata.rb index ec6fd08..9fedd50 100644 --- a/app/lib/parser/video_metadata.rb +++ b/app/lib/parser/video_metadata.rb @@ -18,6 +18,8 @@ def initialize(video, metadata) def apply! check_captions_path video.assign_attributes(simple_attributes) + # Need to extract the ID from the URL, if one has been provided + video.vimeo_id = metadata[:vimeo_id]&.to_s&.match(/(\d+)$/)&.values_at(1)&.first video.authors += authors if authors.present? end diff --git a/app/models/video.rb b/app/models/video.rb index 42053fa..e9c8600 100644 --- a/app/models/video.rb +++ b/app/models/video.rb @@ -8,7 +8,7 @@ class Video include Concerns::MarkdownRenderable attr_accessor :title, :ordinal, :free, :description_md, :short_description, :authors_notes_md, - :authors, :script_file, :root_path, :captions_file, :ref + :authors, :script_file, :root_path, :captions_file, :ref, :vimeo_id attr_markdown :description, source: :description_md, file: false attr_markdown :authors_notes, source: :authors_notes_md, file: false @@ -40,7 +40,7 @@ def segment_type # Used for serialisation def attributes { title: nil, ordinal: nil, free: false, description: nil, short_description: nil, authors_notes: nil, - authors: [], transcript: nil, ref: nil, episode_type:, segment_type: }.stringify_keys + authors: [], transcript: nil, ref: nil, vimeo_id: nil, episode_type:, segment_type: }.stringify_keys end # Used for linting From dfe3b1473dd7eadd7f0cae6c7c4ea0264884d223 Mon Sep 17 00:00:00 2001 From: edith <58082567+jellodiil@users.noreply.github.com> Date: Thu, 12 Oct 2023 23:59:25 +0100 Subject: [PATCH 34/44] WEB-6548: Adds a width_required attribute to ImageProvider --- app/lib/image_provider/provider.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/lib/image_provider/provider.rb b/app/lib/image_provider/provider.rb index 7eefc88..0ca82bd 100644 --- a/app/lib/image_provider/provider.rb +++ b/app/lib/image_provider/provider.rb @@ -5,10 +5,11 @@ module ImageProvider class Provider include Util::Logging - attr_reader :extractor + attr_reader :extractor, :width_required - def initialize(extractor:) + def initialize(extractor:,width_required:) @extractor = extractor + @width_required = width_required end def process From 4a3efbdfa577389986d8740a4bc32988499d5433 Mon Sep 17 00:00:00 2001 From: edith <58082567+jellodiil@users.noreply.github.com> Date: Thu, 12 Oct 2023 23:59:42 +0100 Subject: [PATCH 35/44] WEB-6548: We either require widths or don't --- app/lib/runner/base.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/lib/runner/base.rb b/app/lib/runner/base.rb index f6ccaed..819465b 100644 --- a/app/lib/runner/base.rb +++ b/app/lib/runner/base.rb @@ -19,7 +19,7 @@ def render_book(publish_file:, local: false) parser = Parser::Publish.new(file: publish_file) book = parser.parse image_extractor = ImageProvider::BookExtractor.new(book) - image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor) + image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor, width_required: true) image_provider&.process renderer = Renderer::Book.new(book, image_provider:) renderer.render @@ -31,7 +31,7 @@ def publish_book(publish_file:) parser = Parser::Publish.new(file: publish_file) book = parser.parse image_extractor = ImageProvider::BookExtractor.new(book) - image_provider = ImageProvider::Provider.new(extractor: image_extractor) + image_provider = ImageProvider::Provider.new(extractor: image_extractor, width_required: true) image_provider.process Renderer::Book.new(book, image_provider:).render Api::Alexandria::BookUploader.upload(book) @@ -60,7 +60,7 @@ def render_video_course(release_file:, local: false) parser = Parser::Release.new(file: release_file) video_course = parser.parse image_extractor = ImageProvider::VideoCourseExtractor.new(video_course) - image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor) + image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor, width_required: false) image_provider&.process renderer = Renderer::VideoCourse.new(video_course, image_provider:) renderer.render @@ -112,7 +112,7 @@ def publish_pablo(source:, output:) output ||= default_pablo_output image_extractor = ImageProvider::DirectoryExtractor.new(source) - image_provider = ImageProvider::Provider.new(extractor: image_extractor) + image_provider = ImageProvider::Provider.new(extractor: image_extractor, width_required: false) image_provider.process paths = image_extractor.categories.map { "/#{_1}" }.push('/', '/license', '/instructions', '/styles.css', '/javascript/search.js') @@ -130,7 +130,7 @@ def render_content_module(module_file:, local: false) parser = Parser::Circulate.new(file: module_file) content_module = parser.parse image_extractor = ImageProvider::ContentModuleExtractor.new(content_module) - image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor) + image_provider = local ? nil : ImageProvider::Provider.new(extractor: image_extractor, width_required: false) image_provider&.process renderer = Renderer::ContentModule.new(content_module, image_provider:) renderer.render @@ -156,7 +156,7 @@ def circulate_content_module(module_file:) content_module = parser.parse image_extractor = ImageProvider::ContentModuleExtractor.new(content_module) - image_provider = ImageProvider::Provider.new(extractor: image_extractor) + image_provider = ImageProvider::Provider.new(extractor: image_extractor, width_required: false) image_provider.process Renderer::ContentModule.new(content_module, image_provider:).render Api::Alexandria::ContentModuleUploader.upload(content_module) From eb6fb6eeca5ede7969401f580d995ca8f94b8ade Mon Sep 17 00:00:00 2001 From: edith <58082567+jellodiil@users.noreply.github.com> Date: Fri, 13 Oct 2023 00:00:00 +0100 Subject: [PATCH 36/44] WEB-6548: Checks whether we care for widths before we show an ugly error --- app/lib/renderer/rw_markdown_renderer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/renderer/rw_markdown_renderer.rb b/app/lib/renderer/rw_markdown_renderer.rb index 426289f..9e147a0 100644 --- a/app/lib/renderer/rw_markdown_renderer.rb +++ b/app/lib/renderer/rw_markdown_renderer.rb @@ -22,7 +22,7 @@ def image(node) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics alt_text = node.each.select { |child| child.type == :text }.map { |child| escape_html(child.string_content) }.join(' ') classes = class_list(alt_text) - if width_class?(alt_text) + if width_class?(alt_text) || @image_provider.width_required == false out('
') out(' ') if svg?(alt_text, node.url) From 7827a97075f015dc127423648868f34dde5e2fe4 Mon Sep 17 00:00:00 2001 From: edith <58082567+jellodiil@users.noreply.github.com> Date: Fri, 13 Oct 2023 00:05:41 +0100 Subject: [PATCH 37/44] Update app/lib/image_provider/provider.rb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: sämmi --- app/lib/image_provider/provider.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/image_provider/provider.rb b/app/lib/image_provider/provider.rb index 0ca82bd..1a0b863 100644 --- a/app/lib/image_provider/provider.rb +++ b/app/lib/image_provider/provider.rb @@ -7,7 +7,7 @@ class Provider attr_reader :extractor, :width_required - def initialize(extractor:,width_required:) + def initialize(extractor:, width_required:) @extractor = extractor @width_required = width_required end From e2d21ac591173b66cba8766e6d65e730ae5a4d2b Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Fri, 13 Oct 2023 16:04:32 +0530 Subject: [PATCH 38/44] WEB-6534: Make lesson ref mandatory --- app/lib/renderer/lesson.rb | 2 +- app/models/lesson.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/renderer/lesson.rb b/app/lib/renderer/lesson.rb index c72fea6..07bb394 100644 --- a/app/lib/renderer/lesson.rb +++ b/app/lib/renderer/lesson.rb @@ -10,7 +10,7 @@ class Lesson attr_accessor :disable_transcripts def render - logger.info "Beginning lesson render: #{object.title}" + logger.info "Beginning lesson render: #{object.ref} #{object.title}" attach_images render_markdown object.segments.each do |segment| diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 3c39c50..323838c 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -11,7 +11,7 @@ class Lesson attr_markdown :description, source: :description_md, file: false attr_markdown :learning_objectives, source: :learning_objectives_md, file: false - validates :title, :ordinal, presence: true + validates :title, :ordinal, :ref, presence: true def initialize(attributes = {}) super From 49508782e2cb570bdd47be4a2db10b9dde6e0b93 Mon Sep 17 00:00:00 2001 From: edith <58082567+jellodiil@users.noreply.github.com> Date: Fri, 13 Oct 2023 13:18:08 +0100 Subject: [PATCH 39/44] WEB-6564: Maybe complains if you have the same title? --- app/models/lessons_validator.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/models/lessons_validator.rb b/app/models/lessons_validator.rb index 1c068c4..6685995 100644 --- a/app/models/lessons_validator.rb +++ b/app/models/lessons_validator.rb @@ -7,6 +7,7 @@ def validate_each(record, attribute, value) check_correct_class(record, attribute, value) check_unique_refs(record, attribute, value) + check_unique_title(record, attribute, value) end def check_correct_class(record, attribute, value) @@ -26,4 +27,16 @@ def check_unique_refs(record, attribute, value) non_unique_refs.each { |ref| record.errors.add(attribute, "(#{lesson.title}) segment ref #{ref} is not unique") } end end + + def check_unique_title(record, attribute, value) + return unless value.is_a?(Array) + + value.each do |lesson| + title_counts = Hash.new(0) + lesson.segments.each { |segment| title_counts[segment.title] += 1 } + non_unique_titles = title_counts.select { |_, count| count > 1 }.keys + + non_unique_titles.each { |title| record.errors.add(attribute, "(#{lesson}) segment title #{title} is not unique") } + end + end end From 28922665333115bd2e42ab1af526146b968bfe86 Mon Sep 17 00:00:00 2001 From: edith <58082567+jellodiil@users.noreply.github.com> Date: Fri, 13 Oct 2023 14:39:23 +0100 Subject: [PATCH 40/44] WEB-6569: I was just being silly, we need the title of a lesson. --- app/models/lessons_validator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/lessons_validator.rb b/app/models/lessons_validator.rb index 6685995..aca3586 100644 --- a/app/models/lessons_validator.rb +++ b/app/models/lessons_validator.rb @@ -36,7 +36,7 @@ def check_unique_title(record, attribute, value) lesson.segments.each { |segment| title_counts[segment.title] += 1 } non_unique_titles = title_counts.select { |_, count| count > 1 }.keys - non_unique_titles.each { |title| record.errors.add(attribute, "(#{lesson}) segment title #{title} is not unique") } + non_unique_titles.each { |title| record.errors.add(attribute, "(#{lesson.title}) segment title #{title} is not unique") } end end end From c4ef384e9536d24c3a7446a855dd90a4e56da4b6 Mon Sep 17 00:00:00 2001 From: Kapil Sachdev Date: Sun, 15 Oct 2023 20:12:56 +0530 Subject: [PATCH 41/44] WEB-6578: Update dependencies --- Gemfile | 4 ++-- Gemfile.lock | 48 +++++++++++++++++++++++++++++------------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/Gemfile b/Gemfile index 33cf0fd..272f28c 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,8 @@ source 'https://rubygems.org' git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } # Stealing some bits from rails -gem 'activemodel', '~> 7.0' -gem 'activesupport', '~> 7.0' +gem 'activemodel', '< 7.2' +gem 'activesupport', '< 7.2' # Autoloading explictly will use zeitwerk gem 'zeitwerk', '~> 2.3' diff --git a/Gemfile.lock b/Gemfile.lock index 6f161c1..ad4060b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,27 +14,32 @@ GIT GEM remote: https://rubygems.org/ specs: - activemodel (7.0.8) - activesupport (= 7.0.8) - activesupport (7.0.8) + activemodel (7.1.1) + activesupport (= 7.1.1) + activesupport (7.1.1) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.827.0) - aws-sdk-core (3.183.0) + aws-partitions (1.835.0) + aws-sdk-core (3.185.1) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.71.0) - aws-sdk-core (~> 3, >= 3.177.0) + aws-sdk-kms (1.72.0) + aws-sdk-core (~> 3, >= 3.184.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.135.0) + aws-sdk-s3 (1.136.0) aws-sdk-core (~> 3, >= 3.181.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) @@ -43,12 +48,16 @@ GEM backport (1.2.0) base64 (0.1.1) benchmark (0.2.1) + bigdecimal (3.1.4) cli-ui (2.2.3) coderay (1.1.3) commonmarker (0.23.10) concurrent-ruby (1.2.2) + connection_pool (2.4.1) daemons (1.4.1) diff-lcs (1.5.0) + drb (2.1.1) + ruby2_keywords e2mmap (0.1.0) em-websocket (0.5.3) eventmachine (>= 0.12.9) @@ -66,7 +75,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (>= 0.6, < 0.8) - ffi (1.16.1) + ffi (1.16.3) formatador (1.1.0) git (1.18.0) addressable (~> 2.8) @@ -109,6 +118,7 @@ GEM multi_json (1.15.0) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) + mutex_m (0.1.2) nenv (0.3.0) nokogiri (1.15.4) mini_portile2 (~> 2.8.2) @@ -116,11 +126,11 @@ GEM notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) - octokit (7.1.0) + octokit (7.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) parallel (1.23.0) - parser (3.2.2.3) + parser (3.2.2.4) ast (~> 2.4.1) racc pry (0.14.2) @@ -144,16 +154,16 @@ GEM ffi rbs (2.8.4) rchardet (1.8.0) - regexp_parser (2.8.1) + regexp_parser (2.8.2) reverse_markdown (2.1.1) nokogiri rexml (3.2.6) - rubocop (1.56.3) + rubocop (1.57.1) base64 (~> 0.1.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.3) + parser (>= 3.2.2.4) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) @@ -201,20 +211,20 @@ GEM tilt (2.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.4.2) + unicode-display_width (2.5.0) webrick (1.8.1) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) yard (0.9.34) - zeitwerk (2.6.11) + zeitwerk (2.6.12) PLATFORMS ruby DEPENDENCIES - activemodel (~> 7.0) - activesupport (~> 7.0) + activemodel (< 7.2) + activesupport (< 7.2) aws-sdk-s3 (~> 1.64) cli-ui (~> 2) commonmarker @@ -243,4 +253,4 @@ DEPENDENCIES zeitwerk (~> 2.3) BUNDLED WITH - 2.4.19 + 2.4.20 From eb5c63da035c2d5ecdd8d0de1eb7572a140fe567 Mon Sep 17 00:00:00 2001 From: edith <58082567+jellodiil@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:30:47 +0100 Subject: [PATCH 42/44] WEB-6573: Matches slide generator to carolus title slide plus changes for modular content --- .../content_modules/segment_slide.html.erb | 2 +- app/server/views/styles/components/slide.scss | 42 +++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/app/server/views/content_modules/segment_slide.html.erb b/app/server/views/content_modules/segment_slide.html.erb index 41000c8..7268182 100644 --- a/app/server/views/content_modules/segment_slide.html.erb +++ b/app/server/views/content_modules/segment_slide.html.erb @@ -1,4 +1,4 @@ -
+

<%= content_module.title %>

diff --git a/app/server/views/styles/components/slide.scss b/app/server/views/styles/components/slide.scss index b3ebedc..d75f622 100644 --- a/app/server/views/styles/components/slide.scss +++ b/app/server/views/styles/components/slide.scss @@ -113,7 +113,7 @@ ========================================================================== */ /* Title slide is a bigger size than video to go to a video file. So we need to scale all the sizes up */ -/* 2022 Oct sizing for carolus is height: 602px, width: 1072px.*/ +/* 2022 Oct sizing for carolus is height: 588px, width: 1036px.*/ $slide-ratio: 1.8; .title-slide { @@ -145,29 +145,29 @@ $slide-ratio: 1.8; bottom: 32px * $slide-ratio; h2 { - font-size: 16px * $slide-ratio; - line-height: 1.25; + font-size: 12px * $slide-ratio; + line-height: 1; + letter-spacing: 2px !important; + text-transform: uppercase; font-family: 'Relative', sans-serif; - font-weight: 400; + font-weight: 500; text-align: left; - padding-bottom: 16px * $slide-ratio; - /* Max-width is roughly 55%, but percentage doesn't work cos absolute positioning... */ - max-width: 587px * $slide-ratio; + padding-bottom: 8px * $slide-ratio; + /* Max-width is roughly 75%, but percentage doesn't work cos absolute positioning... */ + max-width: 713px * $slide-ratio; } h3 { font-size: 40px * $slide-ratio; font-family: 'Relative', sans-serif; - font-weight: 400; line-height: 1.25; - /* Max-width is roughly 55%, but percentage doesn't work cos absolute positioning... */ - max-width: 587px * $slide-ratio; + /* Max-width is roughly 75%, but percentage doesn't work cos absolute positioning... and -64px to match carolus */ + max-width: 713px * $slide-ratio; } } figure { - width: 252px * $slide-ratio; - height: 252px * $slide-ratio; + width: 21%; position: absolute; bottom: 32px * $slide-ratio; right: 32px * $slide-ratio; @@ -230,3 +230,21 @@ $slide-ratio: 1.8; } } } + +.segment-slide { + h1 { + max-width: 100%; + padding-right: 32px * $slide-ratio; + } + .slide-episode { + h2 { + text-transform: unset; + letter-spacing: normal !important; + font-weight: 400; + } + + h3 { + font-size: 24px * $slide-ratio; + } + } +} From a882ce61a6dfe192c44e46cc4c365632848d878d Mon Sep 17 00:00:00 2001 From: edith <58082567+jellodiil@users.noreply.github.com> Date: Mon, 16 Oct 2023 14:42:05 +0100 Subject: [PATCH 43/44] WEB-6573: Slide generator decided to suddenly honor the heading style font-weights..... --- app/server/views/styles/components/slide.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/server/views/styles/components/slide.scss b/app/server/views/styles/components/slide.scss index d75f622..2a94d1d 100644 --- a/app/server/views/styles/components/slide.scss +++ b/app/server/views/styles/components/slide.scss @@ -160,6 +160,7 @@ $slide-ratio: 1.8; h3 { font-size: 40px * $slide-ratio; font-family: 'Relative', sans-serif; + font-weight: 400; line-height: 1.25; /* Max-width is roughly 75%, but percentage doesn't work cos absolute positioning... and -64px to match carolus */ max-width: 713px * $slide-ratio; From 847a9b40b13872ae64e349491fddcf471f1fcd5d Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 16 Oct 2023 18:13:29 +0100 Subject: [PATCH 44/44] WEB-6582: Missed a reference to the new width_required argument Also set a default of true, so we'd get a different error in future. --- app/lib/image_provider/provider.rb | 2 +- app/lib/runner/base.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/image_provider/provider.rb b/app/lib/image_provider/provider.rb index 1a0b863..7d16680 100644 --- a/app/lib/image_provider/provider.rb +++ b/app/lib/image_provider/provider.rb @@ -7,7 +7,7 @@ class Provider attr_reader :extractor, :width_required - def initialize(extractor:, width_required:) + def initialize(extractor:, width_required: true) @extractor = extractor @width_required = width_required end diff --git a/app/lib/runner/base.rb b/app/lib/runner/base.rb index 819465b..c5398d3 100644 --- a/app/lib/runner/base.rb +++ b/app/lib/runner/base.rb @@ -74,7 +74,7 @@ def upload_video_course(release_file:) video_course = parser.parse image_extractor = ImageProvider::VideoCourseExtractor.new(video_course) - image_provider = ImageProvider::Provider.new(extractor: image_extractor) + image_provider = ImageProvider::Provider.new(extractor: image_extractor, width_required: false) image_provider.process Renderer::VideoCourse.new(video_course, image_provider:).render Api::Betamax::VideoCourseUploader.upload(video_course)