From 9e35418d13cc90544e7f1fbdf8926a29e91634f3 Mon Sep 17 00:00:00 2001 From: Sarah Proctor Date: Wed, 5 Feb 2025 14:38:12 -0800 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9A=A0=EF=B8=8F=20WIP=20Export=20Questio?= =?UTF-8?q?ns=20as=20Plain=20Text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: - https://github.com/notch8/viva/issues/344 --- README.md | 4 +- app/controllers/search_controller.rb | 64 +++++++++++++++++++ app/javascript/components/ui/Footer/index.jsx | 2 +- .../components/ui/Search/SearchBar.jsx | 8 +++ config/routes.rb | 3 + 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 321cc922..db00ac0d 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ The Search Filters section shows your chosen search criteria. - Click “Export Questions” and select “XML” -This will download an `.xml` file (e.g. `questions-2023-12-13_15_38_01_508.classic-question-canvas.qti.xml`). To upload that file into Canvass you will need to convert the downloaded XML file into a `.zip` file. The way to do this varies by operating system. +This will download an `.xml` file (e.g. `questions-2023-12-13_15_38_01_508.classic-question-canvas.qti.xml`). To upload that file into Canvas you will need to convert the downloaded XML file into a `.zip` file. The way to do this varies by operating system. As of <2023-12-13 Wed> we export into a “classic question” format; the rationalization being two fold: @@ -305,7 +305,7 @@ In the `Rails` console run the following: User.create!(email: "", password: "") ``` -### Reseting Questions and Data +### Resetting Questions and Data Over the course of testing and experimenting, you might want to reset the questions. diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 6e8493ac..95c4b4d1 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,4 +1,6 @@ # frozen_string_literal: true +include ActionView::Helpers::SanitizeHelper +require 'nokogiri' ## # The controller to handle methods related to the search page. @@ -35,8 +37,70 @@ def index end end + def download_as_plain_text + questions = Bookmark.pluck(:question_id).map{|q| Question.where(id: q)}.flatten + content = questions.map do |q| + question_type = q.type.slice(10..-1).titleize + if q.type == "Question::Essay" || q.type == "Question::Upload" + "Question Type: #{question_type}\nQuestion Text: #{q.text}\nData: #{essay_type(q)}" + elsif q.type == "Question::Traditional" || q.type == "Question::SelectAllThatApply" || q.type == "Question::DragAndDrop" + "Question Type: #{question_type}\nQuestion Text: #{q.text}\n#{traditional_type(q)}" + elsif q.type == "Question::Matching" + "Question Type: #{question_type}\nQuestion Text: #{q.text}\n#{matching_type(q)}" + elsif q.type == "Question::Categorization" + "Question Type: #{question_type}\nQuestion Text: #{q.text}\n#{categorization_type(q)}" + elsif q.type == "Question::BowTie" + "Question Type: #{question_type}\nQuestion Text: #{q.text}\n#{bowtie_type(q)}" + end + end.join("\n\n") + send_data content, filename: "questions.txt", type: "text/plain" + end + private + # def essay_type(question) + # strip_tags(question.data['html']) + # end + + def essay_type(question) + rich_text = Nokogiri::HTML(question.data['html']) + rich_text.css('a').each do |link| + link.replace("#{link.text} (#{link['href']})") + end + rich_text.text.strip + end + + def traditional_type(question) + question.data.map.with_index do |answer_set, index| + "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" + end.join('') + end + + def matching_type(question) + question.data.map.with_index do |answer_set, index| + "#{index + 1}) Answer: #{answer_set['answer']} Match: #{answer_set['correct'].first}\n" + end.join('') + end + + def categorization_type(question) + question.data.map do |answer_set| + "Catagory: #{answer_set['answer']}\nCorrect: #{answer_set['correct'].map.with_index { |c, index| "#{index + 1}) #{c}" }.join(' ')}\n" + end.join('') + end + + def bowtie_type(question) + center = question.data['center']['answers'].map.with_index do |answer_set, index| + "Center: #{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" + end + left = question.data['left']['answers'].map.with_index do |answer_set, index| + "Left: #{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" + end + right = question.data['right']['answers'].map.with_index do |answer_set, index| + "Right: #{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" + end + center.join('') + left.join('') + right.join('') + end + def any_question_has_images? @questions.any? { |question| question.images.any? } end diff --git a/app/javascript/components/ui/Footer/index.jsx b/app/javascript/components/ui/Footer/index.jsx index 7c64ed6e..01baa6f6 100644 --- a/app/javascript/components/ui/Footer/index.jsx +++ b/app/javascript/components/ui/Footer/index.jsx @@ -11,7 +11,7 @@ const Footer = () => { Settings diff --git a/app/javascript/components/ui/Search/SearchBar.jsx b/app/javascript/components/ui/Search/SearchBar.jsx index bbfe4619..872cfc51 100644 --- a/app/javascript/components/ui/Search/SearchBar.jsx +++ b/app/javascript/components/ui/Search/SearchBar.jsx @@ -89,6 +89,14 @@ const SearchBar = (props) => { > View Bookmarks + + Export as Text + `bookmarked_question_ids[]=${encodeURIComponent(id)}`).join('&')}`} className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`} diff --git a/config/routes.rb b/config/routes.rb index 22f08e93..3f823167 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,6 +13,9 @@ # Necessary for downloading the XML file from the search result. get '/(.:format)', to: 'search#index' + # download the bookmarked questions in a text file + get 'questions/download', to: 'search#download_as_plain_text' + # settings page routes get '/settings', to: 'settings#index', as: 'settings' patch '/settings/update', to: 'settings#update' From 49f401b733755fc4f800b21332b947feb9d6a1b0 Mon Sep 17 00:00:00 2001 From: Sarah Proctor Date: Mon, 10 Feb 2025 10:20:57 -0800 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9A=A0=EF=B8=8F=20WIP=20Exports=20Bookma?= =?UTF-8?q?rked=20Questions=20in=20Plain=20Text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/controllers/search_controller.rb | 110 +++++++++++++++++++-------- 1 file changed, 79 insertions(+), 31 deletions(-) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 95c4b4d1..46137a67 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -include ActionView::Helpers::SanitizeHelper require 'nokogiri' ## @@ -39,66 +38,115 @@ def index def download_as_plain_text questions = Bookmark.pluck(:question_id).map{|q| Question.where(id: q)}.flatten - content = questions.map do |q| - question_type = q.type.slice(10..-1).titleize - if q.type == "Question::Essay" || q.type == "Question::Upload" - "Question Type: #{question_type}\nQuestion Text: #{q.text}\nData: #{essay_type(q)}" - elsif q.type == "Question::Traditional" || q.type == "Question::SelectAllThatApply" || q.type == "Question::DragAndDrop" - "Question Type: #{question_type}\nQuestion Text: #{q.text}\n#{traditional_type(q)}" - elsif q.type == "Question::Matching" - "Question Type: #{question_type}\nQuestion Text: #{q.text}\n#{matching_type(q)}" - elsif q.type == "Question::Categorization" - "Question Type: #{question_type}\nQuestion Text: #{q.text}\n#{categorization_type(q)}" - elsif q.type == "Question::BowTie" - "Question Type: #{question_type}\nQuestion Text: #{q.text}\n#{bowtie_type(q)}" + content = [] + questions.map do |question| + if question.type == 'Question::Essay' || question.type == 'Question::Upload' + content << essay_type(question) + content << "\n\n**********\n\n" + elsif question.type == 'Question::Traditional' || question.type == 'Question::SelectAllThatApply' || question.type == 'Question::DragAndDrop' + content << traditional_type(question) + content << "\n**********\n\n" + elsif question.type == 'Question::Matching' + content << matching_type(question) + content << "\n**********\n\n" + elsif question.type == 'Question::Categorization' + content << categorization_type(question) + content << "**********\n\n" + elsif question.type == 'Question::BowTie' + content << bowtie_type(question) + content << "\n**********\n\n" + elsif question.type == 'Question::StimulusCaseStudy' + content << stimulus_type(question) + content << "**********\n\n" end - end.join("\n\n") - send_data content, filename: "questions.txt", type: "text/plain" + end + content = content.join('') + send_data content, filename: 'questions.txt', type: 'text/plain' end private - # def essay_type(question) - # strip_tags(question.data['html']) - # end - def essay_type(question) + question_type = question.type.slice(10..-1).titleize + + # Formats HTML into plain text rich_text = Nokogiri::HTML(question.data['html']) - rich_text.css('a').each do |link| - link.replace("#{link.text} (#{link['href']})") + rich_text.css('a').each do |link_tag| + link_tag.replace("#{link_tag.text} (#{link_tag['href']})") + end + rich_text.css('p').each do |p_tag| + p_tag.replace("#{p_tag.text}\n") + end + rich_text.css('li').each do |li_tag| + li_tag.replace("- #{li_tag.text}\n") end - rich_text.text.strip + plain_text = rich_text.text.strip + "Question Type: #{question_type}\nQuestion: #{question.text}\n\nText: #{plain_text}" end def traditional_type(question) - question.data.map.with_index do |answer_set, index| + question_type = question.type.slice(10..-1).titleize + # The preferred name for Traditional questions is "Multiple Choice" + if question.type == 'Question::Traditional' + question_type = 'Multiple Choice' + end + + data = question.data.map.with_index do |answer_set, index| "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" end.join('') + "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{data}" end def matching_type(question) - question.data.map.with_index do |answer_set, index| - "#{index + 1}) Answer: #{answer_set['answer']} Match: #{answer_set['correct'].first}\n" + question_type = question.type.slice(10..-1).titleize + data = question.data.map.with_index do |answer_set, index| + "#{index + 1}) #{answer_set['answer']}\n Correct Match: #{answer_set['correct'].first}\n" end.join('') + "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{data}" end def categorization_type(question) - question.data.map do |answer_set| - "Catagory: #{answer_set['answer']}\nCorrect: #{answer_set['correct'].map.with_index { |c, index| "#{index + 1}) #{c}" }.join(' ')}\n" + question_type = question.type.slice(10..-1).titleize + data = question.data.map do |answer_set| + "Catagory: #{answer_set['answer']}\n#{answer_set['correct'].map.with_index { |c, index| "#{index + 1}) #{c}\n" }.join('')}\n" end.join('') + "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{data}" end def bowtie_type(question) + question_type = question.type.slice(10..-1).titleize center = question.data['center']['answers'].map.with_index do |answer_set, index| - "Center: #{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" + "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" end left = question.data['left']['answers'].map.with_index do |answer_set, index| - "Left: #{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" + "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" end right = question.data['right']['answers'].map.with_index do |answer_set, index| - "Right: #{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" + "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" + end + data = "Center\n#{center.join('')}\nLeft\n#{left.join('')}\nRight\n#{right.join('')}" + "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{data}" + end + + def stimulus_type(question) + question_type = question.type.slice(10..-1).titleize + output = [] + question.child_questions.map do |sub_question| + if sub_question.type == "Question::Scenario" + output << "Scenario: #{sub_question.text}\n\n" + elsif sub_question.type == "Question::Essay" || sub_question.type == "Question::Upload" + output << "#{essay_type(sub_question)}\n" + elsif sub_question.type == "Question::Traditional" || sub_question.type == "Question::SelectAllThatApply" || sub_question.type == "Question::DragAndDrop" + output << "#{traditional_type(sub_question)}\n" + elsif sub_question.type == "Question::Matching" + output << "#{matching_type(sub_question)}\n" + elsif sub_question.type == "Question::Categorization" + output << "#{categorization_type(sub_question)}\n" + elsif sub_question.type == "Question::BowTie" + output << "#{bowtie_type(sub_question)}\n" + end end - center.join('') + left.join('') + right.join('') + "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{output.join('')}\n" end def any_question_has_images? From a0fecb93d0fcfc6962281ec6f10b519663ea96a5 Mon Sep 17 00:00:00 2001 From: Sarah Proctor Date: Mon, 10 Feb 2025 12:28:27 -0800 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=8E=81=20Add=20plaintext=20download?= =?UTF-8?q?=20for=20bookmarked=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds ability to download bookmarked questions as a formatted plain text file. Each question has a specific format based on type: - Essay/Upload questions include HTML content converted to plain text - Multiple Choice and Select All show correct/incorrect answers - Matching questions show terms and their correct matches - Categorization questions group items by category - Bow Tie questions display center/left/right sections - Stimulus Case Study questions include all sub-questions The text download provides a simple way for users to export their bookmarked questions in a readable format, with proper spacing and structure maintained for each question type. Ref: - https://github.com/notch8/viva/issues/344 --- .../plain_text_downloads_controller.rb | 11 + app/controllers/search_controller.rb | 112 ------- .../question_text_formatter_service.rb | 134 ++++++++ config/routes.rb | 2 +- .../plain_text_downloads_controller_spec.rb | 26 ++ .../question_text_formatter_service_spec.rb | 285 ++++++++++++++++++ 6 files changed, 457 insertions(+), 113 deletions(-) create mode 100644 app/controllers/plain_text_downloads_controller.rb create mode 100644 app/services/question_text_formatter_service.rb create mode 100644 spec/controllers/plain_text_downloads_controller_spec.rb create mode 100644 spec/services/question_text_formatter_service_spec.rb diff --git a/app/controllers/plain_text_downloads_controller.rb b/app/controllers/plain_text_downloads_controller.rb new file mode 100644 index 00000000..05df0411 --- /dev/null +++ b/app/controllers/plain_text_downloads_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +## +# Controller to handle plain text downloads of questions +class PlainTextDownloadsController < ApplicationController + def download + questions = Question.where(id: Bookmark.select(:question_id)) + content = questions.map { |question| QuestionTextFormatterService.new(question).format }.join('') + send_data content, filename: 'questions.txt', type: 'text/plain' + end +end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 46137a67..6e8493ac 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -require 'nokogiri' ## # The controller to handle methods related to the search page. @@ -36,119 +35,8 @@ def index end end - def download_as_plain_text - questions = Bookmark.pluck(:question_id).map{|q| Question.where(id: q)}.flatten - content = [] - questions.map do |question| - if question.type == 'Question::Essay' || question.type == 'Question::Upload' - content << essay_type(question) - content << "\n\n**********\n\n" - elsif question.type == 'Question::Traditional' || question.type == 'Question::SelectAllThatApply' || question.type == 'Question::DragAndDrop' - content << traditional_type(question) - content << "\n**********\n\n" - elsif question.type == 'Question::Matching' - content << matching_type(question) - content << "\n**********\n\n" - elsif question.type == 'Question::Categorization' - content << categorization_type(question) - content << "**********\n\n" - elsif question.type == 'Question::BowTie' - content << bowtie_type(question) - content << "\n**********\n\n" - elsif question.type == 'Question::StimulusCaseStudy' - content << stimulus_type(question) - content << "**********\n\n" - end - end - content = content.join('') - send_data content, filename: 'questions.txt', type: 'text/plain' - end - private - def essay_type(question) - question_type = question.type.slice(10..-1).titleize - - # Formats HTML into plain text - rich_text = Nokogiri::HTML(question.data['html']) - rich_text.css('a').each do |link_tag| - link_tag.replace("#{link_tag.text} (#{link_tag['href']})") - end - rich_text.css('p').each do |p_tag| - p_tag.replace("#{p_tag.text}\n") - end - rich_text.css('li').each do |li_tag| - li_tag.replace("- #{li_tag.text}\n") - end - plain_text = rich_text.text.strip - "Question Type: #{question_type}\nQuestion: #{question.text}\n\nText: #{plain_text}" - end - - def traditional_type(question) - question_type = question.type.slice(10..-1).titleize - # The preferred name for Traditional questions is "Multiple Choice" - if question.type == 'Question::Traditional' - question_type = 'Multiple Choice' - end - - data = question.data.map.with_index do |answer_set, index| - "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" - end.join('') - "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{data}" - end - - def matching_type(question) - question_type = question.type.slice(10..-1).titleize - data = question.data.map.with_index do |answer_set, index| - "#{index + 1}) #{answer_set['answer']}\n Correct Match: #{answer_set['correct'].first}\n" - end.join('') - "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{data}" - end - - def categorization_type(question) - question_type = question.type.slice(10..-1).titleize - data = question.data.map do |answer_set| - "Catagory: #{answer_set['answer']}\n#{answer_set['correct'].map.with_index { |c, index| "#{index + 1}) #{c}\n" }.join('')}\n" - end.join('') - "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{data}" - end - - def bowtie_type(question) - question_type = question.type.slice(10..-1).titleize - center = question.data['center']['answers'].map.with_index do |answer_set, index| - "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" - end - left = question.data['left']['answers'].map.with_index do |answer_set, index| - "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" - end - right = question.data['right']['answers'].map.with_index do |answer_set, index| - "#{index + 1}) #{if answer_set['correct'] then 'Correct' else 'Incorrect' end}: #{answer_set['answer']}\n" - end - data = "Center\n#{center.join('')}\nLeft\n#{left.join('')}\nRight\n#{right.join('')}" - "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{data}" - end - - def stimulus_type(question) - question_type = question.type.slice(10..-1).titleize - output = [] - question.child_questions.map do |sub_question| - if sub_question.type == "Question::Scenario" - output << "Scenario: #{sub_question.text}\n\n" - elsif sub_question.type == "Question::Essay" || sub_question.type == "Question::Upload" - output << "#{essay_type(sub_question)}\n" - elsif sub_question.type == "Question::Traditional" || sub_question.type == "Question::SelectAllThatApply" || sub_question.type == "Question::DragAndDrop" - output << "#{traditional_type(sub_question)}\n" - elsif sub_question.type == "Question::Matching" - output << "#{matching_type(sub_question)}\n" - elsif sub_question.type == "Question::Categorization" - output << "#{categorization_type(sub_question)}\n" - elsif sub_question.type == "Question::BowTie" - output << "#{bowtie_type(sub_question)}\n" - end - end - "Question Type: #{question_type}\nQuestion: #{question.text}\n\n#{output.join('')}\n" - end - def any_question_has_images? @questions.any? { |question| question.images.any? } end diff --git a/app/services/question_text_formatter_service.rb b/app/services/question_text_formatter_service.rb new file mode 100644 index 00000000..d9384923 --- /dev/null +++ b/app/services/question_text_formatter_service.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true +require 'nokogiri' + +## +# Service to handle formatting questions into plain text +# rubocop:disable Metrics/ClassLength +class QuestionTextFormatterService + def initialize(question) + @question = question + end + + def format + content = format_by_type + content + "\n**********\n\n" + end + + # These methods are protected instead of private so they can be called by other instances + protected + + def essay_type + format_question_header + format_essay_content + end + + def traditional_type + format_question_header + format_answers(@question.data) { |answer, index| format_traditional_answer(answer, index) } + end + + def matching_type + format_question_header + format_answers(@question.data) { |answer, index| format_matching_answer(answer, index) } + end + + def categorization_type + format_question_header + format_categories(@question.data) + end + + def bowtie_type + format_question_header + format_bowtie_sections + end + + def stimulus_type + output = @question.child_questions.map { |sub_question| format_sub_question(sub_question) } + output[-1] = output[-1].chomp if output.any? + "Question Type: #{question_type}\nQuestion: #{@question.text}\n\n#{output.join('')}" + end + + def format_sub_question(sub_question) + case sub_question.type + when "Question::Scenario" + "Scenario: #{sub_question.text}\n\n" + when "Question::Essay", "Question::Upload" + "#{QuestionTextFormatterService.new(sub_question).essay_type.chomp}\n\n" + when "Question::Traditional", "Question::SelectAllThatApply", "Question::DragAndDrop" + "#{QuestionTextFormatterService.new(sub_question).traditional_type}\n" + when "Question::Matching" + "#{QuestionTextFormatterService.new(sub_question).matching_type}\n" + when "Question::Categorization" + "#{QuestionTextFormatterService.new(sub_question).categorization_type}\n" + when "Question::BowTie" + "#{QuestionTextFormatterService.new(sub_question).bowtie_type}\n" + end + end + + private + + def format_question_header + "Question Type: #{question_type}\nQuestion: #{@question.text}\n\n" + end + + def format_essay_content + plain_text = format_html(@question.data['html']) + "Text: #{plain_text}\n" + end + + def format_answers(data) + data.map.with_index { |answer, index| yield(answer, index) }.join('') + end + + def format_traditional_answer(answer, index) + "#{index + 1}) #{answer['correct'] ? 'Correct' : 'Incorrect'}: #{answer['answer']}\n" + end + + def format_matching_answer(answer, index) + "#{index + 1}) #{answer['answer']}\n Correct Match: #{answer['correct'].first}\n" + end + + def format_categories(data) + data.map do |category| + items = category['correct'].map.with_index { |item, index| "#{index + 1}) #{item}\n" }.join('') + "Category: #{category['answer']}\n#{items}" + end.join("\n") + end + + def format_bowtie_sections + sections = ['center', 'left', 'right'].map do |section| + answers = @question.data[section]['answers'].map.with_index do |answer, index| + format_traditional_answer(answer, index) + end.join('') + "#{section.capitalize}\n#{answers}" + end + sections.join("\n") + end + + def question_type + type_name = @question.type.slice(10..-1).titleize + return 'Multiple Choice' if @question.type == 'Question::Traditional' + type_name + end + + def format_html(html) + rich_text = Nokogiri::HTML(html) + rich_text.css('a').each { |link| link.replace("#{link.text} (#{link['href']})") } + rich_text.css('p').each { |p| p.replace("#{p.text}\n") } + rich_text.css('li').each { |li| li.replace("- #{li.text}\n") } + rich_text.text.strip + end + + def format_by_type + case @question.type + when 'Question::Essay', 'Question::Upload' + essay_type + when 'Question::Traditional', 'Question::SelectAllThatApply', 'Question::DragAndDrop' + traditional_type + when 'Question::Matching' + matching_type + when 'Question::Categorization' + categorization_type + when 'Question::BowTie' + bowtie_type + when 'Question::StimulusCaseStudy' + stimulus_type + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/config/routes.rb b/config/routes.rb index 3f823167..d6ba43f8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,7 +14,7 @@ get '/(.:format)', to: 'search#index' # download the bookmarked questions in a text file - get 'questions/download', to: 'search#download_as_plain_text' + get 'questions/download', to: 'plain_text_downloads#download' # settings page routes get '/settings', to: 'settings#index', as: 'settings' diff --git a/spec/controllers/plain_text_downloads_controller_spec.rb b/spec/controllers/plain_text_downloads_controller_spec.rb new file mode 100644 index 00000000..2df964ef --- /dev/null +++ b/spec/controllers/plain_text_downloads_controller_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PlainTextDownloadsController, type: :controller do + describe 'GET #download' do + let(:user) { create(:user) } + let!(:question) { create(:question_essay) } + let!(:bookmark) { create(:bookmark, user:, question:) } + + before do + sign_in user + end + + it 'returns a text file' do + get :download + expect(response.content_type).to eq('text/plain') + expect(response.headers['Content-Disposition']).to include('questions.txt') + end + + it 'includes bookmarked questions in the response' do + get :download + expect(response.body).to include(question.text) + end + end +end diff --git a/spec/services/question_text_formatter_service_spec.rb b/spec/services/question_text_formatter_service_spec.rb new file mode 100644 index 00000000..b643097d --- /dev/null +++ b/spec/services/question_text_formatter_service_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe QuestionTextFormatterService do + let(:service) { described_class.new(question) } + + describe '#format' do + subject { service.format } + + context 'with an essay question' do + let(:question) do + create(:question_essay, + text: 'Sample essay question', + data: { 'html' => '

Essay prompt

  • Point 1
Link' }) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Essay + Question: Sample essay question + + Text: Essay prompt + - Point 1 + Link (https://example.com) + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + + context 'with an upload question' do + let(:question) do + create(:question_upload, + text: 'Sample upload question', + data: { 'html' => '

Upload instructions

  • File type: PDF
Guidelines' }) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Upload + Question: Sample upload question + + Text: Upload instructions + - File type: PDF + Guidelines (https://example.com) + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + + context 'with a multiple choice question' do + let(:question) do + create(:question_traditional, + text: 'Sample multiple choice', + data: [ + { 'answer' => 'Option A', 'correct' => true }, + { 'answer' => 'Option B', 'correct' => false } + ]) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Multiple Choice + Question: Sample multiple choice + + 1) Correct: Option A + 2) Incorrect: Option B + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + + context 'with a select all that apply question' do + let(:question) do + create(:question_select_all_that_apply, + text: 'Sample select all question', + data: [ + { 'answer' => 'Option A', 'correct' => true }, + { 'answer' => 'Option B', 'correct' => true }, + { 'answer' => 'Option C', 'correct' => false } + ]) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Select All That Apply + Question: Sample select all question + + 1) Correct: Option A + 2) Correct: Option B + 3) Incorrect: Option C + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + + context 'with a drag and drop question' do + let(:question) do + create(:question_drag_and_drop, + text: 'Sample drag and drop question', + data: [ + { 'answer' => 'Item 1', 'correct' => true }, + { 'answer' => 'Item 2', 'correct' => false }, + { 'answer' => 'Item 3', 'correct' => true } + ]) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Drag And Drop + Question: Sample drag and drop question + + 1) Correct: Item 1 + 2) Incorrect: Item 2 + 3) Correct: Item 3 + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + + context 'with a matching question' do + let(:question) do + create(:question_matching, + text: 'Sample matching question', + data: [ + { + 'answer' => 'Term 1', + 'correct' => ['Definition 1'] + }, + { + 'answer' => 'Term 2', + 'correct' => ['Definition 2'] + }, + { + 'answer' => 'Term 3', + 'correct' => ['Definition 3'] + } + ]) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Matching + Question: Sample matching question + + 1) Term 1 + Correct Match: Definition 1 + 2) Term 2 + Correct Match: Definition 2 + 3) Term 3 + Correct Match: Definition 3 + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + + context 'with a categorization question' do + let(:question) do + create(:question_categorization, + text: 'Sample categorization', + data: [ + { 'answer' => 'Category 1', 'correct' => ['Item 1', 'Item 2'] }, + { 'answer' => 'Category 2', 'correct' => ['Item 3'] } + ]) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Categorization + Question: Sample categorization + + Category: Category 1 + 1) Item 1 + 2) Item 2 + + Category: Category 2 + 1) Item 3 + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + + context 'with a bow tie question' do + let(:question) do + create(:question_bow_tie, + text: 'Sample bow tie question', + data: { + 'center' => { + 'label' => 'Center Label', + 'answers' => [ + { 'answer' => 'Center Answer 1', 'correct' => true }, + { 'answer' => 'Center Answer 2', 'correct' => false } + ] + }, + 'left' => { + 'label' => 'Left Label', + 'answers' => [ + { 'answer' => 'Left Answer 1', 'correct' => true }, + { 'answer' => 'Left Answer 2', 'correct' => false } + ] + }, + 'right' => { + 'label' => 'Right Label', + 'answers' => [ + { 'answer' => 'Right Answer 1', 'correct' => true }, + { 'answer' => 'Right Answer 2', 'correct' => false } + ] + } + }) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Bow Tie + Question: Sample bow tie question + + Center + 1) Correct: Center Answer 1 + 2) Incorrect: Center Answer 2 + + Left + 1) Correct: Left Answer 1 + 2) Incorrect: Left Answer 2 + + Right + 1) Correct: Right Answer 1 + 2) Incorrect: Right Answer 2 + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + + context 'with a stimulus case study question' do + let(:scenario) { create(:question_scenario, text: 'Sample scenario') } + let(:sub_question) { create(:question_essay, text: 'Sub question', data: { 'html' => '

Essay prompt

' }) } + let(:question) do + create(:question_stimulus_case_study, + text: 'Main question', + child_questions: [scenario, sub_question]) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + Question Type: Stimulus Case Study + Question: Main question + + Scenario: Sample scenario + + Question Type: Essay + Question: Sub question + + Text: Essay prompt + + ********** + + TEXT + expect(subject).to eq(expected_output) + end + end + end +end From d80412bbc9610e9ad6579f21233c61081d7b0ca6 Mon Sep 17 00:00:00 2001 From: Sarah Proctor Date: Mon, 24 Feb 2025 12:02:13 -0800 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=8E=81=20Adds=20Plain=20Text=20Downlo?= =?UTF-8?q?ad=20of=20Bookmark=20Questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit: - refactors routes and controllers to allow multiple download formats Ref: - https://github.com/notch8/viva/issues/348 --- .../plain_text_downloads_controller.rb | 11 -------- app/controllers/search_controller.rb | 6 +++++ .../components/ui/Search/SearchBar.jsx | 6 ++--- config/routes.rb | 4 +-- .../plain_text_downloads_controller_spec.rb | 26 ------------------- spec/controllers/search_controller_spec.rb | 23 ++++++++++++++++ 6 files changed, 34 insertions(+), 42 deletions(-) delete mode 100644 app/controllers/plain_text_downloads_controller.rb delete mode 100644 spec/controllers/plain_text_downloads_controller_spec.rb diff --git a/app/controllers/plain_text_downloads_controller.rb b/app/controllers/plain_text_downloads_controller.rb deleted file mode 100644 index 05df0411..00000000 --- a/app/controllers/plain_text_downloads_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -## -# Controller to handle plain text downloads of questions -class PlainTextDownloadsController < ApplicationController - def download - questions = Question.where(id: Bookmark.select(:question_id)) - content = questions.map { |question| QuestionTextFormatterService.new(question).format }.join('') - send_data content, filename: 'questions.txt', type: 'text/plain' - end -end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 6e8493ac..7c69a501 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -35,6 +35,12 @@ def index end end + def text_download + questions = Question.where(id: Bookmark.select(:question_id)) + content = questions.map { |question| QuestionTextFormatterService.new(question).format }.join('') + send_data content, filename: 'questions.txt', type: 'text/plain' + end + private def any_question_has_images? diff --git a/app/javascript/components/ui/Search/SearchBar.jsx b/app/javascript/components/ui/Search/SearchBar.jsx index 872cfc51..5f4741f3 100644 --- a/app/javascript/components/ui/Search/SearchBar.jsx +++ b/app/javascript/components/ui/Search/SearchBar.jsx @@ -90,12 +90,12 @@ const SearchBar = (props) => { View Bookmarks - Export as Text + Export as Plain Text `bookmarked_question_ids[]=${encodeURIComponent(id)}`).join('&')}`} @@ -103,7 +103,7 @@ const SearchBar = (props) => { role='button' aria-disabled={!hasBookmarks} > - Export Bookmarks + Export as XML