senkyoshi 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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +92 -0
  4. data/bin/console +14 -0
  5. data/bin/import_blackboard +6 -0
  6. data/lib/senkyoshi/canvas_course.rb +179 -0
  7. data/lib/senkyoshi/collection.rb +26 -0
  8. data/lib/senkyoshi/config.rb +35 -0
  9. data/lib/senkyoshi/exceptions.rb +13 -0
  10. data/lib/senkyoshi/models/announcement.rb +36 -0
  11. data/lib/senkyoshi/models/answer.rb +32 -0
  12. data/lib/senkyoshi/models/assessment.rb +95 -0
  13. data/lib/senkyoshi/models/assignment.rb +26 -0
  14. data/lib/senkyoshi/models/assignment_group.rb +23 -0
  15. data/lib/senkyoshi/models/blog.rb +22 -0
  16. data/lib/senkyoshi/models/content.rb +92 -0
  17. data/lib/senkyoshi/models/content_file.rb +26 -0
  18. data/lib/senkyoshi/models/course.rb +39 -0
  19. data/lib/senkyoshi/models/file.rb +99 -0
  20. data/lib/senkyoshi/models/forum.rb +28 -0
  21. data/lib/senkyoshi/models/gradebook.rb +30 -0
  22. data/lib/senkyoshi/models/group.rb +22 -0
  23. data/lib/senkyoshi/models/module.rb +22 -0
  24. data/lib/senkyoshi/models/module_item.rb +23 -0
  25. data/lib/senkyoshi/models/question.rb +188 -0
  26. data/lib/senkyoshi/models/questions/calculated.rb +72 -0
  27. data/lib/senkyoshi/models/questions/either_or.rb +31 -0
  28. data/lib/senkyoshi/models/questions/essay.rb +11 -0
  29. data/lib/senkyoshi/models/questions/file_upload.rb +4 -0
  30. data/lib/senkyoshi/models/questions/fill_in_blank.rb +15 -0
  31. data/lib/senkyoshi/models/questions/fill_in_blank_plus.rb +20 -0
  32. data/lib/senkyoshi/models/questions/hot_spot.rb +11 -0
  33. data/lib/senkyoshi/models/questions/jumbled_sentence.rb +37 -0
  34. data/lib/senkyoshi/models/questions/matching.rb +57 -0
  35. data/lib/senkyoshi/models/questions/multiple_answer.rb +56 -0
  36. data/lib/senkyoshi/models/questions/multiple_choice.rb +18 -0
  37. data/lib/senkyoshi/models/questions/numerical.rb +31 -0
  38. data/lib/senkyoshi/models/questions/opinion_scale.rb +18 -0
  39. data/lib/senkyoshi/models/questions/ordering.rb +40 -0
  40. data/lib/senkyoshi/models/questions/quiz_bowl.rb +11 -0
  41. data/lib/senkyoshi/models/questions/short_response.rb +4 -0
  42. data/lib/senkyoshi/models/questions/true_false.rb +15 -0
  43. data/lib/senkyoshi/models/quiz.rb +7 -0
  44. data/lib/senkyoshi/models/resource.rb +33 -0
  45. data/lib/senkyoshi/models/scorm_package.rb +109 -0
  46. data/lib/senkyoshi/models/staff_info.rb +72 -0
  47. data/lib/senkyoshi/models/wikipage.rb +34 -0
  48. data/lib/senkyoshi/tasks.rb +90 -0
  49. data/lib/senkyoshi/version.rb +3 -0
  50. data/lib/senkyoshi/xml_parser.rb +154 -0
  51. data/lib/senkyoshi.rb +69 -0
  52. metadata +250 -0
@@ -0,0 +1,4 @@
1
+ module Senkyoshi
2
+ class FileUpload < Question
3
+ end
4
+ end
@@ -0,0 +1,15 @@
1
+ module Senkyoshi
2
+ class FillInBlank < Question
3
+ def iterate_xml(data)
4
+ super
5
+ conditionvar = data.at("resprocessing").at("conditionvar")
6
+ # not all fill in the blank questions have answers(ie: surveys)
7
+ if conditionvar
8
+ answer = Answer.new(conditionvar.at("varequal").text)
9
+ answer.fraction = @max_score
10
+ @answers.push(answer)
11
+ end
12
+ self
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ module Senkyoshi
2
+ class FillInBlankPlus < Question
3
+ def iterate_xml(data)
4
+ super
5
+ conditionvar = data.at("resprocessing").at("conditionvar")
6
+ # not all fill in the blank questions have answers(ie: surveys)
7
+ if conditionvar
8
+ conditionvar.at("and").children.each do |or_child|
9
+ or_child.children.each do |varequal|
10
+ answer = Answer.new(varequal.text)
11
+ answer.resp_ident = varequal.attributes["respident"].value
12
+ answer.fraction = @max_score
13
+ @answers.push(answer)
14
+ end
15
+ end
16
+ end
17
+ self
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ module Senkyoshi
2
+ class HotSpot < Question
3
+ def iterate_xml(data)
4
+ super
5
+ @material = "#{@title} -- This question was imported from an
6
+ external source. It was a #{@blackboard_type} question, which
7
+ is not supported in this quiz tool."
8
+ self
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ module Senkyoshi
2
+ class JumbledSentence < Question
3
+ def initialize
4
+ @responses = []
5
+ super
6
+ end
7
+
8
+ def iterate_xml(data)
9
+ super
10
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
11
+ choices = []
12
+ response_block.at("flow_label").children.each do |response|
13
+ text = response.at("mattext").text
14
+ choices << { id: response.attributes["ident"], text: text }
15
+ end
16
+ set_answers(data.at("resprocessing"))
17
+ correct = data.at("respcondition[title=correct]")
18
+ correct.at("and").children.each do |answer_element|
19
+ id = answer_element.text
20
+ response_label = data.at("response_label[ident='#{id}']")
21
+ answer_text = response_label.at("mattext").text
22
+ answer = Answer.new(answer_text, id)
23
+ resp_ident = answer_element.attributes["respident"].value
24
+ answer.resp_ident = resp_ident
25
+ @responses << { id: resp_ident, choices: choices }
26
+ @answers.push(answer)
27
+ end
28
+ end
29
+ self
30
+ end
31
+
32
+ def canvas_conversion(assessment, _resources = nil)
33
+ @question.responses = @responses
34
+ super
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,57 @@
1
+ module Senkyoshi
2
+ class Matching < Question
3
+ def initialize
4
+ super
5
+ @matches = []
6
+ @matching_answers = {}
7
+ end
8
+
9
+ def iterate_xml(data)
10
+ super
11
+ resprocessing = data.at("resprocessing")
12
+ @matching_answers = set_matching_answers(resprocessing)
13
+ matches_array = []
14
+ if match_block = data.at("flow[@class=RIGHT_MATCH_BLOCK]")
15
+ matches_array = match_block.children.map do |match|
16
+ match.at("mat_formattedtext").text
17
+ end
18
+ end
19
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
20
+ response_block.children.each do |response|
21
+ id = response.at("response_lid").attributes["ident"].value
22
+ question = response.at("mat_formattedtext").text
23
+ answer_id = @matching_answers[id]
24
+ answer = ""
25
+ flow_label = response.at("flow_label")
26
+ flow_label.children.each_with_index do |label, index|
27
+ if label.attributes["ident"].value == answer_id
28
+ answer = matches_array[index]
29
+ end
30
+ end
31
+ @matches << { id: id, question_text: question, answer_text: answer }
32
+ end
33
+ end
34
+ self
35
+ end
36
+
37
+ def canvas_conversion(assessment, _resources = nil)
38
+ @question.matches = @matches
39
+ super
40
+ end
41
+
42
+ def set_matching_answers(resprocessing)
43
+ matching_answers = {}
44
+ respcondition = resprocessing.css("respcondition")
45
+ respcondition.each do |condition|
46
+ if condition.attributes["title"] != "incorrect"
47
+ varequal = condition.at("varequal")
48
+ if varequal
49
+ id = varequal.attributes["respident"].value
50
+ matching_answers[id] = varequal.text
51
+ end
52
+ end
53
+ end
54
+ matching_answers
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,56 @@
1
+ module Senkyoshi
2
+ class MultipleAnswer < Question
3
+ def iterate_xml(data)
4
+ super
5
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
6
+ set_answers(data.at("resprocessing"))
7
+ response_block.at("render_choice").children.each do |choice|
8
+ id = choice.at("response_label").attributes["ident"].value
9
+ @answer_text = choice.at("mat_formattedtext").text
10
+ answer = Answer.new(@answer_text, id)
11
+ answer.fraction = get_fraction(id)
12
+ @answers.push(answer)
13
+ end
14
+ end
15
+ self
16
+ end
17
+
18
+ def set_answers(resprocessing)
19
+ @correct_answers = set_correct_answer(resprocessing)
20
+ @incorrect_answers = set_incorrect_answer(resprocessing)
21
+ end
22
+
23
+ def set_correct_answer(resprocessing)
24
+ correct_answers = {}
25
+ correct = resprocessing.at("respcondition[title=correct]")
26
+ if correct
27
+ correct.at("and").children.each do |answer|
28
+ if answer.name == "varequal"
29
+ id = answer.text
30
+ correct_answers[id] = {}
31
+ correct_answers[id]["name"] = id
32
+ if correct.at("setvar")
33
+ score = correct.at("setvar").text
34
+ score_number = score == "SCORE.max" ? @max_score.to_f : score.to_f
35
+ correct_answers[id]["fraction"] = score_number
36
+ else
37
+ correct_answers[id]["fraction"] = 0
38
+ end
39
+ end
40
+ end
41
+ end
42
+ correct_answers
43
+ end
44
+
45
+ def set_incorrect_answer(resprocessing)
46
+ incorrect = resprocessing.at("respcondition[ident=incorrect]")
47
+ incorrect_answers = {}
48
+ if incorrect && incorrect.at("setvar")
49
+ incorrect_answers["fraction"] = incorrect.at("setvar").text
50
+ else
51
+ incorrect_answers["fraction"] = 0
52
+ end
53
+ incorrect_answers
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,18 @@
1
+ module Senkyoshi
2
+ class MultipleChoice < Question
3
+ def iterate_xml(data)
4
+ super
5
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
6
+ set_answers(data)
7
+ response_block.at("render_choice").children.each do |choice|
8
+ id = choice.at("response_label").attributes["ident"].value
9
+ answer_text = choice.at("mat_formattedtext").text
10
+ answer = Answer.new(answer_text, id)
11
+ answer.fraction = get_fraction(id)
12
+ @answers.push(answer)
13
+ end
14
+ end
15
+ self
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ module Senkyoshi
2
+ class NumericalQuestion < Question
3
+ def initialize
4
+ @ranges = {}
5
+ @tolerances = {}
6
+ super
7
+ end
8
+
9
+ def iterate_xml(data)
10
+ super
11
+ conditionvar = data.at("resprocessing").at("conditionvar")
12
+ if conditionvar
13
+ range = CanvasCc::CanvasCC::Models::Range.new
14
+ range.low_range = conditionvar.at("vargte").text.to_i
15
+ range.high_range = conditionvar.at("varlte").text.to_i
16
+ answer_text = conditionvar.at("varequal").text.to_i
17
+ answer = Answer.new(answer_text)
18
+ @ranges[answer.id] = range
19
+ answer.fraction = @max_score
20
+ @answers.push(answer)
21
+ end
22
+ self
23
+ end
24
+
25
+ def canvas_conversion(assessment, _resources = nil)
26
+ @question.tolerances = @tolerances
27
+ @question.ranges = @ranges
28
+ super
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,18 @@
1
+ module Senkyoshi
2
+ class OpinionScale < Question
3
+ def iterate_xml(data)
4
+ super
5
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
6
+ set_answers(data.at("resprocessing"))
7
+ response_block.at("render_choice").children.each do |choice|
8
+ id = choice.at("response_label").attributes["ident"].value
9
+ @answer_text = choice.at("mat_formattedtext").text
10
+ answer = Answer.new(@answer_text, id)
11
+ answer.fraction = get_fraction(id)
12
+ @answers.push(answer)
13
+ end
14
+ end
15
+ self
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,40 @@
1
+ module Senkyoshi
2
+ class Ordering < Question
3
+ def initialize
4
+ super
5
+ @matches = []
6
+ @order_answers = {}
7
+ end
8
+
9
+ def iterate_xml(data)
10
+ super
11
+ resprocessing = data.at("resprocessing")
12
+ @order_answers = set_order_answers(resprocessing)
13
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
14
+ response_block.at("render_choice").children.each do |choice|
15
+ id = choice.at("response_label").attributes["ident"].value
16
+ question = @order_answers[id].to_s
17
+ answer = choice.at("mat_formattedtext").text
18
+ @matches << { id: id, question_text: question, answer_text: answer }
19
+ end
20
+ @matches = @matches.sort_by { |hsh| hsh[:question_text] }
21
+ end
22
+ self
23
+ end
24
+
25
+ def canvas_conversion(assessment, _resources = nil)
26
+ @question.matches = @matches
27
+ super
28
+ end
29
+
30
+ def set_order_answers(resprocessing)
31
+ order_answers = {}
32
+ correct = resprocessing.at("respcondition[title=correct]")
33
+ correct.at("and").children.each_with_index do |varequal, index|
34
+ id = varequal.text
35
+ order_answers[id] = index + 1
36
+ end
37
+ order_answers
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ module Senkyoshi
2
+ class QuizBowl < Question
3
+ def iterate_xml(data)
4
+ super
5
+ @material = "#{@title} -- This question was imported from
6
+ an external source. It was a #{@blackboard_type} question,
7
+ which is not supported in this quiz tool."
8
+ self
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ module Senkyoshi
2
+ class ShortResponse < Question
3
+ end
4
+ end
@@ -0,0 +1,15 @@
1
+ module Senkyoshi
2
+ class TrueFalse < Question
3
+ def iterate_xml(data)
4
+ super
5
+ answers_array = [true, false]
6
+ set_answers(data.at("resprocessing"))
7
+ answers_array.each do |answer_text|
8
+ answer = Answer.new(answer_text)
9
+ answer.fraction = get_fraction(answer_text)
10
+ @answers.push(answer)
11
+ end
12
+ self
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module Senkyoshi
2
+ class Quiz < Content
3
+ def canvas_conversion(course, _resource)
4
+ create_module(course)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,33 @@
1
+ module Senkyoshi
2
+ class Resource
3
+ def fix_html(contents, resources)
4
+ if contents && contents.respond_to?(:empty?) && !contents.empty?
5
+ node_html = Nokogiri::HTML.fragment(contents)
6
+
7
+ _search_and_replace(resources, node_html, "a", "href")
8
+ _search_and_replace(resources, node_html, "img", "src")
9
+
10
+ node_html.to_s
11
+ else
12
+ contents
13
+ end
14
+ end
15
+
16
+ def matches_xid?(_xid)
17
+ false
18
+ end
19
+
20
+ def _search_and_replace(resources, node_html, tag, attr)
21
+ node_html.search(tag).each do |element|
22
+ original_src = element[attr]
23
+ xid = original_src.split("/").last
24
+ file_resource = resources.detect_xid(xid)
25
+
26
+ if file_resource
27
+ name = file_resource.name
28
+ element[attr] = "#{BASE}/#{IMPORTED_FILES_DIRNAME}/#{name}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,109 @@
1
+ module Senkyoshi
2
+ class ScormPackage
3
+ attr_accessor(:entries, :manifest)
4
+
5
+ ##
6
+ # Scorm packages should include this string in the <schema> tag. We
7
+ # downcase, and remove spaces before checking to see if a manifest contains
8
+ # this schema to determine whether or not it belongs to a scorm package
9
+ ##
10
+ SCORM_SCHEMA = "adlscorm".freeze
11
+
12
+ def initialize(zip_file, manifest)
13
+ @manifest = manifest
14
+ @entries = ScormPackage.get_entries zip_file, manifest
15
+ end
16
+
17
+ ##
18
+ # Returns true if a manifest is a scorm manifest file, false otherwise
19
+ ##
20
+ def self.scorm_manifest?(manifest)
21
+ parsed_manifest = Nokogiri::XML(manifest.get_input_stream.read)
22
+ schema_name = parsed_manifest.
23
+ xpath("//xmlns:metadata/xmlns:schema").
24
+ text.delete(" ").downcase
25
+ return schema_name == SCORM_SCHEMA
26
+ # NOTE we occasionally run into malformed manifest files
27
+ rescue Nokogiri::XML::XPath::SyntaxError => e
28
+ filename = manifest.zipfile
29
+ STDERR.puts "Malformed scorm manifest found: #{manifest} in #{filename}"
30
+ STDERR.puts e.to_s
31
+ false
32
+ end
33
+
34
+ ##
35
+ # Extracts scorm packages from a blackboard export zip file
36
+ ##
37
+ def self.get_scorm_packages(blackboard_export)
38
+ find_scorm_manifests(blackboard_export).map do |manifest|
39
+ ScormPackage.new blackboard_export, manifest
40
+ end
41
+ end
42
+
43
+ ##
44
+ # Returns array of all scorm manifest files inside of blackboard export
45
+ ##
46
+ def self.find_scorm_manifests(zip_file)
47
+ return [] if zip_file.nil?
48
+ zip_file.entries.select do |e|
49
+ File.fnmatch("*imsmanifest.xml", e.name) && scorm_manifest?(e)
50
+ end
51
+ end
52
+
53
+ ##
54
+ # Returns array of paths to scorm packages
55
+ ##
56
+ def self.find_scorm_paths(zip_file)
57
+ manifests = ScormPackage.find_scorm_manifests(zip_file)
58
+ manifests.map { |manifest| File.dirname(manifest.name) }
59
+ end
60
+
61
+ ##
62
+ # Returns array of all zip file entries that belong in scorm package
63
+ ##
64
+ def self.get_entries(zip_file, manifest)
65
+ zip_file.entries.select do |e|
66
+ File.dirname(e.name).start_with?(File.dirname(manifest.name)) &&
67
+ !e.directory?
68
+ end
69
+ end
70
+
71
+ ##
72
+ # Returns file path with relative path to scorm package removed
73
+ ##
74
+ def self.correct_path(path, scorm_path)
75
+ corrected = path.gsub(scorm_path, "")
76
+ corrected.slice(1, corrected.size) if corrected.start_with? "/"
77
+ end
78
+
79
+ ##
80
+ # Writes all entries to a zip file in a temporary directory and returns
81
+ # location of temporary file
82
+ ##
83
+ def write_zip(export_name)
84
+ @@dir ||= Dir.mktmpdir
85
+ scorm_path = File.dirname @manifest.name
86
+ path = "#{@@dir}/#{export_name}"
87
+ Zip::File.open path, Zip::File::CREATE do |zip|
88
+ @entries.each do |entry|
89
+ if entry.file?
90
+ zip.get_output_stream(
91
+ ScormPackage.correct_path(entry.name, scorm_path),
92
+ ) do |file|
93
+ file.write(entry.get_input_stream.read)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ path
99
+ end
100
+
101
+ ##
102
+ # Removes all temp files if they exist
103
+ ##
104
+ def self.cleanup
105
+ @@dir ||= nil
106
+ FileUtils.rm_r @@dir unless @@dir.nil?
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,72 @@
1
+ require "senkyoshi/models/resource"
2
+
3
+ module Senkyoshi
4
+ class StaffInfo < Resource
5
+ attr_reader(
6
+ :id,
7
+ :title,
8
+ :bio,
9
+ :name,
10
+ :email,
11
+ :phone,
12
+ :office_hours,
13
+ :office_address,
14
+ :home_page,
15
+ :image,
16
+ )
17
+
18
+ def parse_name(contact)
19
+ parts = [
20
+ contact.xpath("./NAME/FORMALTITLE/@value").text,
21
+ contact.xpath("./NAME/GIVEN/@value").text,
22
+ contact.xpath("./NAME/FAMILY/@value").text,
23
+ ]
24
+
25
+ resp = ""
26
+ parts.each do |part|
27
+ resp << " " unless resp.empty?
28
+ resp << part unless part.empty?
29
+ end
30
+ resp
31
+ end
32
+
33
+ def iterate_xml(xml, _pre_data)
34
+ contact = xml.xpath("//CONTACT")
35
+ @id = xml.xpath("//STAFFINFO/@id").text
36
+ @title = xml.xpath("//STAFFINFO/TITLE/@value").text
37
+ @bio = xml.xpath("//BIOGRAPHY/TEXT").text
38
+ @name = parse_name(contact)
39
+ @email = xml.xpath("//CONTACT/EMAIL/@value").text
40
+ @phone = xml.xpath("//CONTACT/PHONE/@value").text
41
+ @office_hours = xml.xpath("//OFFICE/HOURS/@value").text
42
+ @office_address = xml.xpath("//OFFICE/ADDRESS/@value").text
43
+ @home_page = xml.xpath("//HOMEPAGE/@value").text
44
+ @image = xml.xpath("//IMAGE/@value").text
45
+
46
+ self
47
+ end
48
+
49
+ def construct_body
50
+ <<-HTML
51
+ <h3>#{@name}</h3>
52
+ <p>#{@bio}</p>
53
+ <ul>
54
+ <li>Email: #{@email}</li>
55
+ <li>Phone: #{@phone}</li>
56
+ <li>Office Hours: #{@office_hours}</li>
57
+ <li>Office Address: #{@office_address}</li>
58
+ </ul>
59
+ HTML
60
+ end
61
+
62
+ def canvas_conversion(course, _resources = nil)
63
+ page = CanvasCc::CanvasCC::Models::Page.new
64
+ page.body = construct_body
65
+ page.identifier = @id
66
+ page.page_name = @title.empty? ? "Contact" : @title
67
+
68
+ course.pages << page
69
+ course
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,34 @@
1
+ require "senkyoshi/models/resource"
2
+
3
+ module Senkyoshi
4
+ class WikiPage < Content
5
+ def canvas_conversion(course, resources)
6
+ unless @title == "--TOP--"
7
+ page_count = course.pages.
8
+ select { |p| p.title.start_with? @title }.count
9
+ @title = "#{@title}-#{page_count + 1}" if page_count > 0
10
+ page = CanvasCc::CanvasCC::Models::Page.new
11
+ if !@url.empty?
12
+ @body = %{
13
+ <a href="#{@url}">
14
+ #{@url}
15
+ </a>
16
+ #{@body}
17
+ }
18
+ end
19
+ page.body = fix_html(@body, resources)
20
+ page.identifier = @id
21
+ page.page_name = @title
22
+ page.workflow_state = "active"
23
+
24
+ # Add page links to page body
25
+ @files.each { |f| page.body << f.canvas_conversion }
26
+ course.pages << page
27
+
28
+ course = create_module(course)
29
+ end
30
+
31
+ course
32
+ end
33
+ end
34
+ end