Class: CanvasQtiToLearnosityConverter::Converter

Inherits:
Object
  • Object
show all
Defined in:
lib/canvas_qti_to_learnosity_converter/convert.rb

Defined Under Namespace

Classes: CanvasEntryTypeNotSupportedError, CanvasQtiQuiz, CanvasQuestionTypeNotSupportedError

Constant Summary collapse

FEATURE_TYPES =
[ :text_only_question ]
QUESTION_TYPES =
[
  :multiple_choice_question,
  :true_false_question,
  :multiple_answers_question,
  :short_answer_question,
  :fill_in_multiple_blanks_question,
  :multiple_dropdowns_question,
  :matching_question,
  :essay_question,
  :file_upload_question,
]
TYPE_MAP =
{
  multiple_choice_question: MultipleChoiceQuestion,
  true_false_question: MultipleChoiceQuestion,
  multiple_answers_question: MultipleAnswersQuestion,
  short_answer_question: ShortAnswerQuestion,
  fill_in_multiple_blanks_question: FillTheBlanksQuestion,
  multiple_dropdowns_question: MultipleDropdownsQuestion,
  matching_question: MatchingQuestion,
  essay_question: EssayQuestion,
  file_upload_question: FileUploadQuestion,
  text_only_question: TextOnlyQuestion,
  numerical_question: NumericalQuestion,
  calculated_question: CalculatedQuestion,

  "cc.multiple_choice.v0p1": MultipleChoiceQuestion,
  "cc.multiple_response.v0p1": MultipleAnswersQuestion,
  "cc.fib.v0p1": ShortAnswerQuestion,
  "cc.true_false.v0p1": MultipleChoiceQuestion,
  "cc.essay.v0p1": EssayQuestion,
}

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeConverter

Returns a new instance of Converter.



61
62
63
64
65
66
67
68
69
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 61

def initialize
  @items = []
  @widgets = []
  @item_banks = []
  @assessments = []
  @assets = {}
  @errors = {}
  @namespace = SecureRandom.uuid
end

Instance Attribute Details

#assessmentsObject

Returns the value of attribute assessments.



59
60
61
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 59

def assessments
  @assessments
end

#assetsObject

Returns the value of attribute assets.



59
60
61
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 59

def assets
  @assets
end

#errorsObject

Returns the value of attribute errors.



59
60
61
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 59

def errors
  @errors
end

#item_banksObject

Returns the value of attribute item_banks.



59
60
61
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 59

def item_banks
  @item_banks
end

#itemsObject

Returns the value of attribute items.



59
60
61
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 59

def items
  @items
end

#widgetsObject

Returns the value of attribute widgets.



59
60
61
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 59

def widgets
  @widgets
end

Instance Method Details

#build_quiz_from_file(path) ⇒ Object



100
101
102
103
104
105
106
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 100

def build_quiz_from_file(path)
  qti_file = File.new path
  qti_string = qti_file.read
  CanvasQtiQuiz.new(qti_string: qti_string)
ensure
  qti_file.close
end

#build_quiz_from_qti_string(qti_string) ⇒ Object



96
97
98
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 96

def build_quiz_from_qti_string(qti_string)
  CanvasQtiQuiz.new(qti_string: qti_string)
end

#build_reference(ident = nil) ⇒ Object



187
188
189
190
191
192
193
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 187

def build_reference(ident = nil)
  if ident.present?
    Digest::UUID.uuid_v5(@namespace, ident)
  else
    SecureRandom.uuid
  end
end

#clean_title(title) ⇒ Object



138
139
140
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 138

def clean_title(title)
  title&.gsub(/["']/, "")
end

#convert_assessment(qti, path) ⇒ Object



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 142

def convert_assessment(qti, path)
  quiz = CanvasQtiQuiz.new(qti_string: qti)
  assessment = quiz.at_css("assessment")
  return nil unless assessment

  ident = assessment.attribute("ident")&.value
  reference = build_reference(ident)
  title = clean_title(assessment.attribute("title").value)

  item_refs = convert_items(quiz, path)
  @assessments <<
    {
      reference:,
      title:,
      data: {
        items: item_refs.uniq.map { |ref| { reference: ref } },
        config: { title: },
      },
      status: "published",
      tags: {},
    }
end

#convert_imscc_export(path) ⇒ Object



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 299

def convert_imscc_export(path)
  Zip::File.open(path) do |zip_file|
    entry = zip_file.find_entry("imsmanifest.xml")
    manifest = entry.get_input_stream.read
    parsed_manifest = Nokogiri.XML(manifest, &:noblanks)

    item_bank_paths = imscc_item_bank_paths(parsed_manifest)
    item_bank_paths.each do |item_bank_path|
      qti = zip_file.find_entry(item_bank_path).get_input_stream.read
      convert_item_bank(qti, File.dirname(item_bank_path))
    end

    assessment_paths = imscc_quiz_paths(parsed_manifest)
    assessment_paths.each do |qti_path|
      qti = zip_file.find_entry(qti_path).get_input_stream.read
      convert_assessment(qti, File.dirname(qti_path))
    end

    {
      errors: @errors,
    }
  end
end

#convert_item(qti_string:) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 117

def convert_item(qti_string:)
  xml = Nokogiri.XML(qti_string, &:noblanks)
  type = extract_type(xml)

  if FEATURE_TYPES.include?(type)
    learnosity_type = "feature"
  else
    learnosity_type = "question"
  end

  question_class = TYPE_MAP[type]

  if question_class
    question = question_class.new(xml)
  else
    raise CanvasQuestionTypeNotSupportedError.new(type)
  end

  [learnosity_type, question]
end

#convert_item_bank(qti_string, path) ⇒ Object



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 165

def convert_item_bank(qti_string, path)
  qti = CanvasQtiQuiz.new(qti_string:)
  item_bank = qti.at_css("objectbank")
  return nil unless item_bank

  ident = item_bank.attribute("ident")&.value
  title = clean_title(qti.css(%{ objectbank > qtimetadata >
    qtimetadatafield > fieldlabel:contains("bank_title")})
    &.first&.next&.text || '')

  meta = {
    original_item_bank_ref: ident,
  }
  item_refs = convert_items(qti, path, meta:, tags: { "Item Bank" => [title] })
  @item_banks <<
    {
      title: title,
      ident: ident,
      item_refs: item_refs,
    }
end

#convert_items(qti, path, meta: {}, tags: {}) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 195

def convert_items(qti, path, meta: {}, tags: {})
  converted_item_refs = []
  qti.css("item,bankentry_item,section").each.with_index do |item, index|
    begin
      ident = item.attribute("ident")&.value
      if item.name == "section"
        next if ident == "root_section"

        item.css("sourcebank_ref").each do |sourcebank_ref|
          item_refs = @items.select { |i| i.dig(:metadata, :original_item_bank_ref) == sourcebank_ref.text }.map { |i| i[:reference] }
          converted_item_refs += item_refs
        end
      elsif item.name == "bankentry_item"
        item_ref = item.attribute("item_ref")&.value
        if item_ref
          converted_item_refs.push(build_reference(item_ref))
        end
      elsif item.name == "item"
        reference = build_reference(ident)
        item_title = item.attribute("title")&.value || ''
        learnosity_type, quiz_item = convert_item(qti_string: item.to_html)

        item_widgets = [
          {
            type: learnosity_type,
            data: quiz_item.convert(@assets, path),
            reference: build_reference,
          }
        ]
        @widgets += item_widgets

        @items << {
          title: item_title,
          reference:,
          metadata: meta.merge({ original_item_ref: ident }),
          definition: {
            widgets: item_widgets.map{ |w| { reference: w[:reference] } },
          },
          questions: item_widgets.select{ |w| w[:type] == "question" }.map{ |w| w[:reference] },
          features: item_widgets.select{ |w| w[:type] == "feature" }.map{ |w| w[:reference] },
          status: "published",
          tags: tags,
          type: learnosity_type,
          dynamic_content_data: quiz_item.dynamic_content_data()
        }

        converted_item_refs.push(reference)
      end

    rescue CanvasQuestionTypeNotSupportedError => e
      @errors[ident] ||= []
      @errors[ident].push({
        index: index,
        error_type: "unsupported_question",
        question_type: e.question_type.to_s,
        message: e.message,
      })
    rescue StandardError => e
      @errors[ident] ||= []
      @errors[ident].push({
        index: index,
        error_type: e.class.to_s,
        message: e.message,
      })
    end
  end
  converted_item_refs
end

#convert_qti_file(path) ⇒ Object



264
265
266
267
268
269
270
271
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 264

def convert_qti_file(path)
  file = File.new(path)
  qti_string = file.read
  convert(qti_string)
ensure
  file.close
  file.unlink
end

#extract_type(xml) ⇒ Object



108
109
110
111
112
113
114
115
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 108

def extract_type(xml)
  xml.css(%{ item > itemmetadata > qtimetadata >
    qtimetadatafield > fieldlabel:contains("question_type")})
    &.first&.next&.text&.to_sym ||
    xml.css(%{ item > itemmetadata > qtimetadata >
      qtimetadatafield > fieldlabel:contains("cc_profile")})
      &.first&.next&.text&.to_sym
end

#generate_learnosity_export(input_path, output_path) ⇒ Object



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 323

def generate_learnosity_export(input_path, output_path)
  result = convert_imscc_export(input_path)

  export_writer = ExportWriter.new(output_path)
  export_writer.write_to_zip("export.json", { version: 2.0 })

  @assessments.each do |activity|
    export_writer.write_to_zip("activities/#{activity[:reference]}.json", activity)
  end
  @items.each do |item|
    export_writer.write_to_zip("items/#{item[:reference]}.json", item)
  end
  @widgets.each do |widget|
    export_writer.write_to_zip("#{widget[:type]}s/#{widget[:reference]}.json", widget)
  end

  Zip::File.open(input_path) do |input|
    @assets.each do |source, destination|
      source = source.gsub(/^\//, '')
      asset = input.find_entry(source) || input.find_entry("web_resources/#{source}")
      if asset
        export_writer.write_asset_to_zip("assets/#{destination}", input.read(asset))
      end
    end
  end
  export_writer.close

  result
end

#imscc_item_bank_paths(parsed_manifest) ⇒ Object



280
281
282
283
284
285
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 280

def imscc_item_bank_paths(parsed_manifest)
  resources = parsed_manifest.css("resources > resource[type='associatedcontent/imscc_xmlv1p1/learning-application-resource']")
  resources.map do |entry|
    resource_path(parsed_manifest, entry)
  end
end

#imscc_quiz_paths(parsed_manifest) ⇒ Object



273
274
275
276
277
278
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 273

def imscc_quiz_paths(parsed_manifest)
  resources = parsed_manifest.css("resources > resource[type^='imsqti_xmlv1p2']")
  resources.map do |entry|
    resource_path(parsed_manifest, entry)
  end
end

#resource_path(parsed_manifest, entry) ⇒ Object



287
288
289
290
291
292
293
294
295
296
297
# File 'lib/canvas_qti_to_learnosity_converter/convert.rb', line 287

def resource_path(parsed_manifest, entry)
  # Use the Canvas non_cc_assignment qti path when possible.  This works for both classic and new quizzes
  entry.css("dependency").each do |dependency|
    ref = dependency.attribute("identifierref").value
    parsed_manifest.css(%{resources > resource[identifier="#{ref}"] > file}).each do |file|
      path = file.attribute("href").value
      return path if path.match?(/^non_cc_assessments/)
    end
  end
  entry.css("file").first&.attribute("href")&.value
end