Greasy Fork is available in English.

Memrise Course Spreadsheet

Allows to see all words of a course / selected levels and to export them in CSV format

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name           Memrise Course Spreadsheet
// @description    Allows to see all words of a course / selected levels and to export them in CSV format
// @match          http://*.memrise.com/course/*
// @match          https://*.memrise.com/course/*
// @match          https://*.memrise.com/community/course/*
// @run-at         document-end
// @version        1.4.3
// @grant          none
// @namespace      https://greasyfork.org/users/213706
// ==/UserScript==

/* jshint esversion: 8 */

if(typeof unsafeWindow == "undefined") {
  unsafeWindow = window;
}

//+--------------------------------------------------------
//|
//| ADD SPEADSHEET TAB
//|
//+--------------------------------------------------------

function onLoad() {
  if(typeof unsafeWindow.MEMRISE == "undefined") {
    return;
  }
  if(!document.getElementById('content') || document.body.classList.contains('course_edit')) {
    return;
  }

  // Get the current course canonical link and ID
  var navbar     = document.querySelector('.course-tabs-wrap .nav'),
      linkCourse = navbar.children[0].querySelector('a').getAttribute('href'),
      getId      = linkCourse.match(/\/course\/(\d+)\//);

  if(!getId || window.location.pathname != linkCourse) {
    return;
  }

  // Add tab Spreadsheet in navbar
  var link       = linkCourse + '#spreadsheet';
      idCourse   = getId[1];

  var li = document.createElement('li'),
      a  = document.createElement('a');

  a.setAttribute('href', link);
  a.innerHTML = 'Spreadsheet';
  li.appendChild(a);

  navbar.appendChild(li);

  // Handle switching to Spreadsheet tab
    var courseTitle      = document.querySelector('h1.course-name').innerText.trim(),
      docTitle           = `Spreadsheet - ${courseTitle} - Memrise`,
      openSpreadsheetTab = openTab.bind(null, {docTitle, linkCourse, idCourse}, li);  

  // ... via URL/click
  if(window.location.hash === "#spreadsheet") {
    openSpreadsheetTab();
  } else {
    li.addEventListener("click", function(){
      unsafeWindow.history.pushState({}, docTitle, link);

      openSpreadsheetTab();
    });
  }

  // ... via browser's back or forward button
  window.addEventListener('popstate', function(e){
    if(window.location.hash === "#spreadsheet") {
        openSpreadsheetTab();
    } else {
        window.location.reload();
    }
  });
}

//+--------------------------------------------------------
//|
//| RENDER CONTENT OF TAB (form)
//|
//+--------------------------------------------------------

function getCookies() {
  let cookie = {};
  document.cookie.split(';').forEach(function(el) {
    let [k,v] = el.split('=');
    cookie[k.trim()] = v;
  })
  return cookie;
}

/**
 * @param object courseInfo - {docTitle, linkCourse, idCourse}
 * @param DOMElement li     - li to set active in navbar
 */
function openTab(courseInfo, li) {
  if(li.classList.contains('active')) {
    return;
  }
  document.title = courseInfo.docTitle;

  // Set the class "active" on tab
  var tab = li;
  while(tab = tab.previousElementSibling) {
    tab.classList.remove("active");
  }
  li.classList.add("active");

  // Get the list of levels
  var container = document.getElementById('content').firstElementChild,
      levelElms = container.querySelectorAll('.level'),
      levels    = [],
      selectbox = {0: "", 1: ""};

  for(let i=0; i<levelElms.length; i++) {
    var elm   = levelElms[i],
        ico   = elm.querySelector('.level-ico').classList,
        level = {
          href : elm.getAttribute('href'),
          idx  : elm.querySelector('.level-index').innerText.trim(),
          title: elm.querySelector('.level-title').innerText.trim(),
          media: (ico.contains('level-ico-multimedia-inactive') || ico.contains('level-ico-multimedia'))
        };
    levels.push(level);
    selectbox[level.media ? 1 : 0] += `<option value="${i}" selected>${level.idx}. ${level.title}</option>`;
  }
  levelElms = null;

  // Empty the page
  container.innerHTML = `
    <style>
      #spreadsheet_conf legend { font-weight: 600; }
      #spreadsheet_conf :disabled { opacity: 0.5; }
      #spreadsheet_conf td { padding: 5px; }

      #spreadsheet_conf .form-inline div { float: right; }
      #spreadsheet_conf .form-check-label,
      #spreadsheet_conf .form-check-input { display: inline-block; }
      #spreadsheet_conf .actions { margin-top: 10px; }

      #spreadsheet_conf button.icon {
          font-size: 2em;
          background: none;
          border: 0;
          box-shadow: none;
          padding: 0;
          opacity: 0.5;
      }
      #spreadsheet_conf button.icon:hover {
          opacity: 1;
      }

      #spreadsheet .loading {
          width: 100%;
          height: 32px;
          position: relative;
          top: -10px;

          background-image: url("https://static.memrise.com/img/icons/loader@2x.gif");
          background-position: center center;
          background-size: 32px 32px;
          background-repeat: no-repeat;
      }
      #spreadsheet { border-top: 1px solid #e5e5e5; padding-top: 20px; }
      #spreadsheet:empty { border-top-color: transparent; }

      #spreadsheet table {
        background: white;
        table-layout: fixed;
        width: 100%;
      }
      #spreadsheet td,
      #spreadsheet th {
        border: 1px solid #e4e4e4;
        padding: 2px 5px;
        vertical-align: top;
      }
      #spreadsheet .num {
        background: rgba(0,0,0,0.03);
        text-align: right;
        width: 5%;
      }
      #spreadsheet td.num {
        color: rgba(0,0,0,0.6);
        white-space: nowrap;
      }
      #spreadsheet td.num.ignored {
        color: rgba(0,0,0,0);
      }
      #spreadsheet .score.left {
        border-right-color: transparent;
        padding-right: 0;
      }
      #spreadsheet .score.right::before {
        content: "/";
        color: rgba(0,0,0,0.2);
      }
      #spreadsheet .score.right.ignored::before {
        content: "-";
        color: rgba(0,0,0,0.6);
      }
      #spreadsheet .score.right {
        text-align: left;
        padding-left: 0;
      }
      #spreadsheet .score.often-missed { color:#ff725b; }
      #spreadsheet .score.sometimes-missed { color:#f08700; }

      #spreadsheet .course-title { font-weight: normal; font-size: 22px; }
      #spreadsheet audio, #spreadsheet video { display: block; max-width: 100%; }

      #spreadsheet .alt span { color: rgba(0,0,0,0.4); }
      #spreadsheet .alt span::before { content: "- "; }

      #spreadsheet .more { margin: 5px 0; }
      #spreadsheet .more + .more { margin-top: 10px; }
      #spreadsheet .more span { padding: 0 5px; line-height: 1em; }
      #spreadsheet .highlight {
        display: block;
        border-bottom: 2px solid white;
        color: rgba(0,0,0,0.4);
      }
    </style>`.replace(/\s+/g, ' ');

  var tooltip = document.querySelector('.tooltip.in');
  if(tooltip) {
    tooltip.parentNode.removeChild(tooltip);
    tooltip = null;
  }

  // Add the selectBox of levels / checkbox display alternatives
  container.innerHTML += `<form id="spreadsheet_conf">
    <legend class="form-label">Spreadsheet should contain ...</legend>

    ${(!selectbox[0] && !selectbox[1]) &&
      `<input type="hidden" id="export0" value="1">`
    || ""}

    <table>
    ${(selectbox[0] || selectbox[1]) &&
      ((selectbox[0] && selectbox[1]) && `
        <tr class="form-group">
          <td class="form-inline form-ab">
            <input class="form-input chooseExport" type="radio"
                   id="chooseExport0" name="chooseExport" value="0" checked>
            <label class="form-label" for="chooseExport0">Classic levels</label>
          </td>
          <td class="form-inline form-ab">
            <input class="form-input chooseExport" type="radio"
                   id="chooseExport1" name="chooseExport" value="1">
            <label class="form-label" for="chooseExport1">Multimedia levels</label>
          </td>
        </tr>`) +

      `<tr class="form-group">
        ${selectbox[0] && `
         <td class="form-inline form-ab">
           <select id="export0" multiple>${selectbox[0]}</select>
         </td>`}
         ${selectbox[1] && `
         <td class="form-inline form-ab">
           <select id="export1" multiple ${selectbox[0] ? 'disabled' : ''}>${selectbox[1]}</select>
         </td>`}
      </tr>`
    }

    <tr class="form-group">
      <td class="form-inline">
        Display:
        <div>
          <input class="form-input" type="checkbox"
                 id="exportAlt" name="exportAlt" value="1">
          <label class="form-label" for="exportAlt">Alternative answers</label>

          <br>
          <input class="form-input" type="checkbox"
                 id="exportMore" name="exportMore" value="1">
          <label class="form-label" for="exportMore">Additional informations</label>
        </div>
      </td>
    </tr>
    </table>

    <div class="actions">
      <button type="submit" name="render">Render</button>
      <button type="submit" name="export">Export CSV</button>
      <button type="button" id="exportInMemory" style="display: none"
              class="icon" title="Export CSV using data in memory (rendered below)">&DownArrowBar;</button>
    </div>
  </form>
  <div id="spreadsheet"></div>`;

  // Choose to export either multimedia or classic levels
  if(selectbox[0] && selectbox[1]) {
    document.getElementById('chooseExport0').addEventListener('click', chooseExport);
    document.getElementById('chooseExport1').addEventListener('click', chooseExport);

    function chooseExport(){
      var val = this.value;

      document.getElementById(`export${val}`).disabled = false;
      document.getElementById(`export${1 - val}`).disabled = true;
      document.getElementById(`exportAlt`).disabled = (val == 1);
      document.getElementById(`exportMore`).disabled = (val == 1);
    }
  }

  // Export using in memory data
  document.getElementById('exportInMemory').addEventListener("click", function(){
    new ExportInMemory();
  });

  // On render/export
  document.getElementById("spreadsheet_conf").addEventListener("submit", function(e){
    e.preventDefault();

    // Get the list of levels selected
    var levelsToExport = [],
        isMultimedia   = (typeof this.elements.export0 == "undefined" || this.elements.export0.disabled) ? 1 : 0,
        item           = this.elements[`export${isMultimedia}`],
        exportAlt      = !isMultimedia && this.elements.exportAlt.checked,
        exportMore     = !isMultimedia && this.elements.exportMore.checked;

    // Course with no level: retrieve level 1 (ex: /course/233943/livre-1001-phrases-pour-parler-allemand/)
    if(item.type && item.type == "hidden") {
      levelsToExport.push({
          href : courseInfo.linkCourse,
          idx  : 1,
          title: '',
          media: false
        });

    // Retrieve selected levels
    } else {
      for(let i = 0; i < item.options.length; i++) {
        if(item.options[i].selected) {
          var rank = item.options[i].value;

          levelsToExport.push(levels[rank]);
        }
      }
    }

    // Render or export spreadsheet
    if(isMultimedia) {
      if(document.activeElement.name == "export"){
        new ExportMultimedia(courseInfo.linkCourse, levelsToExport);
      } else {
        new SpreadSheetMultimedia(courseInfo.linkCourse, levelsToExport);
      }

    } else {
      if(document.activeElement.name == "export"){
        new Export(courseInfo.idCourse, levelsToExport, exportAlt, exportMore);
      } else {
        new SpreadSheet(courseInfo.idCourse, levelsToExport, exportAlt, exportMore);
      }
    }
  });
}

//+--------------------------------------------------------
//|
//| RENDER SPREADSHEET (table)
//|
//+--------------------------------------------------------

class SpreadSheet {

  // DOMElement this.body
  // integer    this.idCourse
  // array      this.levels
  // boolean    this.exportAlt

  /**
   * @param string idCourse
   * @param array levels
   * @param boolean exportAlt  - Export alternatives answers
   * @param boolean exportMore - Export extra columns (visible_info, hidden_info, attributes)
   */
  constructor(idCourse, levels, exportAlt, exportMore) {
    this.idCourse   = idCourse;
    this.levels     = levels;
    this.exportAlt  = exportAlt;
    this.exportMore = exportMore;
    this.cookies    = getCookies();

    // Display a loader
    var container  = document.getElementById("spreadsheet"),
        loading    = this.createLoader(container);

    // Create the spreadsheet
    this.extraHeaders = {};
    this.createBody(container);
    this.createContent(loading);
  }

  /**
   * Create the loader and it to the container
   * @param DOMElement container
   * @return DOMElement
   */
  createLoader(container) {
    var loading = document.createElement("div");

    loading.setAttribute("class", "loading");
    container.innerHTML = "";
    container.appendChild(loading);

    document.getElementById('exportInMemory').style.display = 'none';
    return loading;
  }

  /**
   * Create a table
   * @return DOMElement
   */
  createBody(container) {
    var table = document.createElement("table");
    container.appendChild(table);

    table.innerHTML = `<thead><tr>
      <th class="lvl-idx num">Level</th>
      <th class="item-idx num">#</th>
      <th class="item-label">Label</th>
      <th class="item-definition">Definition</th>
      <th class="score num" colspan="2">Score</th>
      ${this.exportMore ? `<th class="item-more">More</th>` : ``}
      </tr></thead>
      <tbody></tbody>`;

    this.body = table.lastElementChild;
  }

  /**
   * Populate the body
   */
  async createContent(loading) {
    var n       = this.levels.length-1,
        hasErr  = false;

    for(let i = 0; i <= n; i++) {
      let level = this.levels[i];

      let options = {
        method: 'POST',
        credentials: 'include',
        referrer: `https://${window.location.host}/aprender/preview?course_id=${this.idCourse}&level_index=${level.idx}`,
        headers: {
          'Content-Type': 'application/json',
          'X-CSRFToken': this.cookies['csrftoken'] ?? '',
        },
        body: JSON.stringify({
          session_source_id: parseInt(this.idCourse),
          session_source_sub_index: parseInt(level.idx),
          session_source_type: "course_id_and_level_index",
        }),
      };
      await fetch(`https://${window.location.host}/v1.21/learning_sessions/preview/`, options)
      .then((response) => {
        // Returns 400 if column b isn't defined
        return response.ok ? response.json() : null;
      })
      .then((data) => {
        if(data) {
          var rows   = data.learnables,
              scores = data.progress; // current user scores

          for(let j = 0; j < rows.length; j++) {
            var item = rows[j];

            this.createRow(
              level,
              j,
              item.screens[1], // includes attributes as well
              scores && scores[j]
            );
          }
        }
        if(i == n && loading){
          loading.parentNode.removeChild(loading);
          loading = null;
        }

      }).catch((e) => {
        hasErr = true;
        unsafeWindow.console.error(e);

        loading.setAttribute('class', 'alert alert-danger');
        loading.innerHTML = `Something went wrong. Please contact the developer of this script if the error persists.`;
      });

      if(hasErr) {
        break;
      }
    }
    this.end(hasErr);
  }

  /**
   * Returns the URL to retrieve the words of a level
   * @param string|integer idLevel
   * @return string
   */
  getUrl(idLevel) {
    return `https://${window.location.host}/ajax/session/?course_id=${this.idCourse}&level_index=${idLevel}&session_slug=preview`;
  }

  /**
   * Create a new row
   * @param object level  - Data about current level
   * @param integer j     - Current row number
   * @param object data   - Row data
   * @param object score  - User score for current word
   * @param object data
   */
  createRow(level, j, data, score) {
    var tr   = document.createElement('tr'),
        html = "";

    html  = `<td class="lvl-idx num"><a href="${level.href}">${level.idx}</a></td>`;
    html += `<td class="item-idx num">${j+1}</td>`;
    html += `<td class="item-label">${this.getValue(data.item)}</td>`;
    html += `<td class="item-definition">${this.getValue(data.definition)}</td>`;
    html += this.getScore(score);

    if(this.exportMore) {
      html += `<td class="item-more">`;
      html += data.visible_info.map(it => {this.addExtraHeader(it.label); return `<div class="more"><span class="highlight">${it.label}</span> ${this.getValue(it, false)}</div>`;}).join('');
      html += data.hidden_info.map(it => {this.addExtraHeader(it.label); return `<div class="more"><span class="highlight">${it.label}</span> ${this.getValue(it, false)}</div>`;}).join('');
      html += data.attributes.map(it => {this.addExtraHeader(it.label); return `<div class="more"><span class="highlight">${it.label}</span> <span>${escapeHTML(it.value)}</span></div>`;}).join('');
      html += `</td>`;
    }
    tr.innerHTML = html;
    this.body.appendChild(tr);
  }

  /**
   * Keep in mind the extra columns in "More"
   * To be able to export the rendered table to CSV
   * @param string label
   */
  addExtraHeader(label) {
    if(typeof this.extraHeaders[label] == 'undefined') {
      let k = Object.keys(this.extraHeaders).length;

      this.extraHeaders[label] = k;
    }
  }

  /**
   * Returns HTML: the content of the columnm (text, image, audio or video)
   * @param object item
   * @param boolean[optional] checkAlt - [true] Used to disable alternatives in additionnal informations
   * @return string
   */
  getValue(item, checkAlt=true) {
    var txt = "";

    switch(item.kind) {
      case "text" :
        txt = `<span>${escapeHTML(item.value)}</span>`;
        if(checkAlt && this.exportAlt) {
          for(let i=0; i<item.alternatives.length; i++) {
            txt += `<div class="alt">`;
            txt += `<span>${escapeHTML(item.alternatives[i])}</span>`;
            txt += `</div>`;
          }
        }
        break;

      case "image":
        txt = `<img src=${item.value[0]} class="text-image" />`;
        if(checkAlt && this.exportAlt) {
          for(let i=1; i<item.value.length; i++) {
            txt += `<div class="alt">`;
            txt += `<img src=${item.value[i]} class="text-image" />`;
            txt += `</div>`;
          }
        }
        break;

      case "audio":
        txt = `<audio src=${item.value[0].normal} controls></audio>`;
        if(checkAlt && this.exportAlt) {
          for(let i=1; i<item.value.length; i++) {
            txt += `<div class="alt">`;
            txt += `<audio src=${item.value[i].normal} controls></audio>`;
            txt += `</div>`;
          }
        }
        break;

      case "video":
        txt = `<video src=${item.value[0]} controls>Your browser does not support the video tag.</video>`;
        if(checkAlt && this.exportAlt) {
          for(let i=1; i<item.value.length; i++) {
            txt += `<div class="alt">`;
            txt += `<video src=${item.value[i]} controls>Your browser does not support the video tag.</video>`;
            txt += `</div>`;
          }
        }
        break;

      default:
        return "";
    }
    return txt;
  }

  /**
   * Returns HTML: the user's score (correct/attemps)
   * @param object score
   * @return string
   */
  getScore(score) {
    if(!score || !score.attempts) {
      return '<td class="score num" colspan="2">-</td>';
    }
    var successRate, className;

    if(score.ignored) {
      successRate = 'Ignored';
      className   = 'ignored';
    } else {
      successRate = parseInt(score.correct / score.attempts * 100) + '%';
      className   = (successRate == 100 ? "never-missed"
                     : (successRate < 20 ? "often-missed"
                        : (successRate > 80 ? "rarely-missed" : "sometimes-missed")));
    }
    return `<td class="score left num ${className}" title="${successRate}">${this.truncateNum(""+score.correct)}</td>
            <td class="score right num ${className}" title="${successRate}">${this.truncateNum(""+score.attempts)}</td>`;
  }
  
  /**
   * Make sure the number isn't longer than length, or truncate the left (1012, 3 => 12)
   * @param string str
   * @param integer[optional] length - [3]
   * @return string
   */
  truncateNum(str, length=3) {
    if(str <= length) {
      return str;
    }
    return str.substring(str.length-length).replace(/^0+/, '');
  }

  /**
   * Return the filename of the generated CSV
   * @return string
   */
  getFilename() {
    var filename = 'Memrise-' + idCourse;

    if(this.levels.length == 1) {
      filename += '-' + this.levels[0].idx;
    }
    return filename + '.csv';
  }

  /**
   * Called when all levels have been fetched and rendered
   * We keep extra data needed to export the data in-memory
   * (rather than fetching all levels all over again)
   *
   * @param boolean hasErr  Used by subclass
   */
  end(hasErr) {
    this.body.dataset.file = this.getFilename();

    // Keep extra headers labels to export current data
    if(Object.keys(this.extraHeaders).length) {
      let extra = [];

      for(let label in this.extraHeaders) {
        extra[this.extraHeaders[label]] = label;
      }
      this.body.dataset.extraHeaders = JSON.stringify(extra);

    } else {
      delete this.body.dataset.extraHeaders;
    }

    document.getElementById('exportInMemory').style.display = null;
  }
}

//+--------------------------------------------------------
//|
//| RENDER SPREADSHEET - MULTIMEDIA (table)
//|
//+--------------------------------------------------------

class SpreadSheetMultimedia {

  // DOMElement this.body
  // string     this.urlCourse
  // array      this.levels
  
  /**
   * @param string urlCourse
   * @param array levels
   */
  constructor(urlCourse, levels) {
    this.urlCourse = urlCourse;
    this.levels    = levels;

    // Display a loader
    var container = document.getElementById("spreadsheet"),
        loading   = this.createLoader(container);

    // Create the spreadsheet
    this.createBody(container);
    this.createContent(loading);
  }

  /**
   * Create the loader and it to the container
   * @param DOMElement container
   * @return DOMElement
   */
  createLoader(container) {
    var loading = document.createElement("div");

    loading.setAttribute("class", "loading");
    container.innerHTML = "";
    container.appendChild(loading);

    document.getElementById('exportInMemory').style.display = 'none';
    return loading;
  }

  /**
   * Create a table
   * @return DOMElement
   */
  createBody(container) {
    var table = document.createElement("table");
    container.appendChild(table);

    table.innerHTML = `<thead><tr>
      <th class="lvl-idx num">Level</th>
      <th class="item-definition">Content</th>
      </tr></thead>
      <tbody></tbody>`;

    this.body = table.lastElementChild;
  }
  
  /**
   * Populate the body
   */
  async createContent(loading) {
    var n      = this.levels.length-1,
        hasErr = false;

    for(let i = 0; i <= n; i++) {
      let level = this.levels[i];

      await fetch(this.getUrl(level.idx), {
        credentials: "same-origin"
      })
      .then((response) => response.text())
      .then((html) => {
        var data = html.match(/^ *var level_multimedia =(.*)/m);
        if(!data) {
          // Empty level (ex: /course/50121/flags-of-the-world/9/)
          return "";
        }
        eval('data = ' + data[1].trim());
        return data;
      })
      .then((data) => {
        this.createRow(level, data);

        if(i == n){
          loading.parentNode.removeChild(loading);
          loading = null;
        }

      }).catch((e) => {
        hasErr = true;
        unsafeWindow.console.error(e);

        loading.setAttribute('class', 'alert alert-danger');
        loading.innerHTML = `Something went wrong. Please contact the developer of this script if the error persists.`;
      });

      if(hasErr) {
        break;
      }
    }
    this.end(hasErr);
  }

  /**
   * Returns the URL to retrieve the words of a level
   * @param string|integer idLevel
   * @return string
   */
  getUrl(idLevel) {
    return `https://${window.location.host}${this.urlCourse}${idLevel}/`;
  }

  /**
   * Create a new row
   * @param object data
   */
  createRow(level, data) {
    var tr   = document.createElement('tr'),
        html = "";

    html  = `<td class="lvl-idx num"><a href="${level.href}">${level.idx}</a></td>`;
    html += `<td class="item-label">
               <h3 class="course-title">${escapeHTML(level.title)}</h3>
               <div class="multimedia-raw" style="display: none">${escapeHTML(data)}</div>
               <div class="multimedia-wrapper">${this.parseMarkdown(data)}</div>
             </td>`;

    tr.innerHTML = html;
    this.body.appendChild(tr);
    unsafeWindow.MEMRISE.renderer.do_embeds(unsafeWindow.$(tr));
  }

  /**
   * Converts Markdown to HTML using Memrise's renderer
   * @param string txt
   * @return string
   */
  parseMarkdown(txt) {
    return unsafeWindow.MEMRISE.renderer.rich_format(txt);
  }

  /**
   * Return the filename of the generated CSV
   * @return string
   */
  getFilename() {
    var filename = 'Memrise-' + idCourse;

    if(this.levels.length == 1) {
      filename += '-' + this.levels[0].idx;
    }
    return filename + '-multimedia.csv';
  }

  /**
   * Called when all levels have been fetched and rendered
   * We keep extra data needed to export the data in-memory
   * (rather than fetching all levels all over again)
   *
   * @param boolean hasErr  Used by subclass
   */
  end(hasErr) {
    this.body.dataset.file = this.getFilename();
    delete this.body.dataset.extraHeaders;

    document.getElementById('exportInMemory').style.display = null;
  }

}

//+--------------------------------------------------------
//|
//| EXPORT CSV
//|
//+--------------------------------------------------------

class Export extends SpreadSheet {

  /**
   * Create the loader and it to the container
   * @param DOMElement container
   * @return DOMElement
   */
  createLoader(container) {
    var loading = document.createElement("div");
    loading.setAttribute("class", "loading");

    if(container.children.length) {
      container.insertBefore(loading, container.firstElementChild);
    } else {
      container.appendChild(loading);
    }
    return loading;
  }
  
  /**
   * Init the content of the CSV
   * @return DOMElement
   */
  createBody(container) {
    this.body    = '';
    this.headers = {};
  }

  /**
   * Create a new row
   * @param object level
   * @param integer j
   * @param object data
   * @param object score
   */
  createRow(level, j, data, score) {
    this.body += level.idx + ',';
    this.body += (j+1) + ',';
    this.body += this.getValue(data.item) + ',';
    this.body += this.getValue(data.definition) + ',';

    if(score && score.attempts){
      this.body += score.correct + ',';
      this.body += score.attempts + ',';
      this.body += parseInt(score.correct / score.attempts * 100);
    } else {
      this.body += ',,';
    }

    // Retrieve additional columns
    if(this.exportMore) {
      var arr = [];

      this.getExtraColumns(arr, data.visible_info);
      this.getExtraColumns(arr, data.hidden_info);
      this.getExtraColumns(arr, data.attributes);

      // Add columns
      for(let i=0; i<arr.length; i++){
        this.body += ',';
        this.body += arr[i] || '';
      }
    }
    this.body += '\n';
  }

  /**
   * Retrieves the additional content in data
   * And puts it in the right place in arr
   * @param array arr
   * @param object[pointer] data
   */
  getExtraColumns(arr, data){
    var k;
    for(let i=0; i<data.length; i++) {
      var it = data[i];

      if(typeof this.headers[it.label] != 'undefined') {
        k = this.headers[it.label];
      } else {
        k = Object.keys(this.headers).length;
        this.headers[it.label] = k;
      }

      if(typeof it.kind != "undefined") {
        arr[k] = this.getValue(it, false);
      } else {
        arr[k] = escapeCSV(it.value);
      }
    }
  }

  /**
   * Returns CSV-escaped text: the content of the column (text, image, audio or video)
   * @param object item
   * @param boolean[optional] checkAlt - [true] Used to disable alternatives in additionnal informations
   * @return string
   */
  getValue(item, checkAlt=true) {
    var txt;

    switch(item.kind) {
      case "text" :
        txt = item.value;
        if(checkAlt && this.exportAlt) {
          for(let i=0; i<item.alternatives.length; i++) {
            txt += '\n' + item.alternatives[i];
          }
        }
        break;

      case "image":
        txt = item.value[0];
        if(checkAlt && this.exportAlt) {
          for(let i=1; i<item.value.length; i++) {
            txt += '\n' + item.value[i];
          }
        }
        break;

      case "audio":
        txt = item.value[0].normal;
        if(checkAlt && this.exportAlt) {
          for(let i=1; i<item.value.length; i++) {
            txt += '\n' + item.value[i].normal;
          }
        }
        break;

      case "video":
        txt = item.value[0];
        if(checkAlt && this.exportAlt) {
          for(let i=1; i<item.value.length; i++) {
            txt += '\n' + item.value[i];
          }
        }
        break;

      default:
        return "";
    }
    return escapeCSV(txt);
  }

  /**
   * Trigger download of the CSV (in-memory)
   * @param boolean hasErr
   */
  end(hasErr) {
    if(hasErr) {
      return;
    }
    download(this.getFilename(), this.getHeaders() + '\n' + this.body);
  }

  /**
   * Retrieve all headers
   * Includes visible_info / hidden_info / attributes if that option was checked
   * @return string
   */
  getHeaders() {
    var headers = 'Level,#,Label,Definition,Correct,Attempts,Score %';

    if(!this.exportMore) {
      return headers;
    }
    var extra = [];
    for(var label in this.headers) {
      extra[this.headers[label]] = escapeCSV(label);
    }
    return headers + (extra.length ? ',' + extra.join(',') : '');
  }
}

//+--------------------------------------------------------
//|
//| EXPORT CSV - MULTIMEDIA
//|
//+--------------------------------------------------------


class ExportMultimedia extends SpreadSheetMultimedia {

  /**
   * Create the loader and it to the container
   * @param DOMElement container
   * @return DOMElement
   */
  createLoader(container) {
    var loading = document.createElement("div");
    loading.setAttribute("class", "loading");

    if(container.children.length) {
      container.insertBefore(loading, container.firstElementChild);
    } else {
      container.appendChild(loading);
    }
    return loading;
  }
  
  /**
   * Init the content of the CSV
   * @return DOMElement
   */
  createBody(container) {
    this.body = 'Level,Title,Content\n';
  }
  
  /**
   * Create a new row
   * @param object data
   */
  createRow(level, data) {
    this.body += level.idx + ',';
    this.body += escapeCSV(level.title) + ',';
    this.body += escapeCSV(data) + '\n';
  }

  /**
   * Trigger download of the CSV
   * @param boolean hasErr
   */
  end(hasErr) {
    if(!hasErr) {
      download(this.getFilename(), this.body);
    }
  }
}

//+--------------------------------------------------------
//|
//| EXPORT IN-MEMORY
//|
//+--------------------------------------------------------

class ExportInMemory {

  /**
   * Entrypoint
   */
  constructor() {
    var container    = document.getElementById('spreadsheet'),
        body         = container.querySelector('tbody'),
        filename     = body.dataset.file;

    var extraHeaders = this.decodeExtraHeaders(body.dataset.extraHeaders),
        headers      = Array.from(container.querySelector('thead tr').children)
                            .map(node => node.innerText);

    var csv = this.getHeaders(headers, extraHeaders)
            + '\n'
            + this.getData(body, headers, extraHeaders);

    download(filename || ('Memrise-' + idCourse + '.csv'), csv);
  }

  /**
   * @param array headers
   * @param array extraHeaders
   * @return string
   */
  getHeaders(_headers, extraHeaders) {
    var headers = [..._headers];

    var k = headers.indexOf('Score');
    if(k != -1) {
      headers.splice(k, 1, ...['Correct', 'Attempts', 'Score %']);
    }
    k = headers.indexOf('More');
    if(k != -1) {
      headers.splice(k, 1, ...extraHeaders);
    }
    k = headers.indexOf('Content');
    if(k != -1) {
      headers.splice(k, 1, ...['Title', 'Content']);
    }
    return headers.map(label => escapeCSV(label)).join(',');
  }

  /**
   * Retrieve the JSON-decoded list of extra headers
   * Or an empty list
   *
   * @param string|undefined data
   * @return array
   */
  decodeExtraHeaders(data) {
    return typeof data == 'undefined' ? [] : JSON.parse(data);
  }

  /**
   * Retrieve the rendered table as a CSV string
   * @param DOMElement body
   * @return string
   */
  getData(body, headers, extraHeaders) {
    var csv = '';

    for(let i=0; i<body.children.length; i++) {
      let tr    = body.children[i],
          data  = [];

      let k = 0;
      for(let j=0; j<headers.length; j++) {
        let label = headers[j],
            td    = tr.children[k];

        switch(label) {
           case 'Level':
           case '#':
             csv += td.innerText + ',';
             break;

           case 'Score':
             if(!td.hasAttribute('colspan')) {
                let correct = parseInt(td.innerText, 10),
                    attempt = parseInt(tr.children[k+1].innerText, 10);
                k++;

                csv += correct + ',';
                csv += attempt + ',';
                csv += parseInt(correct/attempt * 100);
              } else {
                csv += ',,';
              }

              break;

            case 'More':
              let more  = td.querySelectorAll('.more'),
                  extra = {};

              // Retrieve all additionnal that have been defined
              for(let j2=0; j2<more.length; j2++) {
                let label   = more[j2].firstElementChild.innerText,
                    content = this.getValue(more[j2].lastElementChild);

                 extra[label] = escapeCSV(content);
              }

              // Put them in order
              for(let j2=0; j2<extraHeaders.length; j2++) {
                let label = extraHeaders[j2];

                csv += ',' + (typeof extra[label] == 'undefined' ? '' : extra[label]);
              }
              break;

          // Multimedia
          case 'Content':

            csv += escapeCSV(td.children[0].innerText.trim()) + ',';
            csv += escapeCSV(td.children[1].innerText.trim());
            break;

          default:
             csv += escapeCSV(this.getValue(td.firstElementChild, true)) + ',';
             break;
        }
        k++;
      }
      csv += '\n';
    }
    return csv;
  }

  /**
   * Retrieve the text of a DOMElement
   * @param DOMElement node
   * @param boolean siblings - [false] Return the content of siblings too
   * @return string
   */
  getValue(node, siblings=false) {
    if(["IMG", "AUDIO", "VIDEO"].indexOf(node.nodeName) != -1) {
      var links = Array.from(node.parentNode.querySelectorAll(node.nodeName))
                       .map(node => node.getAttribute('src'));
      return links.join('\n');
    } else {
      return siblings ? node.parentNode.innerText : node.innerText;
    }
  }
}

//+--------------------------------------------------------
//|
//| COMMON FONCTIONS (in-memory)
//|
//+--------------------------------------------------------

/**
 * Escape HTML
 * @param string txt
 * @return txt
 */
function escapeHTML(txt) {
  if(typeof txt != "string") {
    return "";
  }
  return txt.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

/**
 * Escape text for CSV
 * Surround with quotes and escape quotes inside text
 */
function escapeCSV(txt) {
  if(typeof txt != "string") {
    return "";
  }
  return '"' + txt.replace(/"/g, '""') + '"';
}

/**
 * Trigger download of a file
 * @param string filename
 * @param string txt
 */
function download(filename, txt) {
  var blob = new Blob([txt], {type: 'text/csv'});

  if(window.navigator.msSaveOrOpenBlob) {
    window.navigator.msSaveBlob(blob, filename);

  } else {
    var link  = window.document.createElement('a');
    link.href = window.URL.createObjectURL(blob);
    link.download = filename;

    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  }
}

window.addEventListener('load', onLoad, false);