diff --git a/.gitignore b/.gitignore index 42c3d96..d7c1ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ test/version_tmp tmp vendor out -moodle_test.mbz \ No newline at end of file +moodle_test.mbz +.DS_Store +.byebug_history diff --git a/.tool-versions b/.tool-versions index a4023dc..f2a971a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 2.7.5 +ruby 3.2.2 diff --git a/Gemfile b/Gemfile index dd4cbf1..8cb10a0 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,9 @@ source 'https://rubygems.org' gemspec gem 'simplecov' + +gem "htmlentities", "~> 4.3" + +gem "debug", "~> 1.9" + +gem "httparty", "~> 0.21.0" diff --git a/bin/extract b/bin/extract new file mode 100755 index 0000000..9ce26fc --- /dev/null +++ b/bin/extract @@ -0,0 +1,88 @@ +#!/usr/bin/env ruby +require 'rubygems' +require 'nokogiri' +require 'pp' +require 'cgi' + + +def html_entity_decode(str) + CGI.unescapeHTML(str) +end + +# Read input from STDIN (or use ARGV[0] if provided) +input = ARGV[0] + +# Decode HTML entities to get valid XML +decoded_input = html_entity_decode(input) + +begin + # Parse the XML + doc = Nokogiri::XML(decoded_input) { |config| config.strict } + + # Extract the CAS session if available + cas_session = doc.at_xpath('//wirisCasSession') + if cas_session && !cas_session.children.empty? + # Extract CDATA content from wirisCasSession + cdata_content = cas_session.children.first.text + begin + cas_content = Nokogiri::XML(cdata_content) { |config| config.noblanks } + puts "CAS Session Data:" + puts cas_content.to_xml + rescue => e + puts "Error parsing CAS session CDATA: #{e.message}" + puts "Raw CDATA content:" + puts cdata_content + end + end + + # Extract the correct answers + correct_answers = doc.xpath('//correctAnswer') + if !correct_answers.empty? + puts "\nCorrect Answers:" + correct_answers.each_with_index do |answer, index| + puts "Answer #{index + 1}:" + if !answer.children.empty? && answer.children.first.cdata? + begin + answer_content = Nokogiri::XML(answer.children.first.text) { |config| config.noblanks } + puts answer_content.to_xml + rescue => e + puts "Error parsing answer CDATA: #{e.message}" + puts answer.children.first.text + end + else + pp answer.content + end + end + end + + # Extract assertions if available + assertions = doc.xpath('//assertion') + if !assertions.empty? + puts "\nAssertions:" + assertions.each do |assertion| + puts "- #{assertion['name']}" + assertion.xpath('./param').each do |param| + param_content = param.children.first.cdata? ? param.children.first.text : param.content + puts " #{param['name']}: #{param_content}" + end + end + end + + # Extract options if available + options = doc.xpath('//option') + if !options.empty? + puts "\nOptions:" + options.each do |option| + puts "- #{option['name']}: #{option.content}" + end + end + + # Print the entire document structure in a more readable format + puts "\nComplete XML Structure:" + puts doc.to_xml(indent: 2) + +rescue => e + puts "Error processing the XML: #{e.message}" + puts e.backtrace +end + diff --git a/lib/moodle2aa.rb b/lib/moodle2aa.rb index e95ffe5..685309d 100644 --- a/lib/moodle2aa.rb +++ b/lib/moodle2aa.rb @@ -31,6 +31,8 @@ class OpenStruct < ::OpenStruct autoload :ResourceFactory, 'moodle2aa/resource_factory' + autoload :OutputLogger, 'moodle2aa/output_logger' + module CC autoload :Assessment, 'moodle2aa/cc/assessment' autoload :Assignment, 'moodle2aa/cc/assignment' diff --git a/lib/moodle2aa/canvas_cc/assessment_writer.rb b/lib/moodle2aa/canvas_cc/assessment_writer.rb index e8ef1b6..858b318 100644 --- a/lib/moodle2aa/canvas_cc/assessment_writer.rb +++ b/lib/moodle2aa/canvas_cc/assessment_writer.rb @@ -37,7 +37,7 @@ def write_assessment_meta_xml(assessment) end.to_xml file_path = File.join(@work_dir, assessment.meta_file_path) - FileUtils.mkdir_p(File.dirname(file_path)) unless File.exists?(File.dirname(file_path)) + FileUtils.mkdir_p(File.dirname(file_path)) unless File.exist?(File.dirname(file_path)) File.open(file_path, 'w') { |f| f.write(xml) } end @@ -75,8 +75,8 @@ def write_assessment_non_cc_qti_xml(assessment) end.to_xml file_path = File.join(@work_dir, assessment.qti_file_path) - FileUtils.mkdir_p(File.dirname(file_path)) unless File.exists?(File.dirname(file_path)) + FileUtils.mkdir_p(File.dirname(file_path)) unless File.exist?(File.dirname(file_path)) File.open(file_path, 'w') { |f| f.write(xml) } end end -end \ No newline at end of file +end diff --git a/lib/moodle2aa/canvas_cc/models/answer.rb b/lib/moodle2aa/canvas_cc/models/answer.rb index 223c613..bc02624 100644 --- a/lib/moodle2aa/canvas_cc/models/answer.rb +++ b/lib/moodle2aa/canvas_cc/models/answer.rb @@ -9,4 +9,4 @@ def initialize(answer=nil) end end end -end \ No newline at end of file +end diff --git a/lib/moodle2aa/canvas_cc/question_bank_writer.rb b/lib/moodle2aa/canvas_cc/question_bank_writer.rb index fe5e89d..0e1a978 100644 --- a/lib/moodle2aa/canvas_cc/question_bank_writer.rb +++ b/lib/moodle2aa/canvas_cc/question_bank_writer.rb @@ -37,8 +37,8 @@ def write_bank(bank) end.to_xml file_path = File.join(@work_dir, bank_resource.href) - FileUtils.mkdir_p(File.dirname(file_path)) unless File.exists?(File.dirname(file_path)) + FileUtils.mkdir_p(File.dirname(file_path)) unless File.exist?(File.dirname(file_path)) File.open(file_path, 'w') { |f| f.write(xml) } end end -end \ No newline at end of file +end diff --git a/lib/moodle2aa/cli.rb b/lib/moodle2aa/cli.rb index a0b437c..36fb331 100644 --- a/lib/moodle2aa/cli.rb +++ b/lib/moodle2aa/cli.rb @@ -8,10 +8,11 @@ def self.shared_options method_option :format, :type => :string, :default => 'cc' method_option :generate_archive, :type => :boolean, :default => true method_option :generate_report, :type => :boolean, :default => false - method_option :version, :type => :string, :default => '' + method_option :version, :type => :string, :default => '' method_option :convert_unknown_qtypes, :type => :boolean, :default => true method_option :convert_unknown_activities, :type => :boolean, :default => true - method_option :group_by_quiz_page, :type => :boolean, :default => true + method_option :group_by_quiz_page, :type => :boolean, :default => true + method_option :unused_question_mode, :type => :string, :default => 'keep', :enum => ['exclude', 'keep', 'only'] end desc "migrate MOODLE_BACKUP_ZIP EXPORT_DIR", "Migrates Moodle backup ZIP to IMS Common Cartridge package" @@ -59,16 +60,16 @@ def bulkmigrate(*sources, destination) next unless File.directory? source_folder numbackups += Pathname.new(source_folder).children.select { |child| child.directory? }.size end - bar = ProgressBar.new(numbackups) + # bar = ProgressBar.new(numbackups) sources.each do |source_folder| next unless File.directory? source_folder - Pathname.new(source_folder).children.select { |child| child.directory? }.collect { |backup| + Pathname.new(source_folder).children.collect { |backup| puts "Converting #{backup}" migrator = Moodle2AA::Migrator.new backup, destination, options migrator.migrate #puts "#{backup} converted to #{migrator.imscc_path}" if options[:generate_archive] - bar.increment! + # bar.increment! } end end diff --git a/lib/moodle2aa/learnosity/converters.rb b/lib/moodle2aa/learnosity/converters.rb index abe599e..c2dfc99 100644 --- a/lib/moodle2aa/learnosity/converters.rb +++ b/lib/moodle2aa/learnosity/converters.rb @@ -22,5 +22,11 @@ module Converters require_relative 'converters/html_converter' require_relative 'converters/file_converter' require_relative 'converters/gapselect_converter' + + module Wiris + require_relative 'converters/wiris/wiris_converter' + require_relative 'converters/wiris/shortanswerwiris_converter' + require_relative 'converters/wiris/multianswerwiris_converter' + end end end diff --git a/lib/moodle2aa/learnosity/converters/assignment_converter.rb b/lib/moodle2aa/learnosity/converters/assignment_converter.rb index 4b85e79..b349c38 100644 --- a/lib/moodle2aa/learnosity/converters/assignment_converter.rb +++ b/lib/moodle2aa/learnosity/converters/assignment_converter.rb @@ -16,8 +16,10 @@ def convert(moodle_quiz, question_categories) activity.data.config.regions = "main" activity.data.config.navigation.show_intro = true activity.data.config.navigation.show_outro = true - activity.data.config.title = moodle_quiz.name - activity.title = moodle_quiz.name + title = moodle_quiz.name + max_length = [title.length, 149].min + activity.data.config.title = title[0..max_length] + activity.title = title[0..max_length] source = moodle_quiz_url(moodle_quiz) # TODO: handle quiz intro activity.data.items, question_random_usages = resolve_item_references(moodle_quiz.question_instances, question_categories, moodle_quiz) @@ -26,7 +28,9 @@ def convert(moodle_quiz, question_categories) else activity.tags[IMPORT_STATUS_TAG_TYPE] = [IMPORT_STATUS_PARTIAL] end - + + activity.tags['Moodle Course'] = [@moodle_course.shortname] + activity.description = "Moodle source url: #{source}\n" if question_random_usages.count > 0 activity.description += "\n\nRandomization parameters:\n" @@ -37,7 +41,7 @@ def convert(moodle_quiz, question_categories) end private - + def moodle_quiz_url(moodle_quiz) "#{@moodle_course.url}/mod/quiz/view.php?q=#{moodle_quiz.id}" end @@ -72,7 +76,7 @@ def resolve_item_references(question_instances, question_categories, moodle_quiz newquestions = [] newquestions += cat.questions.select {|q| q.qtype!='random' && q.qtype!='description'} #(cat.questions.select {|q| q.type!='random'}).each {|q| puts "#{moodle_quiz.name},#{q.qtype}"} - if recurse + if recurse # find questions in child categories too catids = [cat.id] done = false @@ -80,7 +84,7 @@ def resolve_item_references(question_instances, question_categories, moodle_quiz new_cats = question_categories.select { |c| catids.include?(c.parent) && !catids.include?(c.id) } # add all questions in new_cats if new_cats.count > 0 - new_cats.each do |c| + new_cats.each do |c| newquestions += c.questions.select {|q| q.qtype!='random' && q.qtype!='description'} #(c.questions.select {|q| q.type!='random'}).each {|q| puts "#{moodle_quiz.name},#{q.qtype}"} end @@ -90,7 +94,7 @@ def resolve_item_references(question_instances, question_categories, moodle_quiz end end # add special tags unique to this recursive group. Once random questions - # work in learnosity, we'll configure the quiz to select from this group. + # work in learnosity, we'll configure the quiz to select from this group. # TODO: generate unique tag for hierarchy # TODO: finish random questions else diff --git a/lib/moodle2aa/learnosity/converters/calculated_converter.rb b/lib/moodle2aa/learnosity/converters/calculated_converter.rb index 0ef45d3..786cd84 100644 --- a/lib/moodle2aa/learnosity/converters/calculated_converter.rb +++ b/lib/moodle2aa/learnosity/converters/calculated_converter.rb @@ -54,7 +54,7 @@ def convert_question(moodle_question) de4ad4e9-52e9-3b38-e8c9-25d6ea22fbf3_1 ed8482fc-eedd-fe3f-ef44-ba32d77e5f57_1 ) -# if tofix.include? item.reference +# if tofix.include? item.reference # dump = expr_converter.dump_csv # print "===START===\n"+@moodle_course.fullname+"\n"+item.reference+" - "+moodle_question.name+"\n"+dump+"\n===END===\n" # end @@ -74,18 +74,22 @@ def convert_calculated_question(moodle_question, expr_converter) end question.scale_score(moodle_question.default_mark) + puts '------------------------------' + puts expr_converter.generate_data_table_engine_script + puts '------------------------------' set_penalty_options(question, moodle_question) - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, import_status: import_status, questions: [question], dynamic_content_data: expr_converter.generate_dynamic_content_data, + data_table_script: expr_converter.generate_data_table_engine_script, todo: todo) #if expr_converter.has_truncated_rows? #puts "TRUNCATED_DATASET: #{item.reference}: '#{moodle_question.name}'" #end return item, [question] end - + def convert_calculated_subquestion(moodle_question, reference, expr_converter) question = Moodle2AA::Learnosity::Models::Question.new question.reference = reference @@ -95,7 +99,7 @@ def convert_calculated_subquestion(moodle_question, reference, expr_converter) import_status = IMPORT_STATUS_COMPLETE data = question.data - + data[:stimulus] = convert_question_text moodle_question data[:stimulus] = convert_calculated_text data[:stimulus], expr_converter, moodle_question @@ -103,22 +107,22 @@ def convert_calculated_subquestion(moodle_question, reference, expr_converter) question.type = data[:type] = "formulaV2" data[:ui_style] = {type: "no-input-ui"} data[:is_math] = true - + validation = data[:validation] = {} validation[:scoring_type] = "exactMatch" validation[:alt_responses] = [] - + correct_feedback = [] incorrect_feedback = [] numcorrect = 0 moodle_question.all_answers.each do |answer| # TODO: numerical per-response feedback answer_text = convert_calculated_answer answer.answer_text, expr_converter, moodle_question - options = moodle_question.all_options[answer.id.to_i] + options = moodle_question.all_options[answer.id.to_i] || {} decimal_places = 10 if options[:tolerance].to_f != 0 case options[:tolerancetype].to_i - when RELATIVE_ERROR + when RELATIVE_ERROR answer_text = "#{answer_text} \\pm #{options[:tolerance]}*(#{answer_text})" when NOMINAL_ERROR answer_text += " \\pm #{options[:tolerance]}" @@ -174,18 +178,18 @@ def convert_calculated_subquestion(moodle_question, reference, expr_converter) todo << "Check per response feedback" import_status = IMPORT_STATUS_PARTIAL end - + if error = expr_converter.get_error import_status = IMPORT_STATUS_MANUAL todo << "Check formula error" notes << error end - + expressionnotes = render_expression_variables expr_converter.get_expression_variables if expressionnotes notes << expressionnotes end - + # add equestion information data[:instructor_stimulus] = render_conversion_notes(notes) @@ -197,11 +201,11 @@ def convert_calculated_format_subquestion(moodle_question, reference, expr_conve question.reference = reference notes = [] todo = [] - + import_status = IMPORT_STATUS_COMPLETE data = question.data - + data[:stimulus] = convert_question_text moodle_question data[:stimulus] = convert_calculated_text data[:stimulus], expr_converter, moodle_question @@ -210,7 +214,7 @@ def convert_calculated_format_subquestion(moodle_question, reference, expr_conve question.type = data[:type] = "shorttext" data[:ui_style] = {type: "no-input-ui"} data[:is_math] = true - + validation = data[:validation] = {} validation[:scoring_type] = "exactMatch" validation[:alt_responses] = [] @@ -219,7 +223,7 @@ def convert_calculated_format_subquestion(moodle_question, reference, expr_conve #import_status = IMPORT_STATUS_PARTIAL #notes << "Question may allow answers of varying length, for example with or without leading zeros. Short answer conversion doesn't allow this." #end - + correct_feedback = [] incorrect_feedback = [] numcorrect = 0 @@ -258,7 +262,7 @@ def convert_calculated_format_subquestion(moodle_question, reference, expr_conve end end end - + data[:metadata].merge!(convert_feedback( moodle_question )) if data[:metadata][:general_feedback] data[:metadata][:general_feedback] = convert_calculated_text data[:metadata][:general_feedback], expr_converter, moodle_question @@ -278,7 +282,7 @@ def convert_calculated_format_subquestion(moodle_question, reference, expr_conve todo << "Check per response feedback" import_status = IMPORT_STATUS_PARTIAL end - + if error = expr_converter.get_error import_status = IMPORT_STATUS_MANUAL todo << "Check formula error" @@ -290,13 +294,13 @@ def convert_calculated_format_subquestion(moodle_question, reference, expr_conve if expressionnotes notes << expressionnotes end - + data[:instructor_stimulus] = render_conversion_notes(notes) return question, import_status, todo end - + def convert_calculated_multi_subquestion(moodle_question, reference, expr_converter) question = Moodle2AA::Learnosity::Models::Question.new question.reference = reference @@ -306,25 +310,25 @@ def convert_calculated_multi_subquestion(moodle_question, reference, expr_conver import_status = IMPORT_STATUS_PARTIAL data = question.data - + data[:stimulus] = convert_question_text moodle_question data[:stimulus] = convert_calculated_text data[:stimulus], expr_converter, moodle_question data[:instantfeedback] = true question.type = data[:type] = "mcq" - + data[:multiple_responses] = !moodle_question.single data[:ui_style] = {type: "horizontal"} data[:is_math] = true - + validation = data[:validation] = {} validation[:scoring_type] = "exactMatch" validation[:alt_responses] = [] data[:shuffle_options] = moodle_question.shuffleanswers options = data[:options] = [] - + data[:metadata] = {} rationale = data[:metadata][:distractor_rationale_response_level] = [] @@ -365,7 +369,7 @@ def convert_calculated_multi_subquestion(moodle_question, reference, expr_conver end options.each { |option| data[:is_math] ||= has_math?(option[:label]) } rationale.each { |feedback| data[:is_math] ||= has_math?(feedback) } - + data[:metadata].merge!(convert_feedback( moodle_question )) if data[:metadata][:general_feedback] data[:metadata][:general_feedback] = convert_calculated_text data[:metadata][:general_feedback], expr_converter, moodle_question @@ -386,12 +390,12 @@ def convert_calculated_multi_subquestion(moodle_question, reference, expr_conver notes << error todo << "Check formula error" end - + expressionnotes = render_expression_variables expr_converter.get_expression_variables if expressionnotes notes << expressionnotes end - + # add equestion information data[:instructor_stimulus] = render_conversion_notes(notes) @@ -445,13 +449,14 @@ def convert_calculated_question_group(moodle_question, expr_converter) if expr_converter.has_shuffled_vars? extra_tags['TODO'] = ['Check merged datatable'] end - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, import_status: group_import_status, content: content, extra_tags: extra_tags, dynamic_content_data: expr_converter.generate_dynamic_content_data, + data_table_script: expr_converter.generate_data_table_engine_script, todo: todo) - + #if expr_converter.has_truncated_rows? #puts "TRUNCATED_DATASET: #{item.reference}: '#{moodle_question.name}'" #end @@ -459,17 +464,17 @@ def convert_calculated_question_group(moodle_question, expr_converter) end - # Convert text with embedded variables and expressions + # Convert text with embedded variables and expressions def convert_calculated_text(text, expr_converter, moodle_question) substitute_embedded_expressions(text, expr_converter, moodle_question) end - + # Convert answer text def convert_calculated_answer(text, expr_converter, moodle_question) as_expr, as_var = expr_converter.convert_answer(text, nil, moodle_question) as_var # use the variable by default end - + def convert_calculated_format_answer(text, expr_converter, moodle_question) out = [] opts = moodle_question.calculatedformat_options @@ -497,7 +502,7 @@ def convert_calculated_format_answer(text, expr_converter, moodle_question) else '' end - + format = "%"+separator+lengthint+'.'+lengthfrac+base as_expr, as_var = expr_converter.convert_answer(text, format, moodle_question) diff --git a/lib/moodle2aa/learnosity/converters/converter_helper.rb b/lib/moodle2aa/learnosity/converters/converter_helper.rb index a929f89..124aeb4 100644 --- a/lib/moodle2aa/learnosity/converters/converter_helper.rb +++ b/lib/moodle2aa/learnosity/converters/converter_helper.rb @@ -1,6 +1,6 @@ module Moodle2AA module Learnosity::Converters::ConverterHelper - + # Migration status values, for "Migration status" tag IMPORT_STATUS_TAG_TYPE = "Import status" IMPORT_STATUS_BAD = "Incomplete" @@ -10,10 +10,13 @@ module Learnosity::Converters::ConverterHelper MIGRATION_STATUS_TAG_TYPE = "Migration status" MIGRATION_STATUS_INITIAL = "Not started" - + MOODLE_QUESTION_TYPE_TAG_TYPE = "Moodle question type" CATEGORY_TAG_TYPE = "category" + DATA_TABLE_SCRIPT_TAG_TYPE = "other" + DATA_TABLE_SCRIPT_TAG_NAME = "data_table_script" + def generate_unique_resource_path(base_path, readable_name, file_extension = 'html') @@ -97,14 +100,14 @@ def html_non_empty?(text) # get single tag for category def get_tag_for_category(category, categories, recursive) - cats = get_parent_categories(category, categories) + cats = get_parent_categories(category, categories) format_category_tag(cats, recursive) - end + end # get all tags for questions in a category. Includes the category tag, # along with any needed by recursive random questions. def get_tags_for_category(category, categories) - cats = get_parent_categories(category, categories) + cats = get_parent_categories(category, categories) # join into a single tag tags = [format_category_tag(cats, false)] # look for any recursive random questions @@ -118,7 +121,7 @@ def get_tags_for_category(category, categories) end is_random_category = needs_extra_tag || category.questions.find { |q| q.type == "random" } return tags, is_random_category - end + end def format_category_tag(cats, recursive) # remove "default for" components diff --git a/lib/moodle2aa/learnosity/converters/essay_converter.rb b/lib/moodle2aa/learnosity/converters/essay_converter.rb index 0d99205..ab6f651 100644 --- a/lib/moodle2aa/learnosity/converters/essay_converter.rb +++ b/lib/moodle2aa/learnosity/converters/essay_converter.rb @@ -1,6 +1,7 @@ module Moodle2AA::Learnosity::Converters class EssayConverter < QuestionConverter register_converter_type 'essay' + register_converter_type 'essaywiris' def convert_question(moodle_question) @@ -37,7 +38,7 @@ def convert_question(moodle_question) data['show_word_limit'] = "off" data['show_word_count'] = false - data['ui_style'] = { + data['ui_style'] = { min_height: "#{moodle_question.responsefieldlines.to_i*20}px" # ~ 20 px per line } data[:validation] = { @@ -55,7 +56,7 @@ def convert_question(moodle_question) question.scale_score(moodle_question.default_mark) questions << question - + if moodle_question.attachments.to_i != 0 # add file upload question question = Moodle2AA::Learnosity::Models::Question.new @@ -80,18 +81,18 @@ def convert_question(moodle_question) data[:allow_ms_word] = true data[:allow_ms_excel] = true data[:allow_open_office] = true - + data[:validation] = { max_score: 0 # I guess let the essay component have the score } questions << question end - + if moodle_question.responseformat == "noinline" && questions.count == 2 # noinline means no essay, so copy the important parts # and remove the essay - + upload = questions[1] essay = questions[0] @@ -105,7 +106,7 @@ def convert_question(moodle_question) # no penalties / multiple tries #set_penalty_options(question, moodle_question) questions.each {|question| add_instructor_stimulus(question, moodle_question) } - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, import_status: IMPORT_STATUS_COMPLETE, questions: questions) return item, questions diff --git a/lib/moodle2aa/learnosity/converters/expression_converter.rb b/lib/moodle2aa/learnosity/converters/expression_converter.rb index 5e646f7..0207cc7 100644 --- a/lib/moodle2aa/learnosity/converters/expression_converter.rb +++ b/lib/moodle2aa/learnosity/converters/expression_converter.rb @@ -1,3 +1,6 @@ +require 'byebug' +require_relative "../data_table_engine_script_generator" + module Moodle2AA::Learnosity::Converters class ExpressionConverter include ConverterHelper @@ -7,17 +10,20 @@ def initialize(moodle_question, moodle_course, html_converter) @moodle_course = moodle_course @html_converter = html_converter @expressions = {} - + + @dte_converter = Moodle2AA::Learnosity::DataTableEngineScriptGenerator.new + # load all variables from question definition collect_vars(moodle_question) normalize_vars shuffle_vars + populate_data_table_engine #pp @vars end # update @vars with moodle_question variables def collect_vars(moodle_question) - if moodle_question.type == 'calculatedquestiongroup' || + if moodle_question.type == 'calculatedquestiongroup' || moodle_question.type == 'quizpage' moodle_question.questions.each do |subquestion| collect_vars(subquestion) @@ -68,7 +74,7 @@ def collect_vars(moodle_question) # remove empty variables @vars = @vars.select { |set| set[:values].count != 0 } end - + def normalize_vars # make sure all vars have the same number of values maxcount = (@vars.map { |set| set[:values].count }).max mincount = (@vars.map { |set| set[:values].count }).min @@ -99,7 +105,7 @@ def shuffle_vars # shuffle vars so different groups aren't synchronized by accid groups.each do |group| matching = @vars.select {|set| set[:group] == group} - + seed = (rng.rand*1000000).to_i matching.each do |set| # deterministic shuffle @@ -109,6 +115,12 @@ def shuffle_vars # shuffle vars so different groups aren't synchronized by accid end + def populate_data_table_engine + @vars.each do |set| + @dte_converter.add_dataset(set[:output_name], set[:values]) + end + end + def convert_expression(expr, moodle_question) expr = expr.gsub(/&/, '&') expr = expr.gsub(/>/, '>') @@ -127,14 +139,14 @@ def convert_expression(expr, moodle_question) as_expr = as_var = expr warn "Not a moodle expression: #{expr}" end - #print " Converted #{expr} -> #{as_expr} || #{as_var}\n" + # puts " Converted #{expr} -> #{as_expr} || #{as_var}\n" [as_expr, as_var] # as an expression ans as a single (synthetic) variable end def convert_answer(expr, format, moodle_question) convert_formula expr, format, moodle_question, 'ans' end - + # convert a single variable, e.g. {A} def convert_variable var, moodle_question @@ -147,6 +159,7 @@ def convert_variable var, moodle_question end out end + def get_expression_variables @expressions end @@ -163,7 +176,7 @@ def convert_formula expr, format, moodle_question, type as_expr = convert_formula_as_formula expr, format, moodle_question as_var = convert_formula_as_variable expr, format, moodle_question, type out = [as_expr, as_var] - + if m = as_var.match(/^\{\{var:(.*)\}\}$/) raw_var = m[1] else @@ -200,6 +213,7 @@ def convert_formula_as_variable(expr, format, moodle_question, type) } #binding.pry if 50458 == moodle_question.id.to_i calculate_data_values expr, format, set, moodle_question + @dte_converter.add_answer(output_name, expr) # see if this matches any other calculated column and reuse if so #@vars.select {|oldset| oldset[:output_name].match(/^#{type}/)}.each do |oldset| # if set[:values].count > 1 && set[:values] == oldset[:values] @@ -215,7 +229,7 @@ def convert_formula_as_variable(expr, format, moodle_question, type) end def calculate_data_values(expr, format, set, moodle_question) - #print "Evaluating #{expr}\n" + # print "Evaluating #{expr}\n" # number of variants = maximum number of values over all variables maxcount = (@vars.map { |set| set[:values].count }).max maxcount ||= 0 @@ -226,14 +240,14 @@ def calculate_data_values(expr, format, set, moodle_question) evalobj.add_variable(var[:name], var[:values]) end end - + Range.new(0,maxcount-1).each do |variant| evalobj.set_variant variant set[:values][variant] = evalobj.evaluate expr, format end #pp set[:values] end - + # Convert an expression from Moodle to Learnosity syntax. def formula_to_learnosity(expr, format) # TODO this isn't complete @@ -270,13 +284,13 @@ def has_nan? def has_truncated_rows? @truncated_rows end - + def generate_dynamic_content_data # number of variants = maximum number of values over all variables maxcount = (@vars.map { |set| set[:values].count }).max return '' if maxcount == 0 || !maxcount - + unique = [] @vars.each do |var| unique << var unless unique.detect {|var2| var2[:output_name] == var[:output_name]} @@ -293,10 +307,14 @@ def generate_dynamic_content_data data end + def generate_data_table_engine_script + @dte_converter.generate + end + def dump_variables return '' if @vars.count == 0 out = [] - if has_shuffled_vars? + if has_shuffled_vars? out << "SHUFFLED Dataset:" end out << (@vars.map {|v| v[:datasetid]?v[:output_name]:v[:output_name]+"="+v[:name]}).join(",").gsub(/[{}]/,'') @@ -307,7 +325,7 @@ def dump_variables end out.join "\n" end - + def dump_csv return '' if @vars.count == 0 CSV.generate do |csv| diff --git a/lib/moodle2aa/learnosity/converters/file_converter.rb b/lib/moodle2aa/learnosity/converters/file_converter.rb index 25f970a..a54cd59 100644 --- a/lib/moodle2aa/learnosity/converters/file_converter.rb +++ b/lib/moodle2aa/learnosity/converters/file_converter.rb @@ -1,7 +1,7 @@ module Moodle2AA::Learnosity::Converters class FileConverter include ConverterHelper - + def initialize(moodle_course) @moodle_course = moodle_course end diff --git a/lib/moodle2aa/learnosity/converters/html_converter.rb b/lib/moodle2aa/learnosity/converters/html_converter.rb index dafeb89..7671706 100644 --- a/lib/moodle2aa/learnosity/converters/html_converter.rb +++ b/lib/moodle2aa/learnosity/converters/html_converter.rb @@ -2,7 +2,7 @@ module Moodle2AA::Learnosity::Converters class HtmlConverter include ConverterHelper - WEB_CONTENT_TOKEN = "__BASE_URI__" + WEB_CONTENT_TOKEN = "___EXPORT_ROOT___/assets/" def initialize(learnosity_files, moodle_course) @moodle_course = moodle_course @@ -26,9 +26,9 @@ def fix_unicode(content) #content = content.gsub("@", '@'); content end - + def fix_html(content) - # learnosity converts font-weight: bold to a nested , which messes up + # learnosity converts font-weight: bold to a nested , which messes up # table formatting in ece 252 content = content.gsub(/(]*)font-weight:\s*bold/, "\\1font-weight: 700") # remove text-align on tables, as it breaks the WYSIWYG editor @@ -103,7 +103,7 @@ def lookup_cc_file(link, component, file_area, item_id) end def file_link(moodle_path, component=nil, file_area=nil, item_id=nil) - moodle_path = CGI::unescape(moodle_path) + moodle_path = CGI::unescape(URI(moodle_path).path) cc_file = @learnosity_files.find do |file| result = moodle_path == file.file_path if component diff --git a/lib/moodle2aa/learnosity/converters/match_converter.rb b/lib/moodle2aa/learnosity/converters/match_converter.rb index c0ffb37..751049c 100644 --- a/lib/moodle2aa/learnosity/converters/match_converter.rb +++ b/lib/moodle2aa/learnosity/converters/match_converter.rb @@ -1,6 +1,7 @@ module Moodle2AA::Learnosity::Converters class MatchConverter < QuestionConverter register_converter_type 'match' + register_converter_type 'matchwiris' def convert_question(moodle_question) @@ -13,7 +14,7 @@ def convert_question(moodle_question) data[:is_math] = has_math?(data[:stimulus]) data[:instantfeedback] = true question.type = data[:type] = "association" - + data[:duplicate_responses] = true # moodle always allows this validation = data[:validation] = {} @@ -71,7 +72,7 @@ def convert_question(moodle_question) question.scale_score(moodle_question.default_mark) set_penalty_options(question, moodle_question) add_instructor_stimulus(question, moodle_question) - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, import_status: IMPORT_STATUS_COMPLETE, questions: [question]) return item, [question] diff --git a/lib/moodle2aa/learnosity/converters/moodle_eval.rb b/lib/moodle2aa/learnosity/converters/moodle_eval.rb index 7d5b51e..9664890 100644 --- a/lib/moodle2aa/learnosity/converters/moodle_eval.rb +++ b/lib/moodle2aa/learnosity/converters/moodle_eval.rb @@ -1,5 +1,5 @@ # bitwise ops on floats, like in php :) -class Float +class Float def &(b) (self.to_i & b.to_i).to_f end @@ -59,7 +59,7 @@ def _replace_vars(expr) end def _fix_expression(expr) - + # deal with php falsy values and ? : out = expr i = 0 @@ -68,7 +68,7 @@ def _fix_expression(expr) if out[i] == '?' j = i-1 level = 0 - while j >=0 + while j >=0 if out[j] == ')' level += 1 elsif out[j] == '(' @@ -90,17 +90,17 @@ def _fix_expression(expr) # very specific fix for 252 handle (v1) && (v2) out = out.gsub(/(\(v[0-9]+\)) *(\&\&|\|\|) *(\(v[0-9]+\))/, "(\\1 != 0) \\2 (\\3 != 0)") - + out = out.gsub(/\s+\(/, "(") #remove whitespace before parens, it's not needed and can confuse ruby # add .0 to all integers to force float conversion out = out.gsub(/([^.0-9a-zA-Z_]|^)([0-9]+)((?=[^.0-9x])|$)/, "\\1\\2.0") #remove whitespace before parens, it's not needed and can confuse ruby - out = out.gsub(/([0-9]+[eE]-?[0-9]+)[.]0/, "\\1") #Oops, need to fix anything like 3.4e-4.0 + out = out.gsub(/([0-9]+[eE]-?[0-9]+)[.]0/, "\\1") #Oops, need to fix anything like 3.4e-4.0 # decimal .1 needs leading 0 out = out.gsub(/([^0-9]|^)[.]([0-9])/, "\\1 0.\\2") #print "CHECK #{expr} : #{old}\n" if expr != old - + out - # All ? needs a space in ruby + # All ? needs a space in ruby #out = out.gsub(/\?/, " ? ") end @@ -114,14 +114,12 @@ def to_boolean(a) end def evaluate(expr,format) - expr = _replace_vars expr expr = _fix_expression expr #puts "Evaluate #{expr} => #{new}" # We should really do some sanitation, but leaving as an eval for now - - + result = nil begin self.instance_eval "result=(#{expr})" @@ -168,48 +166,48 @@ def evaluate(expr,format) result end - - def format_by_fmt(fmt, x) + + def format_by_fmt(fmt, x) groupre = '(?:[,_])?' regex = /^%([pP]?)((?:[,_])?)(\d*)(?:\.(\d+))?([bodxBODX])$/ - #if (preg_match(regex, fmt, regs)) + #if (preg_match(regex, fmt, regs)) if (m = regex.match(fmt)) #list(fullmatch, showprefix, group, lengthint, lengthfrac, basestr) = regs fullmatch, showprefix, group, lengthint, lengthfrac, basestr = m[0], m[1], m[2], m[3], m[4], m[5] base = 0 basestr = strtolower(basestr) - if (basestr == 'b') + if (basestr == 'b') base = 2 - elsif (basestr == 'o') + elsif (basestr == 'o') base = 8 - elsif (basestr == 'd') + elsif (basestr == 'd') base = 10 - elsif (basestr == 'x') + elsif (basestr == 'x') base = 16 - else + else raise "coding error" end lengthint = intval(lengthint) lengthfrac = intval(lengthfrac) - if (group == ',') + if (group == ',') groupdigits = 3 - elsif (group == '_') + elsif (group == '_') groupdigits = 4 - else + else groupdigits = 0 end showprefix = strtolower(showprefix) - if (showprefix == 'p') + if (showprefix == 'p') showprefix = true - else + else showprefix = false end @@ -222,10 +220,10 @@ def format_by_fmt(fmt, x) #// Not a valid format. end - + def qtype_calculatedformat_format_in_base( x, base=10, lengthint=1, lengthfrac=0, groupdigits=0, showprefix=false ) digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" - + masklengthint = lengthint answer = x @@ -249,82 +247,82 @@ def qtype_calculatedformat_format_in_base( x, base=10, lengthint=1, lengthfrac=0 end end - if (base == 2) + if (base == 2) x = sprintf('%0' + (lengthint + lengthfrac).to_s + 'b', answer) - elsif (base == 8) + elsif (base == 8) x = sprintf('%0' + (lengthint + lengthfrac).to_s + 'o', answer) - elsif (base == 16) + elsif (base == 16) x = sprintf('%0' + (lengthint + lengthfrac).to_s + 'X', answer) - else + else width = lengthint - if (lengthfrac > 0) + if (lengthfrac > 0) #// Include fractional digits and decimal point. width += lengthfrac + 1 end x = sprintf('%0' + width.to_s + '.' + lengthfrac.to_s + 'f', answer) end - if (base != 10) + if (base != 10) #// Insert radix point if there are fractional digits. - if (lengthfrac > 0) + if (lengthfrac > 0) #x = substr_replace(x, '.', -lengthfrac, 0) x = x.insert(-(lengthfrac+1), '.') end end - if ((base == 2) || (base == 8) || (base == 16)) - if (masklengthint < 1) + if ((base == 2) || (base == 8) || (base == 16)) + if (masklengthint < 1) #// Strip leading zeros. #x = ltrim(x, '0') x = x.gsub(/^0+/, '') - if (strlen(x) < 1) + if (strlen(x) < 1) x = '0' - elsif (x[0] == '.') + elsif (x[0] == '.') x = '0' . x end end end - if (groupdigits > 0) + if (groupdigits > 0) #parts = explode('.', x, 2) parts = x.split('.', 2) - if (parts.count > 1) + if (parts.count > 1) integer = parts[0] fraction = parts[1] - else + else integer = x fraction = '' end #// Add group separator(s). nextgrouppos = strlen(integer) - groupdigits - while (nextgrouppos > 0) + while (nextgrouppos > 0) #integer = substr_replace(integer, '_', nextgrouppos, 0) integer = integer.insert(nextgrouppos, '_') nextgrouppos -= groupdigits end - if (strlen(fraction) > 0) + if (strlen(fraction) > 0) x = integer + '.' + fraction - else + else x = integer end end prefix = '' - if (showprefix) - if (base == 2) + if (showprefix) + if (base == 2) prefix = '0b' - elsif (base == 8) + elsif (base == 8) prefix = '0o' - elsif (base == 10) + elsif (base == 10) prefix = '0d' - elsif (base == 16) + elsif (base == 16) prefix = '0x' end end @@ -332,13 +330,13 @@ def qtype_calculatedformat_format_in_base( x, base=10, lengthint=1, lengthfrac=0 return sign + prefix + x end - def qtype_calculatedformat_mask_value(x, base, lengthint, lengthfrac) - if ((base != 2) && (base != 8) && (base != 16)) + def qtype_calculatedformat_mask_value(x, base, lengthint, lengthfrac) + if ((base != 2) && (base != 8) && (base != 16)) raise "Illegal base" end numbits = 0; - #for (mask = 1; mask < base; mask <<= 1) + #for (mask = 1; mask < base; mask <<= 1) # numbits++; #end mask = 1 @@ -347,7 +345,7 @@ def qtype_calculatedformat_mask_value(x, base, lengthint, lengthfrac) mask = mask << 1 end - if (lengthint < 1) + if (lengthint < 1) return x end @@ -361,7 +359,7 @@ def qtype_calculatedformat_mask_value(x, base, lengthint, lengthfrac) #// Construct mask with exact bit length. mask = 0 - #for (i = 0; i < numbits; i++) + #for (i = 0; i < numbits; i++) # mask <<= 1; # mask |= 1; #end @@ -383,6 +381,9 @@ def qtype_calculatedformat_mask_value(x, base, lengthint, lengthfrac) #### php/moodle functions for use in expressions #### + def exp(*args) Math.exp(*args) end + def Exp(*args) Math.exp(*args) end + def max(*args) args.max end def min(*args) args.min end @@ -427,10 +428,10 @@ def rad2deg(a) a*180/pi() end def is_finite(a) (a.to_f).finite? end def is_infinite(a) (a.to_f).infinite? end def is_nan(a) (a.to_f).nan? end - + def strlen(a) (a.to_s).length end def strtolower(a) (a.to_s).downcase end - + #def rand(a) Math.abs(a) end end diff --git a/lib/moodle2aa/learnosity/converters/multianswer_converter.rb b/lib/moodle2aa/learnosity/converters/multianswer_converter.rb index f7a5b7a..883ccca 100644 --- a/lib/moodle2aa/learnosity/converters/multianswer_converter.rb +++ b/lib/moodle2aa/learnosity/converters/multianswer_converter.rb @@ -1,3 +1,5 @@ +require 'byebug' + module Moodle2AA::Learnosity::Converters class MultiAnswerConverter < QuestionConverter register_converter_type 'multianswer' @@ -14,7 +16,7 @@ def convert_question(moodle_question) question_text = convert_question_text moodle_question - while true + while true if embedded_questions.count == 0 # malformed question. Gaps courses don't have any of these. abort "Multi answer with no subquestions??" @@ -35,7 +37,7 @@ def convert_question(moodle_question) # cloze questions into multiple questions. validation[:rounding] = "none" validation[:valid_response] = {score: 0, value: []} - + currenttype = nil while subquestion = embedded_questions[0] currenttype ||= embedded_questions[0].class @@ -54,9 +56,9 @@ def convert_question(moodle_question) abort "missing cloze marker??" if !after data[:template] += before+"{{response}}" - question_text = after + question_text = after - case + case when currenttype == Moodle2AA::Moodle2::Models::Quizzes::MultichoiceQuestion question.type = data[:type] = "clozedropdown" data[:case_sensitive] = true @@ -68,16 +70,15 @@ def convert_question(moodle_question) data[:possible_responses] << moodle_subquestion.answers.map {|a| convert_answer_text(a)} correct = moodle_subquestion.answers.select {|a| a.fraction.to_f == 1} validation[:valid_response][:value] << convert_answer_text(correct[0]) - + all = moodle_subquestion.answers.select {|a| a.fraction.to_f > 1} - if all.count > 1 + if all.count > 1 # We don't convert multiple answers automatically. It may be possible # manually. todo << "Check cloze conversion" notes << "This cloze question contained multiple correct answers in Moodle, some of which were not converted automatically." import_status = IMPORT_STATUS_MANUAL end - when currenttype == Moodle2AA::Moodle2::Models::Quizzes::ShortanswerQuestion question.type = data[:type] = "clozetext" data[:case_sensitive] = moodle_subquestion.casesensitive @@ -85,9 +86,9 @@ def convert_question(moodle_question) correct = moodle_subquestion.answers.select {|a| a.fraction.to_f == 1} validation[:valid_response][:value] << convert_answer_text(correct[0]) data[:max_length] = [15, convert_answer_text(correct[0]).length+1].min - + all = moodle_subquestion.answers.select {|a| a.fraction.to_f > 1} - if all.count > 1 + if all.count > 1 # We don't convert multiple answers automatically. It may be possible # manually. todo << "Check cloze conversion" @@ -106,9 +107,9 @@ def convert_question(moodle_question) options: { decimalPlaces: 10 } }] validation[:valid_response][:value] << value - + all = moodle_subquestion.answers.select {|a| a.fraction.to_f > 1} - if all.count > 1 + if all.count > 1 # We don't convert multiple answers automatically. It may be possible # manually. todo << "Check cloze conversion" @@ -119,16 +120,16 @@ def convert_question(moodle_question) abort "Unknown subquestion type" end end - + notes = notes.uniq data[:instructor_stimulus] = render_conversion_notes(notes) if embedded_questions.count == 0 # no parts remaining data[:template] += question_text # add whatever text is left - break + break end - + # otherwise must be a mixture of different answer elements, so loop to # create another question. @@ -147,7 +148,7 @@ def convert_question(moodle_question) questions.each {|question| question.scale_score(moodle_question.default_mark)} questions.each {|question| set_penalty_options(question, moodle_question) } questions.each {|question| add_instructor_stimulus(question, moodle_question) } - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, import_status: import_status, questions: questions, todo: todo) diff --git a/lib/moodle2aa/learnosity/converters/multichoice_converter.rb b/lib/moodle2aa/learnosity/converters/multichoice_converter.rb index 921908f..35bdfc5 100644 --- a/lib/moodle2aa/learnosity/converters/multichoice_converter.rb +++ b/lib/moodle2aa/learnosity/converters/multichoice_converter.rb @@ -1,6 +1,7 @@ module Moodle2AA::Learnosity::Converters class MultiChoiceConverter < QuestionConverter register_converter_type 'multichoice' + register_converter_type 'multichoicewiris' def convert_question(moodle_question) @@ -17,7 +18,7 @@ def convert_question(moodle_question) data[:ui_style] = {type: "horizontal"} data[:shuffle_options] = moodle_question.shuffle - + options = data[:options] = [] validation = data[:validation] = {} validation[:alt_responses] = [] @@ -62,7 +63,7 @@ def convert_question(moodle_question) question.scale_score(moodle_question.default_mark) set_penalty_options(question, moodle_question) add_instructor_stimulus(question, moodle_question) - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, import_status: IMPORT_STATUS_COMPLETE, questions: [question]) return item, [question] diff --git a/lib/moodle2aa/learnosity/converters/question_converter.rb b/lib/moodle2aa/learnosity/converters/question_converter.rb index 1084348..88c8ae0 100644 --- a/lib/moodle2aa/learnosity/converters/question_converter.rb +++ b/lib/moodle2aa/learnosity/converters/question_converter.rb @@ -1,3 +1,5 @@ +require "byebug" + module Moodle2AA::Learnosity::Converters class QuestionConverter include ConverterHelper @@ -20,12 +22,13 @@ def convert(moodle_question) if item return item, content end - + type = moodle_question.type if type && c = @@subclasses[type] item, content = c.new(@moodle_course, @html_converter).convert_question(moodle_question) else report_add_warn(moodle_question, LEARNING, "unknown_question_type=#{type}", "question/preview.php?id=#{moodle_question.id}&courseid=#{report_current_course_id}") + puts "Unknown question type: #{type}" # Pretend it's a description question and convert # TODO: convert unknown question types to something #raise "Unknown converter type: #{type}" if !Moodle2AA::MigrationReport.convert_unknown_qtypes? @@ -46,7 +49,7 @@ def convert_question(moodle_question) #question.comment = moodle_question.general_feedback #question.name = moodle_question.name #question.text = convert_question_text(moodle_question) - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, title: moodle_question.name, import_status: IMPORT_STATUS_COMPLETE, features: [feature]) @@ -87,47 +90,71 @@ def convert_question_stub(moodle_question, manual_conversion = false) question.data[:is_math] = has_math?(question.data[:stimulus]) set_penalty_options(question, moodle_question) add_instructor_stimulus(question, moodle_question) - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, title: name, import_status: manual_conversion ? IMPORT_STATUS_MANUAL : IMPORT_STATUS_BAD, questions: [question]) return item, [question] end - def create_item(moodle_question: , - title: nil, + def create_item(moodle_question: , + title: nil, import_status: , - notes: [], + notes: [], todo: [], features: [], questions: [], content: [], extra_tags: [], - dynamic_content_data: []) + dynamic_content_data: nil, + data_table_script: nil) + item = Moodle2AA::Learnosity::Models::Item.new + item.reference = generate_unique_identifier_for(moodle_question.id, '_item') item.status = 'published' - item.title = title || moodle_question.name + item.source = moodle_question_url(moodle_question) + item.metadata ||= {} + item.metadata[:moodle_question_id] = moodle_question.id + item.metadata[:moodle_question_type] = moodle_question.qtype + item.metadata[:moodle_question_url] = moodle_question_url(moodle_question) + + title = title || moodle_question.name + max_length = [149, title.length].min + item.title = title[0..max_length] item.tags[MIGRATION_STATUS_TAG_TYPE] = [MIGRATION_STATUS_INITIAL] item.tags[IMPORT_STATUS_TAG_TYPE] = [import_status] item.tags[MOODLE_QUESTION_TYPE_TAG_TYPE] = moodle_question_type_tag(moodle_question) + extra_tags.each do |type, value| item.tags[type] ||= [] item.tags[type] += value end + if todo && todo.length > 0 item.tags['TODO'] ||= [] item.tags['TODO'] += todo item.tags['TODO'] = item.tags['TODO'].uniq end + notes = notes.join("\n") if notes.is_a? Array item.note = notes - item.definition.template = "dynamic" - item.source = moodle_question_url(moodle_question) - item.dynamic_content_data = dynamic_content_data + + unless dynamic_content_data.nil? + item.definition.template = "dynamic" + item.dynamic_content_data = dynamic_content_data + end + + unless data_table_script.nil? + item.definition.template = "dynamic" + item.tags[DATA_TABLE_SCRIPT_TAG_TYPE] = [DATA_TABLE_SCRIPT_TAG_NAME] + item.source = data_table_script + end + content += features content += questions content.each {|f| add_content_to_item(item, f)} + item end @@ -150,7 +177,7 @@ def moodle_question_url(moodle_question, preview=false) questions = [moodle_question] end - if preview + if preview urls = questions.map {|q| "#{@moodle_course.url}/question/preview.php?id=#{q.id}&courseid=#{report_current_course_id}&behaviour=immediatefeedback" } else urls = questions.map {|q| "#{@moodle_course.url}/question/question.php?id=#{q.id}&courseid=#{report_current_course_id}" } @@ -193,14 +220,14 @@ def convert_question_text(moodle_question) material = material.gsub(//m, '') @html_converter.convert(material, 'question', 'questiontext', moodle_question.id) end - + def convert_answer_text(moodle_answer) material = moodle_answer.answer_text || '' material = RDiscount.new(material).to_html if moodle_answer.answer_format.to_i == 4 # markdown material = convert_latex material @html_converter.convert(material, 'question', 'answer', moodle_answer.id) end - + def convert_answer_feedback(moodle_answer) material = moodle_answer.feedback || '' material = RDiscount.new(material).to_html if moodle_answer.feedback_format.to_i == 4 # markdown @@ -260,7 +287,7 @@ def convert_general_feedback(moodle_question) material = convert_latex material @html_converter.convert(material, 'question', 'generalfeedback', moodle_question.id) end - + def convert_other_feedback(moodle_question, feedback, file_area) material = feedback || '' material = convert_latex material @@ -324,13 +351,13 @@ def convert_from_override(moodle_question) question.scale_score(moodle_question.default_mark) set_penalty_options(question, moodle_question) #add_instructor_stimulus(question, moodle_question) - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, title: moodle_question.name, import_status: IMPORT_STATUS_COMPLETE, questions: [question]) return item, [question] end - + def set_penalty_options(question, moodle_question) if moodle_question.penalty penalty = moodle_question.penalty.to_f * 100 @@ -340,7 +367,7 @@ def set_penalty_options(question, moodle_question) question.data[:feedback_attempts] = moodle_question.hints.count + 1 question.data[:instant_feedback] = true end - + def add_instructor_stimulus(subcontent, moodle_question) subcontent = [subcontent] if !subcontent.is_a? Array subcontent[0].data[:instructor_stimulus] ||= '' diff --git a/lib/moodle2aa/learnosity/converters/shortanswer_converter.rb b/lib/moodle2aa/learnosity/converters/shortanswer_converter.rb index bedddfc..5b45a0c 100644 --- a/lib/moodle2aa/learnosity/converters/shortanswer_converter.rb +++ b/lib/moodle2aa/learnosity/converters/shortanswer_converter.rb @@ -32,7 +32,7 @@ def convert_question(moodle_question) response = {score: answer.fraction.to_f} # deal with wildcards, when we can - + # leading/trailing *'s become substring matches if value[0] == '*' response['matching_rule'] = 'contains' @@ -67,7 +67,7 @@ def convert_question(moodle_question) question.scale_score(moodle_question.default_mark) set_penalty_options(question, moodle_question) add_instructor_stimulus(question, moodle_question) - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, import_status: import_status, questions: [question], todo: todo) diff --git a/lib/moodle2aa/learnosity/converters/truefalse_converter.rb b/lib/moodle2aa/learnosity/converters/truefalse_converter.rb index 3a49ba9..25fab4f 100644 --- a/lib/moodle2aa/learnosity/converters/truefalse_converter.rb +++ b/lib/moodle2aa/learnosity/converters/truefalse_converter.rb @@ -1,6 +1,7 @@ module Moodle2AA::Learnosity::Converters class TrueFalseConverter < QuestionConverter register_converter_type 'truefalse' + register_converter_type 'truefalsewiris' def convert_question(moodle_question) @@ -14,15 +15,15 @@ def convert_question(moodle_question) data[:instantfeedback] = true question.type = data[:type] = "mcq" data[:ui_style] = {type: "horizontal"} - + options = data[:options] = [] validation = data[:validation] = {} validation[:scoring_type] = "exactMatch" - + data[:metadata] = {} data[:metadata].merge!(convert_feedback( moodle_question )) data[:is_math] ||= has_math?(data[:metadata]) - + value = 0; moodle_question.answers.each do |answer| options << {label: convert_answer_text(answer), @@ -39,7 +40,7 @@ def convert_question(moodle_question) question.scale_score(moodle_question.default_mark) set_penalty_options(question, moodle_question) add_instructor_stimulus(question, moodle_question) - item = create_item(moodle_question: moodle_question, + item = create_item(moodle_question: moodle_question, import_status: IMPORT_STATUS_COMPLETE, questions: [question]) return item, [question] diff --git a/lib/moodle2aa/learnosity/converters/wiris/multianswerwiris_converter.rb b/lib/moodle2aa/learnosity/converters/wiris/multianswerwiris_converter.rb new file mode 100644 index 0000000..c3ffa19 --- /dev/null +++ b/lib/moodle2aa/learnosity/converters/wiris/multianswerwiris_converter.rb @@ -0,0 +1,148 @@ +require "debug" + +module Moodle2AA::Learnosity::Converters::Wiris + class MultiAnswerWirisConverter < WirisConverter + + register_converter_type 'multianswerwiris' + + def convert_question(moodle_question) + questions = [] # may have to break into multiple questions + notes = [] + todo = [] + import_status = IMPORT_STATUS_COMPLETE # best case + + embedded_questions = moodle_question.embedded_questions.clone + total_parts = embedded_questions.count + + question_text = convert_question_text(moodle_question) + + while true + if embedded_questions.count == 0 + # malformed question. Gaps courses don't have any of these. + abort "Multi answer with no subquestions??" + end + + # create a new cloze question + question = Moodle2AA::Learnosity::Models::Question.new + questions << question + question.reference = generate_unique_identifier_for(moodle_question.id, "_question_#{questions.count}") + + data = question.data + data[:instantfeedback] = true + data[:template] = '' + + validation = data[:validation] = {} + validation[:scoring_type] = "partialMatchV2" # One point per item. Moodle tends to do it this way, + # and this is compatible with breaking up inhomogenious + # cloze questions into multiple questions. + validation[:rounding] = "none" + validation[:valid_response] = {score: 0, value: []} + + currenttype = nil + while subquestion = embedded_questions[0] + currenttype ||= embedded_questions[0].class + if !(currenttype == embedded_questions[0].class) + # mixed subquestion types. + todo << "Check cloze conversion" + notes << "This cloze question contains a mix of different types of answer elements and has automatically been split into multiple questions." + import_status = IMPORT_STATUS_MANUAL + break # loop to start a new question + end + moodle_subquestion = embedded_questions.shift + # each part is equal weight + validation[:valid_response][:score] += 1.0/total_parts + + before, after = question_text.split(/{#\d+}/, 2) + abort "missing cloze marker??" if !after + + data[:template] += before+"{{response}}" + question_text = after + + case + when currenttype == Moodle2AA::Moodle2::Models::Quizzes::Wiris::MultichoiceWirisQuestion + question.type = data[:type] = "clozedropdown" + data[:case_sensitive] = true + data[:ui_style] = {type: "horizontal"} + data[:shuffle_options] = moodle_subquestion.shuffle + data[:response_container] = { pointer: "left" } + + data[:possible_responses] ||= [] + data[:possible_responses] << moodle_subquestion.answers.map {|a| replace_wiris_variables(convert_answer_text(a))} + correct = moodle_subquestion.answers.select {|a| a.fraction.to_f == 1} + validation[:valid_response][:value] << replace_wiris_variables(convert_answer_text(correct[0])) + + all = moodle_subquestion.answers.select {|a| a.fraction.to_f > 1} + if all.count > 1 + # We don't convert multiple answers automatically. It may be possible + # manually. + todo << "Check cloze conversion" + notes << "This cloze question contained multiple correct answers in Moodle, some of which were not converted automatically." + import_status = IMPORT_STATUS_MANUAL + end + when currenttype == Moodle2AA::Moodle2::Models::Quizzes::Wiris::ShortAnswerWirisQuestion + question.type = data[:type] = "clozetext" + data[:case_sensitive] = moodle_subquestion.casesensitive + + correct = moodle_subquestion.answers.select {|a| a.fraction.to_f == 1} + answer_text = replace_wiris_variables(convert_answer_text(correct[0])) + validation[:valid_response][:value] << answer_text + data[:max_length] = [15, answer_text.length+1].min + + all = moodle_subquestion.answers.select {|a| a.fraction.to_f > 1} + if all.count > 1 + # We don't convert multiple answers automatically. It may be possible + # manually. + todo << "Check cloze conversion" + notes << "This cloze question contained multiple correct answers in Moodle, some of which were not converted automatically." + import_status = IMPORT_STATUS_MANUAL + end + else + abort "Unknown subquestion type" + end + end + + notes = notes.uniq + data[:instructor_stimulus] = render_conversion_notes(notes) + + if embedded_questions.count == 0 + # no parts remaining + data[:template] += question_text # add whatever text is left + break + end + + # otherwise must be a mixture of different answer elements, so loop to + # create another question. + + end + + data[:is_math] = false + questions.each do |question| + data[:is_math] ||= has_math?(question.data[:template]) + end + + data[:metadata] = {} + + data[:metadata].merge!(convert_feedback( moodle_question )) + data[:is_math] ||= has_math?(data[:metadata]) + + questions.each {|question| question.scale_score(moodle_question.default_mark)} + questions.each {|question| set_penalty_options(question, moodle_question) } + questions.each {|question| add_instructor_stimulus(question, moodle_question) } + + + script, is_valid = generate_datatable_script(moodle_question) + + if !is_valid + import_status = IMPORT_STATUS_PARTIAL + todo << "Was unable to generate valid JS. Check Data table Script for best-attempt" + end + + item = create_item(moodle_question: moodle_question, + import_status: import_status, + questions: questions, + todo: todo, + data_table_script: script) + return item, questions + end + end +end diff --git a/lib/moodle2aa/learnosity/converters/wiris/shortanswerwiris_converter.rb b/lib/moodle2aa/learnosity/converters/wiris/shortanswerwiris_converter.rb new file mode 100644 index 0000000..c0a1f94 --- /dev/null +++ b/lib/moodle2aa/learnosity/converters/wiris/shortanswerwiris_converter.rb @@ -0,0 +1,139 @@ +require 'byebug' + +module Moodle2AA::Learnosity::Converters::Wiris + class ShortAnswerWirisConverter < WirisConverter + + register_converter_type 'shortanswerwiris' + + def convert_question(moodle_question) + question = Moodle2AA::Learnosity::Models::Question.new + question.reference = generate_unique_identifier_for(moodle_question.id, '_question') + + data = question.data + + data[:stimulus] = convert_question_text(moodle_question) + data[:is_math] = has_math?(data[:stimulus]) + data[:case_sensitive] = moodle_question.casesensitive + question.type = data[:type] = "clozeformulaV2" + + validation = data[:validation] = {} + validation[:alt_responses] = [] + + data[:metadata] = {} + rationale = data[:metadata][:distractor_rationale_response_level] = [] + + data[:metadata].merge!(convert_feedback( moodle_question )) + data[:is_math] ||= has_math?(data[:metadata]) + + import_status = IMPORT_STATUS_COMPLETE + todo = [] + + if moodle_question.grade_compound == "distribute" + validation[:scoring_type] = "partialMatchV2" + else + validation[:scoring_type] = "exactMatch" + end + + tolerance = get_tolerance(moodle_question) + + moodle_question.answers.each do |answer| + response = {score: answer.fraction.to_f} + + if moodle_question.has_compound_answer + lines = answer.answer_text_plain.scan(/(.+)=(.+)$/) + + response[:value] = lines.map do |match| + [{ + method: "equivValue", + value: replace_wiris_variables(match[1]), + options: tolerance + }] + end + else + response[:value] = [[{ + method: "equivValue", + value: replace_wiris_variables(answer.answer_text_plain), + options: tolerance + }]] + end + + rationale << convert_answer_feedback(answer) + + if answer.fraction.to_f == 1 && !validation[:valid_response] + validation[:valid_response] = response + elsif answer.fraction.to_f > 0 + validation[:alt_responses] << response + end + end + + data[:template] = convert_fomula_template(moodle_question, validation[:valid_response][:value].length) + + rationale.each { |feedback| data[:is_math] ||= has_math?(feedback) } + question.scale_score(moodle_question.default_mark) + set_penalty_options(question, moodle_question) + add_instructor_stimulus(question, moodle_question) + + script, is_valid = generate_datatable_script(moodle_question) + + if !is_valid + import_status = IMPORT_STATUS_PARTIAL + todo << "Was unable to generate valid JS. Check Data table Script for best-attempt" + end + + item = create_item(moodle_question: moodle_question, + import_status: import_status, + questions: [question], + todo: todo, + data_table_script: script) + + return item, [question] + end + + def get_tolerance(moodle_question) + return {} if moodle_question.tolerance.nil? + + if moodle_question.tolerance_digits + { + decimalPlaces: moodle_question.tolerance, + } + elsif moodle_question.relative_tolerance + { + tolerance: '\\percentage', + tolerancePercent: moodle_question.tolerance * 100, + } + else + {} + end + end + + def convert_fomula_template(question, num_responses) + # I'm not sure why, but whether or not intial content is set is hit or mis + if question.initial_content + math_xml = Nokogiri::XML(question.initial_content).root + + return question.initial_content if math_xml.nil? + + lines = [] + line = "" + + math_xml.children.each do |child| + if child.text == "=" + line << child.to_xml + lines << " #{line} {{response}}" + line = "" + else + line << child.to_xml + end + end + + lines.join("
") + elsif question.has_compound_answer + math_xml = Nokogiri::XML(question.answers.first.answer_text).root + replace_variables_in_math_ml(math_xml) { "{{response}}" } + else + return "{{response}}
" * num_responses if question.initial_content.nil? + end + + end + end +end diff --git a/lib/moodle2aa/learnosity/converters/wiris/wiris_converter.rb b/lib/moodle2aa/learnosity/converters/wiris/wiris_converter.rb new file mode 100644 index 0000000..c873b49 --- /dev/null +++ b/lib/moodle2aa/learnosity/converters/wiris/wiris_converter.rb @@ -0,0 +1,167 @@ + +require 'nokogiri' + +module Moodle2AA::Learnosity::Converters::Wiris + class WirisConverter < Moodle2AA::Learnosity::Converters::QuestionConverter + SUBSTITUTION_VARIABLE_REGEX = /#([\D][\w\d]*)/ + + DATA_TABLE_SCRIPT_TEMPLATE = <<~JS + var PRECISION = {precision} + seed({seed_value}) + setColumns({columns}) + + {variable_declarations} + + + for (var {loop_var} = 0; {loop_var} < NUM_ROWS; {loop_var}++) \{ + {algorithm} + + addRow([{row}]) + \} + JS + + def convert_question_text(question) + text = super + text = text.gsub("mathcolor=\"#FF0000\"", "") # Quick hack to stop it from replacing the color + + node = Nokogiri::HTML(text) + math_nodes = node.xpath("//math") + + + if math_nodes.empty? + # Probably plain-text + text = replace_wiris_variables(text) + else + math_nodes.each do |math| + replacement = replace_variables_in_math_ml(math) { |v| "{{var:#{v}}}" } + math.replace(replacement) + end + + text = node.to_xml + end + + text + end + + def replace_wiris_variables(text) + text.gsub(SUBSTITUTION_VARIABLE_REGEX, "{{var:\\1}}") + end + + def generate_datatable_script(question) + return [nil, true] if question.algorithms_format == :none || question.substitution_variables.empty? + + script = DATA_TABLE_SCRIPT_TEMPLATE.dup + script.gsub!('{precision}', (question.precision || 15).to_i.to_s) + script.gsub!('{seed_value}', rand(10000).to_s) + script.gsub!('{columns}', question.substitution_variables.to_a.to_s) + script.gsub!('{variable_declarations}', question.script_variables.map { |v| "var #{v};" }.join("\n")) + + script.gsub!('{algorithm}', question.algorithms.map { |a| format_algorithm(a) }.join("\n")) + script.gsub!('{row}', question.substitution_variables.map { |v| "format(#{v}, { precision: PRECISION })" }.join(',')) + + if question.script_variables.include?('i') + script.gsub!('{loop_var}', '_rowIndex_') + else + script.gsub!('{loop_var}', 'i') + end + + unsupported_symbols = ['solve', 'numerical_solve', 'integrate', "_calc_apply", "wedge_operator", "_calc_plot"] + + is_valid = unsupported_symbols.none? { |f| script.include?(f) } + + puts '-----------------------------------' + puts "Name: #{question.name}" + puts "Type: #{question.type}" + puts "Format: #{question.algorithms_format}" + puts "Valid: #{is_valid}" + puts '-----------------------------------' + puts question.algorithms.join("\n") + puts '-----------------------------------' + puts script + puts '-----------------------------------' + + [script, is_valid] + end + + def format_algorithm(algorithm) + algorithm. + gsub(/\/#.+WirisQuizzes.+#\//m, ""). # Remove Wiris starting comment + gsub(/\/#/, '/*'). # Multiline Comments start + gsub(/#\//, '*/'). # Multiline Comments end + gsub(/#/, "//"). # Single line comments + gsub(/\^/, '**'). # Replace ^ with ** for exponentiation + gsub(/if (.+) then\n/ , "if (\\1) {\n "). # Replace if statements + gsub(/else/, '} else {'). + gsub(/end/, '}'). # End of blocks + gsub("_calc_approximate(,empty_relation)", ""). # Useless line + gsub(/{(.+\,?)+}/, '[\1]'). # Arrays + gsub(/\((\w+) subindex_operator\((.+)\)\)/) { |m| "#{$1}[#{$2} - 1]" }. # Array indexing + # gsub(/wedge_operator/, '&&'). # Logical AND + # The way that elements are being seperated is inconsistent + # This normalizes it to be a comma seperated list + gsub(/\((.+)\.\.(.+)\.\.(.+)\)/, "(\\1, \\2, \\3)"). + gsub(/\((.+)\.\.(.+)\)/, "(\\1, \\2)"). + gsub(/\((.+);(.+);(.+)\)/, "(\\1, \\2, \\3)"). + gsub(/\((.+);(.+)\)/, "(\\1, \\2)"). + gsub(/_calc_approximate\((.+),empty_relation\)/, "\\1"). # stips _calc_approximate function calls, returns the inner value + gsub(":=", "="). # Wiris uses := for assignment without evaluating the right hand side + split("\n") + .map { |l| " #{l}" } + .join("\n") + end + + # This is a really naive approach as it + # assumes that the variables are not nested in any way + # In practice this isn't ideal, but it's good enough for NJIT's use case + def replace_variables_in_math_ml(root) + return nil if root.nil? + + lines = [] + line = "" + variable = "" + replacing_variable = false + + root.children.each do |node| + if node.text == "#" + replacing_variable = true + next + end + + if replacing_variable + if node.name == "mspace" + replacing_variable = false + val = yield(variable) + lines << " #{line} #{val}" + line = "" + variable = "" + else + variable << node.text + end + else + # Learnosity MathML doesn't support mfenced + # so we replace it with parentheses explicitly + if node.name == "mfenced" + open = node.attributes["open"]&.value || "(" + close = node.attributes["close"]&.value || ")" + line << "#{open}" + line << node.children.to_xml + line << "#{close}" + else + line << node.to_xml + end + end + end + + if line != "" + if variable != "" + val = yield(variable) + lines << " #{line} #{val}" + else + lines << " #{line} " + end + end + + lines.join("
") + end + end +end diff --git a/lib/moodle2aa/learnosity/data_table_engine_script_generator.rb b/lib/moodle2aa/learnosity/data_table_engine_script_generator.rb new file mode 100644 index 0000000..f559232 --- /dev/null +++ b/lib/moodle2aa/learnosity/data_table_engine_script_generator.rb @@ -0,0 +1,90 @@ +# NOTE: Generates a datatable script for a moodle calculated question type. +# Wiris questions have their datatable script generated by the WirisConverter +class Moodle2AA::Learnosity::DataTableEngineScriptGenerator + DATA_TABLE_SCRIPT_TEMPLATE = <<~JS + NUM_ROWS = {num_rows}; + seed({seed_value}); + setColumns({columns}); + + var dataset = \{ + {dataset_declarations} + \}; + + {variable_declarations} + + for (var {loop_var} = 0; {loop_var} < NUM_ROWS; {loop_var}++) \{ + {algorithm} + + addRow([{row}]); + \} + JS + + def initialize + @datasets = [] + @output_variables = [] + @answers = [] + end + + def generate + script = DATA_TABLE_SCRIPT_TEMPLATE.dup + + script.gsub!('{num_rows}', @datasets.map { |d| d[:values].size }.min.to_s ) + script.gsub!('{seed_value}', rand(10000).to_s) + script.gsub!('{columns}', @output_variables.to_s) + script.gsub!('{dataset_declarations}', dataset_declarations) + script.gsub!('{variable_declarations}', variable_declarations) + script.gsub!('{algorithm}', format_body) + script.gsub!('{row}', @output_variables.map { |o| "format(#{o})" }.join(',')) + script.gsub!('{loop_var}', 'i') + + script + end + + def add_dataset(name, values) + @datasets << { name: name, values: values } + @output_variables << name + end + + def add_answer(name, formula) + @answers << { name: name, formula: replace_vars(formula) } + @output_variables << name + end + + private + + def replace_vars(expr) + expr.gsub(/\{([^{}=]+)\}/) do |match| + $1 + end + end + + def dataset_declarations + lines = @datasets.map do |d| + name = d[:name] + values = d[:values].join(',') + + " #{name}: [#{values}]," + end + + lines.join("\n") + end + + def variable_declarations + ( + @datasets.map { |d| "var #{d[:name]};" } + + @answers.map { |a| "var #{a[:name]};" } + ).join("\n") + end + + def format_body + ( + @datasets.map { |d| " #{d[:name]} = dataset.#{d[:name]}[i];" } + + @answers.map { |a| " #{a[:name]} = #{format_formula(a[:formula])};" } + ).join("\n") + end + + def format_formula(expr) + # moodle treats log as ln + expr.gsub(/log\(/, 'ln(') + end +end diff --git a/lib/moodle2aa/learnosity/migrator.rb b/lib/moodle2aa/learnosity/migrator.rb index 4f7f058..7db2bcb 100644 --- a/lib/moodle2aa/learnosity/migrator.rb +++ b/lib/moodle2aa/learnosity/migrator.rb @@ -26,7 +26,12 @@ def migrate(moodle_course) fix_assignment_tags(learnosity.activities, learnosity.items) convert_html!(learnosity, moodle_course) learnosity.files = remove_unused_files(learnosity.files) - @path = Moodle2AA::Learnosity::Writers::AtomicAssessments.new(learnosity, moodle_course).create(@output_dir) if Moodle2AA::MigrationReport.generate_archive? + filter_items!(learnosity) + if Moodle2AA::MigrationReport.generate_archive? + writer = Moodle2AA::Learnosity::Writers::AtomicAssessments.new(learnosity, moodle_course) + # writer = Moodle2AA::Learnosity::Writers::Json.new(learnosity, moodle_course) + @path = writer.create(@output_dir) + end end private @@ -229,7 +234,8 @@ def consolidate_shared_questions(questions, moodle_quiz) new_question.id = (questions.map { |q| q.id }).join('-') # save question scores - new_question.max_score = [] + # This was an array before, which didn't make sense. Now it's a hash. + new_question.max_score = {} question_ids = questions.map {|q| q.id} moodle_quiz.question_instances.each do |instance| if question_ids.include? instance[:question] @@ -342,5 +348,50 @@ def convert_html!(learnosity, moodle_course) end end + def filter_items!(learnosity) + puts "Filtering items" + puts "Currently including: #{learnosity.items.count} items and #{learnosity.questions.count} questions." + item_references = learnosity.activities.flatten.map do |activity| + activity.data.items + end.flatten.to_set + + case Moodle2AA::MigrationReport.unused_question_mode + when 'exclude' + puts "Excluding unused items" + + learnosity.items.select! do |item| + item_references.include?(item.reference) + end + + question_references = learnosity.items.map do |item| + item.questions.map(&:reference) + end.flatten.to_set + + learnosity.questions.select! do |question| + question_references.include?(question.reference) + end + when 'only' + puts "Including only unused items" + + learnosity.items.reject! do |question| + item_references.include?(question.reference) + end + + question_references = learnosity.items.map do |item| + item.questions.map(&:reference) + end.flatten.to_set + + learnosity.questions.select! do |question| + question_references.include?(question.reference) + end + + # There won't be any activities left since we filter out all their items + learnosity.activities = [] + when 'keep' + puts "Keeping all items" + end + + puts "Included #{learnosity.items.count} items and #{learnosity.questions.count} questions." + end end end diff --git a/lib/moodle2aa/learnosity/models/export.rb b/lib/moodle2aa/learnosity/models/export.rb index d567c66..68db56c 100644 --- a/lib/moodle2aa/learnosity/models/export.rb +++ b/lib/moodle2aa/learnosity/models/export.rb @@ -11,5 +11,15 @@ def initialize @_item_groups = [] @files = [] end + + + def to_json + JSON.generate({ + activities: @activities, + items: @items, + questions: @questions, + features: @features, + }) + end end end diff --git a/lib/moodle2aa/learnosity/models/question.rb b/lib/moodle2aa/learnosity/models/question.rb index 4b088d9..0d9c0fd 100644 --- a/lib/moodle2aa/learnosity/models/question.rb +++ b/lib/moodle2aa/learnosity/models/question.rb @@ -18,7 +18,7 @@ def scale_score(max_score) if !data[:validation] return #nothing to do end - max_score = max_score.to_f + max_score = max_score.to_f _scale_score(max_score) end diff --git a/lib/moodle2aa/learnosity/writers.rb b/lib/moodle2aa/learnosity/writers.rb index 270d78e..364558e 100644 --- a/lib/moodle2aa/learnosity/writers.rb +++ b/lib/moodle2aa/learnosity/writers.rb @@ -5,5 +5,6 @@ module Writers require_relative 'writers/export' require_relative 'writers/file_writer' require_relative 'writers/atomicassessments' + require_relative 'writers/json' end end diff --git a/lib/moodle2aa/learnosity/writers/json.rb b/lib/moodle2aa/learnosity/writers/json.rb new file mode 100644 index 0000000..5e4da36 --- /dev/null +++ b/lib/moodle2aa/learnosity/writers/json.rb @@ -0,0 +1,35 @@ + +require 'json' + +module Moodle2AA::Learnosity::Writers + class Json + + def initialize(learnosity, moodle_course) + @moodle_course = moodle_course + @learnosity = learnosity + end + + def create(out_dir) + out_file = File.join(out_dir, filename) + + File.open(out_file,"w") do |f| + f.write(@learnosity.to_json) + end + + out_file + end + + def filename + source = File.basename Moodle2AA::MigrationReport.source + source = source.gsub(/(\.zip|\.mbz)/,'') + "#{source}-learnosity.json" + #title = @moodle_course.fullname.gsub(/::/, '/'). + #gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2'). + #gsub(/([a-z\d])([A-Z])/, '\1_\2'). + #gsub(/[\/|\.]/, '_'). + #tr('- ', '_').downcase. + #gsub(/_{2,}/, '_') + #"#{title}-learnosity.zip" + end + end +end diff --git a/lib/moodle2aa/migration_report.rb b/lib/moodle2aa/migration_report.rb index ac5b818..981a792 100644 --- a/lib/moodle2aa/migration_report.rb +++ b/lib/moodle2aa/migration_report.rb @@ -19,7 +19,7 @@ class MigrationReport "generate_report" => false, "convert_unknown_qtypes" => false, "convert_unknown_activities" => false, - } + "unused_question_mode" => 'keep' } def self.options() @@options @@ -28,7 +28,7 @@ def self.options() def self.options=(options) @@options = @@options.merge(options) end - + def self.source() @@source end @@ -56,11 +56,15 @@ def self.convert_unknown_qtypes?() def self.convert_unknown_activities?() @@options["convert_unknown_activities"] end - + def self.group_by_quiz_page?() @@options["group_by_quiz_page"] end + def self.unused_question_mode() + @@options["unused_question_mode"] + end + def self.create(out_dir, moodle_course) Thread.current[:__moodle2aa_migration_report__] = MigrationReport.new(out_dir, moodle_course) end diff --git a/lib/moodle2aa/migrator.rb b/lib/moodle2aa/migrator.rb index 15ca10c..65a7033 100644 --- a/lib/moodle2aa/migrator.rb +++ b/lib/moodle2aa/migrator.rb @@ -16,7 +16,7 @@ def initialize(source, destination, options={}) Moodle2AA::MigrationReport.siteurl = options['siteurl'] || '' Moodle2AA::MigrationReport.options = options Moodle2AA::MigrationReport.source = @source - raise(Moodle2AA::Error, "'#{@source}' does not exist") unless File.exists?(@source) + raise(Moodle2AA::Error, "'#{@source}' does not exist") unless File.exist?(@source) raise(Moodle2AA::Error, "'#{@destination}' is not a directory") unless File.directory?(@destination) raise(Moodle2AA::Error, "'#{@format}' is not a valid format. Please use 'cc' or 'canvas'.") unless ['cc', 'canvas'].include?(@format) @converter_class = @format == 'cc' ? Moodle2AA::CC::Converter : Moodle2AA::Canvas::Converter @@ -31,6 +31,7 @@ def migrate migrate_moodle_2 end rescue StandardError => error + raise @last_error = error Moodle2AA::Logger.add_warning 'error migrating content', error end @@ -58,9 +59,9 @@ def migrate_moodle_2 def moodle_version if File.directory?(@source) - if File.exists?(File.join(@source, 'moodle_backup.xml')) + if File.exist?(File.join(@source, 'moodle_backup.xml')) MOODLE_2 - elsif File.exists?(File.join(@source, 'moodle.xml')) + elsif File.exist?(File.join(@source, 'moodle.xml')) MOODLE_1_9 end else diff --git a/lib/moodle2aa/moodle2/models/quizzes.rb b/lib/moodle2aa/moodle2/models/quizzes.rb index 648b85a..5fb807a 100644 --- a/lib/moodle2aa/moodle2/models/quizzes.rb +++ b/lib/moodle2aa/moodle2/models/quizzes.rb @@ -14,5 +14,12 @@ module Moodle2AA::Moodle2::Models::Quizzes require_relative 'quizzes/essay_question' require_relative 'quizzes/unknowntype_question' require_relative 'quizzes/gapselect_question' + + module Wiris + require_relative 'quizzes/wiris/wiris_question' + require_relative 'quizzes/wiris/multianswerwiris_question' + require_relative 'quizzes/wiris/shortanswerwiris_question' + require_relative 'quizzes/wiris/multichoicewiris_question' + end end diff --git a/lib/moodle2aa/moodle2/models/quizzes/answer.rb b/lib/moodle2aa/moodle2/models/quizzes/answer.rb index 0ec4d44..6b0d7b5 100644 --- a/lib/moodle2aa/moodle2/models/quizzes/answer.rb +++ b/lib/moodle2aa/moodle2/models/quizzes/answer.rb @@ -1,5 +1,7 @@ module Moodle2AA::Moodle2::Models::Quizzes class Answer attr_accessor :id, :answer_text, :answer_format, :fraction, :feedback, :feedback_format + # Wiris + attr_accessor :answer_text_plain end end diff --git a/lib/moodle2aa/moodle2/models/quizzes/essay_question.rb b/lib/moodle2aa/moodle2/models/quizzes/essay_question.rb index 84cec43..8b77439 100644 --- a/lib/moodle2aa/moodle2/models/quizzes/essay_question.rb +++ b/lib/moodle2aa/moodle2/models/quizzes/essay_question.rb @@ -1,6 +1,8 @@ module Moodle2AA::Moodle2::Models::Quizzes class EssayQuestion < Question register_question_type 'essay' + register_question_type 'essaywiris' + attr_accessor :responseformat, :attachments, :attachmentsrequired attr_accessor :graderinfo, :graderinfoformat, :responsetemplate attr_accessor :responsetemplateformat, :responsefieldlines diff --git a/lib/moodle2aa/moodle2/models/quizzes/match_question.rb b/lib/moodle2aa/moodle2/models/quizzes/match_question.rb index 0a81db7..f10aec8 100644 --- a/lib/moodle2aa/moodle2/models/quizzes/match_question.rb +++ b/lib/moodle2aa/moodle2/models/quizzes/match_question.rb @@ -1,6 +1,8 @@ module Moodle2AA::Moodle2::Models::Quizzes class MatchQuestion < Question register_question_type 'match' + register_question_type 'matchwiris' + attr_accessor :matches attr_accessor :shuffle, :correctfeedback, :incorrectfeedback, :partiallycorrectfeedback diff --git a/lib/moodle2aa/moodle2/models/quizzes/multianswer_question.rb b/lib/moodle2aa/moodle2/models/quizzes/multianswer_question.rb index 0e4c2e3..964348c 100644 --- a/lib/moodle2aa/moodle2/models/quizzes/multianswer_question.rb +++ b/lib/moodle2aa/moodle2/models/quizzes/multianswer_question.rb @@ -1,6 +1,7 @@ module Moodle2AA::Moodle2::Models::Quizzes class MultianswerQuestion < Question register_question_type 'multianswer' + attr_accessor :embedded_question_references, :embedded_questions def initialize @@ -25,4 +26,4 @@ def resolve_embedded_question_references(question_categories) end end end -end \ No newline at end of file +end diff --git a/lib/moodle2aa/moodle2/models/quizzes/multichoice_question.rb b/lib/moodle2aa/moodle2/models/quizzes/multichoice_question.rb index 33cf7e2..a8dcc90 100644 --- a/lib/moodle2aa/moodle2/models/quizzes/multichoice_question.rb +++ b/lib/moodle2aa/moodle2/models/quizzes/multichoice_question.rb @@ -1,6 +1,7 @@ module Moodle2AA::Moodle2::Models::Quizzes class MultichoiceQuestion < Question register_question_type 'multichoice' + attr_accessor :single, :shuffle, :correctfeedback, :incorrectfeedback, :partiallycorrectfeedback end end diff --git a/lib/moodle2aa/moodle2/models/quizzes/question.rb b/lib/moodle2aa/moodle2/models/quizzes/question.rb index 9b1ca59..b8e918e 100644 --- a/lib/moodle2aa/moodle2/models/quizzes/question.rb +++ b/lib/moodle2aa/moodle2/models/quizzes/question.rb @@ -33,6 +33,7 @@ def self.register_question_type(name) attr_accessor :category_name # for learnosity random conversion attr_accessor :category_id # for learnosity synchronized calc conversion attr_accessor :hints, :penalty + attr_accessor :question_text_plain def initialize @answers = [] diff --git a/lib/moodle2aa/moodle2/models/quizzes/question_category.rb b/lib/moodle2aa/moodle2/models/quizzes/question_category.rb index 4dddbf9..5ad68ff 100644 --- a/lib/moodle2aa/moodle2/models/quizzes/question_category.rb +++ b/lib/moodle2aa/moodle2/models/quizzes/question_category.rb @@ -10,9 +10,9 @@ def initialize end def resolve_embedded_question_references(question_categories) - @questions.select{|q| q.is_a?(MultianswerQuestion)}.each do |q| + @questions.select{|q| q.respond_to?(:resolve_embedded_question_references) }.each do |q| q.resolve_embedded_question_references(question_categories) end end end -end \ No newline at end of file +end diff --git a/lib/moodle2aa/moodle2/models/quizzes/true_false_question.rb b/lib/moodle2aa/moodle2/models/quizzes/true_false_question.rb index bcda672..cb7a082 100644 --- a/lib/moodle2aa/moodle2/models/quizzes/true_false_question.rb +++ b/lib/moodle2aa/moodle2/models/quizzes/true_false_question.rb @@ -1,6 +1,7 @@ module Moodle2AA::Moodle2::Models::Quizzes class TrueFalseQuestion < Question register_question_type 'truefalse' + register_question_type 'truefalsewiris' attr_accessor :true_false_id, :true_answer, :false_answer end -end \ No newline at end of file +end diff --git a/lib/moodle2aa/moodle2/models/quizzes/wiris/multianswerwiris_question.rb b/lib/moodle2aa/moodle2/models/quizzes/wiris/multianswerwiris_question.rb new file mode 100644 index 0000000..b4fee22 --- /dev/null +++ b/lib/moodle2aa/moodle2/models/quizzes/wiris/multianswerwiris_question.rb @@ -0,0 +1,49 @@ + +module Moodle2AA::Moodle2::Models::Quizzes::Wiris + class MultiansweWirisQuestion < WirisQuestion + register_question_type 'multianswerwiris' + + attr_accessor :embedded_question_references, :embedded_questions + + def initialize + super + @embedded_questions = [] + end + + def resolve_embedded_question_references(question_categories) + return unless @embedded_question_references + + @embedded_questions ||= [] + @embedded_question_references.each do |ref| + question = nil + question_categories.each do |qc| + if question = qc.questions.detect{|q| q.id.to_s == ref && q.parent.to_s == @id} + qc.questions.delete(question) + break + end + end + + @embedded_questions << question if question + end + end + + + def substitution_variables + return @substitution_variables if @substitution_variables + + @substitution_variables = super + + embedded_questions.each do |question| + question.answers.each do |answer| + @substitution_variables.merge(answer.answer_text.scan(SUBSTITUTION_VARIABLE_REGEX).flatten) + end + end + + @substitution_variables + end + + def script_variables + @script_variables ||= super.merge(embedded_questions.map(&:script_variables).to_set.flatten) + end + end +end diff --git a/lib/moodle2aa/moodle2/models/quizzes/wiris/multichoicewiris_question.rb b/lib/moodle2aa/moodle2/models/quizzes/wiris/multichoicewiris_question.rb new file mode 100644 index 0000000..78f9056 --- /dev/null +++ b/lib/moodle2aa/moodle2/models/quizzes/wiris/multichoicewiris_question.rb @@ -0,0 +1,7 @@ +module Moodle2AA::Moodle2::Models::Quizzes::Wiris + class MultichoiceWirisQuestion < WirisQuestion + register_question_type 'multichoicewiris' + + attr_accessor :single, :shuffle, :correctfeedback, :incorrectfeedback, :partiallycorrectfeedback + end +end diff --git a/lib/moodle2aa/moodle2/models/quizzes/wiris/shortanswerwiris_question.rb b/lib/moodle2aa/moodle2/models/quizzes/wiris/shortanswerwiris_question.rb new file mode 100644 index 0000000..1a87a40 --- /dev/null +++ b/lib/moodle2aa/moodle2/models/quizzes/wiris/shortanswerwiris_question.rb @@ -0,0 +1,8 @@ + +module Moodle2AA::Moodle2::Models::Quizzes::Wiris + class ShortAnswerWirisQuestion < WirisQuestion + register_question_type 'shortanswerwiris' + + attr_accessor :casesensitive + end +end diff --git a/lib/moodle2aa/moodle2/models/quizzes/wiris/wiris_question.rb b/lib/moodle2aa/moodle2/models/quizzes/wiris/wiris_question.rb new file mode 100644 index 0000000..ee2afdc --- /dev/null +++ b/lib/moodle2aa/moodle2/models/quizzes/wiris/wiris_question.rb @@ -0,0 +1,47 @@ +require "byebug" + +module Moodle2AA::Moodle2::Models::Quizzes::Wiris + class WirisQuestion < Moodle2AA::Moodle2::Models::Quizzes::Question + attr_accessor :algorithms, :algorithms_format, :has_compound_answer, :initial_content, :tolerance, :relative_tolerance, :tolerance_digits, :precision, :grade_compound + + SUBSTITUTION_VARIABLE_REGEX = /#([\D][\w\d]*)\b/ + + SCRIPT_VARIABLE_REGEX = /\s*([\w\d]+)?\s?=/ + + def learnosity_question_text + @learnosity_question_text ||= question_text.gsub(SUBSTITUTION_VARIABLE_REGEX, '{{var:\1}}') + end + + # Variables can be in: question_text, answers[*].answer_text, + def substitution_variables + return @substitution_variables if @substitution_variables + + @substitution_variables = Set.new + + @substitution_variables.merge(question_text_plain.scan(SUBSTITUTION_VARIABLE_REGEX).flatten) + + answers.each do |answer| + next unless answer.answer_text_plain + @substitution_variables.merge(answer.answer_text_plain.scan(SUBSTITUTION_VARIABLE_REGEX).flatten) + end + + @substitution_variables.filter! { |v| script_variables.include?(v) } + + @substitution_variables + end + + def script_variables + return @script_variables if @script_variables + + @script_variables = Set.new + + (algorithms || []).each do |algorithm| + @script_variables.merge(algorithm.scan(SCRIPT_VARIABLE_REGEX).flatten) + end + + @script_variables.filter! { |v| v != "" && !v.nil? } + + @script_variables + end + end +end diff --git a/lib/moodle2aa/moodle2/parsers/file_parser.rb b/lib/moodle2aa/moodle2/parsers/file_parser.rb index 7f5a081..72da8a2 100644 --- a/lib/moodle2aa/moodle2/parsers/file_parser.rb +++ b/lib/moodle2aa/moodle2/parsers/file_parser.rb @@ -39,7 +39,7 @@ def parse file.repository_id = parse_text(node, 'repositoryid') file.reference = parse_text(node, 'reference') file.file_location = File.join(@work_dir, FILES_DIR, file.content_hash[0..1], file.content_hash) - if file.file_size > 0 && File.exists?(file.file_location) + if file.file_size > 0 && File.exist?(file.file_location) files << file else missing_files << file @@ -49,4 +49,4 @@ def parse end end -end \ No newline at end of file +end diff --git a/lib/moodle2aa/moodle2/parsers/forum_parser.rb b/lib/moodle2aa/moodle2/parsers/forum_parser.rb index 638637a..f6be7c2 100644 --- a/lib/moodle2aa/moodle2/parsers/forum_parser.rb +++ b/lib/moodle2aa/moodle2/parsers/forum_parser.rb @@ -47,7 +47,7 @@ def parse_forum(forum_dir, module_name) forum.completion_posts = parse_text(forum_xml, "/activity/#{module_name}/completionposts") end grade_file = File.join(activity_dir, "grades.xml") - if File.exists?(grade_file) + if File.exist?(grade_file) File.open(grade_file) do |f| grade_xml = Nokogiri::XML(f) if node = grade_xml.at_xpath("activity_gradebook/grade_items/grade_item/grademax") @@ -60,4 +60,4 @@ def parse_forum(forum_dir, module_name) end end -end \ No newline at end of file +end diff --git a/lib/moodle2aa/moodle2/parsers/parser_helper.rb b/lib/moodle2aa/moodle2/parsers/parser_helper.rb index 02faa2b..534eb9d 100644 --- a/lib/moodle2aa/moodle2/parsers/parser_helper.rb +++ b/lib/moodle2aa/moodle2/parsers/parser_helper.rb @@ -30,6 +30,11 @@ def parse_boolean(node, xpath) value && (value == '1' || value.downcase == 'true' || value.downcase == 'y') ? true : false end + def parse_number(node, xpath) + value = parse_text(node, xpath) + value && value.to_f + end + def parse_module(activity_dir, activity) File.open(File.join(activity_dir, MODULE_XML)) do |f| xml = Nokogiri::XML(f) diff --git a/lib/moodle2aa/moodle2/parsers/question_category_parser.rb b/lib/moodle2aa/moodle2/parsers/question_category_parser.rb index 2961a0f..0854d05 100644 --- a/lib/moodle2aa/moodle2/parsers/question_category_parser.rb +++ b/lib/moodle2aa/moodle2/parsers/question_category_parser.rb @@ -41,6 +41,7 @@ def question_parser(node) rescue Exception => e Moodle2AA::OutputLogger.logger.info e.message nil + raise # TODO: Remove this later end end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers.rb b/lib/moodle2aa/moodle2/parsers/question_parsers.rb index 283160c..90e387d 100644 --- a/lib/moodle2aa/moodle2/parsers/question_parsers.rb +++ b/lib/moodle2aa/moodle2/parsers/question_parsers.rb @@ -13,5 +13,16 @@ module QuestionParsers require_relative 'question_parsers/essay_parser' require_relative 'question_parsers/unknowntype_parser' require_relative 'question_parsers/gapselect_parser' + require_relative 'question_parsers/multichoiceset_parser' + + module Wiris + require_relative 'question_parsers/wiris/wiris_parser' + require_relative 'question_parsers/wiris/shortanswerwiris_parser' + require_relative 'question_parsers/wiris/multichoicewiris_parser' + require_relative 'question_parsers/wiris/multianswerwiris_parser' + require_relative 'question_parsers/wiris/essaywiris_parser' + require_relative 'question_parsers/wiris/trufalsewiris_parser' + require_relative 'question_parsers/wiris/matchwiris_parser' + end end end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/multichoiceset_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/multichoiceset_parser.rb new file mode 100644 index 0000000..933fd2f --- /dev/null +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/multichoiceset_parser.rb @@ -0,0 +1,10 @@ +module Moodle2AA::Moodle2 + class Parsers::QuestionParsers::MultichoicesetParser < Parsers::QuestionParsers::QuestionParser + register_parser_type 'multichoiceset' + + def parse_question(node) + # TODO: how to handles these question types + super + end + end +end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/question_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/question_parser.rb index ce7776f..755b8b5 100644 --- a/lib/moodle2aa/moodle2/parsers/question_parsers/question_parser.rb +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/question_parser.rb @@ -45,7 +45,7 @@ def parse_question(node, question_type = nil) question.version = parse_text(node, 'version') question.hidden = parse_boolean(node, 'hidden') question.penalty = parse_text(node, 'penalty') - question.hints = node.search('question_hints/question_hint').map do |n| + question.hints = node.search('question_hints/question_hint').map do |n| parse_text(n, 'hint') end @@ -53,6 +53,7 @@ def parse_question(node, question_type = nil) rescue Exception => e Moodle2AA::OutputLogger.logger.info e.message nil + exit end end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/essaywiris_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/essaywiris_parser.rb new file mode 100644 index 0000000..411e9cf --- /dev/null +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/essaywiris_parser.rb @@ -0,0 +1,20 @@ +require "byebug" +module Moodle2AA::Moodle2 + class Parsers::QuestionParsers::Wiris::EssaywirisParser < Moodle2AA::Moodle2::Parsers::QuestionParsers::Wiris::QuestionParser + register_parser_type 'essaywiris' + + def parse_question(node) + question = super + plugin_node = node.at_xpath('plugin_qtype_essaywiris_question') + question.responseformat = parse_text(plugin_node, 'essay/responseformat') + question.attachments = parse_text(plugin_node, 'essay/attachments') + question.attachmentsrequired = parse_text(plugin_node, 'essay/attachmentsrequired') + question.graderinfo = parse_text(plugin_node, 'essay/graderinfo') + question.graderinfoformat = parse_text(plugin_node, 'essay/graderinfoformat') + question.responsetemplate = parse_text(plugin_node, 'essay/responsetemplate') + question.responsetemplateformat = parse_text(plugin_node, 'essay/responsetemplateformat') + question.responsefieldlines = parse_text(plugin_node, 'essay/responsefieldlines') + question + end + end +end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/matchwiris_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/matchwiris_parser.rb new file mode 100644 index 0000000..8107dbf --- /dev/null +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/matchwiris_parser.rb @@ -0,0 +1,29 @@ +require 'byebug' + +module Moodle2AA::Moodle2 + class Parsers::QuestionParsers::Wiris::MatchwirisParser < Moodle2AA::Moodle2::Parsers::QuestionParsers::Wiris::QuestionParser + register_parser_type 'matchwiris' + + def parse_question(node) + question = super(node, 'matchwiris') + + plugin_node = node.at_xpath("plugin_qtype_#{question.qtype}_question") + + plugin_node.search('matches/match').each do |m_node| + question.matches << { + :id => m_node.attributes['id'].value, + :question_text => parse_text(m_node, 'questiontext'), + :question_text_format => parse_text(m_node, 'questiontextformat'), + :answer_text => parse_text(m_node, 'answertext') + } + end + + question.shuffle = parse_boolean(plugin_node.search('matchoptions'), 'shuffleanswers') + question.correctfeedback = parse_text(plugin_node.search('matchoptions'), 'correctfeedback') + question.partiallycorrectfeedback = parse_text(plugin_node.search('matchoptions'), 'partiallycorrectfeedback') + question.incorrectfeedback = parse_text(plugin_node.search('matchoptions'), 'incorrectfeedback') + + question + end + end +end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/multianswerwiris_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/multianswerwiris_parser.rb new file mode 100644 index 0000000..14c94c2 --- /dev/null +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/multianswerwiris_parser.rb @@ -0,0 +1,24 @@ + +module Moodle2AA::Moodle2 + class Parsers::QuestionParsers::Wiris::MultianswerwirisParser < Moodle2AA::Moodle2::Parsers::QuestionParsers::Wiris::QuestionParser + register_parser_type 'multianswerwiris' + + def parse_question(node) + question = super + + plugin_node = node.at_xpath('plugin_qtype_multianswerwiris_question') + + answer_parser = Parsers::AnswerParser.new + question.answers += plugin_node.search('answers/answer').map { |n| answer_parser.parse(n) } + + if sequence = plugin_node.at_xpath('multianswer/sequence') + question.embedded_question_references = sequence.text.split(',') + end + + question.algorithms, question.algorithms_format = get_code(node, 'multianswerwiris', question.id) + + question + end + end +end + diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/multichoicewiris_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/multichoicewiris_parser.rb new file mode 100644 index 0000000..bbbf965 --- /dev/null +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/multichoicewiris_parser.rb @@ -0,0 +1,23 @@ +module Moodle2AA::Moodle2 + class Parsers::QuestionParsers::MultichoicewirisParser < Parsers::QuestionParsers::Wiris::QuestionParser + register_parser_type 'multichoicewiris' + + # NOTE that this question type doesn't resolve any wiris variables + def parse_question(node) + question = super + + plugin_node = get_plugin_node(node, 'multichoicewiris') + + answer_parser = Parsers::AnswerParser.new + question.answers += plugin_node.search('answers/answer').map { |n| answer_parser.parse(n) } + + question.single = parse_boolean(plugin_node, 'multichoice/single') + question.shuffle = parse_boolean(plugin_node.search('multichoice'), 'shuffleanswers') + question.correctfeedback = parse_text(plugin_node.search('multichoice'), 'correctfeedback') + question.partiallycorrectfeedback = parse_text(plugin_node.search('multichoice'), 'partiallycorrectfeedback') + question.incorrectfeedback = parse_text(plugin_node.search('multichoice'), 'incorrectfeedback') + + question + end + end +end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/shortanswerwiris_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/shortanswerwiris_parser.rb new file mode 100644 index 0000000..2cb0ee8 --- /dev/null +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/shortanswerwiris_parser.rb @@ -0,0 +1,38 @@ +require 'byebug' + +module Moodle2AA::Moodle2 + class Parsers::QuestionParsers::Wiris::ShortnswerwirisParser < Moodle2AA::Moodle2::Parsers::QuestionParsers::Wiris::QuestionParser + register_parser_type 'shortanswerwiris' + + def parse_question(node) + question = super + + plugin_node = get_plugin_node(node, 'shortanswerwiris') + question_xml = get_wiris_node(node, 'shortanswerwiris') + + question.casesensitive = parse_boolean(plugin_node, 'shortanswer/usecase') + question.algorithms, question.algorithms_format = get_code(node, 'shortanswerwiris', question.id) + + if question_xml + question.tolerance = parse_text(question_xml, 'options/option[@name="tolerance"]').to_f + question.relative_tolerance = parse_boolean(question_xml, 'options/option[@name="relative_tolerance"]') + question.tolerance_digits = parse_boolean(question_xml, 'options/option[@name="tolerance_digits"]') + + # Defines how multi-part answers are graded + # - 'distribute' - Grade each part separately + # - 'and' - Grade the whole answer as one + question.grade_compound = parse_text(question_xml, '//localData/data[@name="gradeCompound"]') + + # In Compound answers, there's only one answer object, but it has multiple parts, we need to split it apart + question.has_compound_answer = parse_boolean(question_xml, "//localData/data[@name='inputCompound']") + question.initial_content = question_xml.at_xpath("//initialContent")&.children&.first&.text + question.initial_content = nil if question.initial_content == "" + else + question.has_compound_answer = false + question.initial_content = nil + end + + question + end + end +end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/trufalsewiris_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/trufalsewiris_parser.rb new file mode 100644 index 0000000..c58b5c9 --- /dev/null +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/trufalsewiris_parser.rb @@ -0,0 +1,19 @@ + +module Moodle2AA::Moodle2 + class Parsers::QuestionParsers::Wiris::TrufalsewirisParser < Moodle2AA::Moodle2::Parsers::QuestionParsers::Wiris::QuestionParser + register_parser_type 'truefalsewiris' + + def parse_question(node) + question = super + plugin_node = node.at_xpath('plugin_qtype_truefalsewiris_question') + question.true_false_id = plugin_node.at_xpath('truefalse/@id').value + question.true_answer = parse_text(plugin_node, 'truefalse/trueanswer') + question.false_answer = parse_text(plugin_node, 'truefalse/falseanswer') + + answer_parser = Parsers::AnswerParser.new + question.answers += plugin_node.search('answers/answer').map { |n| answer_parser.parse(n) } + + question + end + end +end diff --git a/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/wiris_parser.rb b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/wiris_parser.rb new file mode 100644 index 0000000..0f27f43 --- /dev/null +++ b/lib/moodle2aa/moodle2/parsers/question_parsers/wiris/wiris_parser.rb @@ -0,0 +1,155 @@ +require 'digest/md5' +require 'httparty' +require "byebug" + +module Moodle2AA::Moodle2 + class Parsers::QuestionParsers::Wiris::QuestionParser < Moodle2AA::Moodle2::Parsers::QuestionParsers::QuestionParser + + def parse_question(node, questiontype = nil) + question = super + + question.answers = get_answers(node, question.type) + + question.answers.each do |answer| + answer.answer_text_plain = clean_text(answer.answer_text) + end + + question.question_text_plain = clean_text(question.question_text) + + cas_session = get_cas_session(node, question.type) + if cas_session + question.precision = parse_number(cas_session, "/wiriscalc/properties/property[@name='precision']") + end + + question + end + + def clean_text(text) + text = fix_html(text) + html = Nokogiri::HTML(text) + html&.root&.text || text + end + + def fix_html(content) + # learnosity converts font-weight: bold to a nested , which messes up + # table formatting in ece 252 + content = content.gsub(/(]*)font-weight:\s*bold/, "\\1font-weight: 700") + # remove text-align on tables, as it breaks the WYSIWYG editor + content = content.gsub(/(]*)text-align:\s*[a-z]+\s*;?/, "\\1") + + content = content.gsub(/^

(.*)<\/p>$/m) do |match| + # if no other p tags, strip the outer to match learnosity convention + inner = $1 + if inner.match('

') + match + else + inner + end + end + + # convert mathml + lb = "\u00AB" + rb = "\u00BB" + quote = "\u00A8" + singlequote = "\u0060" + amp = "\u00A7" + re = / ]*>.+?<\/math> + | #{lb}math[^#{rb}]*#{rb}.+?#{lb}\/math#{rb} + /xm + content = content.gsub(re) do |match| + match.to_s.tr("#{lb}#{rb}#{quote}#{singlequote}#{amp}", "<>\"'&") + end + + # convert latex + latexre = /.*?)["\'])?>(.+?)<\/tex>|\$\$(?.+?)\$\$|\[tex\](?.+?)\[\/tex\]/m + content = content.gsub(latexre) do | match| + latex=$~[:e] + latex = latex.tr("\n"," ") + latex = latex.gsub(/\\\((.*?)\\\)/, "\\text{\\1}") + + '\\('+latex+'\\)' + end + + # fix mathml empty elements so learnosity doesn't strip them + content = content.gsub(/<\/mrow>/, " <\/mrow>") + content = content.gsub("", "\n") + content = content.gsub(//, "\n") + end + + def get_plugin_node(node, type) + node.at_xpath("plugin_qtype_#{type}_question") + end + + def get_wiris_node(node, type) + plugin_node = get_plugin_node(node, type) + return nil unless plugin_node + + question_xml = plugin_node.at_xpath("question_xml/xml") + + # Question doesn't have anything for us to parse out + return nil if question_xml.nil? || question_xml.text == "<question/>" + + Nokogiri::XML(question_xml.text) + end + + def get_cas_session(node, type) + wiris_node = get_wiris_node(node, type) + return nil unless wiris_node + + text = wiris_node.xpath("//wirisCasSession")&.text + return nil if text.empty? + + Nokogiri::XML(text) + end + + def get_answers(node, type) + plugin_node = get_plugin_node(node, type) + return [] unless plugin_node + + parser = Parsers::AnswerParser.new + plugin_node.search('answers/answer').map { |n| parser.parse(n) } + end + + def get_code(node, type, id) + sheet = get_wiris_node(node, type) + return [[], :none] if sheet.nil? + + cas_session = sheet.xpath("question/wirisCasSession").text + return [[], :none] if cas_session.empty? + + sheet_algorithms = get_algorithm_from_session(id, cas_session) + algorithms_format = :sheet + + [[sheet_algorithms], algorithms_format] + end + + def get_algorithm_from_session(id, cas_session) + cas_session_hash = Digest::MD5.hexdigest(cas_session) + filepath = "out/njit/cached_algorithms/#{id}_#{cas_session_hash}" + + if File.exist?(filepath) + File.read(filepath) + else + puts "Fetching algorithm for #{id}" + sleep rand(0..2) # Sleep for a random amount of time to (hopefully) avoid rate limiting + algorithms = convert_sheet_to_algorithm(id, cas_session) + + File.write(filepath, algorithms) + algorithms + end + end + + def convert_sheet_to_algorithm(id, cas_session) + res = HTTParty.post( + 'https://calcme.com/session2algorithm?httpstatus=true', + body: URI.encode_www_form({data: cas_session}) + ) + + if !res.success? + raise "Failed to fetch algorithms for #{id}" + end + + res.body + end + end +end diff --git a/lib/moodle2aa/moodle2converter/migrator.rb b/lib/moodle2aa/moodle2converter/migrator.rb index ae9ac3b..18ac325 100644 --- a/lib/moodle2aa/moodle2converter/migrator.rb +++ b/lib/moodle2aa/moodle2converter/migrator.rb @@ -44,7 +44,7 @@ def migrate() cc_course.resolve_question_references! @path = Moodle2AA::Learnosity::Migrator.new(@output_dir).migrate(moodle_course) - #@path = Moodle2AA::CanvasCC::CartridgeCreator.new(cc_course).create(@output_dir) if Moodle2AA::MigrationReport.generate_archive? + # @path = Moodle2AA::CanvasCC::CartridgeCreator.new(cc_course).create(@output_dir) if Moodle2AA::MigrationReport.generate_archive? Moodle2AA::MigrationReport.close end @path diff --git a/lib/moodle2aa/moodle2converter/question_converters/true_false_converter.rb b/lib/moodle2aa/moodle2converter/question_converters/true_false_converter.rb index 62ebdec..2830cde 100644 --- a/lib/moodle2aa/moodle2converter/question_converters/true_false_converter.rb +++ b/lib/moodle2aa/moodle2converter/question_converters/true_false_converter.rb @@ -2,6 +2,8 @@ module Moodle2AA::Moodle2Converter module QuestionConverters class TrueFalseConverter < QuestionConverter register_converter_type 'truefalse' + register_converter_type 'truefalsewiris' + self.canvas_question_type = 'true_false_question' def convert_question(moodle_question) @@ -14,4 +16,4 @@ def convert_question(moodle_question) end end end -end \ No newline at end of file +end diff --git a/test/acceptance/migrator_test.rb b/test/acceptance/migrator_test.rb index 56089e6..7ac9ee8 100644 --- a/test/acceptance/migrator_test.rb +++ b/test/acceptance/migrator_test.rb @@ -18,6 +18,6 @@ def teardown def test_it_creates_a_cc_package migrator = Moodle2AA::Migrator.new @source, @destination migrator.migrate - assert File.exists?(@package), "#{@package} not created" + assert File.exist?(@package), "#{@package} not created" end end diff --git a/test/unit/canvas/converter_test.rb b/test/unit/canvas/converter_test.rb index 902a76f..8715e33 100644 --- a/test/unit/canvas/converter_test.rb +++ b/test/unit/canvas/converter_test.rb @@ -19,7 +19,7 @@ def test_it_has_the_path_to_the_imscc_package end def test_it_creates_imscc_package - assert File.exists?(@converter.imscc_path) + assert File.exist?(@converter.imscc_path) end def test_it_creates_imsmanifest_xml @@ -165,7 +165,7 @@ def test_imsmanifest_has_file_resources end def test_it_deletes_all_files_except_imscc - refute File.exists? @converter.imscc_tmp_path - assert File.exists? @converter.imscc_path + refute File.exist? @converter.imscc_tmp_path + assert File.exist? @converter.imscc_path end end diff --git a/test/unit/cc/converter_test.rb b/test/unit/cc/converter_test.rb index 36a2719..b85ea5e 100644 --- a/test/unit/cc/converter_test.rb +++ b/test/unit/cc/converter_test.rb @@ -19,7 +19,7 @@ def test_it_has_the_path_to_the_imscc_package end def test_it_creates_imscc_package - assert File.exists?(@converter.imscc_path) + assert File.exist?(@converter.imscc_path) end def test_it_creates_imsmanifest_xml @@ -167,7 +167,7 @@ def test_imsmanifest_has_file_resources end def test_it_deletes_all_files_except_imscc - refute File.exists? @converter.imscc_tmp_path - assert File.exists? @converter.imscc_path + refute File.exist? @converter.imscc_tmp_path + assert File.exist? @converter.imscc_path end end diff --git a/test/unit/cc/web_link_test.rb b/test/unit/cc/web_link_test.rb index dee0370..4f1f92e 100644 --- a/test/unit/cc/web_link_test.rb +++ b/test/unit/cc/web_link_test.rb @@ -154,6 +154,6 @@ def test_it_does_not_create_xml_for_local_files tmp_dir = File.expand_path('../../../tmp', __FILE__) web_link = Moodle2AA::CC::WebLink.new @mod web_link.create_xml(tmp_dir) - refute File.exists?(File.join(tmp_dir, "#{web_link.identifier}.xml")), 'xml file was created for local file' + refute File.exist?(File.join(tmp_dir, "#{web_link.identifier}.xml")), 'xml file was created for local file' end end diff --git a/test/unit/migrator_test.rb b/test/unit/migrator_test.rb index 8d1296b..632e6f7 100644 --- a/test/unit/migrator_test.rb +++ b/test/unit/migrator_test.rb @@ -51,7 +51,7 @@ def test_it_converts_moodle_backup migrator = Moodle2AA::Migrator.new @valid_source, @valid_destination migrator.migrate imscc_file = File.join(@valid_destination, "my-course.imscc") - assert File.exists?(imscc_file), "#{imscc_file} does not exist" + assert File.exist?(imscc_file), "#{imscc_file} does not exist" end def test_it_detects_moodle2_package