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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 367e7235a2a58b7fd63af3999e63cb960e34f46fa7f459c08d3608470d55b6c0
4
- data.tar.gz: 9e33eabe1f9ddecf970ee13fec9e9dc93e5062f658e5122dc90ea3c0a37a8677
3
+ metadata.gz: ff0914859bacf0cab5c24c74ca5fbdab49478d9df31714434eaf2ac460e1bc31
4
+ data.tar.gz: 76403083b18998db6fcf6226bcb081e2f0d3d9e0bf3e5336a13842feca75bfff
5
5
  SHA512:
6
- metadata.gz: 62c09c119cab2a055a09f89dbfbb961d1a05f8922475940a86b276d77c16252b9ee56ff8f32aba4a52d9e2e87c2c0c7eeeced67bc0b98d59b11c117a4081589e
7
- data.tar.gz: 1735c52bf62ae1708e3a1a4eb72af38127eaebd7489e51a206496fa3207e7f350e7bd57e628c4eb8c32f59d4d435246216c61362f683785adfb40b5c67144448
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", "~> 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"
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
- FEATURE_TYPES = [ :text_only_question ]
20
- QUESTION_TYPES = [
21
- :multiple_choice_question,
22
- :true_false_question,
23
- :multiple_answers_question,
24
- :short_answer_question,
25
- :fill_in_multiple_blanks_question,
26
- :multiple_dropdowns_question,
27
- :matching_question,
28
- :essay_question,
29
- :file_upload_question,
30
- ]
31
-
32
- TYPE_MAP = {
33
- multiple_choice_question: MultipleChoiceQuestion,
34
- true_false_question: MultipleChoiceQuestion,
35
- multiple_answers_question: MultipleAnswersQuestion,
36
- short_answer_question: ShortAnswerQuestion,
37
- fill_in_multiple_blanks_question: FillTheBlanksQuestion,
38
- multiple_dropdowns_question: MultipleDropdownsQuestion,
39
- matching_question: MatchingQuestion,
40
- essay_question: EssayQuestion,
41
- file_upload_question: FileUploadQuestion,
42
- text_only_question: TextOnlyQuestion,
43
- numerical_question: NumericalQuestion,
44
- calculated_question: CalculatedQuestion,
45
-
46
- "cc.multiple_choice.v0p1": MultipleChoiceQuestion,
47
- "cc.multiple_response.v0p1": MultipleAnswersQuestion,
48
- "cc.fib.v0p1": ShortAnswerQuestion,
49
- "cc.true_false.v0p1": MultipleChoiceQuestion,
50
- "cc.essay.v0p1": EssayQuestion,
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
- class CanvasQtiQuiz
62
- extend Forwardable
63
- def_delegators :@xml, :css
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
- def initialize(qti_string:)
66
- @xml = Nokogiri.XML(qti_string, &:noblanks)
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
- def self.read_file(path)
72
- file = File.new path
73
- file.read
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
- def self.build_quiz_from_qti_string(qti_string)
80
- CanvasQtiQuiz.new(qti_string: qti_string)
81
- end
96
+ def initialize(qti_string:)
97
+ @xml = Nokogiri.XML(qti_string, &:noblanks)
98
+ end
99
+ end
82
100
 
83
- def self.build_quiz_from_file(path)
84
- qti_file = File.new path
85
- qti_string = qti_file.read
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
- def self.add_files_to_assets(assets, path, text)
92
- text.scan(/(%24|\$)IMS-CC-FILEBASE\1\/([^"]+)/) do |_delimiter, asset_path|
93
- decoded_path = URI::DEFAULT_PARSER.unescape(asset_path)
94
- assets[decoded_path] ||= []
95
- assets[decoded_path].push(path)
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
- def self.extract_type(xml)
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("cc_profile")})
105
- &.first&.next&.text&.to_sym
106
- end
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
- def self.convert_item(qti_string:)
109
- xml = Nokogiri.XML(qti_string, &:noblanks)
110
- type = extract_type(xml)
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
- if FEATURE_TYPES.include?(type)
113
- learnosity_type = "feature"
114
- else
115
- learnosity_type = "question"
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
- question_class = TYPE_MAP[type]
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
- if question_class
121
- question = question_class.new(xml)
122
- else
123
- raise CanvasQuestionTypeNotSupportedError.new(type)
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
- [learnosity_type, question]
127
- end
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
- def self.clean_title(title)
130
- title.gsub(/["']/, "")
131
- end
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
- def self.convert(qti, assets, errors)
134
- quiz = CanvasQtiQuiz.new(qti_string: qti)
135
- assessment = quiz.css("assessment")
136
- ident = assessment.attribute("ident").value
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
- items = []
280
+ child_widgets = limit_child_questions(child_widgets, item_ref)
141
281
 
142
- quiz.css("item").each.with_index do |item, index|
143
- begin
144
- next if item.children.length == 0
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
- item_title = item.attribute("title")&.value || ''
147
- learnosity_type, quiz_item = convert_item(qti_string: item.to_html)
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
- item = {
150
- title: item_title,
151
- type: learnosity_type,
152
- data: quiz_item.to_learnosity,
153
- dynamic_content_data: quiz_item.dynamic_content_data()
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
- items.push(item)
157
- path = [items.count - 1, :data]
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
- quiz_item.add_learnosity_assets(assets[ident], path)
160
- rescue CanvasQuestionTypeNotSupportedError => e
161
- errors[ident].push({
162
- index: index,
163
- error_type: "unsupported_question",
164
- question_type: e.question_type.to_s,
165
- message: e.message,
166
- })
167
- rescue StandardError => e
168
- errors[ident].push({
169
- index: index,
170
- error_type: e.class.to_s,
171
- message: e.message,
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
- title: clean_title(assessment.attribute("title").value),
178
- ident: ident,
179
- items: items,
180
- }
181
- end
369
+ def convert_items(qti, path, meta: {}, tags: {})
370
+ converted_item_refs = []
182
371
 
183
- def self.convert_qti_file(path)
184
- file = File.new(path)
185
- qti_string = file.read
186
- convert(qti_string)
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
- def self.imscc_quiz_paths(parsed_manifest)
193
- resources = parsed_manifest.css("resources > resource[type^='imsqti_xmlv1p2']")
194
- resources.map do |entry|
195
- resource_path(parsed_manifest, entry)
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
- def self.resource_path(parsed_manifest, entry)
200
- # Use the Canvas non_cc_assignment qti path when possible. This works for both classic and new quizzes
201
- entry.css("dependency").each do |dependency|
202
- ref = dependency.attribute("identifierref").value
203
- parsed_manifest.css(%{resources > resource[identifier="#{ref}"] > file}).each do |file|
204
- path = file.attribute("href").value
205
- return path if path.match?(/^non_cc_assessments/)
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
- def self.convert_imscc_export(path)
212
- Zip::File.open(path) do |zip_file|
213
- entry = zip_file.find_entry("imsmanifest.xml")
214
- manifest = entry.get_input_stream.read
215
- parsed_manifest = Nokogiri.XML(manifest, &:noblanks)
216
- paths = imscc_quiz_paths(parsed_manifest)
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
- assets = {}
219
- errors = {}
220
- converted_assesments = paths.map do |qti_path|
221
- qti = zip_file.find_entry(qti_path).get_input_stream.read
222
- convert(qti, assets, errors)
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
- assessments: converted_assesments,
227
- assets: assets,
228
- errors: errors,
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
- learnosity = to_learnosity
42
- CanvasQtiToLearnosityConverter.add_files_to_assets(
40
+ def add_learnosity_assets(assets, path, learnosity)
41
+ process_assets!(
43
42
  assets,
44
- path + [:stimulus],
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
- learnosity = to_learnosity
17
- CanvasQtiToLearnosityConverter.add_files_to_assets(
15
+ def add_learnosity_assets(assets, path, learnosity)
16
+ process_assets!(
18
17
  assets,
19
- path + [:stimulus],
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
- learnosity = to_learnosity
30
- CanvasQtiToLearnosityConverter.add_files_to_assets(
28
+ def add_learnosity_assets(assets, path, learnosity)
29
+ process_assets!(
31
30
  assets,
32
- path + [:stimulus],
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
- learnosity = to_learnosity
16
-
17
- CanvasQtiToLearnosityConverter.add_files_to_assets(
14
+ def add_learnosity_assets(assets, path, learnosity)
15
+ process_assets!(
18
16
  assets,
19
- path + [:template],
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
- learnosity = to_learnosity
71
- CanvasQtiToLearnosityConverter.add_files_to_assets(
69
+ def add_learnosity_assets(assets, path, learnosity)
70
+ process_assets!(
72
71
  assets,
73
- path + [:stimulus],
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
- learnosity = to_learnosity
51
- CanvasQtiToLearnosityConverter.add_files_to_assets(
49
+ def add_learnosity_assets(assets, path, learnosity)
50
+ process_assets!(
52
51
  assets,
53
- path + [:stimulus],
52
+ path,
54
53
  learnosity[:stimulus]
55
54
  )
56
55
 
57
56
  learnosity[:options].each.with_index do |option, index|
58
- CanvasQtiToLearnosityConverter.add_files_to_assets(
57
+ process_assets!(
59
58
  assets,
60
- path + [:options, index, "label"],
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
- learnosity = to_learnosity
58
- CanvasQtiToLearnosityConverter.add_files_to_assets(
56
+ def add_learnosity_assets(assets, path, learnosity)
57
+ process_assets!(
59
58
  assets,
60
- path + [:stimulus],
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
- learnosity = to_learnosity
33
- CanvasQtiToLearnosityConverter.add_files_to_assets(
31
+ def add_learnosity_assets(assets, path, learnosity)
32
+ process_assets!(
34
33
  assets,
35
- path + [:stimulus],
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
- learnosity = to_learnosity
31
-
32
- CanvasQtiToLearnosityConverter.add_files_to_assets(
29
+ def add_learnosity_assets(assets, path, learnosity)
30
+ process_assets!(
33
31
  assets,
34
- path + [:template],
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
- learnosity = to_learnosity
15
- CanvasQtiToLearnosityConverter.add_files_to_assets(
13
+ def add_learnosity_assets(assets, path, learnosity)
14
+ process_assets!(
16
15
  assets,
17
- path + [:content],
16
+ path,
18
17
  learnosity[:content]
19
18
  )
19
+ learnosity
20
20
  end
21
21
  end
22
22
  end
@@ -1,3 +1,3 @@
1
1
  module CanvasQtiToLearnosityConverter
2
- VERSION = "2.5.0"
2
+ VERSION = "3.1.0"
3
3
  end
@@ -2,5 +2,4 @@ require 'canvas_qti_to_learnosity_converter/version'
2
2
  require 'canvas_qti_to_learnosity_converter/convert'
3
3
 
4
4
  module CanvasQtiToLearnosityConverter
5
- # Your code goes here...
6
5
  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: 2.5.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-07-30 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
@@ -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