mwilliams / Batch Upload Sections

// ==UserScript==
// @name         Batch Upload Sections
// @namespace    mw784
// @version      1
// @license      MIT
// @description  Batch Upload Section shells direct in the settings area of a Canvas course site.
// @author       Matthew Williams
// @include      https://canvas.newcastle.edu.au/courses/*/settings
// @include      https://newcastle.test.instructure.com/courses/*/settings
// ==/UserScript==

(function () {
  'use strict';

  const uniqueLinkId = 'mw_section_upload';
  addSectionButton('Import Course Sections', 'icon-upload');

  function addSectionButton(linkText, iconType) {
    if (!document.getElementById(uniqueLinkId)) {
      const insBefore = document.querySelector('aside#right-side > div > .import_content');
      if (insBefore) {
        const anchor = document.createElement('a');
        anchor.id = uniqueLinkId;
        anchor.classList.add('Button', 'Button--link', 'Button--link--has-divider', 'Button--course-settings');
        const icon = document.createElement('i');
        icon.classList.add(iconType);
        anchor.appendChild(icon);
        anchor.appendChild(document.createTextNode(`${linkText} `));
        anchor.addEventListener('click', openDialog);
        insBefore.parentNode.insertBefore(anchor, insBefore);
      }
    }
    return;
  }

  function createDialog() {
    var el = document.querySelector('#mw_sections_dialog');
    if (!el) {
      el = document.createElement('div');
      el.id = 'mw_sections_dialog';
      el.classList.add('ic-Form-control');
      var label = document.createElement('label');
      label.htmlFor = 'mw_section_text';
      label.textContent = 'Section Data';
      label.classList.add('ic-Label');
      el.appendChild(label);
      var textarea = document.createElement('textarea');
      textarea.setAttribute('rows', '9');
      textarea.id = 'mw_section_text';
      textarea.classList.add('ic-Input');
      textarea.placeholder = `Paste tab-delimited section data from Excel into this textbox, without headers.\n
First column should be section name, second column should be section SIS ID (optional), third column should be Student ID (optional). For example:
\nC1A \tC1A.NURS1234.2022.S1\tc1111111
C1A \tC1A.NURS1234.2022.S1\tc2222222
C1B \tC1B.NURS1234.2022.S1\tc3333333
O1A \tO1A.NURS1234.2022.S1\tc4444444
O1B \tO1B.NURS1234.2022.S1\tc5555555`;
      el.appendChild(textarea);
      var msg = document.createElement('div');
      msg.id = 'mw_rubric_msg';
      msg.classList.add('ic-flash-warning');
      msg.style.display = 'none';
      el.appendChild(msg);
      var parent = document.querySelector('body');
      parent.appendChild(el);
    }
  }

  function openDialog() {
    try {
      createDialog();

      $('#mw_sections_dialog').dialog({
        'title': 'Import Sections and Section Enrolments',
        'autoOpen': false,
        'buttons': [{
          'text': 'Cancel',
          'click': closeDialog
        }, {
          'text': 'Import',
          'click': checkDialog,
          'class': 'Button Button--primary'
        }],

        'modal': true,
        'height': 'auto',
        'width': '80%'
      });
      if (!$('#mw_sections_dialog').dialog('isOpen')) {
        $('#mw_sections_dialog').dialog('open');
      }
    } catch (e) {
      console.log(e);
    }
  }

  function checkDialog() {
    var rawtext = document.getElementById('mw_section_text');
    var courseIdFlag = checkCourseId();
    if (!courseIdFlag) {
      alert('Unable to determine where to import sections.');
      return;
    }

    if (rawtext.value && rawtext.value.trim() !== '' && courseIdFlag) {
      parseDialog(rawtext.value)
    } else {
      alert('You must paste your section data into the textbox.');
    }
  }

  function checkCourseId() {
    const courseId = getCourseId();
    if (!(Number(courseId))) {
      return false;
    }
    else {
      return true;
    }
  }

  function parseDialog(txt) {
    var linesOfText = txt.split('\n');

    // remove possible newlines at start and end
    while (linesOfText.at(-1) === '' || linesOfText.at(0) === '') {
      if (linesOfText.at(-1) === '') {
        linesOfText.pop()
      }
      else if (linesOfText.at(-0) === '') {
        linesOfText.shift()
      }
    }

    // split each line up by \t
    // rows becomes an array of objects
    var rows = [];
    for (let i = 0; i < linesOfText.length; i++) {
      rows[i] = {
        sectionName: linesOfText[i].split('\t')[0],
        sectionSISId: linesOfText[i].split('\t')[1],
        studentId: linesOfText[i].split('\t')[2]
      }

      // abort if row contains a studentId but no sectionSISId
      if (!rows[i]['sectionSISId'] && rows[i]['studentId']) {
        alert(`Student cannot be imported to a section without a SIS ID. (check row ${i + 1})`);
        return;
      }

      // abort if row contains no sectionName or sectionSISId
      if (!rows[i]['sectionName']) {
        alert(`Row does not contain section name. (check row ${i + 1})`);
        return;
      }

    }

    //create arrays of unique sections for posting and unique students for posting
    const uniqueSections = removeDuplicateSections(rows);
    const uniqueStudents = removeDuplicateStudents(rows);

    // remove students from uniqueStudents who aren't already enrolled in Canvas - to avoid enrolling them inadvertently
    const uniqueStudentsInCourse = removeStudentsNotInCourse(uniqueStudents);

    processDialog(uniqueSections, uniqueStudentsInCourse);
  }

  function removeStudentsNotInCourse(uniqueStudents) {
    var settings = {
      "url": `${window.location.origin}/api/v1/courses/${getCourseId()}/enrollments`,
      "type": "GET",
      "timeout": 0,
      "async": false,
      "headers": {
        "X-CSRFToken": getCsrfToken()
      },
    }

    var resp = $.parseJSON($.ajax(settings).responseText);

    const studentsInCourse = [];
    for (let i = 0; i < resp.length; i++) {
      studentsInCourse[i] = resp[i].user.sis_user_id
    }

    //compare uniqueStudents with studentsInCourse
    const uniqueStudentsInCourse = []
    for (let i = uniqueStudents.length - 1; i >= 0; i--) {
      if (studentsInCourse.indexOf(uniqueStudents[i]['studentId']) !== -1) uniqueStudentsInCourse.push(uniqueStudents[i])
    }

    return uniqueStudentsInCourse;
  }

  function processDialog(uniqueSections, uniqueStudentsInCourse) {
    for (let i = 0; i < uniqueSections.length; i++) {
      var settings = {
        "url": `${window.location.origin}/api/v1/courses/${courseId}/sections`,
        "type": "POST",
        "timeout": 0,
        "async": false,
        "data": {
          'course_section': {
            'name': uniqueSections[i]['sectionName'],
            'sis_section_id': uniqueSections[i]['sectionSISId'],
          }
        },
        "headers": {
          "X-CSRFToken": getCsrfToken()
        },
      };

      postSection(settings);
    }

    

    for (let i = 0; i < uniqueStudentsInCourse.length; i++) {
      var settings = {
        "url": `${window.location.origin}/api/v1/sections/sis_section_id:${uniqueStudentsInCourse[i]['sectionSISId']}/enrollments`,
        "type": "POST",
        "timeout": 0,
        "async": false,
        "data": {
          'enrollment': {
            'user_id': `sis_user_id:${uniqueStudentsInCourse[i]['studentId']}`,
            'enrollment_state': 'active',
            'notify': false,
          }
        },
        "headers": {
          "X-CSRFToken": getCsrfToken()
        },
      };

      postStudent(settings);
    }

    $('#mw_sections_dialog').dialog('close');
    window.location.reload(true);
  }

  function postStudent(settings){
    $.ajax(settings).fail(function () {
      console.log(`Student "${settings['data']['enrollment']['user_id']}" couldn't be imported.`)
    });
  }

  function postSection(settings) {
    $.ajax(settings).fail(function () {
      console.log(`Section "${settings['data']['course_section']['name']}" couldn't be imported. Most likely the SIS ID is already in use.`)
    });
  }

  function removeDuplicateSections(input2dArray) {
    var flagArray = [];
    var outputArray = [];
    var j = -1;
    for (var i = 0, l = input2dArray.length; i < l; i++) {
      if (flagArray[input2dArray[i]['sectionSISId']] !== true) {
        flagArray[input2dArray[i]['sectionSISId']] = true;
        outputArray[++j] = {
          sectionName: input2dArray[i]['sectionName'],
          sectionSISId: input2dArray[i]['sectionSISId']
        }
      }
      else if (!input2dArray[i]['sectionSISId'] || input2dArray[i]['sectionSISId'].length === 0) {
        outputArray[++j] = {
          sectionName: input2dArray[i]['sectionName'],
          sectionSISId: ''
        }
      }
    }

    return outputArray;
  }

  function removeDuplicateStudents(input2dArray) {
    var flagArray = [];
    var outputArray = [];
    var j = -1;
    for (var i = 0, l = input2dArray.length; i < l; i++) {
      if (flagArray[input2dArray[i]['studentId']] !== true) {
        flagArray[input2dArray[i]['studentId']] = true;
        outputArray[++j] = {
          sectionSISId: input2dArray[i]['sectionSISId'],
          studentId: input2dArray[i]['studentId']
        }
      }
    }

    return outputArray;
  }

  function closeDialog() {
    $(this).dialog('close');
    var el = document.getElementById('mw_section_text');
    if (el) {
      el.value = '';
    }
  }

  function getCsrfToken() {
    var csrfRegex = new RegExp('^_csrf_token=(.*)$');
    var csrf;
    var cookies = document.cookie.split(';');
    for (var i = 0; i < cookies.length; i++) {
      var cookie = cookies[i].trim();
      var match = csrfRegex.exec(cookie);
      if (match) {
        csrf = decodeURIComponent(match[1]);
        break;
      }
    }
    return csrf;
  }

  function getCourseId() {
    let id = false;
    const courseRegex = new RegExp('^/courses/([0-9]+)');
    const matches = courseRegex.exec(window.location.pathname);
    if (matches) {
      id = matches[1];
    }
    return id;
  }

})();