diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 1f9ca01..2ffec71 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -22,7 +22,7 @@ def index
# conversations with the client, we're looking to only export classic (as you can migrate a
# classic question to new format). This filename is another "helpful clue" and introduces
# later considerations for what the file format might be.
- filename = "questions-#{now.strftime('%Y-%m-%d_%H:%M:%S:%L')}.classic-question-canvas.qti.xml"
+ filename = "#{export_filename(now)}.classic-question-canvas.qti.xml"
@questions = Question.filter(**filter_values)
if any_question_has_images?
@@ -35,14 +35,35 @@ def index
end
end
- def text_download
- questions = Question.where(id: Bookmark.select(:question_id))
- content = questions.map { |question| PlainTextFormatterService.new(question).format }.join('')
- send_data content, filename: 'questions.txt', type: 'text/plain'
+ # download bookmarked questions
+ def download
+ @questions = Question.where(id: Bookmark.select(:question_id))
+ case params[:format]
+ when 'md'
+ md_download
+ when 'txt'
+ text_download
+ else
+ redirect_to authenticated_root_path, alert: t('.alert')
+ end
end
private
+ def export_filename(now = Time.current)
+ "questions-#{now.strftime('%Y-%m-%d_%H:%M:%S:%L')}"
+ end
+
+ def text_download
+ content = @questions.map { |question| QuestionFormatter::PlainTextService.new(question).format_content }.join('')
+ send_data content, filename: "#{export_filename}.txt", type: 'text/plain'
+ end
+
+ def md_download
+ content = @questions.map { |question| QuestionFormatter::MarkdownService.new(question).format_content }.join('')
+ send_data content, filename: "#{export_filename}.md", type: 'text/plain'
+ end
+
def any_question_has_images?
@questions.any? { |question| question.images.any? }
end
diff --git a/app/javascript/components/ui/Search/SearchBar.jsx b/app/javascript/components/ui/Search/SearchBar.jsx
index 5f4741f..da169d5 100644
--- a/app/javascript/components/ui/Search/SearchBar.jsx
+++ b/app/javascript/components/ui/Search/SearchBar.jsx
@@ -90,13 +90,21 @@ const SearchBar = (props) => {
View Bookmarks
Export as Plain Text
+
+ Export as Markdown
+
`bookmarked_question_ids[]=${encodeURIComponent(id)}`).join('&')}`}
className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
diff --git a/app/models/question/bow_tie.rb b/app/models/question/bow_tie.rb
index 0ec7f67..7133b03 100644
--- a/app/models/question/bow_tie.rb
+++ b/app/models/question/bow_tie.rb
@@ -32,7 +32,7 @@
# => true
class Question::BowTie < Question
self.type_name = "Bow Tie"
- self.model_exporter = 'bow_tie'
+ self.model_exporter = 'bowtie_type'
# NOTE: We're not storing this in a JSONB data type, but instead favoring a text field. The need
# for the data to be used in the application, beyond export of data, is minimal.
diff --git a/app/models/question/categorization.rb b/app/models/question/categorization.rb
index dc53adf..49e879a 100644
--- a/app/models/question/categorization.rb
+++ b/app/models/question/categorization.rb
@@ -8,7 +8,7 @@ class Question::Categorization < Question
include MatchingQuestionBehavior
self.type_name = "Categorization"
- self.model_exporter = 'categorization'
+ self.model_exporter = 'categorization_type'
self.export_as_xml = true
self.choice_cardinality_is_multiple = true
diff --git a/app/models/question/drag_and_drop.rb b/app/models/question/drag_and_drop.rb
index b0f9eb8..25d9008 100644
--- a/app/models/question/drag_and_drop.rb
+++ b/app/models/question/drag_and_drop.rb
@@ -21,7 +21,7 @@
# data: [{ answer: "Aardvark", correct: true }, { answer: "Blue", correct: false }, { answer: "Yellow", correct:false }, { answer: "Cat", correct: true }])
class Question::DragAndDrop < Question
self.type_name = "Drag and Drop"
- self.model_exporter = 'drag_and_drop'
+ self.model_exporter = 'traditional_type'
##
# Represents the mapping process of a CSV Row to the underlying {Question::DragAndDrop}.
diff --git a/app/models/question/essay.rb b/app/models/question/essay.rb
index 06aaea5..1298859 100644
--- a/app/models/question/essay.rb
+++ b/app/models/question/essay.rb
@@ -9,7 +9,7 @@ class Question::Essay < Question
include MarkdownQuestionBehavior
self.type_name = "Essay"
- self.model_exporter = 'essay'
+ self.model_exporter = 'essay_type'
self.export_as_xml = true
class ImportCsvRow < MarkdownQuestionBehavior::ImportCsvRow
diff --git a/app/models/question/matching.rb b/app/models/question/matching.rb
index 1377d33..de158af 100644
--- a/app/models/question/matching.rb
+++ b/app/models/question/matching.rb
@@ -8,7 +8,7 @@ class Question::Matching < Question
include MatchingQuestionBehavior
self.type_name = "Matching"
- self.model_exporter = 'matching'
+ self.model_exporter = 'matching_type'
self.export_as_xml = true
self.choice_cardinality_is_multiple = false
diff --git a/app/models/question/select_all_that_apply.rb b/app/models/question/select_all_that_apply.rb
index 89fa5d9..40f9312 100644
--- a/app/models/question/select_all_that_apply.rb
+++ b/app/models/question/select_all_that_apply.rb
@@ -7,7 +7,7 @@
# duplication than more complicated inheritance.
class Question::SelectAllThatApply < Question
self.type_name = "Select All That Apply"
- self.model_exporter = 'select_all_that_apply'
+ self.model_exporter = 'traditional_type'
##
# Represents the mapping process of a CSV Row to the underlying {Question::SelectAllThatApply}.
diff --git a/app/models/question/stimulus_case_study.rb b/app/models/question/stimulus_case_study.rb
index d8a1636..1eaeab9 100644
--- a/app/models/question/stimulus_case_study.rb
+++ b/app/models/question/stimulus_case_study.rb
@@ -5,7 +5,7 @@
class Question::StimulusCaseStudy < Question
self.type_label = "Case Study"
self.type_name = "Stimulus Case Study"
- self.model_exporter = 'stimulus_case_study'
+ self.model_exporter = 'stimulus_type'
self.has_parts = true
has_many :as_parent_question_aggregations,
diff --git a/app/models/question/traditional.rb b/app/models/question/traditional.rb
index c362919..9e4f656 100644
--- a/app/models/question/traditional.rb
+++ b/app/models/question/traditional.rb
@@ -10,7 +10,7 @@ class Question::Traditional < Question
#
# @see https://github.com/notch8/viva/issues/261
self.type_name = "Multiple Choice"
- self.model_exporter = 'multiple_choice'
+ self.model_exporter = 'traditional_type'
##
# Represents the mapping process of a CSV Row to the underlying {Question::Traditional}.
diff --git a/app/models/question/upload.rb b/app/models/question/upload.rb
index 4a36ef3..6a4aedd 100644
--- a/app/models/question/upload.rb
+++ b/app/models/question/upload.rb
@@ -10,7 +10,7 @@ class Question::Upload < Question
include MarkdownQuestionBehavior
self.type_name = "Upload"
- self.model_exporter = 'upload'
+ self.model_exporter = 'essay_type'
self.export_as_xml = true
class ImportCsvRow < MarkdownQuestionBehavior::ImportCsvRow
diff --git a/app/services/base_formatter_service.rb b/app/services/base_formatter_service.rb
deleted file mode 100644
index 4662764..0000000
--- a/app/services/base_formatter_service.rb
+++ /dev/null
@@ -1,126 +0,0 @@
-# frozen_string_literal: true
-require 'nokogiri'
-
-##
-# Service to handle formatting questions for downloads
-
-class BaseFormatterService
- def initialize(question, subq = false)
- @question = question
- @subq = subq
- end
-
- def format
- format_by_type + content_divider
- end
-
- protected
-
- def content_divider
- raise NotImplementedError, "Subclasses must implement content_divider"
- end
-
- def format_by_type
- method = @question.class.model_exporter
- send(method)
- end
-
- def format_question_header
- raise NotImplementedError, "Subclasses must implement format_question_header"
- end
-
- def essay
- format_question_header + format_essay_content
- end
-
- def upload
- format_question_header + format_essay_content
- end
-
- def multiple_choice
- format_question_header + format_answers(@question.data) { |answer, index| format_traditional_answer(answer, index) }
- end
-
- def select_all_that_apply
- format_question_header + format_answers(@question.data) { |answer, index| format_traditional_answer(answer, index) }
- end
-
- def drag_and_drop
- format_question_header + format_answers(@question.data) { |answer, index| format_traditional_answer(answer, index) }
- end
-
- def matching
- format_question_header + format_answers(@question.data) { |answer, index| format_matching_answer(answer, index) }
- end
-
- def categorization
- format_question_header + format_categories(@question.data)
- end
-
- def bow_tie
- format_question_header + format_bow_tie_sections
- end
-
- def stimulus_case_study
- output = @question.child_questions.map { |sub_question| format_sub_question(sub_question) }
- # removes additional line breaks from the sub-questions
- output[-1] = output[-1].chomp if output.any?
- "#{format_question_header}#{output.join('')}"
- end
-
- def format_sub_question(sub_question)
- if sub_question.type == "Question::Scenario"
- "Scenario: #{sub_question.text}\n\n"
- else
- "#{self.class.new(sub_question, true).format_by_type}\n"
- end
- end
-
- private
-
- def question_type
- @question.class.type_name.titleize
- 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_bow_tie_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 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
-end
diff --git a/app/services/plain_text_formatter_service.rb b/app/services/plain_text_formatter_service.rb
deleted file mode 100644
index eadeb79..0000000
--- a/app/services/plain_text_formatter_service.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-##
-# Service to handle formatting questions into plain text
-
-class PlainTextFormatterService < BaseFormatterService
- protected
-
- def content_divider
- "\n==========\n\n"
- end
-
- def format_question_header
- return "QUESTION TYPE: #{question_type}\nQUESTION: #{@question.text}\n\n" unless @subq
- "Subquestion Type: #{question_type}\nSubquestion: #{@question.text}\n\n"
- end
-end
diff --git a/app/services/question_formatter/base_service.rb b/app/services/question_formatter/base_service.rb
new file mode 100644
index 0000000..49dcf01
--- /dev/null
+++ b/app/services/question_formatter/base_service.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+require 'nokogiri'
+
+module QuestionFormatter
+ class BaseService
+ ##
+ # Service to handle formatting questions for downloads
+ class_attribute :output_format, default: nil
+ attr_reader :question, :subq
+
+ def initialize(question, subq = false)
+ @question = question
+ @subq = subq
+ end
+
+ def format_content
+ format_by_type + divider_line
+ 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) }
+ # remove extra line breaks
+ output[-1] = output[-1].chomp if output.any?
+ "#{format_question_header}#{output.join('')}"
+ end
+
+ def format_sub_question(sub_question)
+ case sub_question.type
+ when "Question::Scenario"
+ format_scenario(sub_question)
+ else
+ "#{self.class.new(sub_question, true).format_by_type}\n"
+ end
+ end
+
+ def format_by_type
+ method = @question.class.model_exporter
+ send(method)
+ end
+
+ private
+
+ def divider_line
+ raise NotImplementedError, "Subclasses must implement divider_line"
+ end
+
+ def format_scenario(question)
+ raise NotImplementedError, "Subclasses must implement format_scenario"
+ end
+
+ def format_question_header
+ raise NotImplementedError, "Subclasses must implement format_question_header"
+ 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)
+ raise NotImplementedError, "Subclasses must implement format_categories"
+ 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
+ @question.class.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
+ end
+end
diff --git a/app/services/question_formatter/markdown_service.rb b/app/services/question_formatter/markdown_service.rb
new file mode 100644
index 0000000..9b88923
--- /dev/null
+++ b/app/services/question_formatter/markdown_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+##
+# Service to handle formatting questions into markdown
+module QuestionFormatter
+ class MarkdownService < BaseService
+ self.output_format = 'md'
+
+ private
+
+ def divider_line
+ "\n---\n\n"
+ end
+
+ def format_scenario(sub_question)
+ "**Scenario:** #{sub_question.text}\n\n"
+ end
+
+ def format_question_header
+ headers = case @subq
+ when true
+ "### Subquestion Type: #{question_type}\n**Subquestion:** #{@question.text}\n\n"
+ else
+ "## QUESTION TYPE: #{question_type}\n**QUESTION:** #{@question.text}\n\n"
+ end
+ headers
+ end
+
+ def format_essay_content
+ plain_text = format_html(@question.data['html'])
+ "**Text:** #{plain_text}\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
+ end
+end
diff --git a/app/services/question_formatter/plain_text_service.rb b/app/services/question_formatter/plain_text_service.rb
new file mode 100644
index 0000000..e1742ea
--- /dev/null
+++ b/app/services/question_formatter/plain_text_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+##
+# Service to handle formatting questions into plain text
+module QuestionFormatter
+ class PlainTextService < BaseService
+ self.output_format = 'txt'
+
+ private
+
+ def divider_line
+ "\n==========\n\n"
+ end
+
+ def format_scenario(sub_question)
+ "Scenario: #{sub_question.text}\n\n"
+ end
+
+ def format_question_header
+ return "QUESTION TYPE: #{question_type}\nQUESTION: #{@question.text}\n\n" unless @subq
+ "Subquestion Type: #{question_type}\nSubquestion: #{@question.text}\n\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
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e50aaeb..4d6ebb8 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -49,3 +49,6 @@ en:
success: "Settings updated successfully."
update_password:
success: "Password updated successfully."
+ search:
+ download:
+ alert: "Invalid format. Please select a valid format."
diff --git a/config/routes.rb b/config/routes.rb
index 5a74de9..228b89b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -13,8 +13,8 @@
# Necessary for downloading the XML file from the search result.
get '/(.:format)', to: 'search#index'
- # download the bookmarked questions in a plain text file
- get 'questions/text_download', to: 'search#text_download'
+ # download the bookmarked questions in a text file
+ get 'questions/download', to: 'search#download', as: 'download_questions'
# settings page routes
get '/settings', to: 'settings#index', as: 'settings'
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index b0a2754..bb0ad82 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -142,21 +142,39 @@
end
end
- context 'plain text download' do
+ context 'downloading question text' do
let(:question) { FactoryBot.create(:question_traditional) }
let(:user) { FactoryBot.create(:user) }
+ let(:bookmark) { Bookmark.create!(user:, question:) }
- it 'returns a text file' do
- get :text_download
- expect(response.content_type).to eq('text/plain')
- expect(response.headers['Content-Disposition']).to include('questions.txt')
+ before do
+ bookmark
end
- it 'includes bookmarked questions in the response' do
- Bookmark.create!(user:, question:)
+ context 'downloading as plain text' do
+ it 'returns a txt file' do
+ get :download, format: :txt
+ expect(response.content_type).to eq('text/plain')
+ expect(response.headers['Content-Disposition']).to match(/questions-.*\.txt/)
+ end
+
+ it 'includes bookmarked questions in the response' do
+ get :download, format: :txt
+ expect(response.body).to include(question.text)
+ end
+ end
- get :text_download
- expect(response.body).to include(question.text)
+ context 'downloading as markdown' do
+ it 'returns a md file' do
+ get :download, format: :md
+ expect(response.content_type).to eq('text/plain')
+ expect(response.headers['Content-Disposition']).to match(/questions-.*\.md/)
+ end
+
+ it 'includes bookmarked questions in the response' do
+ get :download, format: :md
+ expect(response.body).to include(question.text)
+ end
end
end
end
diff --git a/spec/services/question_formatter/base_service_spec.rb b/spec/services/question_formatter/base_service_spec.rb
new file mode 100644
index 0000000..4361abd
--- /dev/null
+++ b/spec/services/question_formatter/base_service_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe QuestionFormatter::BaseService do
+ let(:subject) { described_class.new(question) }
+ let(:question) do
+ create(:question_essay,
+ text: 'Sample essay question',
+ data: { 'html' => ' Essay prompt
Link' })
+ end
+
+ it 'has the correct public methods' do
+ expect(subject).to respond_to(:format_content)
+ end
+end
diff --git a/spec/services/question_formatter/markdown_service_spec.rb b/spec/services/question_formatter/markdown_service_spec.rb
new file mode 100644
index 0000000..1146528
--- /dev/null
+++ b/spec/services/question_formatter/markdown_service_spec.rb
@@ -0,0 +1,299 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe QuestionFormatter::MarkdownService do
+ let(:service) { described_class.new(question) }
+
+ describe '#format' do
+ subject { service.format_content }
+
+ context 'with an essay question' do
+ let(:question) do
+ create(:question_essay,
+ text: 'Sample essay question',
+ data: { 'html' => '
Essay prompt
Upload instructions
Essay prompt
' }) } + let(:sub_question_mc) do + create(:question_traditional, + text: 'Multiple choice sub question', + data: [ + { 'answer' => 'Option A', 'correct' => true }, + { 'answer' => 'Option B', 'correct' => false } + ]) + end + let(:question) do + create(:question_stimulus_case_study, + text: 'Main question', + child_questions: [scenario, sub_question_essay, sub_question_mc]) + end + + it 'formats the question correctly' do + expected_output = <<~TEXT + ## QUESTION TYPE: Stimulus Case Study + **QUESTION:** Main question + + **Scenario:** Sample scenario + + ### Subquestion Type: Essay + **Subquestion:** Sub question + + **Text:** Essay prompt + + ### Subquestion Type: Multiple Choice + **Subquestion:** Multiple choice sub question + + 1) Correct: Option A + 2) Incorrect: Option B + + --- + + TEXT + expect(subject).to eq(expected_output) + end + end + end +end diff --git a/spec/services/plain_text_formatter_service_spec.rb b/spec/services/question_formatter/plain_text_service_spec.rb similarity index 98% rename from spec/services/plain_text_formatter_service_spec.rb rename to spec/services/question_formatter/plain_text_service_spec.rb index d4a6712..55ade23 100644 --- a/spec/services/plain_text_formatter_service_spec.rb +++ b/spec/services/question_formatter/plain_text_service_spec.rb @@ -2,11 +2,11 @@ require 'rails_helper' -RSpec.describe PlainTextFormatterService do +RSpec.describe QuestionFormatter::PlainTextService do let(:service) { described_class.new(question) } describe '#format' do - subject { service.format } + subject { service.format_content } context 'with an essay question' do let(:question) do @@ -119,7 +119,7 @@ it 'formats the question correctly' do expected_output = <<~TEXT - QUESTION TYPE: Drag And Drop + QUESTION TYPE: Drag and Drop QUESTION: Sample drag and drop question 1) Correct: Item 1