// ==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)">⤓</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, '<').replace(/>/g, '>');
}
/**
* 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);