word_2_quiz 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ word2quiz
@@ -0,0 +1 @@
1
+ 2.1.9
@@ -0,0 +1,20 @@
1
+ ---
2
+ language: ruby
3
+ cache: bundler
4
+
5
+ rvm:
6
+ - 2.1.9
7
+ - 2.2.2
8
+ - 2.3.3
9
+
10
+ before_install:
11
+ - gem update bundler
12
+
13
+ install:
14
+ - bundle install --path vendor/bundle
15
+
16
+ branches:
17
+ only:
18
+ - master
19
+
20
+ script: bundle exec rake
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in word_2_quiz.gemspec
4
+ gemspec
@@ -0,0 +1,49 @@
1
+ # Word2Quiz
2
+
3
+ Converts word document quizzes to a Word2Quiz::Quiz, which can be converted to a hash or to an Instructure canvas hash format.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'word_2_quiz'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install word_2_quiz
20
+
21
+ ## Usage
22
+
23
+ Expects a docx quiz where every question is on a new line started with a number
24
+ followed by a period, and every answer is below each question. Every answer is
25
+ on a new line, and begins with a letter followed by a period. The title is
26
+ assumed to be on lines 3 & 4, and the description is generated from lines 0-5 of
27
+ the quiz.
28
+
29
+ The answer key should be in a doc file, where each solution consists of a
30
+ number and an answer - e.g `1. C 2.B`
31
+
32
+ To get the parse the quiz, run
33
+ `quiz = Word2Quiz.parse_quiz("path/to/quiz.docx", "path/to/solutions.doc")`
34
+
35
+ To get the quiz as a hash, run `quiz.to_h`
36
+
37
+ To get the quiz for creating on canvas run `quiz.to_canvas`
38
+
39
+ To get the quiz questions for creating on canvas run `quiz.questions_as_canvas`
40
+
41
+ ## Development
42
+
43
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
44
+
45
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
46
+
47
+ ## Contributing
48
+
49
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/word_2_quiz.
@@ -0,0 +1,12 @@
1
+ require "bundler/setup"
2
+ require "rspec"
3
+
4
+ begin
5
+ require "rspec/core/rake_task"
6
+ RSpec::Core::RakeTask.new(:spec)
7
+ rescue LoadError
8
+ end
9
+
10
+ Bundler::GemHelper.install_tasks
11
+
12
+ task default: :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "word_2_quiz"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ LIB_DIR = File.join(__dir__, "..", "lib")
4
+ $LOAD_PATH << LIB_DIR
5
+ require "bundler/setup"
6
+ require "word_2_quiz/quiz_parser"
7
+ require "word_2_quiz/quiz_solutions_parser"
8
+
9
+ # You can add fixtures and/or initialization code here to make experimenting
10
+ # with your gem easier. You can also use a different console, if you like.
11
+ #Switch this out with parse solutions
12
+ #Word2Quiz.parse_quiz(ARGV[0])
13
+ Word2Quiz.parse_quiz(ARGV[0], ARGV[1])
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,5 @@
1
+ require "word_2_quiz/version"
2
+ require "word_2_quiz/quiz_parser"
3
+
4
+ module Word2Quiz
5
+ end
@@ -0,0 +1,52 @@
1
+ require "word_2_quiz/helpers"
2
+
3
+ module Word2Quiz
4
+ ##
5
+ # Answer contains all data for a single answer. An answer has text and whether
6
+ # it is correct.
7
+ ##
8
+ class Answer
9
+ attr_accessor :text, :correct
10
+
11
+ # index of the very first answer paragraph
12
+ ANSWER_START = 0
13
+
14
+ # Canvas expects a weight greather than zero for correct, 0 for incorrect.
15
+ CORRECT_WEIGHT = 100
16
+ INCORRECT_WEIGHT = 0
17
+
18
+ def initialize(text = "", correct = false)
19
+ @text = text
20
+ @correct = correct
21
+ end
22
+
23
+ ##
24
+ # Expects an array of paragraphs that are a single answer, and
25
+ # The correct answer for that question, e.g. answer="a"
26
+ ##
27
+ def self.from_paragraphs(paragraphs, solution)
28
+ non_empty_paragraphs = Helpers.strip_blanks(paragraphs)
29
+ text = non_empty_paragraphs.map(&:to_html).join("\n")
30
+ start_paragraph = non_empty_paragraphs[ANSWER_START]
31
+ is_correct = start_paragraph.text.downcase.start_with?(solution.downcase)
32
+
33
+ Answer.new(text, is_correct)
34
+ end
35
+
36
+ def to_h
37
+ {
38
+ text: @text,
39
+ correct: @correct,
40
+ }
41
+ end
42
+
43
+ # Canvas has an undocumented answer_html field for answers that we have to
44
+ # use because we are sending html, not plain text.
45
+ def to_canvas
46
+ {
47
+ answer_html: @text,
48
+ answer_weight: @correct ? CORRECT_WEIGHT : INCORRECT_WEIGHT,
49
+ }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,104 @@
1
+ require "numbers_in_words"
2
+ module Word2Quiz
3
+ module Helpers
4
+ TIME_TO_MINUTES_MAP = {
5
+ "hours" => 60,
6
+ "minutes" => 1,
7
+ }.freeze
8
+
9
+ # indexes of paragraphs where title and description start and end
10
+ TITLE_START = 3
11
+ TITLE_END = 4
12
+ DESCRIPTION_START = 0
13
+ DESCRIPTION_END = 5
14
+ QUESTION_START = 0
15
+
16
+ ##
17
+ # Takes in an array of indexes, and returns out an array of arrays of
18
+ # paragraphs bounded by the indexes, e.g. if indexes is [2, 5] then it
19
+ # returns:
20
+ # [paragraphs[0...2], paragraphs[2...5], paragraphs[5...paragraphs.count]]
21
+ ##
22
+ def self.map_to_boundaries(indexes:, paragraphs:)
23
+ indexes.map.with_index do |start_index, i|
24
+ first_index = start_index
25
+ last_index = indexes[i + 1] || paragraphs.count
26
+
27
+ paragraphs[first_index...last_index]
28
+ end
29
+ end
30
+
31
+ def self.get_quiz_duration(paragraphs)
32
+ time_paragraph = paragraphs.find do |p|
33
+ p.text.include?("one session not to exceed ")
34
+ end
35
+
36
+ time = time_paragraph.text.sub(
37
+ "This examination consists of one session not to exceed ",
38
+ "",
39
+ ).chomp(".")
40
+
41
+ time_number = time.split(" ").first
42
+ time_unit = time.split(" ").last
43
+ unit_conversion = TIME_TO_MINUTES_MAP[time_unit]
44
+
45
+ NumbersInWords.in_numbers(time_number) * unit_conversion
46
+ end
47
+
48
+ ##
49
+ # Returns the quiz title. The 3rd and 4th lines contain the best title for
50
+ # the quiz.
51
+ ##
52
+ def self.get_quiz_title(paragraphs)
53
+ paragraphs[TITLE_START..TITLE_END].map(&:text).join(" ")
54
+ end
55
+
56
+ ##
57
+ # Returns the quiz description. Lines 0-5 are the best description for the
58
+ # quiz.
59
+ ##
60
+ def self.get_quiz_description(paragraphs)
61
+ paragraphs[DESCRIPTION_START..DESCRIPTION_END].map(&:to_html).join("\n")
62
+ end
63
+
64
+ ##
65
+ # Returns the question number
66
+ #
67
+ ##
68
+ def self.get_question_number(paragraphs)
69
+ match = paragraphs[QUESTION_START].text.match(/^(\d+)\./)
70
+ match ? match.captures.first : nil
71
+ end
72
+
73
+ ##
74
+ # Finds the index of the paragraph where the questions start
75
+ ##
76
+ def self.get_question_start_index(paragraphs)
77
+ paragraphs.find_index { |p| p.text.include?("Multiple Choice") }
78
+ end
79
+
80
+ ##
81
+ # Finds the index of the paragraph where the questions end
82
+ ##
83
+ def self.get_question_end_index(paragraphs)
84
+ paragraphs.find_index { |p| p.text.include?("End of examination") }
85
+ end
86
+
87
+ ##
88
+ # Returns the indexes of the start of each question.
89
+ ##
90
+ def self.get_question_indexes(paragraphs)
91
+ paragraphs.each_index.select { |i| paragraphs[i].text.match(/^\d*\./) }
92
+ end
93
+
94
+ ##
95
+ # Returns an array of paragraphs with leading and trailing blank paragraphs
96
+ # removed.
97
+ ##
98
+ def self.strip_blanks(paragraphs)
99
+ t = paragraphs.drop_while { |p| p.text.chomp.empty? }
100
+ t.pop while t.last.text.chomp.empty?
101
+ t
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,65 @@
1
+ require "word_2_quiz/helpers"
2
+
3
+ module Word2Quiz
4
+ ##
5
+ # Question contains all data for a single question. A question has text, and
6
+ # answers.
7
+ ##
8
+ class Question
9
+ attr_accessor :text, :answers
10
+
11
+ POINTS_POSSIBLE = 5
12
+
13
+ def initialize(text = "", answers = [])
14
+ @answers = answers
15
+ @text = text
16
+ end
17
+
18
+ ##
19
+ # Creates a question from an array of strings
20
+ # paragraphs: an array of nokogiri nodes containing each line for the
21
+ # question text and answers
22
+ # solution: a string containing which answer(a,b,c,d) is correct
23
+ ##
24
+ def self.from_paragraphs(paragraphs, solution)
25
+ answers = []
26
+
27
+ answer_start_indexes = paragraphs.each_index.select do |i|
28
+ # an answer starts with a letter then a dot
29
+ paragraphs[i].text.match(/^[a-z]\./)
30
+ end
31
+
32
+ all_answer_paragraphs = Helpers.map_to_boundaries(
33
+ indexes: answer_start_indexes,
34
+ paragraphs: paragraphs,
35
+ )
36
+
37
+ question_paragraphs = paragraphs.take(answer_start_indexes.first)
38
+ question_paragraphs = Helpers.strip_blanks(question_paragraphs)
39
+ question_text = question_paragraphs.map(&:to_html).join("\n")
40
+
41
+ all_answer_paragraphs.each do |answer_paragraphs|
42
+ answer = Answer.from_paragraphs(answer_paragraphs, solution)
43
+ answers.push(answer)
44
+ end
45
+
46
+ Question.new(question_text, answers)
47
+ end
48
+
49
+ def to_h
50
+ {
51
+ text: @text,
52
+ answers: @answers.map(&:to_h),
53
+ }
54
+ end
55
+
56
+ def to_canvas
57
+ {
58
+ question_type: "multiple_choice_question",
59
+ question_text: @text,
60
+ points_possible: POINTS_POSSIBLE,
61
+ answers: @answers.map(&:to_canvas),
62
+ }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,86 @@
1
+ require "word_2_quiz/question"
2
+ require "word_2_quiz/answer"
3
+ require "word_2_quiz/helpers"
4
+
5
+ module Word2Quiz
6
+ ##
7
+ # Quiz contains all quiz data. A quiz has a description,
8
+ # a title, a time limit, and questions.
9
+ ##
10
+ class Quiz
11
+ attr_accessor :questions, :description, :title, :time_limit
12
+
13
+ ALLOWED_ATTEMPTS = 1
14
+
15
+ def initialize(title:, time_limit:, description: "")
16
+ @questions = []
17
+ @description = description
18
+ @title = title
19
+ @time_limit = time_limit
20
+ end
21
+
22
+ def self.from_paragraphs(paragraphs, answers)
23
+ all_question_paragraphs = Quiz.get_question_paragraphs(paragraphs)
24
+
25
+ quiz_title = Helpers.get_quiz_title(paragraphs)
26
+ quiz_description = Helpers.get_quiz_description(paragraphs)
27
+
28
+ quiz_duration = Helpers.get_quiz_duration(paragraphs)
29
+
30
+ quiz = Quiz.new(
31
+ title: quiz_title,
32
+ time_limit: quiz_duration,
33
+ description: quiz_description,
34
+ )
35
+
36
+ all_question_paragraphs.each do |question_paragraphs|
37
+ question_number = Helpers.get_question_number(question_paragraphs)
38
+ question = Question.from_paragraphs(
39
+ question_paragraphs,
40
+ answers[question_number],
41
+ )
42
+
43
+ quiz.questions.push(question)
44
+ end
45
+ quiz
46
+ end
47
+
48
+ def self.get_question_paragraphs(paragraphs)
49
+ question_start_index = Helpers.get_question_start_index(paragraphs)
50
+ question_end_index = Helpers.get_question_end_index(paragraphs)
51
+
52
+ question_range = paragraphs[question_start_index...question_end_index]
53
+
54
+ question_indexes = Helpers.get_question_indexes(question_range)
55
+
56
+ Helpers.map_to_boundaries(
57
+ indexes: question_indexes,
58
+ paragraphs: question_range,
59
+ )
60
+ end
61
+
62
+ def to_h
63
+ {
64
+ title: @title,
65
+ questions: @questions.map(&:to_h),
66
+ description: @description,
67
+ time_limit: @time_limit,
68
+ }
69
+ end
70
+
71
+ def to_canvas
72
+ {
73
+ title: @title,
74
+ question_count: @questions.count,
75
+ quiz_type: "assignment",
76
+ description: @description,
77
+ allowed_attempts: ALLOWED_ATTEMPTS,
78
+ time_limit: @time_limit,
79
+ }
80
+ end
81
+
82
+ def questions_as_canvas
83
+ @questions.map(&:to_canvas)
84
+ end
85
+ end
86
+ end