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: b87553f49f64e8fb54d2334edf92b467322364f3b32d8fa169c6f31cfef2d4be
4
- data.tar.gz: 502a402267786ee374ced98d282bc4774b69243bee341e2ed35bd051dd6e7db9
3
+ metadata.gz: ff0914859bacf0cab5c24c74ca5fbdab49478d9df31714434eaf2ac460e1bc31
4
+ data.tar.gz: 76403083b18998db6fcf6226bcb081e2f0d3d9e0bf3e5336a13842feca75bfff
5
5
  SHA512:
6
- metadata.gz: 13688b93f41f22b5bfb9d1ef9a73c50828fa4c43f8a0c8b5e7102f3d5842b9d47d9735beaa5991babfc44374f3cb204d43a3a6d366ba71e6a13cbc8006ea4362
7
- data.tar.gz: f74370f50a3e2a9c7052bc0e82a48ec6ed08ebc05151ffdc463768ff122b75cefafba0ee538b756e27f78ce6bf0fd3ce98539816cdefaa475a3aa0687e4c757c
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", "~> 10.0"
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
- item_ref = item.attribute("item_ref")&.value
209
- if item_ref
210
- converted_item_refs.push(build_reference(item_ref))
211
- 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))
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
- type: learnosity_type,
220
- data: quiz_item.convert(@assets, path),
221
- reference: build_reference,
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,
@@ -1,3 +1,3 @@
1
1
  module CanvasQtiToLearnosityConverter
2
- VERSION = "3.0.0"
2
+ VERSION = "3.1.0"
3
3
  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: 3.0.0
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: 2024-08-27 00:00:00.000000000 Z
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: '10.0'
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: '10.0'
55
+ version: '11.0'
56
56
  - !ruby/object:Gem::Dependency
57
57
  name: rspec
58
58
  requirement: !ruby/object:Gem::Requirement