canvas_qti_to_learnosity_converter 3.0.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
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,7 +20,7 @@ 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"
|
@@ -56,6 +56,11 @@ module CanvasQtiToLearnosityConverter
|
|
56
56
|
"cc.essay.v0p1": EssayQuestion,
|
57
57
|
}
|
58
58
|
|
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
|
+
|
59
64
|
attr_accessor :items, :widgets, :item_banks, :assessments, :assets, :errors
|
60
65
|
|
61
66
|
def initialize
|
@@ -114,6 +119,175 @@ module CanvasQtiToLearnosityConverter
|
|
114
119
|
&.first&.next&.text&.to_sym
|
115
120
|
end
|
116
121
|
|
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
|
129
|
+
|
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
|
142
|
+
end
|
143
|
+
|
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 }
|
149
|
+
|
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
|
158
|
+
end
|
159
|
+
|
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
|
196
|
+
|
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] }]
|
274
|
+
|
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
|
279
|
+
|
280
|
+
child_widgets = limit_child_questions(child_widgets, item_ref)
|
281
|
+
|
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] }]
|
285
|
+
|
286
|
+
@widgets += child_widgets
|
287
|
+
@items.push(new_item)
|
288
|
+
new_item
|
289
|
+
end
|
290
|
+
|
117
291
|
def convert_item(qti_string:)
|
118
292
|
xml = Nokogiri.XML(qti_string, &:noblanks)
|
119
293
|
type = extract_type(xml)
|
@@ -194,9 +368,27 @@ module CanvasQtiToLearnosityConverter
|
|
194
368
|
|
195
369
|
def convert_items(qti, path, meta: {}, tags: {})
|
196
370
|
converted_item_refs = []
|
371
|
+
|
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"
|
376
|
+
|
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
|
+
|
197
384
|
qti.css("item,bankentry_item,section").each.with_index do |item, index|
|
198
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
|
+
|
199
389
|
ident = item.attribute("ident")&.value
|
390
|
+
item_ref = item.attribute("item_ref")&.value
|
391
|
+
|
200
392
|
if item.name == "section"
|
201
393
|
next if ident == "root_section"
|
202
394
|
|
@@ -204,32 +396,31 @@ module CanvasQtiToLearnosityConverter
|
|
204
396
|
item_refs = @items.select { |i| i.dig(:metadata, :original_item_bank_ref) == sourcebank_ref.text }.map { |i| i[:reference] }
|
205
397
|
converted_item_refs += item_refs
|
206
398
|
end
|
207
|
-
elsif item.name == "bankentry_item"
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
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))
|
212
404
|
elsif item.name == "item"
|
213
405
|
reference = build_reference(ident)
|
214
406
|
item_title = item.attribute("title")&.value || ''
|
215
407
|
learnosity_type, quiz_item = convert_item(qti_string: item.to_html)
|
216
408
|
|
217
|
-
item_widgets =
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
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
|
+
|
224
417
|
@widgets += item_widgets
|
225
418
|
|
226
419
|
@items << {
|
227
420
|
title: item_title,
|
228
421
|
reference:,
|
229
422
|
metadata: meta.merge({ original_item_ref: ident }),
|
230
|
-
definition
|
231
|
-
widgets: item_widgets.map{ |w| { reference: w[:reference] } },
|
232
|
-
},
|
423
|
+
definition:,
|
233
424
|
questions: item_widgets.select{ |w| w[:type] == "question" }.map{ |w| w[:reference] },
|
234
425
|
features: item_widgets.select{ |w| w[:type] == "feature" }.map{ |w| w[:reference] },
|
235
426
|
status: "published",
|
@@ -250,8 +441,8 @@ module CanvasQtiToLearnosityConverter
|
|
250
441
|
message: e.message,
|
251
442
|
})
|
252
443
|
rescue StandardError => e
|
253
|
-
@errors[ident] ||= []
|
254
|
-
@errors[ident].push({
|
444
|
+
@errors[ident || item_ref] ||= []
|
445
|
+
@errors[ident || item_ref].push({
|
255
446
|
index: index,
|
256
447
|
error_type: e.class.to_s,
|
257
448
|
message: e.message,
|
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: 3.
|
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
|