canvas_qti_to_learnosity_converter 2.5.0 → 3.1.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.
- checksums.yaml +4 -4
- data/canvas_qti_to_learnosity_converter.gemspec +2 -1
- data/lib/canvas_qti_to_learnosity_converter/convert.rb +483 -171
- data/lib/canvas_qti_to_learnosity_converter/export_writer.rb +21 -0
- data/lib/canvas_qti_to_learnosity_converter/questions/calculated.rb +4 -4
- data/lib/canvas_qti_to_learnosity_converter/questions/essay.rb +4 -4
- data/lib/canvas_qti_to_learnosity_converter/questions/file_upload.rb +4 -4
- data/lib/canvas_qti_to_learnosity_converter/questions/fill_the_blanks.rb +4 -5
- data/lib/canvas_qti_to_learnosity_converter/questions/matching.rb +4 -4
- data/lib/canvas_qti_to_learnosity_converter/questions/multiple_choice.rb +6 -6
- data/lib/canvas_qti_to_learnosity_converter/questions/numerical.rb +4 -4
- data/lib/canvas_qti_to_learnosity_converter/questions/question.rb +29 -0
- data/lib/canvas_qti_to_learnosity_converter/questions/short_answer.rb +4 -4
- data/lib/canvas_qti_to_learnosity_converter/questions/template_question.rb +4 -5
- data/lib/canvas_qti_to_learnosity_converter/questions/text_only.rb +4 -4
- data/lib/canvas_qti_to_learnosity_converter/version.rb +1 -1
- data/lib/canvas_qti_to_learnosity_converter.rb +0 -1
- metadata +19 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ff0914859bacf0cab5c24c74ca5fbdab49478d9df31714434eaf2ac460e1bc31
|
4
|
+
data.tar.gz: 76403083b18998db6fcf6226bcb081e2f0d3d9e0bf3e5336a13842feca75bfff
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3eb86fdef2a36743f096f47668a47430b9cdb36bedcb0a3f4493765cdab872b0a7df844efd1e9ce5a38136d73c10807b2058be7c2737cc1314ac3a2f538b8df3
|
7
|
+
data.tar.gz: 39d75a49a6fc9706c6fa443c1cca36b327b84b2260d3b518be6a2522e1438f442906cf32b18f2e876c41e9412e6790572bf12ca18795adfbd8609597cef7ce3f
|
@@ -20,8 +20,9 @@ Gem::Specification.new do |spec|
|
|
20
20
|
|
21
21
|
spec.add_development_dependency "bundler", "~> 2.3"
|
22
22
|
spec.add_development_dependency "byebug"
|
23
|
-
spec.add_development_dependency "rake", "~>
|
23
|
+
spec.add_development_dependency "rake", "~> 11.0"
|
24
24
|
spec.add_development_dependency "rspec", "~> 3.0"
|
25
25
|
spec.add_dependency "nokogiri"
|
26
26
|
spec.add_dependency "rubyzip"
|
27
|
+
spec.add_dependency "activesupport"
|
27
28
|
end
|
@@ -3,7 +3,12 @@ require "forwardable"
|
|
3
3
|
require "ostruct"
|
4
4
|
require "zip"
|
5
5
|
require "uri"
|
6
|
+
require "active_support"
|
7
|
+
require "active_support/core_ext/digest/uuid"
|
8
|
+
require "active_support/core_ext/securerandom"
|
9
|
+
require "active_support/core_ext/object"
|
6
10
|
|
11
|
+
require "canvas_qti_to_learnosity_converter/export_writer"
|
7
12
|
require "canvas_qti_to_learnosity_converter/questions/multiple_choice"
|
8
13
|
require "canvas_qti_to_learnosity_converter/questions/short_answer"
|
9
14
|
require "canvas_qti_to_learnosity_converter/questions/fill_the_blanks"
|
@@ -16,217 +21,524 @@ require "canvas_qti_to_learnosity_converter/questions/numerical"
|
|
16
21
|
require "canvas_qti_to_learnosity_converter/questions/calculated"
|
17
22
|
|
18
23
|
module CanvasQtiToLearnosityConverter
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
class CanvasQuestionTypeNotSupportedError < RuntimeError
|
54
|
-
attr_reader :question_type
|
55
|
-
def initialize(question_type)
|
56
|
-
@question_type = question_type.to_s
|
57
|
-
super("Unsupported question type #{@question_type}")
|
58
|
-
end
|
59
|
-
end
|
24
|
+
class Converter
|
25
|
+
FEATURE_TYPES = [ :text_only_question ]
|
26
|
+
QUESTION_TYPES = [
|
27
|
+
:multiple_choice_question,
|
28
|
+
:true_false_question,
|
29
|
+
:multiple_answers_question,
|
30
|
+
:short_answer_question,
|
31
|
+
:fill_in_multiple_blanks_question,
|
32
|
+
:multiple_dropdowns_question,
|
33
|
+
:matching_question,
|
34
|
+
:essay_question,
|
35
|
+
:file_upload_question,
|
36
|
+
]
|
37
|
+
|
38
|
+
TYPE_MAP = {
|
39
|
+
multiple_choice_question: MultipleChoiceQuestion,
|
40
|
+
true_false_question: MultipleChoiceQuestion,
|
41
|
+
multiple_answers_question: MultipleAnswersQuestion,
|
42
|
+
short_answer_question: ShortAnswerQuestion,
|
43
|
+
fill_in_multiple_blanks_question: FillTheBlanksQuestion,
|
44
|
+
multiple_dropdowns_question: MultipleDropdownsQuestion,
|
45
|
+
matching_question: MatchingQuestion,
|
46
|
+
essay_question: EssayQuestion,
|
47
|
+
file_upload_question: FileUploadQuestion,
|
48
|
+
text_only_question: TextOnlyQuestion,
|
49
|
+
numerical_question: NumericalQuestion,
|
50
|
+
calculated_question: CalculatedQuestion,
|
51
|
+
|
52
|
+
"cc.multiple_choice.v0p1": MultipleChoiceQuestion,
|
53
|
+
"cc.multiple_response.v0p1": MultipleAnswersQuestion,
|
54
|
+
"cc.fib.v0p1": ShortAnswerQuestion,
|
55
|
+
"cc.true_false.v0p1": MultipleChoiceQuestion,
|
56
|
+
"cc.essay.v0p1": EssayQuestion,
|
57
|
+
}
|
60
58
|
|
61
|
-
|
62
|
-
|
63
|
-
|
59
|
+
# Canvas lets you create questions that are associated with a stimulus. Authoring
|
60
|
+
# these in Learnosity is terrible if there are too many questions in the same item,
|
61
|
+
# so we limit the number of questions per item to 30.
|
62
|
+
MAX_QUESTIONS_PER_ITEM = 30
|
63
|
+
|
64
|
+
attr_accessor :items, :widgets, :item_banks, :assessments, :assets, :errors
|
65
|
+
|
66
|
+
def initialize
|
67
|
+
@items = []
|
68
|
+
@widgets = []
|
69
|
+
@item_banks = []
|
70
|
+
@assessments = []
|
71
|
+
@assets = {}
|
72
|
+
@errors = {}
|
73
|
+
@namespace = SecureRandom.uuid
|
74
|
+
end
|
64
75
|
|
65
|
-
|
66
|
-
|
76
|
+
class CanvasQuestionTypeNotSupportedError < RuntimeError
|
77
|
+
attr_reader :question_type
|
78
|
+
def initialize(question_type)
|
79
|
+
@question_type = question_type.to_s
|
80
|
+
super("Unsupported question type #{@question_type}")
|
81
|
+
end
|
67
82
|
end
|
68
|
-
end
|
69
83
|
|
84
|
+
class CanvasEntryTypeNotSupportedError < RuntimeError
|
85
|
+
attr_reader :question_type
|
86
|
+
def initialize(entry_type)
|
87
|
+
@entry_type = entry_type.to_s
|
88
|
+
super("Unsupported entry type #{@entry_type}")
|
89
|
+
end
|
90
|
+
end
|
70
91
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
ensure
|
75
|
-
file.close
|
76
|
-
# Do we need to unlink?
|
77
|
-
end
|
92
|
+
class CanvasQtiQuiz
|
93
|
+
extend Forwardable
|
94
|
+
def_delegators :@xml, :css, :at_css
|
78
95
|
|
79
|
-
|
80
|
-
|
81
|
-
|
96
|
+
def initialize(qti_string:)
|
97
|
+
@xml = Nokogiri.XML(qti_string, &:noblanks)
|
98
|
+
end
|
99
|
+
end
|
82
100
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
CanvasQtiQuiz.new(qti_string: qti_string)
|
87
|
-
ensure
|
88
|
-
qti_file.close
|
89
|
-
end
|
101
|
+
def build_quiz_from_qti_string(qti_string)
|
102
|
+
CanvasQtiQuiz.new(qti_string: qti_string)
|
103
|
+
end
|
90
104
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
105
|
+
def build_quiz_from_file(path)
|
106
|
+
qti_file = File.new path
|
107
|
+
qti_string = qti_file.read
|
108
|
+
CanvasQtiQuiz.new(qti_string: qti_string)
|
109
|
+
ensure
|
110
|
+
qti_file.close
|
96
111
|
end
|
97
|
-
end
|
98
112
|
|
99
|
-
|
100
|
-
xml.css(%{ item > itemmetadata > qtimetadata >
|
101
|
-
qtimetadatafield > fieldlabel:contains("question_type")})
|
102
|
-
&.first&.next&.text&.to_sym ||
|
113
|
+
def extract_type(xml)
|
103
114
|
xml.css(%{ item > itemmetadata > qtimetadata >
|
104
|
-
qtimetadatafield > fieldlabel:contains("
|
105
|
-
&.first&.next&.text&.to_sym
|
106
|
-
|
115
|
+
qtimetadatafield > fieldlabel:contains("question_type")})
|
116
|
+
&.first&.next&.text&.to_sym ||
|
117
|
+
xml.css(%{ item > itemmetadata > qtimetadata >
|
118
|
+
qtimetadatafield > fieldlabel:contains("cc_profile")})
|
119
|
+
&.first&.next&.text&.to_sym
|
120
|
+
end
|
107
121
|
|
108
|
-
|
109
|
-
|
110
|
-
|
122
|
+
def parent_stimulus_ident(item)
|
123
|
+
if item.name == "bankentry_item" || item.name == "section"
|
124
|
+
item.attribute("parent_stimulus_item_ident")&.value
|
125
|
+
else
|
126
|
+
item.css("itemmetadata > qtimetadata > qtimetadatafield > fieldlabel:contains('parent_stimulus_item_ident')").first&.next&.text
|
127
|
+
end
|
128
|
+
end
|
111
129
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
130
|
+
def limit_child_questions(child_widgets, parent_ident)
|
131
|
+
if child_widgets.count > MAX_QUESTIONS_PER_ITEM
|
132
|
+
# We only want to include the first MAX_QUESTIONS_PER_ITEM questions
|
133
|
+
child_widgets = child_widgets.first(MAX_QUESTIONS_PER_ITEM)
|
134
|
+
@errors[parent_ident] ||= []
|
135
|
+
@errors[parent_ident].push({
|
136
|
+
error_type: "too_many_questions",
|
137
|
+
message: "Too many questions for item, only the first #{MAX_QUESTIONS_PER_ITEM} will be included",
|
138
|
+
})
|
139
|
+
end
|
140
|
+
|
141
|
+
child_widgets
|
116
142
|
end
|
117
143
|
|
118
|
-
|
144
|
+
def clone_bank_widget(parent_ident, child_item)
|
145
|
+
# If the item is from a question bank we want to create a new copy of the widget
|
146
|
+
# as Learnosity doesn't really share widgets between items
|
147
|
+
child_item_ref = child_item.attribute("item_ref")&.value
|
148
|
+
converted_widget = @widgets.find { |w| w[:metadata][:original_item_ref] == child_item_ref }
|
119
149
|
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
150
|
+
if converted_widget.blank?
|
151
|
+
raise "Could not find converted widget for item_ref #{child_item_ref}"
|
152
|
+
end
|
153
|
+
|
154
|
+
new_widget = converted_widget.deep_dup
|
155
|
+
new_widget[:reference] = build_reference("#{parent_ident}_#{child_item_ref}")
|
156
|
+
new_widget[:metadata][:original_item_ref] = "#{parent_ident}_#{child_item_ref}"
|
157
|
+
new_widget
|
124
158
|
end
|
125
159
|
|
126
|
-
|
127
|
-
|
160
|
+
def convert_child_item(child_item:, path:, parent_ident:, new_references: false)
|
161
|
+
if child_item.name == "section"
|
162
|
+
item_refs = child_item.css("sourcebank_ref").map do |sourcebank_ref|
|
163
|
+
@items.select do |i|
|
164
|
+
(
|
165
|
+
i.dig(:metadata, :original_item_bank_ref) == sourcebank_ref.text &&
|
166
|
+
!(i.dig(:definition, :regions)&.any? { |r| r[:widgets].blank? }) # Exclude empty stimulus items
|
167
|
+
)
|
168
|
+
end.map { |i| i[:metadata][:original_item_ref] }
|
169
|
+
end.flatten
|
170
|
+
|
171
|
+
bank_widgets = item_refs.map { |ref| @widgets.find { |w| w[:metadata][:original_item_ref] == ref } }
|
172
|
+
|
173
|
+
bank_widgets.map do |widget|
|
174
|
+
new_widget = widget.deep_dup
|
175
|
+
new_widget[:reference] = build_reference
|
176
|
+
new_widget
|
177
|
+
end
|
178
|
+
elsif child_item.name == "bankentry_item"
|
179
|
+
new_widget = clone_bank_widget(parent_ident, child_item)
|
180
|
+
if new_references
|
181
|
+
new_widget[:reference] = build_reference
|
182
|
+
end
|
183
|
+
new_widget
|
184
|
+
else
|
185
|
+
child_ident = child_item.attribute("ident")&.value
|
186
|
+
child_learnosity_type, child_quiz_item = convert_item(qti_string: child_item.to_html)
|
187
|
+
|
188
|
+
{
|
189
|
+
type: child_learnosity_type,
|
190
|
+
data: child_quiz_item.convert(@assets, path),
|
191
|
+
reference: build_reference("#{parent_ident}_#{child_ident}"),
|
192
|
+
metadata: { original_item_ref: child_ident },
|
193
|
+
}
|
194
|
+
end
|
195
|
+
end
|
128
196
|
|
129
|
-
|
130
|
-
|
131
|
-
|
197
|
+
# Canvas new quizzes can have a stimulus with associated questions. These have
|
198
|
+
# a material orientation attribute that specifies the orientation of the stimulus.
|
199
|
+
# We create a single Learnosity item with multiple widgets for these items.
|
200
|
+
def build_item_definition(item, learnosity_type, quiz_item, path, child_items)
|
201
|
+
ident = item.attribute("ident")&.value
|
202
|
+
|
203
|
+
item_widgets = [{
|
204
|
+
type: learnosity_type,
|
205
|
+
data: quiz_item.convert(@assets, path),
|
206
|
+
reference: build_reference("#{ident}_widget"),
|
207
|
+
metadata: { original_item_ref: ident },
|
208
|
+
}]
|
209
|
+
|
210
|
+
definition = {}
|
211
|
+
|
212
|
+
if item.css("presentation > material[orientation]").present?
|
213
|
+
child_widgets = child_items.map do |child_item|
|
214
|
+
convert_child_item(child_item:, path:, parent_ident: ident)
|
215
|
+
end.flatten
|
216
|
+
|
217
|
+
child_widgets = limit_child_questions(child_widgets, ident)
|
218
|
+
|
219
|
+
item_widgets += child_widgets
|
220
|
+
end
|
221
|
+
|
222
|
+
if item.css("presentation > material[orientation='left']").present?
|
223
|
+
definition[:regions] = [
|
224
|
+
{
|
225
|
+
widgets: [{ reference: item_widgets.first[:reference] }],
|
226
|
+
width: 50,
|
227
|
+
type: "column"
|
228
|
+
},
|
229
|
+
{
|
230
|
+
widgets: child_widgets.map{ |w| { reference: w[:reference] } },
|
231
|
+
width: 50,
|
232
|
+
type: "column"
|
233
|
+
}
|
234
|
+
]
|
235
|
+
definition[:scroll] = { enabled: false }
|
236
|
+
definition[:type] = "root"
|
237
|
+
else
|
238
|
+
definition[:widgets] = item_widgets.map{ |w| { reference: w[:reference] } }
|
239
|
+
end
|
240
|
+
|
241
|
+
[item_widgets, definition]
|
242
|
+
end
|
243
|
+
|
244
|
+
# We need to create a new item for stimuluses that are from a question bank, as
|
245
|
+
# the item in the assessment will need to have multiple widgets in it, and the item
|
246
|
+
# from the bank only has the stimulus. We don't want to modify the original item in
|
247
|
+
# the bank as it could be used in multiple assessments and it's convenient to have
|
248
|
+
# the original to clone. We also create new widgets, so that the item can be modified
|
249
|
+
# without affecting any other items that use the same widgets.
|
250
|
+
def clone_bank_item(parent_item, child_items, path)
|
251
|
+
item_ref = parent_item.attribute("item_ref").value
|
252
|
+
bank_item = @items.find { |i| i[:metadata][:original_item_ref] == item_ref }
|
253
|
+
new_item = bank_item.deep_dup
|
254
|
+
|
255
|
+
if new_item[:definition][:regions].blank?
|
256
|
+
raise "Trying to add a child item to a stimulus from a question bank that wasn't converted with regions"
|
257
|
+
end
|
258
|
+
|
259
|
+
# We need new references throughout because we can't consistently generate
|
260
|
+
# references when this stimulus/questions could be used in multiple assessments with
|
261
|
+
# different questions for the same bank stimulus
|
262
|
+
new_item[:reference] = build_reference
|
263
|
+
|
264
|
+
# Use the bank item reference in the metadata so we don't accidentally
|
265
|
+
# find this cloned one if the original is used elsewhere
|
266
|
+
new_item[:metadata][:original_item_ref] = bank_item[:reference]
|
267
|
+
|
268
|
+
cloned_stimulus = @widgets.find { |w| w[:metadata][:original_item_ref] == item_ref }.deep_dup
|
269
|
+
cloned_stimulus[:reference] = build_reference
|
270
|
+
@widgets.push(cloned_stimulus)
|
271
|
+
|
272
|
+
stimulus_region = new_item[:definition][:regions].find { |r| r[:widgets].present? }
|
273
|
+
stimulus_region[:widgets] = [{ reference: cloned_stimulus[:reference] }]
|
132
274
|
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
assets[ident] = {}
|
138
|
-
errors[ident] = []
|
275
|
+
empty_region = new_item[:definition][:regions].find { |r| r[:widgets].blank? }
|
276
|
+
child_widgets = child_items.map do |child_item|
|
277
|
+
convert_child_item(child_item:, path:, parent_ident: item_ref, new_references: true)
|
278
|
+
end.flatten
|
139
279
|
|
140
|
-
|
280
|
+
child_widgets = limit_child_questions(child_widgets, item_ref)
|
141
281
|
|
142
|
-
|
143
|
-
|
144
|
-
|
282
|
+
empty_region[:widgets] = child_widgets.map{ |w| { reference: w[:reference] } }
|
283
|
+
new_item[:questions] = child_widgets.select{ |w| w[:type] == "question" }.map{ |w| w[:reference] }
|
284
|
+
new_item[:features] = [{ reference: cloned_stimulus[:reference] }]
|
145
285
|
|
146
|
-
|
147
|
-
|
286
|
+
@widgets += child_widgets
|
287
|
+
@items.push(new_item)
|
288
|
+
new_item
|
289
|
+
end
|
290
|
+
|
291
|
+
def convert_item(qti_string:)
|
292
|
+
xml = Nokogiri.XML(qti_string, &:noblanks)
|
293
|
+
type = extract_type(xml)
|
294
|
+
|
295
|
+
if FEATURE_TYPES.include?(type)
|
296
|
+
learnosity_type = "feature"
|
297
|
+
else
|
298
|
+
learnosity_type = "question"
|
299
|
+
end
|
300
|
+
|
301
|
+
question_class = TYPE_MAP[type]
|
302
|
+
|
303
|
+
if question_class
|
304
|
+
question = question_class.new(xml)
|
305
|
+
else
|
306
|
+
raise CanvasQuestionTypeNotSupportedError.new(type)
|
307
|
+
end
|
308
|
+
|
309
|
+
[learnosity_type, question]
|
310
|
+
end
|
148
311
|
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
312
|
+
def clean_title(title)
|
313
|
+
title&.gsub(/["']/, "")
|
314
|
+
end
|
315
|
+
|
316
|
+
def convert_assessment(qti, path)
|
317
|
+
quiz = CanvasQtiQuiz.new(qti_string: qti)
|
318
|
+
assessment = quiz.at_css("assessment")
|
319
|
+
return nil unless assessment
|
320
|
+
|
321
|
+
ident = assessment.attribute("ident")&.value
|
322
|
+
reference = build_reference(ident)
|
323
|
+
title = clean_title(assessment.attribute("title").value)
|
324
|
+
|
325
|
+
item_refs = convert_items(quiz, path)
|
326
|
+
@assessments <<
|
327
|
+
{
|
328
|
+
reference:,
|
329
|
+
title:,
|
330
|
+
data: {
|
331
|
+
items: item_refs.uniq.map { |ref| { reference: ref } },
|
332
|
+
config: { title: },
|
333
|
+
},
|
334
|
+
status: "published",
|
335
|
+
tags: {},
|
154
336
|
}
|
337
|
+
end
|
155
338
|
|
156
|
-
|
157
|
-
|
339
|
+
def convert_item_bank(qti_string, path)
|
340
|
+
qti = CanvasQtiQuiz.new(qti_string:)
|
341
|
+
item_bank = qti.at_css("objectbank")
|
342
|
+
return nil unless item_bank
|
158
343
|
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
344
|
+
ident = item_bank.attribute("ident")&.value
|
345
|
+
title = clean_title(qti.css(%{ objectbank > qtimetadata >
|
346
|
+
qtimetadatafield > fieldlabel:contains("bank_title")})
|
347
|
+
&.first&.next&.text || '')
|
348
|
+
|
349
|
+
meta = {
|
350
|
+
original_item_bank_ref: ident,
|
351
|
+
}
|
352
|
+
item_refs = convert_items(qti, path, meta:, tags: { "Item Bank" => [title] })
|
353
|
+
@item_banks <<
|
354
|
+
{
|
355
|
+
title: title,
|
356
|
+
ident: ident,
|
357
|
+
item_refs: item_refs,
|
358
|
+
}
|
359
|
+
end
|
360
|
+
|
361
|
+
def build_reference(ident = nil)
|
362
|
+
if ident.present?
|
363
|
+
Digest::UUID.uuid_v5(@namespace, ident)
|
364
|
+
else
|
365
|
+
SecureRandom.uuid
|
173
366
|
end
|
174
367
|
end
|
175
368
|
|
176
|
-
{
|
177
|
-
|
178
|
-
ident: ident,
|
179
|
-
items: items,
|
180
|
-
}
|
181
|
-
end
|
369
|
+
def convert_items(qti, path, meta: {}, tags: {})
|
370
|
+
converted_item_refs = []
|
182
371
|
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
ensure
|
188
|
-
file.close
|
189
|
-
file.unlink
|
190
|
-
end
|
372
|
+
items_by_parent_stimulus = {}
|
373
|
+
qti.css("item,bankentry_item,section").each do |item|
|
374
|
+
ident = item.attribute("ident")&.value
|
375
|
+
next if ident == "root_section"
|
191
376
|
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
377
|
+
parent_ident = parent_stimulus_ident(item)
|
378
|
+
if parent_ident.present?
|
379
|
+
items_by_parent_stimulus[parent_ident] ||= []
|
380
|
+
items_by_parent_stimulus[parent_ident].push(item)
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
qti.css("item,bankentry_item,section").each.with_index do |item, index|
|
385
|
+
begin
|
386
|
+
# Skip items that have a parent, as we'll convert them when we convert the parent
|
387
|
+
next if parent_stimulus_ident(item).present?
|
388
|
+
|
389
|
+
ident = item.attribute("ident")&.value
|
390
|
+
item_ref = item.attribute("item_ref")&.value
|
391
|
+
|
392
|
+
if item.name == "section"
|
393
|
+
next if ident == "root_section"
|
394
|
+
|
395
|
+
item.css("sourcebank_ref").each do |sourcebank_ref|
|
396
|
+
item_refs = @items.select { |i| i.dig(:metadata, :original_item_bank_ref) == sourcebank_ref.text }.map { |i| i[:reference] }
|
397
|
+
converted_item_refs += item_refs
|
398
|
+
end
|
399
|
+
elsif item.name == "bankentry_item" && item_ref.present? && items_by_parent_stimulus[item_ref].present?
|
400
|
+
new_item = clone_bank_item(item, items_by_parent_stimulus[item_ref], path)
|
401
|
+
converted_item_refs.push(new_item[:reference])
|
402
|
+
elsif item.name == "bankentry_item" && item_ref.present?
|
403
|
+
converted_item_refs.push(build_reference(item_ref))
|
404
|
+
elsif item.name == "item"
|
405
|
+
reference = build_reference(ident)
|
406
|
+
item_title = item.attribute("title")&.value || ''
|
407
|
+
learnosity_type, quiz_item = convert_item(qti_string: item.to_html)
|
408
|
+
|
409
|
+
item_widgets, definition = build_item_definition(
|
410
|
+
item,
|
411
|
+
learnosity_type,
|
412
|
+
quiz_item,
|
413
|
+
path,
|
414
|
+
items_by_parent_stimulus.fetch(ident, [])
|
415
|
+
)
|
416
|
+
|
417
|
+
@widgets += item_widgets
|
418
|
+
|
419
|
+
@items << {
|
420
|
+
title: item_title,
|
421
|
+
reference:,
|
422
|
+
metadata: meta.merge({ original_item_ref: ident }),
|
423
|
+
definition:,
|
424
|
+
questions: item_widgets.select{ |w| w[:type] == "question" }.map{ |w| w[:reference] },
|
425
|
+
features: item_widgets.select{ |w| w[:type] == "feature" }.map{ |w| w[:reference] },
|
426
|
+
status: "published",
|
427
|
+
tags: tags,
|
428
|
+
type: learnosity_type,
|
429
|
+
dynamic_content_data: quiz_item.dynamic_content_data()
|
430
|
+
}
|
431
|
+
|
432
|
+
converted_item_refs.push(reference)
|
433
|
+
end
|
434
|
+
|
435
|
+
rescue CanvasQuestionTypeNotSupportedError => e
|
436
|
+
@errors[ident] ||= []
|
437
|
+
@errors[ident].push({
|
438
|
+
index: index,
|
439
|
+
error_type: "unsupported_question",
|
440
|
+
question_type: e.question_type.to_s,
|
441
|
+
message: e.message,
|
442
|
+
})
|
443
|
+
rescue StandardError => e
|
444
|
+
@errors[ident || item_ref] ||= []
|
445
|
+
@errors[ident || item_ref].push({
|
446
|
+
index: index,
|
447
|
+
error_type: e.class.to_s,
|
448
|
+
message: e.message,
|
449
|
+
})
|
450
|
+
end
|
451
|
+
end
|
452
|
+
converted_item_refs
|
196
453
|
end
|
197
|
-
end
|
198
454
|
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
455
|
+
def convert_qti_file(path)
|
456
|
+
file = File.new(path)
|
457
|
+
qti_string = file.read
|
458
|
+
convert(qti_string)
|
459
|
+
ensure
|
460
|
+
file.close
|
461
|
+
file.unlink
|
462
|
+
end
|
463
|
+
|
464
|
+
def imscc_quiz_paths(parsed_manifest)
|
465
|
+
resources = parsed_manifest.css("resources > resource[type^='imsqti_xmlv1p2']")
|
466
|
+
resources.map do |entry|
|
467
|
+
resource_path(parsed_manifest, entry)
|
206
468
|
end
|
207
469
|
end
|
208
|
-
entry.css("file").first&.attribute("href")&.value
|
209
|
-
end
|
210
470
|
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
471
|
+
def imscc_item_bank_paths(parsed_manifest)
|
472
|
+
resources = parsed_manifest.css("resources > resource[type='associatedcontent/imscc_xmlv1p1/learning-application-resource']")
|
473
|
+
resources.map do |entry|
|
474
|
+
resource_path(parsed_manifest, entry)
|
475
|
+
end
|
476
|
+
end
|
217
477
|
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
478
|
+
def resource_path(parsed_manifest, entry)
|
479
|
+
# Use the Canvas non_cc_assignment qti path when possible. This works for both classic and new quizzes
|
480
|
+
entry.css("dependency").each do |dependency|
|
481
|
+
ref = dependency.attribute("identifierref").value
|
482
|
+
parsed_manifest.css(%{resources > resource[identifier="#{ref}"] > file}).each do |file|
|
483
|
+
path = file.attribute("href").value
|
484
|
+
return path if path.match?(/^non_cc_assessments/)
|
485
|
+
end
|
223
486
|
end
|
487
|
+
entry.css("file").first&.attribute("href")&.value
|
488
|
+
end
|
224
489
|
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
490
|
+
def convert_imscc_export(path)
|
491
|
+
Zip::File.open(path) do |zip_file|
|
492
|
+
entry = zip_file.find_entry("imsmanifest.xml")
|
493
|
+
manifest = entry.get_input_stream.read
|
494
|
+
parsed_manifest = Nokogiri.XML(manifest, &:noblanks)
|
495
|
+
|
496
|
+
item_bank_paths = imscc_item_bank_paths(parsed_manifest)
|
497
|
+
item_bank_paths.each do |item_bank_path|
|
498
|
+
qti = zip_file.find_entry(item_bank_path).get_input_stream.read
|
499
|
+
convert_item_bank(qti, File.dirname(item_bank_path))
|
500
|
+
end
|
501
|
+
|
502
|
+
assessment_paths = imscc_quiz_paths(parsed_manifest)
|
503
|
+
assessment_paths.each do |qti_path|
|
504
|
+
qti = zip_file.find_entry(qti_path).get_input_stream.read
|
505
|
+
convert_assessment(qti, File.dirname(qti_path))
|
506
|
+
end
|
507
|
+
|
508
|
+
{
|
509
|
+
errors: @errors,
|
510
|
+
}
|
511
|
+
end
|
512
|
+
end
|
513
|
+
|
514
|
+
def generate_learnosity_export(input_path, output_path)
|
515
|
+
result = convert_imscc_export(input_path)
|
516
|
+
|
517
|
+
export_writer = ExportWriter.new(output_path)
|
518
|
+
export_writer.write_to_zip("export.json", { version: 2.0 })
|
519
|
+
|
520
|
+
@assessments.each do |activity|
|
521
|
+
export_writer.write_to_zip("activities/#{activity[:reference]}.json", activity)
|
522
|
+
end
|
523
|
+
@items.each do |item|
|
524
|
+
export_writer.write_to_zip("items/#{item[:reference]}.json", item)
|
525
|
+
end
|
526
|
+
@widgets.each do |widget|
|
527
|
+
export_writer.write_to_zip("#{widget[:type]}s/#{widget[:reference]}.json", widget)
|
528
|
+
end
|
529
|
+
|
530
|
+
Zip::File.open(input_path) do |input|
|
531
|
+
@assets.each do |source, destination|
|
532
|
+
source = source.gsub(/^\//, '')
|
533
|
+
asset = input.find_entry(source) || input.find_entry("web_resources/#{source}")
|
534
|
+
if asset
|
535
|
+
export_writer.write_asset_to_zip("assets/#{destination}", input.read(asset))
|
536
|
+
end
|
537
|
+
end
|
538
|
+
end
|
539
|
+
export_writer.close
|
540
|
+
|
541
|
+
result
|
230
542
|
end
|
231
543
|
end
|
232
544
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class ExportWriter
|
2
|
+
def initialize(temp_file)
|
3
|
+
@zip = Zip::File.open(temp_file.path, Zip::File::CREATE)
|
4
|
+
end
|
5
|
+
|
6
|
+
def close
|
7
|
+
@zip.close
|
8
|
+
end
|
9
|
+
|
10
|
+
def write_to_zip(filename, content)
|
11
|
+
@zip.get_output_stream(filename) do |file|
|
12
|
+
file << content.to_json
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def write_asset_to_zip(filename, content)
|
17
|
+
@zip.get_output_stream(filename) do |file|
|
18
|
+
file << content
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -37,13 +37,13 @@ module CanvasQtiToLearnosityConverter
|
|
37
37
|
}
|
38
38
|
end
|
39
39
|
|
40
|
-
def add_learnosity_assets(assets, path)
|
41
|
-
|
42
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
40
|
+
def add_learnosity_assets(assets, path, learnosity)
|
41
|
+
process_assets!(
|
43
42
|
assets,
|
44
|
-
path
|
43
|
+
path,
|
45
44
|
learnosity[:stimulus]
|
46
45
|
)
|
46
|
+
learnosity
|
47
47
|
end
|
48
48
|
|
49
49
|
def dynamic_content_data()
|
@@ -12,13 +12,13 @@ module CanvasQtiToLearnosityConverter
|
|
12
12
|
}
|
13
13
|
end
|
14
14
|
|
15
|
-
def add_learnosity_assets(assets, path)
|
16
|
-
|
17
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
15
|
+
def add_learnosity_assets(assets, path, learnosity)
|
16
|
+
process_assets!(
|
18
17
|
assets,
|
19
|
-
path
|
18
|
+
path,
|
20
19
|
learnosity[:stimulus]
|
21
20
|
)
|
21
|
+
learnosity
|
22
22
|
end
|
23
23
|
end
|
24
24
|
end
|
@@ -25,13 +25,13 @@ module CanvasQtiToLearnosityConverter
|
|
25
25
|
}
|
26
26
|
end
|
27
27
|
|
28
|
-
def add_learnosity_assets(assets, path)
|
29
|
-
|
30
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
28
|
+
def add_learnosity_assets(assets, path, learnosity)
|
29
|
+
process_assets!(
|
31
30
|
assets,
|
32
|
-
path
|
31
|
+
path,
|
33
32
|
learnosity[:stimulus]
|
34
33
|
)
|
34
|
+
learnosity
|
35
35
|
end
|
36
36
|
end
|
37
37
|
end
|
@@ -11,14 +11,13 @@ module CanvasQtiToLearnosityConverter
|
|
11
11
|
}
|
12
12
|
end
|
13
13
|
|
14
|
-
def add_learnosity_assets(assets, path)
|
15
|
-
|
16
|
-
|
17
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
14
|
+
def add_learnosity_assets(assets, path, learnosity)
|
15
|
+
process_assets!(
|
18
16
|
assets,
|
19
|
-
path
|
17
|
+
path,
|
20
18
|
learnosity[:template]
|
21
19
|
)
|
20
|
+
learnosity
|
22
21
|
end
|
23
22
|
|
24
23
|
def extract_validation()
|
@@ -66,13 +66,13 @@ module CanvasQtiToLearnosityConverter
|
|
66
66
|
end
|
67
67
|
end
|
68
68
|
|
69
|
-
def add_learnosity_assets(assets, path)
|
70
|
-
|
71
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
69
|
+
def add_learnosity_assets(assets, path, learnosity)
|
70
|
+
process_assets!(
|
72
71
|
assets,
|
73
|
-
path
|
72
|
+
path,
|
74
73
|
learnosity[:stimulus]
|
75
74
|
)
|
75
|
+
learnosity
|
76
76
|
end
|
77
77
|
end
|
78
78
|
end
|
@@ -46,21 +46,21 @@ module CanvasQtiToLearnosityConverter
|
|
46
46
|
}
|
47
47
|
end
|
48
48
|
|
49
|
-
def add_learnosity_assets(assets, path)
|
50
|
-
|
51
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
49
|
+
def add_learnosity_assets(assets, path, learnosity)
|
50
|
+
process_assets!(
|
52
51
|
assets,
|
53
|
-
path
|
52
|
+
path,
|
54
53
|
learnosity[:stimulus]
|
55
54
|
)
|
56
55
|
|
57
56
|
learnosity[:options].each.with_index do |option, index|
|
58
|
-
|
57
|
+
process_assets!(
|
59
58
|
assets,
|
60
|
-
path
|
59
|
+
path,
|
61
60
|
option["label"]
|
62
61
|
)
|
63
62
|
end
|
63
|
+
learnosity
|
64
64
|
end
|
65
65
|
end
|
66
66
|
|
@@ -53,13 +53,13 @@ module CanvasQtiToLearnosityConverter
|
|
53
53
|
}
|
54
54
|
end
|
55
55
|
|
56
|
-
def add_learnosity_assets(assets, path)
|
57
|
-
|
58
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
56
|
+
def add_learnosity_assets(assets, path, learnosity)
|
57
|
+
process_assets!(
|
59
58
|
assets,
|
60
|
-
path
|
59
|
+
path,
|
61
60
|
learnosity[:stimulus]
|
62
61
|
)
|
62
|
+
learnosity
|
63
63
|
end
|
64
64
|
end
|
65
65
|
end
|
@@ -31,5 +31,34 @@ module CanvasQtiToLearnosityConverter
|
|
31
31
|
def dynamic_content_data()
|
32
32
|
{}
|
33
33
|
end
|
34
|
+
|
35
|
+
def process_assets!(assets, path, text)
|
36
|
+
doc = Nokogiri::XML.fragment(text)
|
37
|
+
changed = false
|
38
|
+
doc.css("img").each do |node|
|
39
|
+
source = node["src"]
|
40
|
+
next if !source
|
41
|
+
|
42
|
+
source = URI::DEFAULT_PARSER.unescape(source)
|
43
|
+
if /^\$IMS-CC-FILEBASE\$(.*)/.match(source) || /^((?!https?:).*)/.match(source)
|
44
|
+
if source.start_with?("$IMS-CC-FILEBASE$")
|
45
|
+
path = ''
|
46
|
+
end
|
47
|
+
asset_path = $1
|
48
|
+
asset_path = asset_path.split("?").first.gsub(/^\//, '')
|
49
|
+
asset_path = File.join(path, asset_path)
|
50
|
+
clean_ext = File.extname(asset_path).gsub(/[^a-z0-9_.-]/i, '')
|
51
|
+
assets[asset_path] ||= "#{SecureRandom.uuid}#{clean_ext}"
|
52
|
+
node["src"] = "___EXPORT_ROOT___/assets/#{assets[asset_path]}"
|
53
|
+
changed = true
|
54
|
+
end
|
55
|
+
end
|
56
|
+
text.replace(doc.to_xml) if changed
|
57
|
+
end
|
58
|
+
|
59
|
+
def convert(assets, path)
|
60
|
+
object = to_learnosity
|
61
|
+
add_learnosity_assets(assets, path, object)
|
62
|
+
end
|
34
63
|
end
|
35
64
|
end
|
@@ -28,13 +28,13 @@ module CanvasQtiToLearnosityConverter
|
|
28
28
|
}
|
29
29
|
end
|
30
30
|
|
31
|
-
def add_learnosity_assets(assets, path)
|
32
|
-
|
33
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
31
|
+
def add_learnosity_assets(assets, path, learnosity)
|
32
|
+
process_assets!(
|
34
33
|
assets,
|
35
|
-
path
|
34
|
+
path,
|
36
35
|
learnosity[:stimulus]
|
37
36
|
)
|
37
|
+
learnosity
|
38
38
|
end
|
39
39
|
end
|
40
40
|
end
|
@@ -26,14 +26,13 @@ module CanvasQtiToLearnosityConverter
|
|
26
26
|
extract_mattext(template_node_list.first)
|
27
27
|
end
|
28
28
|
|
29
|
-
def add_learnosity_assets(assets, path)
|
30
|
-
|
31
|
-
|
32
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
29
|
+
def add_learnosity_assets(assets, path, learnosity)
|
30
|
+
process_assets!(
|
33
31
|
assets,
|
34
|
-
path
|
32
|
+
path,
|
35
33
|
learnosity[:template]
|
36
34
|
)
|
35
|
+
learnosity
|
37
36
|
end
|
38
37
|
end
|
39
38
|
end
|
@@ -10,13 +10,13 @@ module CanvasQtiToLearnosityConverter
|
|
10
10
|
}
|
11
11
|
end
|
12
12
|
|
13
|
-
def add_learnosity_assets(assets, path)
|
14
|
-
|
15
|
-
CanvasQtiToLearnosityConverter.add_files_to_assets(
|
13
|
+
def add_learnosity_assets(assets, path, learnosity)
|
14
|
+
process_assets!(
|
16
15
|
assets,
|
17
|
-
path
|
16
|
+
path,
|
18
17
|
learnosity[:content]
|
19
18
|
)
|
19
|
+
learnosity
|
20
20
|
end
|
21
21
|
end
|
22
22
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: canvas_qti_to_learnosity_converter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 3.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Atomic Jolt
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2025-01-15 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: bundler
|
@@ -45,14 +45,14 @@ dependencies:
|
|
45
45
|
requirements:
|
46
46
|
- - "~>"
|
47
47
|
- !ruby/object:Gem::Version
|
48
|
-
version: '
|
48
|
+
version: '11.0'
|
49
49
|
type: :development
|
50
50
|
prerelease: false
|
51
51
|
version_requirements: !ruby/object:Gem::Requirement
|
52
52
|
requirements:
|
53
53
|
- - "~>"
|
54
54
|
- !ruby/object:Gem::Version
|
55
|
-
version: '
|
55
|
+
version: '11.0'
|
56
56
|
- !ruby/object:Gem::Dependency
|
57
57
|
name: rspec
|
58
58
|
requirement: !ruby/object:Gem::Requirement
|
@@ -95,6 +95,20 @@ dependencies:
|
|
95
95
|
- - ">="
|
96
96
|
- !ruby/object:Gem::Version
|
97
97
|
version: '0'
|
98
|
+
- !ruby/object:Gem::Dependency
|
99
|
+
name: activesupport
|
100
|
+
requirement: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '0'
|
105
|
+
type: :runtime
|
106
|
+
prerelease: false
|
107
|
+
version_requirements: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '0'
|
98
112
|
description:
|
99
113
|
email:
|
100
114
|
- support@atomicjolt.com
|
@@ -115,6 +129,7 @@ files:
|
|
115
129
|
- canvas_qti_to_learnosity_converter.gemspec
|
116
130
|
- lib/canvas_qti_to_learnosity_converter.rb
|
117
131
|
- lib/canvas_qti_to_learnosity_converter/convert.rb
|
132
|
+
- lib/canvas_qti_to_learnosity_converter/export_writer.rb
|
118
133
|
- lib/canvas_qti_to_learnosity_converter/questions/calculated.rb
|
119
134
|
- lib/canvas_qti_to_learnosity_converter/questions/essay.rb
|
120
135
|
- lib/canvas_qti_to_learnosity_converter/questions/file_upload.rb
|