word_2_quiz 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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