Class: CanvasQtiToLearnosityConverter::QuizQuestion

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Defined in:
lib/canvas_qti_to_learnosity_converter/questions/question.rb

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(xml) ⇒ QuizQuestion

Returns a new instance of QuizQuestion.



12
13
14
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 12

def initialize(xml)
  @xml = xml
end

Class Method Details

.for(xml) ⇒ Object



8
9
10
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 8

def self.for(xml)
  new(xml)
end

Instance Method Details

#convert(assets, path) ⇒ Object



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 110

def convert(assets, path)
  object = to_learnosity
  add_learnosity_assets(assets, path, object) # mutates object in place; return value unused
  feedback = extract_feedback
  unless feedback.empty?
    # Ensure any asset references in feedback HTML are processed and collected.
    feedback.each do |key, value|
      if value.is_a?(String)
        process_assets!(assets, path, value)
      elsif key.to_s == "distractor_rationale_response_level" && value.is_a?(Array)
        value.each do |entry|
          process_assets!(assets, path, entry) if entry.is_a?(String)
        end
      end
    end
    object[:metadata] ||= {}
    object[:metadata].merge!(feedback)
  end

  object
end

#dynamic_content_dataObject



57
58
59
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 57

def dynamic_content_data()
  {}
end

#extract_feedbackObject



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 27

def extract_feedback
  feedback = {}
  distractor_rationale = []

  @xml.css("item > itemfeedback").each do |node|
    ident = node.attribute("ident")&.value
    text = node.css("flow_mat > material > mattext").first&.content
    next if text.nil? || text.empty?

    case ident
    when "correct_fb"           then feedback[:correct_feedback] = text
    when "general_fb"           then feedback[:general_feedback] = text
    when "general_incorrect_fb" then feedback[:incorrect_feedback] = text
    # Any other *_fb ident (e.g. per-answer labels like "abc123_fb") is treated as per-answer distractor rationale.
    else distractor_rationale << text if ident&.end_with?("_fb")
    end
  end

  feedback[:distractor_rationale_response_level] = distractor_rationale unless distractor_rationale.empty?
  feedback
end

#extract_mattext(mattext_node) ⇒ Object



49
50
51
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 49

def extract_mattext(mattext_node)
  mattext_node.content
end

#extract_points_possibleObject



21
22
23
24
25
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 21

def extract_points_possible
  @xml.css(%{ item > itemmetadata > qtimetadata >
    qtimetadatafield > fieldlabel:contains("points_possible")})
    &.first&.next&.text&.to_f || 1.0
end

#extract_stimulusObject



16
17
18
19
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 16

def extract_stimulus()
  mattext = @xml.css("item > presentation > material > mattext").first
  extract_mattext(mattext)
end

#make_identifierObject



53
54
55
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 53

def make_identifier()
  SecureRandom.uuid
end

#process_assets!(assets, path, text) ⇒ Object



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/canvas_qti_to_learnosity_converter/questions/question.rb', line 61

def process_assets!(assets, path, text)
  doc = Nokogiri::XML.fragment(text)
  changed = false
  doc.css("img").each do |node|
    source = node["src"]
    next if !source

    source = URI::DEFAULT_PARSER.unescape(source)
    if /^\$IMS-CC-FILEBASE\$(.*)/.match(source) || /^((?!https?:).*)/.match(source)
      if source.start_with?("$IMS-CC-FILEBASE$")
        path = ''
      end
      asset_path = $1
      asset_path = asset_path.split("?").first.gsub(/^\//, '')
      asset_path = File.join(path, asset_path)
      clean_ext = File.extname(asset_path).gsub(/[^a-z0-9_.-]/i, '')
      assets[asset_path] ||= "#{SecureRandom.uuid}#{clean_ext}"
      node["src"] = "___EXPORT_ROOT___/assets/#{assets[asset_path]}"
      changed = true
    end
  end
  doc.css("audio").each do |node|
    source_node = node.css("source").first
    raw_src = source_node&.[]("src") || node["src"]
    next unless raw_src

    src = URI::DEFAULT_PARSER.unescape(raw_src)
    if /^\$IMS-CC-FILEBASE\$(.*)/.match(src) || /^((?!https?:).*)/.match(src)
      asset_path_base = src.start_with?("$IMS-CC-FILEBASE$") ? '' : path
      asset_path = $1.split("?").first.gsub(/^\//, '')
      asset_path = File.join(asset_path_base, asset_path)
      clean_ext = File.extname(asset_path).gsub(/[^a-z0-9_.-]/i, '')
      assets[asset_path] ||= "#{SecureRandom.uuid}#{clean_ext}"
      src = "___EXPORT_ROOT___/assets/#{assets[asset_path]}"
    end

    span = Nokogiri::XML::Node.new("span", doc)
    span["class"] = "learnosity-feature"
    span["data-player"] = "bar"
    span["data-simplefeature_id"] = SecureRandom.uuid
    span["data-src"] = src
    span["data-type"] = "audioplayer"
    span.add_child(Nokogiri::XML::Text.new("", doc))
    node.replace(span)
    changed = true
  end
  text.replace(doc.to_xml) if changed
end