Greasy Fork is available in English.

WaniKani Review Answer History

Displays the history of answers for each item in review sessions

// ==UserScript==
// @name         WaniKani Review Answer History
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  Displays the history of answers for each item in review sessions
// @author       Wantitled
// @match        https://www.wanikani.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wanikani.com
// @grant        none
// @license      MIT
// ==/UserScript==

// Checks for Wanikani Open Framework
if (!window.wkof){
    if(
        confirm(` WaniKani Review Answer History requires Wanikani Open Framework.
            Click "OK" to be forwarded to installation instructions.`)){
        window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549'}
    return;
}

// Inits
let WKAnswerHistory
var observer;
var input;
var itemElem;
const url = window.location.href;
let item, items_by_id;
let bySlug;
let pageItem, itemType;

// Item data only needed for review sessions
wkof.include('ItemData');
wkof.ready('ItemData').then(get_history).then(get_items).then(check_history_data).then(initiate);

async function check_history_data() {
    if (WKAnswerHistory["radicals"]){
        console.log("Old item type name found, renaming to 'radical'.");
        WKAnswerHistory["radical"] = WKAnswerHistory["radicals"];
        delete WKAnswerHistory["radicals"];
        wkof.file_cache.save("WKAnswerHistory", WKAnswerHistory);
    }
    if ((/\D/.test(Object.keys(WKAnswerHistory["radical"])[0]) && Object.keys(WKAnswerHistory["radical"]).length > 0 )
        || (/\D/.test(Object.keys(WKAnswerHistory["kanji"])[0]) && Object.keys(WKAnswerHistory["kanji"]).length > 0 )
        || (/\D/.test(Object.keys(WKAnswerHistory["vocabulary"])[0]) && Object.keys(WKAnswerHistory["vocabulary"]).length > 0)){
        alert("Old item name(s) found, please wait a moment...");
        wkof.file_cache.save("WKAnswerHistory_backup", WKAnswerHistory);
        bySlug = wkof.ItemData.get_index(item, "slug");
        for (let item_type in WKAnswerHistory){
            for (let item_slug in WKAnswerHistory[item_type]){
                if (/\D/.test(item_slug)){
                    let item_id = get_id(item_slug, item_type);
                    WKAnswerHistory[item_type][item_id] = WKAnswerHistory[item_type][item_slug];
                    delete WKAnswerHistory[item_type][item_slug];
                }
            }
        }
        wkof.file_cache.save("WKAnswerHistory", WKAnswerHistory);
        alert("Items converted!");
    }
}

const get_id = (slug, type) => {
    const matches = bySlug[slug];
    if (Array.isArray(matches)) {
        return matches.find(m => m.object === type)?.id;
    } else {
        return matches?.id;
    }
}

// Gets the answer history
async function get_history () {
    if (!wkof.file_cache.dir["WKAnswerHistory"]){
        WKAnswerHistory = {
            "radical": {}, "kanji": {}, "vocabulary": {}
        };
        if (confirm("Wanikani Review Answer History has not detected any review answer data. If this is your first time using the script, click OK. If this is a mistake, please cancel.")){
            wkof.file_cache.save("WKAnswerHistory", WKAnswerHistory);
        }
    } else {
        WKAnswerHistory = await wkof.file_cache.load("WKAnswerHistory");
    }
}

// Gets item data
async function get_items() {
    item = await wkof.ItemData.get_items('assignments');
    items_by_id = wkof.ItemData.get_index(item, 'subject_id');
}

// Adds the input to the item history object
const save_data = (answer, itemType, item, status, language, override) => {
    if (!WKAnswerHistory[itemType][item]){
        WKAnswerHistory[itemType][item] = {
            "answers": [],
            "timestamps": [],
            "itemStatus": [],
            "SRSLevel": [],
            "language": []
        }
    }
    if (!override) {
        let lang;
        if (language === null){
            lang = "en";
        } else {
            lang = "ja";
        }
        WKAnswerHistory[itemType][item].answers.push(answer);
        WKAnswerHistory[itemType][item].timestamps.push(getTimestamp());
        WKAnswerHistory[itemType][item].itemStatus.push(status);
        WKAnswerHistory[itemType][item].SRSLevel.push(returnItemInfo());
        WKAnswerHistory[itemType][item].language.push(lang);
    } else {
        WKAnswerHistory[itemType][item].itemStatus[WKAnswerHistory[itemType][item].itemStatus.length - 1] = status;
    }
    wkof.file_cache.save("WKAnswerHistory", WKAnswerHistory);
}

// Gets time and date of review (seconds are included as the same item can be reviewed a few seconds apart)
const getTimestamp = () => {
    let time = new Date();
    let addZero = (num) => {
        if (String(num).length === 1){
            num = "0" + String(num);
        }
        return num;
    }
    let year = time.getFullYear(); let month = addZero(parseInt(time.getMonth()) + 1); let day = addZero(time.getDate());
    let hours = time.getHours(); let minutes = addZero(time.getMinutes()); let seconds = addZero(time.getSeconds())
    return year + "/" + month + "/" + day + ", " + hours + ":" + minutes + ":" + seconds ;
}

// Gets current item data
const returnItemInfo = () => {
    let review_item = $.jStorage.get('currentItem');
    let item = items_by_id[review_item.id];
    return getSRSLevel(item)
}

const additional_info = (item, info) => {
    let item_obj = items_by_id[item];
    switch (info){
        case "unlock": return item_obj?.assignments?.unlocked_at;break;
        case "lesson": return item_obj?.assignments?.started_at;break;
        case "guru": return item_obj?.assignments?.passed_at;break;
        case "burn": return item_obj?.assignments?.burned_at;break;
        case "resurrect": return item_obj?.assignments?.resurrected_at;break;
    }
}

// Gets the current item's SRS level (the level the item is being reviewed at)
const getSRSLevel = (wkof_item) => {return wkof_item?.assignments?.srs_stage ?? -1}

// Status checker checks for a class change on the input field which signifies an answer or an override
const statusChecker = (fieldsetElem, lastClass) => {
    observer = new MutationObserver((mutationsList) => {

        let itemType = itemElem.classList[0];
        let item = $.jStorage.get('currentItem').id;
        let answer = input.value;
        let lang = input.getAttribute("lang");

        for(let mutation of mutationsList) {
            if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
                let currentClass = mutation.target.classList[0];
                if (currentClass){
                    if (lastClass === undefined) {
                        lastClass = currentClass
                        switch (currentClass){
                            case "correct": save_data(answer, itemType, item, currentClass, lang, false);break;
                            case "incorrect": save_data(answer, itemType, item, currentClass, lang, false);break;
                        }
                    } else {
                        switch (currentClass){
                            case "correct": save_data(answer, itemType, item, currentClass, lang, true);break;
                            case "incorrect": save_data(answer, itemType, item, currentClass, lang, true);break;
                            case "WKO_ignored": save_data(answer, itemType, item, currentClass, lang, true);break;
                            default: break;
                        }
                    }
                } else {lastClass = currentClass}
            }}
    });
    observer.observe(fieldsetElem, {attributes: true});
}

// Creates the table headers for the table on the item page
const createHeaders = () => {
    let headers = document.createElement("tr");

    const reviewTypeHeader = document.createElement("th");
    reviewTypeHeader.innerText = "Review Type";

    const answerHeader = document.createElement("th");
    answerHeader.innerText = "Answer";

    const srsHeader = document.createElement("th");
    srsHeader.innerText = "SRS Level";

    const timestampHeader = document.createElement("th");
    timestampHeader.innerText = "Date Answered";

    headers.appendChild(reviewTypeHeader);
    headers.appendChild(answerHeader);
    headers.appendChild(srsHeader);
    headers.appendChild(timestampHeader);
    return headers;
}

// Creates the table rows and inserts the data for the item page table
const buildTable = (itemObj, tbody) => {;
    let milestones = get_milestones(pageItem);
    console.log(milestones);
    for (let i = itemObj.answers.length - 1; i >= 0; i--){
        for (let j = milestones.milestone_time.length - 1; j >= 0; j--){
            let time_obj = new Date(milestones.milestone_time[j]);
            if (convertTime(itemObj.timestamps[i]).getTime() < time_obj.getTime()){
                let milestone_tr = construct_milestone(milestones.milestone_name[j], milestones.milestone_time[j]);
                tbody.appendChild(milestone_tr);
                milestones.milestone_name.pop();
                milestones.milestone_time.pop();
            }
        }

        let tr = document.createElement("tr");
        tr.style.backgroundColor = getColor(itemObj.itemStatus[i]);
        tr.style.color = "#FFF";

        let ans_td = document.createElement("td");
        ans_td.innerText = itemObj.answers[i];
        if (itemObj.language[i] === "ja"){ans_td.setAttribute("lang", "ja");}
        ans_td.style.textAlign = "center";

        let srs_td = document.createElement("td");
        srs_td.innerText = srs(itemObj.SRSLevel[i]) + " Level";
        srs_td.style.textAlign = "center";

        let date_td = document.createElement("td");
        date_td.innerText = fix_date(convertTime(itemObj.timestamps[i]));
        date_td.style.textAlign = "center";

        let lang_td = document.createElement("td");
        if (itemType === "radical"){
            lang_td.innerText = "Name";
        } else {
            lang_td.innerText = reviewType(itemObj.language[i]);
        }
        lang_td.style.textAlign = "center";

        tr.appendChild(lang_td);
        tr.appendChild(ans_td);
        tr.appendChild(srs_td);
        tr.appendChild(date_td);
        tbody.appendChild(tr);

        if (i === 0){
            for (let j = milestones.milestone_time.length - 1; j >= 0; j--){
                let time_obj = new Date(milestones.milestone_time[j]);
                let milestone_tr = construct_milestone(milestones.milestone_name[j], milestones.milestone_time[j]);
                tbody.appendChild(milestone_tr);
            }
        }
    }
}

// Converts the answer status to the corresponding color
const getColor = (status) => {
    switch (status) {
        case "correct": return "#88CC00";break;
        case "incorrect": return "#FF0033"; break;
        case "WKO_ignored": return "#FFCC00"; break;
    }
}

// Converts the language of the item to get the review type
const reviewType = (lang) => {
    if (lang === "ja"){return "Reading"}
    else {return "Meaning"}
}

const convertTime = (time) => {
    let first_half = time.substr(0,time.indexOf(","));
    let second_half = time.substr(time.indexOf(" ") + 1, time.length);
    first_half = first_half.replaceAll("/", "-");
    let converted_time = new Date(first_half + "T" + second_half);
    return converted_time;
}

// Gets the srs level from the srs value
const srs = (srsKey) => {
    switch (srsKey){
        case -1: return "Missing";break;
        case 1: return "Apprentice 1";break;
        case 2: return "Apprentice 2";break;
        case 3: return "Apprentice 3";break;
        case 4: return "Apprentice 4";break;
        case 5: return "Guru 1";break;
        case 6: return "Guru 2";break;
        case 7: return "Master";break;
        case 8: return "Enlightened";break;
        default: return "";break;
    }
}

const get_milestones = (item_id) => {
    let milestonesArr = ["unlock", "lesson", "guru", "burn", "resurrect"];
    let milestones = {"milestone_name": [], "milestone_time": [],}
    for (let i = 0; i < milestonesArr.length; i++){
        let milestone_time = additional_info(item_id, milestonesArr[i]);
        if (milestone_time !== null){
            milestones.milestone_name.push(milestonesArr[i]);
            milestones.milestone_time.push(milestone_time);
        }
    }
    return milestones;
}

const construct_milestone = (milestone, time) => {
    let milestone_tr = document.createElement("tr");
    milestone_tr.style.backgroundColor = milestone_color(milestone)
    milestone_tr.style.lineHeight = "1.2rem";
    milestone_tr.style.color = "#FFF";
    milestone_tr.style.fontWeight = "500";

    let type_td = document.createElement("td");
    type_td.innerText = "Milestone";
    type_td.style.textAlign = "center";

    let milestone_td = document.createElement("td");
    milestone_td.innerText = milestone_text(milestone);
    milestone_td.style.textAlign = "center";

    let time_td = document.createElement("td");
    let time_obj = new Date(time);
    time_td.innerText = fix_date(time_obj);
    time_td.style.textAlign = "center";


    milestone_tr.appendChild(type_td);
    milestone_tr.appendChild(document.createElement("td"));
    milestone_tr.appendChild(milestone_td);
    milestone_tr.appendChild(time_td);
    return milestone_tr;
}

const fix_date = (time) => {
    let first_half = time.toDateString();
    let second_half = time.toLocaleTimeString()
    return first_half + ", " + second_half;
}

const milestone_color = (milestone) => {
    switch (milestone){
        case "unlock": return "#A4A4A4";break;
        case "lesson": return "#F400A2";break;
        case "guru": return "#9D34B7";break;
        case "burn": return "#4F4F4F";break;
        case "resurrect": return "#FAAF0E";break;
    }
}

const milestone_text = (milestone) => {
    switch (milestone){
        case "unlock": return "Unlocked";break;
        case "lesson": return "Completed Lesson";break;
        case "guru": return "First Guru";break;
        case "burn": return "Burned";break;
        case "resurrect": return "Resurrected";break;
    }
}


function initiate() {
    'use strict';

    // Checks for the review page to collect answer data
    if (/\/review\/session+/.test(url)){
        input = document.getElementById("user-response");
        itemElem = document.getElementById("character");

        let fieldsetElem = input.parentElement;
        let lastClass = fieldsetElem.classList[0];

        statusChecker(fieldsetElem, lastClass);
    }
    // Checks for an item info page to display the data
    if (/\/radicals\/+/.test(url) || /\/kanji\/+/.test(url) || /\/vocabulary\/+/.test(url)){

        // Gets the page's item
        pageItem = parseInt(document.querySelector('meta[name=subject_id]').content);
        itemType = url.substring(url.indexOf("wanikani.com/") + 13, url.lastIndexOf("/"));
        if (itemType === "radicals"){itemType = "radical"};

        // Adds navigation button to top bar
        const history_li = document.createElement("li");
        const history_a = document.createElement("a");
        history_a.innerText = "Review History";
        history_a.setAttribute("href", "#history");
        history_li.appendChild(history_a);
        if (document.querySelector("[href='#progress']")){
            let progress_li = document.querySelector("[href='#progress']").parentNode;
            document.querySelector(".page-list-header").parentNode.insertBefore(history_li, progress_li);
        } else {
            document.querySelector(".page-list-header").parentNode.appendChild(history_li);
        }

        // Section element for the review history
        const reviewHistorySection = document.createElement("section");
        reviewHistorySection.setAttribute("id", "history");
        reviewHistorySection.style.fontFamily = '"Ubuntu", Helvetica, Arial, sans-serif';
        reviewHistorySection.style.fontSize = "16px";

        // Title for the section
        const sectionHead = document.createElement("h2");
        sectionHead.innerText = "Review History";

        // Table element
        const table = document.createElement("table");
        table.style.width = "100%";
        table.style.lineHeight = "1.5rem";

        // Body section of the table
        const tbody = document.createElement("tbody");

        // Headers for the table
        let headers = createHeaders();
        headers.style.position = "sticky";
        headers.style.top = "0";
        headers.style.backgroundColor = "#eee";
        headers.style.boxShadow = "0 2px 2px -1px rgba(0, 0, 0, 0.2)";
        table.appendChild(headers);

        // Table wrapped in a div to allow scrolling when the table is taller than 500px
        const tableDiv = document.createElement("div");

        // Displays the table if item data is found, otherwise displays a messsage
        if (WKAnswerHistory[itemType][pageItem]){
            buildTable(WKAnswerHistory[itemType][pageItem], tbody);
            table.appendChild(tbody);

            tableDiv.style.maxHeight = "500px";
            tableDiv.style.display = "block";
            tableDiv.style.overflowY = "auto";

            tableDiv.appendChild(table);
        } else {
            tableDiv.innerText = ("No answers have been recorded for this item yet.");
            tableDiv.style.color = "#666";
        }
        reviewHistorySection.appendChild(sectionHead);
        reviewHistorySection.appendChild(tableDiv);

        if (itemType === "radical"){
            document.getElementById("information").parentNode.appendChild(reviewHistorySection);
        } else {
            document.getElementById("meaning").parentNode.appendChild(reviewHistorySection);
        }
    }
};