// ==UserScript==
// @name WK Custom SRS
// @namespace leohumnew.wk
// @version 0.2.6
// @description Add custom word packs to WaniKani!
// @author leohumnew
// @match https://www.wanikani.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=wanikani.com
// @grant GM.setValue
// @grant GM.getValue
// @run-at document-start
// ==/UserScript==
(async() => {
// ------------------------ Define and create custom HTML structures ------------------------
const srsNames = ["Lesson", "Apprentice 1", "Apprentice 2", "Apprentice 3", "Apprentice 4", "Guru 1", "Guru 2", "Master", "Enlightened", "Burned"];
// --------- Main popup ---------
let overviewPopup = document.createElement("dialog");
overviewPopup.id = "overview-popup";
let overviewPopupStyle = document.createElement("style");
// Styles copied in from styles.css
overviewPopupStyle.innerHTML = /*css*/ `
/* General styling */
.content-box {
background-color: var(--color-wk-panel-background);
border-radius: 3px;
padding: 1rem;
}
/* Main popup styling */
#overview-popup {
background-color: var(--color-menu, white);
width: 60%;
max-width: 50rem;
height: 50%;
max-height: 40rem;
border: none;
border-radius: 3px;
box-shadow: 0 0 1rem rgb(0 0 0 / .5);
&:focus-visible {
border: none }
& p {
margin: 0 }
& input, & select {
margin-bottom: 0.5rem }
& button {
cursor: pointer;
background-color: transparent;
border: none;
&[type="submit"], &.outline-button {
border: 1px solid var(--color-text);
border-radius: 5px;
padding: 0.2rem 0.8rem;
}
}
& button:hover {
color: var(--color-tertiary, #a5a5a5);
&[type="submit"], &.outline-button {
border-color: var(--color-tertiary, #a5a5a5) }
}
& > header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--color-tertiary, --color-text);
margin-bottom: 1rem;
& > h1 {
font-size: x-large;
color: var(--color-tertiary, --color-text);
}
& > button {
border: none;
color: var(--color-tertiary, --color-text);
font-size: x-large;
&:hover {
color: var(--color-text) }
&:focus-visible {
outline: none }
}
}
&::backdrop {
background-color: rgba(0, 0, 0, 0.5) }
}
/* Styling for top tabs */
#tabs {
display: flex;
flex-wrap: wrap;
& > input {
display: none }
& > label {
cursor: pointer;
padding: 0.5rem 1rem;
max-width: 20%;
}
& > div {
display: none;
padding: 1rem;
order: 1;
width: 100%;
}
& > input:checked + label {
color: var(--color-tertiary, --color-text);
border-bottom: 2px solid var(--color-tertiary, gray);
}
& > input:checked + label + div {
display: initial }
}
/* Styling for the overview tab */
#tab-1__content > div {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 1.5rem;
text-align: center;
& p {
font-size: xxx-large }
}
/* Styling for packs in the packs tab */
#tabs .pack {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
& span {
font-style: italic }
& > div {
margin: auto 0 }
& button {
margin-left: 10px }
& > div > span, & > div > input {
margin: 0;
vertical-align: middle;
}
}
/* Styling for the pack edit tab */
#tab-3__content > .content-box {
margin: 1rem 0;
& input, & ul {
margin-left: 0 }
& > div {
margin-top: 1.5rem }
& div {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
& li {
margin: 0.25rem;
justify-content: space-between;
display: flex;
& button {
margin-left: 10px }
}
& li:hover {
color: var(--color-tertiary, rgb(165, 165, 165));
}
}
#tab-3__content:has(#pack-select [value="new"]:checked) .content-box :is(div, ul) {
display: none }
#tab-3__content:has(#pack-select [value="import"]:checked) .pack-box {
display: none }
#tab-3__content:has(#pack-select [value="import"]:checked) .import-box {
display: grid !important }
#pack-items {
background-color: var(--color-menu, white) }
/* Styling for the item edit tab */
#tab-4__content:has(#item-type [value="Vocabulary"]:checked) #item-vocab-specific {
display: grid !important }
#tab-4__content:has(#item-type [value="Kana-vocabulary"]:checked) #item-kanavocab-specific {
display: grid !important }
#tab-4__content:has(#item-type [value="Kanji"]:checked) #item-kanji-specific {
display: grid !important }
#tab-4__content .content-box, #tab-4__content .content-box > div, #tab-3__content > .content-box {
display: grid;
gap: 0.5rem;
grid-template-columns: 1fr 1fr;
align-items: center;
& input, & select {
justify-self: end }
}
`;
overviewPopup.innerHTML = /*html*/ `
<header>
<h1>WaniKani Custom SRS</h1>
<button class="close-button fa-regular fa-xmark" onclick="document.getElementById('overview-popup').close();"></button>
</header>
<div id="tabs">
<input type="radio" name="custom-srs-tab" id="tab-1" checked>
<label for="tab-1">Overview</label>
<div id="tab-1__content">
<div>
<div class="content-box">
<h2>Lessons</h2>
<p>0</p>
</div>
<div class="content-box">
<h2>Reviews</h2>
<p></p>
</div>
</div>
</div>
<input type="radio" name="custom-srs-tab" id="tab-2">
<label for="tab-2">Packs</label>
<div id="tab-2__content"></div>
<input type="radio" name="custom-srs-tab" id="tab-3">
<label for="tab-3">Edit Pack</label>
<div id="tab-3__content">
<label for="pack-select">Pack: </label>
<select id="pack-select"></select><br>
<form class="content-box pack-box">
<label for="pack-name">Name: </label>
<input id="pack-name" required type="text">
<label for="pack-author">Author: </label>
<input id="pack-author" required type="text">
<label for="pack-version">Version: </label>
<input id="pack-version" required type="number" step="0.1">
<div style="grid-column: 1 / span 2">
<p>Items: </p>
<button id="new-item-button" class="fa-regular fa-plus" title="Add Item" type="button"></button>
</div>
<ul style="grid-column: 1 / span 2" class="content-box" id="pack-items"></ul>
<button style="grid-column: 1 / span 2" type="submit">Save</button>
</form>
<form class="content-box import-box" style="display: none;">
<label for="item-type">Paste Pack JSON here: </label>
<textarea id="pack-import" required></textarea>
<button style="grid-column: 1 / span 2" type="submit">Import</button>
</form>
</div>
<input type="radio" name="custom-srs-tab" id="tab-4">
<label for="tab-4">Edit Item</label>
<div id="tab-4__content">
<div>Select item from Pack edit tab.</div>
<form class="content-box" style="display: none;">
<label for="item-type">Type: </label>
<select id="item-type">
<option value="Radical">Radical</option>
<option value="Kanji">Kanji</option>
<option value="Vocabulary">Vocabulary</option>
<option value="Kana-vocabulary">Kana Vocabulary</option>
</select>
<label for="item-characters">Characters: </label>
<input id="item-characters" required type="text">
<label for="item-meanings">Meanings (comma separated): </label>
<input id="item-meanings" required type="text">
<label for="item-meaning-explanation">Meaning Explanation: </label>
<input id="item-meaning-explanation" type="text">
<div id="item-vocab-specific" style="display: none; grid-column: 1 / span 2">
<label for="item-readings">Readings (comma separated): </label>
<input id="item-readings" type="text">
<label for="item-reading-explanation">Reading Explanation: </label>
<input id="item-reading-explanation" type="text">
</div>
<div id="item-kanavocab-specific" style="display: none; grid-column: 1 / span 2">
<label for="item-kana-readings">Readings (comma separated): </label>
<input id="item-kana-readings" type="text">
</div>
<div id="item-kanji-specific" style="display: none; grid-column: 1 / span 2">
<label for="knaji-primary-reading">Primary Reading: </label>
<select id="kanji-primary-reading">
<option value="onyomi">On'yomi</option>
<option value="kunyomi">Kun'yomi</option>
<option value="nanori">Nanori</option>
</select>
<label for="kanji-onyomi">On'yomi: </label>
<input id="kanji-onyomi" type="text">
<label for="kanji-kunyomi">Kun'yomi: </label>
<input id="kanji-kunyomi" type="text">
<label for="kanji-nanori">Nanori: </label>
<input id="kanji-nanori" type="text">
<label for="kanji-reading-explanation">Reading Explanation: </label>
<input id="kanji-reading-explanation" type="text">
</div>
<button style="grid-column: 1 / span 2" type="submit">Add</button>
</form>
</div>
<input type="radio" name="custom-srs-tab" id="tab-5">
<label for="tab-5">Settings</label>
<div></div>
</div>
`;
// --------- Popup open button ---------
let overviewPopupButton, buttonLI;
if (window.location.pathname.includes("/review")) {
overviewPopupButton = document.createElement("a");
overviewPopupButton.classList = "chat-button quiz-footer__button";
overviewPopupButton.innerText = "WK Custom SRS";
overviewPopupButton.style = `
padding: 8px 10px;
color: #999;
`;
} else if (window.location.pathname.includes("/dashboard") || window.location.pathname === "/") {
overviewPopupButton = document.createElement("button");
overviewPopupButton.classList = "sitemap__section-header";
overviewPopupButton.style = `
display: flex;
align-items: center;
`;
let buttonSpan = document.createElement("span");
buttonSpan.classList = "font-sans";
buttonSpan.innerText = "WK Custom SRS";
overviewPopupButton.appendChild(buttonSpan);
buttonLI = document.createElement("li");
buttonLI.classList = "sitemap__section";
buttonLI.appendChild(overviewPopupButton);
}
overviewPopupButton.title = "Custom SRS";
overviewPopupButton.onclick = () => {
changeTab(1);
overviewPopup.showModal();
};
// --------- Add custom elements to page ---------
document.addEventListener("DOMContentLoaded", () => {
document.head.appendChild(overviewPopupStyle);
document.body.appendChild(overviewPopup);
// Add event listeners for buttons etc.
for(let i = 1; i <= 5; i++) {
document.querySelector(`#tab-${i}`).onchange = () => {
changeTab(i) };
}
document.querySelector("#pack-select").onchange = () => {
loadPackEditDetails(document.querySelector("#pack-select").value) };
document.querySelector("#new-item-button").onclick = () => {
changeTab(4, null) };
// Add popup button to page
if (window.location.pathname.includes("/review")) {
document.querySelector(".quiz-footer .quiz-footer__content").prepend(overviewPopupButton);
} else if (window.location.pathname.includes("/dashboard") || window.location.pathname === "/") {
document.querySelector("#sitemap").prepend(buttonLI);
}
});
// ---------- Change tab ----------
function changeTab(tab, data) {
document.querySelector(`#tab-${tab}`).checked = true;
switch(tab) {
case 1:
updateOverviewTab();
break;
case 2:
updatePacksTab();
break;
case 3:
updateEditPackTab(data);
break;
case 4:
updateEditItemTab(data);
break;
}
}
// ---------- Update popup content ----------
function updateOverviewTab() {
//document.querySelector("#tab-1__content .content-box:first-child p").innerText = activePackProfile.getActiveLessons().length;
document.querySelector("#tab-1__content .content-box:last-child p").innerText = activePackProfile.getNumActiveReviews();
}
function updatePacksTab() {
let packsTab = document.querySelector("#tab-2__content");
packsTab.innerHTML = "";
for(let i = 0; i < activePackProfile.customPacks.length; i++) {
let pack = activePackProfile.customPacks[i];
let packElement = document.createElement("div");
packElement.classList = "pack content-box";
packElement.innerHTML = `
<h3>${pack.name}: <span>${pack.items.length} items</span><br><span>${pack.author}</span></h3>
<div>
<span>Active: </span>
<input type="checkbox" id="pack-${i}-active" ${pack.active ? "checked" : ""}>
<button class="edit-pack fa-regular fa-pen-to-square" title="Edit Pack"></button>
<button class="export-pack fa-regular fa-file-export" title="Export Pack"></button>
<button class="delete-pack fa-regular fa-trash" title="Delete Pack"></button>
</div>
`;
packElement.querySelector(".edit-pack").onclick = () => { // Pack edit button
changeTab(3, i);
};
packElement.querySelector(".export-pack").onclick = () => { // Pack export button to make JSON and then copy it to the clipboard
let data = StorageManager.packToJSON(activePackProfile.customPacks[i]);
navigator.clipboard.writeText(data).then(() => {
alert("Pack JSON copied to clipboard");
});
};
packElement.querySelector(".delete-pack").onclick = () => { // Pack delete button
activePackProfile.removePack(i);
StorageManager.savePackProfile(activePackProfile, "main");
changeTab(2);
};
packElement.querySelector(`#pack-${i}-active`).onchange = () => { // Pack active checkbox
activePackProfile.customPacks[i].active = !activePackProfile.customPacks[i].active;
StorageManager.savePackProfile(activePackProfile, "main");
};
packsTab.appendChild(packElement);
}
// New pack button
let newPackButton = document.createElement("button");
newPackButton.classList = "outline-button";
newPackButton.style = "width: 48%";
newPackButton.innerHTML = "New Pack";
newPackButton.onclick = () => {
changeTab(3, "new");
};
let importPackButton = document.createElement("button");
importPackButton.classList = "outline-button";
importPackButton.style = "width: 48%; float: right;";
importPackButton.innerHTML = "Import Pack";
importPackButton.onclick = () => {
changeTab(3, "import");
};
packsTab.append(newPackButton, importPackButton);
}
function updateEditPackTab(editPack) {
let packSelect = document.querySelector("#pack-select");
packSelect.innerHTML = "<option value='new'>New Pack</option><option value='import'>Import Pack</option>";
for(let i = 0; i < activePackProfile.customPacks.length; i++) {
let pack = activePackProfile.customPacks[i];
packSelect.innerHTML += `<option value="${i}">${pack.name} - ${pack.author}</option>`;
}
if(editPack !== undefined) packSelect.value = editPack;
else packSelect.value = "new";
packSelect.onchange();
}
function updateEditItemTab(editItem) {
if(editItem !== undefined) {
// Show add item edit tab and make sure inputs are empty
document.querySelector("#tab-4__content > form").style.display = "grid";
document.querySelector("#tab-4__content > div").style.display = "none";
if(editItem !== null) {
let editItemDetails = activePackProfile.customPacks[document.querySelector("#pack-select").value].getItem(editItem);
document.querySelector("#item-type").value = editItemDetails.type;
document.querySelector("#item-characters").value = editItemDetails.characters;
document.querySelector("#item-meanings").value = editItemDetails.meanings.join(", ");
document.querySelector("#item-meaning-explanation").value = editItemDetails.meaning_explanation;
if(editItemDetails.readings) document.querySelector("#item-readings").value = editItemDetails.readings.join(", ");
if(editItemDetails.primary_reading_type) document.querySelector("#kanji-primary-reading").value = editItemDetails.primary_reading_type;
if(editItemDetails.onyomi) document.querySelector("#kanji-onyomi").value = editItemDetails.onyomi.join(", ");
if(editItemDetails.kunyomi) document.querySelector("#kanji-kunyomi").value = editItemDetails.kunyomi.join(", ");
if(editItemDetails.nanori) document.querySelector("#kanji-nanori").value = editItemDetails.nanori.join(", ");
if(editItemDetails.reading_explanation) {
document.querySelector("#item-reading-explanation").value = editItemDetails.reading_explanation;
document.querySelector("#kanji-reading-explanation").value = editItemDetails.reading_explanation;
}
document.querySelector("#tab-4__content button[type='submit']").innerText = "Update";
} else {
document.querySelector("#item-characters").value = "";
document.querySelector("#item-meanings").value = "";
document.querySelector("#item-readings").value = "";
document.querySelector("#kanji-onyomi").value = "";
document.querySelector("#kanji-kunyomi").value = "";
document.querySelector("#kanji-nanori").value = "";
document.querySelector("#item-meaning-explanation").value = "";
document.querySelector("#item-reading-explanation").value = "";
document.querySelector("#kanji-reading-explanation").value = "";
document.querySelector("#tab-4__content button[type='submit']").innerText = "Add";
}
// Add event listener to form
document.querySelector("#tab-4__content form").onsubmit = (e) => {
e.preventDefault();
let itemType = document.querySelector("#item-type").value;
let characters = document.querySelector("#item-characters").value;
let meanings = document.querySelector("#item-meanings").value.split(",").map(s => s.trim());
let meaningExplanation = document.querySelector("#item-meaning-explanation").value;
let readings, readingExplanation;
let pack = activePackProfile.customPacks[document.querySelector("#pack-select").value];
// Add or edit item
switch(itemType) {
case "Radical":
if(editItem !== null) pack.editRadical(editItem, characters, meanings, meaningExplanation);
else pack.addRadical(characters, meanings, meaningExplanation);
break;
case "Kanji":
let primary_reading_type = document.querySelector("#kanji-primary-reading").value;
let onyomi = document.querySelector("#kanji-onyomi").value;
onyomi = onyomi.trim() ? onyomi.split(",").map(s => s.trim()) : [];
let kunyomi = document.querySelector("#kanji-kunyomi").value;
kunyomi = kunyomi.trim() ? kunyomi.split(",").map(s => s.trim()) : [];
let nanori = document.querySelector("#kanji-nanori").value;
nanori = nanori.trim() ? nanori.split(",").map(s => s.trim()) : [];
readingExplanation = document.querySelector("#kanji-reading-explanation").value;
if(editItem !== null) pack.editKanji(editItem, characters, meanings, primary_reading_type, onyomi, kunyomi, nanori, meaningExplanation, readingExplanation);
else pack.addKanji(characters, meanings, primary_reading_type, onyomi, kunyomi, nanori, meaningExplanation, readingExplanation);
break;
case "Vocabulary":
readings = document.querySelector("#item-readings").value.split(",").map(s => s.trim());
readingExplanation = document.querySelector("#item-reading-explanation").value;
if(editItem !== null) pack.editVocabulary(editItem, characters, meanings, readings, meaningExplanation, readingExplanation);
else pack.addVocabulary(characters, meanings, readings, meaningExplanation, readingExplanation);
break;
case "KanaVocabulary":
readings = document.querySelector("#item-readings").value.split(",").map(s => s.trim());
if(editItem !== null) pack.editKanaVocabulary(editItem, characters, meanings, readings, meaningExplanation);
else pack.addKanaVocabulary(characters, meanings, readings, meaningExplanation);
break;
default:
console.error("Invalid item type");
}
document.querySelector("#tab-4__content > form").style.display = "none";
document.querySelector("#tab-4__content > div").style.display = "block";
loadPackEditDetails(document.querySelector("#pack-select").value);
StorageManager.savePackProfile(activePackProfile, "main");
changeTab(3, document.querySelector("#pack-select").value);
};
} else {
// Hide add item edit tab
document.querySelector("#tab-4__content > form").style.display = "none";
document.querySelector("#tab-4__content > div").style.display = "block";
}
}
// ---------- Tabs details ----------
function loadPackEditDetails(i) {
let packNameInput = document.querySelector("#pack-name");
let packAuthorInput = document.querySelector("#pack-author");
let packVersionInput = document.querySelector("#pack-version");
let packItems = document.querySelector("#pack-items");
let importBox = document.querySelector("#pack-import");
if(i === "new") { // If creating a new pack
packNameInput.value = "";
packAuthorInput.value = "";
packVersionInput.value = 0.1;
} else if(i === "import") { // If importing a pack
importBox.value = "";
} else { // If editing an existing pack
let pack = activePackProfile.customPacks[i];
packNameInput.value = pack.name;
packAuthorInput.value = pack.author;
packVersionInput.value = pack.version;
packItems.innerHTML = "";
for(let j = 0; j < pack.items.length; j++) {
let item = pack.items[j];
let itemElement = document.createElement("li");
itemElement.classList = "pack-item";
itemElement.innerHTML = `
${item.characters} - ${item.meanings[0]} - SRS: ${srsNames[item.srs_stage]}
<div>
<button class="edit-item fa-regular fa-pen-to-square" title="Edit Item" type="button"></button>
<button class="delete-item fa-regular fa-trash" title="Delete Item" type="button"></button>
</div>
`;
itemElement.querySelector(".edit-item").onclick = () => { // Item edit button
changeTab(4, j);
};
itemElement.querySelector(".delete-item").onclick = () => { // Item delete button
pack.items.splice(j, 1);
loadPackEditDetails(i);
};
packItems.appendChild(itemElement);
}
}
document.querySelector("#tab-3__content form.pack-box").onsubmit = (e) => { // Pack save button
e.preventDefault();
let packName = packNameInput.value;
let packAuthor = packAuthorInput.value;
let packVersion = packVersionInput.value;
if(i === "new") {
let pack = new CustomItemPack(packName, packAuthor, packVersion);
activePackProfile.addPack(pack);
changeTab(3, activePackProfile.customPacks.length - 1);
} else {
activePackProfile.customPacks[i].name = packName;
activePackProfile.customPacks[i].author = packAuthor;
activePackProfile.customPacks[i].version = packVersion;
}
StorageManager.savePackProfile(activePackProfile, "main");
changeTab(2);
};
document.querySelector("#tab-3__content form.import-box").onsubmit = (e) => { // Pack import button
e.preventDefault();
let pack = JSON.parse(importBox.value);
let packExistingStatus = activePackProfile.doesPackExist(pack.name, pack.author, pack.version); // Check if pack already exists
if(packExistingStatus == "exists") {
alert("Import failed: A pack with the same name, author, and version already exists.");
} else if(packExistingStatus == "no") {
activePackProfile.addPack(StorageManager.packFromJSON(pack));
StorageManager.savePackProfile(activePackProfile, "main");
changeTab(2);
} else {
if(confirm("A pack with the same name and author but different version already exists. Do you want to update it?")) {
activePackProfile.updatePack(packExistingStatus, pack);
StorageManager.savePackProfile(activePackProfile, "main");
changeTab(2);
}
}
};
}
// ---------- Item details ----------
function makeDetailsHTML(item) {
switch(item.type) {
case "Radical":
return /*html*/ `
<turbo-frame class="subject-info" id="subject-info">
<div class="container">
<section class="subject-section subject-section--meaning subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["meaning"]}">
<a class='wk-nav__anchor' id='information'></a>
<h2 class='subject-section__title'>
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-meaning">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right"></i>
<span class='subject-section__title-text'>Name</span>
</a>
</h2>
<section id="section-meaning" class="subject-section__content" data-toggle-target="content" hidden="hidden">
<section class="subject-section__subsection">
<div class='subject-section__meanings'>
<h2 class='subject-section__meanings-title'>Primary</h2>
<p class='subject-section__meanings-items'>${item.meanings[0]}</p>
</div>
${item.meanings.length > 1 ? `
<div class="subject-section__meanings">
<h2 class="subject-section__meanings-title">Alternatives</h2>
<p class="subject-section__meanings-items">${item.meanings.slice(1).join(', ')}</p>
</div>` : ''}
<!--<div class='subject-section__meanings'>
<h2 class='subject-section__meanings-title'>User Synonyms</h2>
<p class='subject-section__meanings-items'><i>User synonyms are currently disabled for custom items.</i></p>
</div>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Mnemonic</h3>
<p class="subject-section__text">${item.meaning_explanation}</p>
<!--<aside class="subject-hint">
<h3 class="subject-hint__title">
<i class="fa-solid fa-circle-question subject-hint__title-icon" aria-hidden="true"></i>
<span class="subject-hint__title-text">Hints</span>
</h3>
<p class="subject-hint__text"></p>
</aside>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Note</h3>
<p class="subject-section__text"><i>Notes are currently disabled for custom items.</i></p>
</section>
</section>
</section>
<section class="subject-section subject-section--amalgamations subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":[]}">
<a class='wk-nav__anchor' id='amalgamations'></a>
<h2 class='subject-section__title'>
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-amalgamations">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right"></i>
<span class='subject-section__title-text'>Found In Kanji</span>
</a>
</h2>
<section id="section-amalgamations" class="subject-section__content" data-toggle-target="content" hidden="hidden">
<div class="subject-character-grid">
<ol class="subject-character-grid__items">
<!--<li class="subject-character-grid__item">
<a class="subject-character subject-character--kanji subject-character--grid subject-character--burned" title="じょう" href="https://www.wanikani.com/kanji/%E4%B8%8A" data-turbo-frame="_blank">
<div class="subject-character__content">
<span class="subject-character__characters" lang="ja">上</span>
<div class="subject-character__info">
<span class="subject-character__reading">じょう</span>
<span class="subject-character__meaning">Above</span>
</div>
</div>
</a>
</li>-->
</ol>
</div>
</section>
</section>
</div>
</turbo-frame>
`;
case "Kanji":
return /*html*/ `
<turbo-frame class="subject-info" id="subject-info">
<div class="container">
<!-- Radical combination -->
<section class="subject-section subject-section--components subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["meaning"]}">
<h2 class="subject-section__title">
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-components" data-controller-connected="true">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right" aria-hidden="true"></i>
<span class="subject-section__title-text">Radical Combination</span>
</a>
</h2>
<section id="section-components" class="subject-section__content" data-toggle-target="content">
<div class="subject-list subject-list--with-separator">
<ul class="subject-list__items">
<!--<li class="subject-list__item">
<a class="subject-character subject-character--radical subject-character--small-with-meaning subject-character--burned subject-character--expandable" title="Head" href="https://www.wanikani.com/radicals/head" data-turbo-frame="_blank">
<div class="subject-character__content">
<span class="subject-character__characters" lang="ja">冂</span>
<div class="subject-character__info">
<span class="subject-character__meaning">Head</span>
</div>
</div>
</a>
</li>-->
</ul>
</div>
</section>
</section>
<!-- Meaning -->
<section class="subject-section subject-section--meaning subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["meaning"]}">
<a class='wk-nav__anchor' id='meaning'></a>
<h2 class='subject-section__title'>
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-meaning">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right"></i>
<span class='subject-section__title-text'>Meaning</span>
</a>
</h2>
<section id="section-meaning" class="subject-section__content" data-toggle-target="content" hidden="hidden">
<section class="subject-section__subsection">
<div class='subject-section__meanings'>
<h2 class='subject-section__meanings-title'>Primary</h2>
<p class='subject-section__meanings-items'>${item.meanings[0]}</p>
</div>
${item.meanings.length > 1 ? `
<div class="subject-section__meanings">
<h2 class="subject-section__meanings-title">Alternative</h2>
<p class="subject-section__meanings-items">${item.meanings.slice(1).join(', ')}</p>
</div>` : ''}
<!--<div class='subject-section__meanings'>
<h2 class='subject-section__meanings-title'>User Synonyms</h2>
<p class='subject-section__meanings-items'><i>User synonyms are currently disabled for custom items.</i></p>
</div>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Mnemonic</h3>
<p class="subject-section__text">${item.meaning_explanation}</p>
<!--<aside class="subject-hint">
<h3 class="subject-hint__title">
<i class="fa-solid fa-circle-question subject-hint__title-icon" aria-hidden="true"></i>
<span class="subject-hint__title-text">Hints</span>
</h3>
<p class="subject-hint__text"></p>
</aside>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Note</h3>
<p class="subject-section__text"><i>Notes are currently disabled for custom items.</i></p>
</section>
</section>
</section>
<!-- Reading -->
<section class="subject-section subject-section--reading subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["reading"]}">
<a class='wk-nav__anchor' id='reading'></a>
<h2 class='subject-section__title'>
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-reading">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right"></i>
<span class='subject-section__title-text'>Reading</span>
</a>
</h2>
<section id="section-reading" class="subject-section__content" data-toggle-target="content" hidden="hidden">
<section class="subject-section__subsection">
<div class="subject-readings">
<div class="subject-readings__reading ${item.primary_reading_type == "onyomi" ? "subject-readings__reading--primary" : ""}">
<h3 class="subject-readings__reading-title">On’yomi</h3>
<p class="subject-readings__reading-items" lang="ja">
${item.onyomi.length > 0 ? item.onyomi.join(', ') : "None"}
</p>
</div>
<div class="subject-readings__reading ${item.primary_reading_type == "kunyomi" ? "subject-readings__reading--primary" : ""}">
<h3 class="subject-readings__reading-title">Kun’yomi</h3>
<p class="subject-readings__reading-items" lang="ja">
${item.kunyomi.length > 0 ? item.kunyomi.join(', ') : "None"}
</p>
</div>
<div class="subject-readings__reading ${item.primary_reading_type == "nanori" ? "subject-readings__reading--primary" : ""}">
<h3 class="subject-readings__reading-title">Nanori</h3>
<p class="subject-readings__reading-items" lang="ja">
${item.nanori.length > 0 ? item.nanori.join(', ') : "None"}
</p>
</div>
</div>
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Mnemonic</h3>
<p class="subject-section__text">${item.reading_explanation}</p>
<!--<aside class="subject-hint">
<h3 class="subject-hint__title">
<i class="fa-solid fa-circle-question subject-hint__title-icon" aria-hidden="true"></i>
<span class="subject-hint__title-text">Hints</span>
</h3>
<p class="subject-hint__text"></p>
</aside>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Note</h3>
<p class="subject-section__text"><i>Notes are currently disabled for custom items.</i></p>
</section>
</section>
</section>
<!-- Found in vocabulary -->
<section class="subject-section subject-section--amalgamations subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":[]}">
<a class="wk-nav__anchor" id="amalgamations"></a>
<h2 class="subject-section__title">
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-amalgamations" data-controller-connected="true">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right" aria-hidden="true"></i>
<span class="subject-section__title-text">Found In Vocabulary</span>
</a>
</h2>
<section id="section-amalgamations" class="subject-section__content" data-toggle-target="content">
<div class="subject-character-grid subject-character-grid--single-column">
<ol class="subject-character-grid__items">
<!--<li class="subject-character-grid__item">
<a class="subject-character subject-character--vocabulary subject-character--grid subject-character--burned" title="うち" href="https://www.wanikani.com/vocabulary/%E5%86%85" data-turbo-frame="_blank">
<div class="subject-character__content">
<span class="subject-character__characters" lang="ja">内</span>
<div class="subject-character__info">
<span class="subject-character__reading">うち</span>
<span class="subject-character__meaning">Inside</span>
</div>
</div>
</a>
</li>-->
</ol>
</div>
</section>
</section>
</div>
</turbo-frame>
`;
case "Vocabulary":
return /*html*/ `
<turbo-frame class="subject-info" id="subject-info">
<div class="container">
<!-- Meaning -->
<section class="subject-section subject-section--meaning subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["meaning"]}">
<a class='wk-nav__anchor' id='meaning'></a>
<h2 class='subject-section__title'>
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-meaning">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right"></i>
<span class='subject-section__title-text'>Meaning</span>
</a>
</h2>
<section id="section-meaning" class="subject-section__content" data-toggle-target="content" hidden="hidden">
<section class="subject-section__subsection">
<div class='subject-section__meanings'>
<h2 class='subject-section__meanings-title'>Primary</h2>
<p class='subject-section__meanings-items'>${item.meanings[0]}</p>
</div>
${item.meanings.length > 1 ? `
<div class="subject-section__meanings">
<h2 class="subject-section__meanings-title">Alternatives</h2>
<p class="subject-section__meanings-items">${item.meanings.slice(1).join(', ')}</p>
</div>` : ''}
<!--<div class='subject-section__meanings'>
<h2 class='subject-section__meanings-title'>User Synonyms</h2>
<p class='subject-section__meanings-items'><i>User synonyms are currently disabled for custom items.</i></p>
</div>-->
<!--<div class="subject-section__meanings">
<h2 class="subject-section__meanings-title">Word Type</h2>
<p class="subject-section__meanings-items">noun, の adjective</p>
</div>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Explanation</h3>
<p class="subject-section__text">${item.meaning_explanation}</p>
<!--<aside class="subject-hint">
<h3 class="subject-hint__title">
<i class="fa-solid fa-circle-question subject-hint__title-icon" aria-hidden="true"></i>
<span class="subject-hint__title-text">Hints</span>
</h3>
<p class="subject-hint__text"></p>
</aside>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Note</h3>
<p class="subject-section__text"><i>Notes are currently disabled for custom items.</i></p>
</section>
</section>
</section>
<!-- Reading -->
<section class="subject-section subject-section--reading subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["reading"]}">
<a class='wk-nav__anchor' id='reading'></a>
<h2 class='subject-section__title'>
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-reading">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right"></i>
<span class='subject-section__title-text'>Reading</span>
</a>
</h2>
<section id="section-reading" class="subject-section__content" data-toggle-target="content" hidden="hidden">
<section class="subject-section__subsection">
<div class="subject-readings-with-audio">
<div class="subject-readings-with-audio__item">
<div class="reading-with-audio">
<div class="reading-with-audio__reading" lang='ja'>${item.readings[0]}</div>
<ul class="reading-with-audio__audio-items">
</ul>
</div>
</div>
</div>
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Explanation</h3>
<p class="subject-section__text">${item.reading_explanation}</p>
<!--<aside class="subject-hint">
<h3 class="subject-hint__title">
<i class="fa-solid fa-circle-question subject-hint__title-icon" aria-hidden="true"></i>
<span class="subject-hint__title-text">Hints</span>
</h3>
<p class="subject-hint__text"></p>
</aside>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Note</h3>
<p class="subject-section__text"><i>Notes are currently disabled for custom items.</i></p>
</section>
</section>
</section>
<!-- Context -->
<section class="subject-section subject-section--context subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["meaning"]}">
<a class="wk-nav__anchor" id="context"></a>
<h2 class="subject-section__title">
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-context" data-controller-connected="true">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right" aria-hidden="true"></i>
<span class="subject-section__title-text">Context</span>
</a>
</h2>
<section id="section-context" class="subject-section__content" data-toggle-target="content">
<!--<section class="subject-section__subsection">
<div class="subject-collocations" data-controller="tabbed-content" data-tabbed-content-next-tab-hotkey-value="s" data-tabbed-content-previous-tab-hotkey-value="w" data-hotkey-registered="true">
<div class="subject-collocations__patterns">
<h3 class="subject-collocations__title subject-collocations__title--patterns">Pattern of Use</h3>
<div class="subject-collocations__pattern-names">
<a class="subject-collocations__pattern-name" data-tabbed-content-target="tab" data-action="tabbed-content#changeTab" aria-controls="collocations-710736400-0" aria-selected="true" role="tab" lang="ja" href="#collocations-710736400-0">農業を〜</a>
</div>
</div>
<div class="subject-collocations__collocations">
<h3 class="subject-collocations__title">Common Word Combinations</h3>
<ul class="subject-collocations__pattern-collocations">
<li class="subject-collocations__pattern-collocation" id="collocations-710736400-0" data-tabbed-content-target="content" role="tabpanel">
<div class="context-sentences">
<p class="wk-text" lang="ja">農業を行う</p>
<p class="wk-text">to carry out farming</p>
</div>
</li>
</ul>
</div>
</div>
</section>-->
<section class="subject-section__subsection">
<h3 class="subject-section__subtitle">Context Sentences</h3>
<!--<div class="subject-section__text subject-section__text--grouped">
<p lang="ja">私たちの町では、米の農業をしてる人々が多いです。</p>
<p>In our town, there are many people who are farming rice.</p>
</div>-->
</section>
</section>
</section>
<!-- Kanji Composition -->
<section class="subject-section subject-section--components subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":[]}">
<a class="wk-nav__anchor" id="components"></a>
<h2 class="subject-section__title">
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-components" data-controller-connected="true">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right" aria-hidden="true"></i>
<span class="subject-section__title-text">Kanji Composition</span>
</a>
</h2>
<section id="section-components" class="subject-section__content" data-toggle-target="content">
<div class="subject-character-grid">
<ol class="subject-character-grid__items">
<!--<li class="subject-character-grid__item">
<a class="subject-character subject-character--kanji subject-character--grid subject-character--burned" title="のう" href="https://www.wanikani.com/kanji/%E8%BE%B2" data-turbo-frame="_blank">
<div class="subject-character__content">
<span class="subject-character__characters" lang="ja">農</span>
<div class="subject-character__info">
<span class="subject-character__reading">のう</span>
<span class="subject-character__meaning">Farming</span>
</div>
</div>
</a>
</li>-->
</ol>
</div>
</section>
</section>
</div>
</turbo-frame>
`;
case "KanaVocabulary":
return /*html*/ `
<turbo-frame class="subject-info" id="subject-info">
<div class="container">
<!-- Meaning -->
<section class="subject-section subject-section--meaning subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["meaning"]}">
<a class='wk-nav__anchor' id='meaning'></a>
<h2 class='subject-section__title'>
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-meaning">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right"></i>
<span class='subject-section__title-text'>Meaning</span>
</a>
</h2>
<section id="section-meaning" class="subject-section__content" data-toggle-target="content" hidden="hidden">
<section class="subject-section__subsection">
<div class='subject-section__meanings'>
<h2 class='subject-section__meanings-title'>Primary</h2>
<p class='subject-section__meanings-items'>${item.meanings[0]}</p>
</div>
${item.meanings.length > 1 ? `
<div class="subject-section__meanings">
<h2 class="subject-section__meanings-title">Alternatives</h2>
<p class="subject-section__meanings-items">${item.meanings.slice(1).join(', ')}</p>
</div>` : ''}
<!--<div class='subject-section__meanings'>
<h2 class='subject-section__meanings-title'>User Synonyms</h2>
<p class='subject-section__meanings-items'><i>User synonyms are currently disabled for custom items.</i></p>
</div>-->
<!--<div class="subject-section__meanings">
<h2 class="subject-section__meanings-title">Word Type</h2>
<p class="subject-section__meanings-items">noun, suffix</p>
</div>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Explanation</h3>
<p class="subject-section__text">${item.meaning_explanation}</p>
<!--<aside class="subject-hint">
<h3 class="subject-hint__title">
<i class="fa-solid fa-circle-question subject-hint__title-icon" aria-hidden="true"></i>
<span class="subject-hint__title-text">Hints</span>
</h3>
<p class="subject-hint__text"></p>
</aside>-->
</section>
<section class="subject-section__subsection">
<h3 class='subject-section__subtitle'>Note</h3>
<p class="subject-section__text"><i>Notes are currently disabled for custom items.</i></p>
</section>
</section>
</section>
<!-- Pronunciation -->
<section class="subject-section subject-section--reading subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["reading"]}">
<a class='wk-nav__anchor' id='reading'></a>
<h2 class='subject-section__title'>
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-reading">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right"></i>
<span class='subject-section__title-text'>Pronunciation</span>
</a>
</h2>
<section id="section-reading" class="subject-section__content" data-toggle-target="content" hidden="hidden">
<section class="subject-section__subsection">
<div class="subject-readings-with-audio">
<div class="subject-readings-with-audio__item">
<div class="reading-with-audio">
<div class="reading-with-audio__reading" lang='ja'>${item.readings[0]}</div>
<ul class="reading-with-audio__audio-items">
</ul>
</div>
</div>
</div>
</section>
</section>
</section>
<!-- Context -->
<section class="subject-section subject-section--context subject-section--collapsible" data-controller="toggle" data-toggle-context-value="{"auto_expand_question_types":["meaning"]}">
<a class="wk-nav__anchor" id="context"></a>
<h2 class="subject-section__title">
<a class="subject-section__toggle" data-toggle-target="toggle" data-action="toggle#toggle" aria-expanded="false" aria-controls="section-context" data-controller-connected="true">
<i class="subject-section__toggle-icon fa-regular fa-chevron-right" aria-hidden="true"></i>
<span class="subject-section__title-text">Context</span>
</a>
</h2>
<section id="section-context" class="subject-section__content" data-toggle-target="content">
<!--<section class="subject-section__subsection">
<div class="subject-collocations" data-controller="tabbed-content" data-tabbed-content-next-tab-hotkey-value="s" data-tabbed-content-previous-tab-hotkey-value="w" data-hotkey-registered="true">
<div class="subject-collocations__patterns">
<h3 class="subject-collocations__title subject-collocations__title--patterns">Pattern of Use</h3>
<div class="subject-collocations__pattern-names">
<a class="subject-collocations__pattern-name" data-tabbed-content-target="tab" data-action="tabbed-content#changeTab" aria-controls="collocations-710736400-0" aria-selected="true" role="tab" lang="ja" href="#collocations-710736400-0">農業を〜</a>
</div>
</div>
<div class="subject-collocations__collocations">
<h3 class="subject-collocations__title">Common Word Combinations</h3>
<ul class="subject-collocations__pattern-collocations">
<li class="subject-collocations__pattern-collocation" id="collocations-710736400-0" data-tabbed-content-target="content" role="tabpanel">
<div class="context-sentences">
<p class="wk-text" lang="ja">農業を行う</p>
<p class="wk-text">to carry out farming</p>
</div>
</li>
</ul>
</div>
</div>
</section>-->
<section class="subject-section__subsection">
<h3 class="subject-section__subtitle">Context Sentences</h3>
<!--<div class="subject-section__text subject-section__text--grouped">
<p lang="ja">このパン、5ドルだって。</p>
<p>It says this bread costs $5.</p>
</div>-->
</section>
</section>
</section>
</div>
</turbo-frame>
`;
}
}
const srsGaps = [0, 4*60*60*1000, 8*60*60*1000, 23*60*60*1000, 47*60*60*1000, 167*60*60*1000, 335*60*60*1000, 730*60*60*1000, 2920*60*60*1000];
class CustomItem {
// WK variables
id;
type; // KanaVocabulary, Vocabulary, Kanji, Radical
subject_category; // Vocabulary, Kanji, Radical
characters;
meanings;
auxiliary_meanings = [];
// Custom variables
srs_stage = 0;
last_reviewed_at = 0;
meaning_explanation = "This item does not have a meaning explanation.";
constructor(id, type, subject_category, characters, meanings, meaning_explanation) {
this.id = id;
this.type = type;
this.subject_category = subject_category;
this.characters = characters;
this.meanings = meanings;
this.last_reviewed_at = Date.now();
if(meaning_explanation) this.meaning_explanation = meaning_explanation;
}
isReadyForReview() {
return this.last_reviewed_at < Date.now() - srsGaps[this.srs_stage] && this.srs_stage > -1; // TODO: Change SRS stage check to > 0 once lessons are implemented
}
incrementSRS() {
if(this.srs_stage < 9) this.srs_stage++;
this.last_reviewed_at = Date.now();
StorageManager.savePackProfile(activePackProfile, "main");
}
decrementSRS() {
if(this.srs_stage > 1) {
if(this.srs_stage < 5) this.srs_stage--;
else this.srs_stage -= 2;
}
this.last_reviewed_at = Date.now();
StorageManager.savePackProfile(activePackProfile, "main");
}
getSRSText(packID) {
return "[" + Utils.cantorNumber(packID, this.id) + "," + this.srs_stage + "]";
}
static fromObject(object) {
let item;
switch(object.type) {
case "Radical":
item = new RadicalCustomItem(object.id, object.type, object.subject_category, object.characters, object.meanings, object.meaning_explanation);
break;
case "Kanji":
item = new KanjiCustomItem(object.id, object.type, object.subject_category, object.characters, object.meanings, object.primary_reading_type, object.onyomi, object.kunyomi, object.nanori, object.meaning_explanation, object.reading_explanation);
break;
case "Vocabulary":
item = new VocabularyCustomItem(object.id, object.type, object.subject_category, object.characters, object.meanings, object.readings, object.meaning_explanation, object.reading_explanation);
break;
case "KanaVocabulary":
item = new KanaVocabularyCustomItem(object.id, object.type, object.subject_category, object.characters, object.meanings, object.readings, object.meaning_explanation);
break;
}
/*item.auxiliary_meanings = object.auxiliary_meanings;
item.auxiliary_readings = object.auxiliary_readings;
item.kanji = object.kanji;*/
item.srs_stage = object.srs_stage;
item.last_reviewed_at = object.last_reviewed_at;
return item;
}
}
class RadicalCustomItem extends CustomItem {
kanji = [];
constructor(id, type, subject_category, characters, meanings, meaning_explanation) {
super(id, type, subject_category, characters, meanings, meaning_explanation);
}
getQueueItem(packID) {
return {
id: Utils.cantorNumber(packID, this.id),
type: this.type,
subject_category: this.subject_category,
characters: this.characters,
meanings: this.meanings,
auxiliary_meanings: this.auxiliary_meanings,
kanji: this.kanji
};
}
}
class KanjiCustomItem extends CustomItem {
primary_reading_type;
onyomi = [];
kunyomi = [];
nanori = [];
radicals = [];
vocabulary = [];
reading_explanation = "This item does not have a reading explanation.";
constructor(id, type, subject_category, characters, meanings, primary_reading_type, onyomi, kunyomi, nanori, meaning_explanation, reading_explanation) {
super(id, type, subject_category, characters, meanings, meaning_explanation);
this.primary_reading_type = primary_reading_type;
this.onyomi = onyomi;
this.kunyomi = kunyomi;
this.nanori = nanori;
if(reading_explanation) this.reading_explanation = reading_explanation;
}
getQueueItem(packID) {
return {
id: Utils.cantorNumber(packID, this.id),
type: this.type,
subject_category: this.subject_category,
characters: this.characters,
meanings: this.meanings,
auxiliary_meanings: this.auxiliary_meanings,
primary_reading_type: this.primary_reading_type,
onyomi: this.onyomi,
kunyomi: this.kunyomi,
nanori: this.nanori,
auxiliary_readings: [],
radicals: this.radicals,
vocabulary: this.vocabulary
};
}
}
class VocabularyCustomItem extends CustomItem {
readings;
auxiliary_readings = [];
kanji = [];
reading_explanation = "This item does not have a reading explanation.";
constructor(id, type, subject_category, characters, meanings, readings, meaning_explanation, reading_explanation) {
super(id, type, subject_category, characters, meanings, meaning_explanation);
this.readings = readings;
if(reading_explanation) this.reading_explanation = reading_explanation;
}
getQueueItem(packID) {
return {
id: Utils.cantorNumber(packID, this.id),
type: this.type,
subject_category: this.subject_category,
characters: this.characters,
meanings: this.meanings,
auxiliary_meanings: this.auxiliary_meanings,
readings: this.readings.map(reading => ({"reading": reading, "pronunciations": []})),
auxiliary_readings: this.auxiliary_readings,
kanji: this.kanji
};
}
}
class KanaVocabularyCustomItem extends CustomItem {
readings;
reading_explanation = "Kana vocab is pronounced as per the kana, so no need to learn any other readings!";
constructor(id, type, subject_category, characters, meanings, readings, meaning_explanation) {
super(id, type, subject_category, characters, meanings, meaning_explanation);
this.readings = readings;
}
getQueueItem(packID) {
return {
id: Utils.cantorNumber(packID, this.id),
type: this.type,
subject_category: this.subject_category,
characters: this.characters,
meanings: this.meanings,
auxiliary_meanings: this.auxiliary_meanings,
readings: this.readings.map(reading => ({"reading": reading, "pronunciations": []}))
};
}
}
class CustomItemPack {
name;
author;
version;
items = [];
active = true;
nextID = 0;
constructor(name, author, version) {
this.name = name;
this.author = author;
this.version = version;
}
getItem(id) {
return this.items[id];
}
addRadical(characters, meanings, meaning_explanation) {
let id = this.nextID++;
let radical = new RadicalCustomItem(id, "Radical", "Radical", characters, meanings, meaning_explanation);
this.items.push(radical);
}
addKanji(characters, meanings, primary_reading_type, onyomi, kunyomi, nanori, meaning_explanation, reading_explanation) {
let id = this.nextID++;
let kanji = new KanjiCustomItem(id, "Kanji", "Kanji", characters, meanings, primary_reading_type, onyomi, kunyomi, nanori, meaning_explanation, reading_explanation);
this.items.push(kanji);
}
addVocabulary(characters, meanings, readings, meaning_explanation, reading_explanation) {
let id = this.nextID++;
let vocabulary = new VocabularyCustomItem(id, "Vocabulary", "Vocabulary", characters, meanings, readings, meaning_explanation, reading_explanation);
this.items.push(vocabulary);
}
addKanaVocabulary(characters, meanings, readings, meaning_explanation) {
let id = this.nextID++;
let kanaVocabulary = new KanaVocabularyCustomItem(id, "KanaVocabulary", "Vocabulary", characters, meanings, readings, meaning_explanation);
this.items.push(kanaVocabulary);
}
editItem(item, characters, meanings, meaning_explanation) {
item.characters = characters;
item.meanings = meanings;
item.meaning_explanation = meaning_explanation;
}
editRadical(id, characters, meanings, meaning_explanation) {
let radical = this.items[id];
this.editItem(radical, characters, meanings, meaning_explanation);
}
editKanji(id, characters, meanings, primary_reading_type, onyomi, kunyomi, nanori, meaning_explanation, reading_explanation) {
let kanji = this.items[id];
this.editItem(kanji, characters, meanings, meaning_explanation);
kanji.primary_reading_type = primary_reading_type;
kanji.onyomi = onyomi;
kanji.kunyomi = kunyomi;
kanji.nanori = nanori;
if(reading_explanation) kanji.reading_explanation = reading_explanation;
}
editVocabulary(id, characters, meanings, readings, meaning_explanation, reading_explanation) {
let vocabulary = this.items[id];
this.editItem(vocabulary, characters, meanings, meaning_explanation);
vocabulary.readings = readings;
if(reading_explanation) vocabulary.reading_explanation = reading_explanation;
}
editKanaVocabulary(id, characters, meanings, readings, meaning_explanation) {
let kanaVocabulary = this.items[id];
this.editItem(kanaVocabulary, characters, meanings, meaning_explanation);
kanaVocabulary.readings = readings;
}
removeItem(id) {
this.items.splice(id, 1);
}
getActiveReviews(packID) { // Get all items that were last reviewed more than 24 hours ago
if(!this.active) return [];
return this.items.filter(item => item.isReadyForReview()).map(item => item.getQueueItem(packID)); // TODO: Change SRS stage check
}
getActiveReviewsSRSText(packID) {
if(!this.active) return [];
return this.items.filter(item => item.isReadyForReview()).map(item => item.getSRSText(packID));
}
getNumActiveReviews() {
if(!this.active) return 0;
return this.items.filter(item => item.isReadyForReview()).length;
}
static fromObject(object) {
let pack = new CustomItemPack(object.name, object.author, object.version);
pack.items = object.items.map(item => CustomItem.fromObject(item));
pack.active = object.active;
pack.nextID = (object.lastID || pack.items.length); // If lastID is not present, use the length of the items array
return pack;
}
}
class CustomPackProfile {
customPacks = [];
getPack(id) {
return this.customPacks[id];
}
addPack(newPack) {
this.customPacks.push(newPack);
}
removePack(id) {
this.customPacks.splice(id, 1);
}
doesPackExist(packName, packAuthor, packVersion) {
for(let i = 0; i < this.customPacks.length; i++) {
let pack = this.customPacks[i];
if(pack.name === packName && pack.author === packAuthor) {
if(pack.version === packVersion) return "exists";
else return i;
}
}
return "no";
}
updatePack(id, newPack) { // Update pack but keeping the SRS stages of items that are in both the old and new pack
let oldPack = this.customPacks[id];
newPack = StorageManager.packFromJSON(newPack);
for(let i = 0; i < newPack.items.length; i++) {
let newItem = newPack.items[i];
let oldItem = oldPack.items.find(item => item.id === newItem.id);
if(oldItem) {
newItem.srs_stage = oldItem.srs_stage;
newItem.last_reviewed_at = oldItem.last_reviewed_at;
}
}
this.customPacks[id] = newPack;
}
getActiveReviews() {
let activeReviews = [];
for(let i = 0; i < this.customPacks.length; i++) {
activeReviews.push(...this.customPacks[i].getActiveReviews(i));
}
return activeReviews;
}
getNumActiveReviews() {
return this.customPacks.reduce((acc, pack) => acc + pack.getNumActiveReviews(), 0);
}
getActiveReviewsSRS() {
let activeReviewsSRS = [];
for(let i = 0; i < this.customPacks.length; i++) {
activeReviewsSRS.push(...this.customPacks[i].getActiveReviewsSRSText(i));
}
return activeReviewsSRS;
}
getSubjectInfo(cantorNum) { // Get details of custom item for review page details display
let [packID, itemID] = Utils.reverseCantorNumber(cantorNum);
let item = this.getPack(packID).getItem(itemID);
return makeDetailsHTML(item);
}
submitReview(cantorNum, meaningIncorrectNum, readingIncorrectNum) {
let [packID, itemID] = Utils.reverseCantorNumber(cantorNum);
let item = this.customPacks[packID].items[itemID];
if(meaningIncorrectNum > 0 || readingIncorrectNum > 0) {
item.decrementSRS();
} else {
item.incrementSRS();
}
}
static fromObject(object) {
let packProfile = new CustomPackProfile();
packProfile.customPacks = object.customPacks.map(pack => CustomItemPack.fromObject(pack));
return packProfile;
}
}
// ------------------- Utility classes -------------------
class Utils {
static cantorNumber(a, b) {
return -(0.5 * (a + b) * (a + b + 1) + b) - 1;
}
static reverseCantorNumber(z) {
z = -z - 1;
let w = Math.floor((Math.sqrt(8 * z + 1) - 1) / 2);
let y = z - ((w * w + w) / 2);
let x = w - y;
return [x, y];
}
static get_controller(name) {
return Stimulus.getControllerForElementAndIdentifier(document.querySelector(`[data-controller~="${name}"]`),name);
}
static promise(){let a,b,c=new Promise(function(d,e){a=d;b=e;});c.resolve=a;c.reject=b;return c;}
}
class StorageManager {
// Get custom packs saved in GM storage
static async loadPackProfile(profileName) {
let savedPackProfile = CustomPackProfile.fromObject(await GM.getValue("customPackProfile_" + profileName, new CustomPackProfile()));
/*let savedPackProfile = new CustomPackProfile();
Object.assign(savedPackProfile, await GM.getValue("customPackProfile_" + profileName, new CustomPackProfile()));
// Convert CustomItemPacks and their CustomItems
savedPackProfile.customPacks = savedPackProfile.customPacks.map(pack => CustomItemPack.fromObject(pack));*/
return savedPackProfile;
}
// Save custom packs to GM storage
static async savePackProfile(packProfile, profileName) {
GM.setValue("customPackProfile_" + profileName, packProfile);
}
static packFromJSON(json) {
let pack = CustomItemPack.fromObject(json);
return pack;
}
static packToJSON(pack) {
let packJSON = JSON.parse(JSON.stringify(pack));
// Set all item last_reviewed_at and srs_stage to 0 to avoid saving unnecessary data
packJSON.items.forEach(item => {
item.last_reviewed_at = 0;
item.srs_stage = 0;
});
return JSON.stringify(packJSON);
}
}
class TestData {
// Create pack with custom test items
static createTestPack() {
let testPack = new CustomItemPack("Test Pack", "Test Author", 0.1);
testPack.addRadical("少", ["Radical"]);
testPack.addKanji("犬", ["dog"], "onyomi", ["いん"], ["いぬ"], ["いぬ"]);
testPack.addVocabulary("猫猫", ["cat"], ["ねこ"]);
testPack.addKanaVocabulary("いぬ", ["dog"], ["いぬ"]);
return testPack;
}
}
let activePackProfile = await StorageManager.loadPackProfile("main");
// ----------- If on review page -----------
if (window.location.pathname.includes("/review")) {
if(activePackProfile.getNumActiveReviews() === 0) return;
// Add custom items to the quiz queue
document.addEventListener("DOMContentLoaded", () => {
let queueEl = document.getElementById('quiz-queue');
let parentEl = queueEl.parentElement;
queueEl.remove();
let cloneEl = queueEl.cloneNode(true);
let newQuizQueueSRS = activePackProfile.getActiveReviewsSRS().join();
cloneEl.querySelector("script[data-quiz-queue-target='subjectIdsWithSRS']").innerHTML = cloneEl.querySelector("script[data-quiz-queue-target='subjectIdsWithSRS']").innerHTML.replace("[", "[" + newQuizQueueSRS + ",");
let quizQueue = JSON.parse(cloneEl.querySelector("script[data-quiz-queue-target='subjects']").innerHTML);
quizQueue = activePackProfile.getActiveReviews().concat(quizQueue);
cloneEl.querySelector("script[data-quiz-queue-target='subjects']").innerHTML = JSON.stringify(quizQueue);
parentEl.appendChild(cloneEl);
// Update remaining review count
let quizController;
let p = Utils.promise();
async function waitForController() {
quizController = Utils.get_controller('quiz-statistics');
if(quizController) {
quizController.remainingCountTarget.innerText = parseInt(quizController.remainingCountTarget.innerText) + 1;
p.resolve();
} else {
setTimeout(waitForController, 50);
}
}
waitForController();
});
// Catch submission fetch and stop it if submitted item is a custom item
const { fetch: originalFetch } = unsafeWindow;
unsafeWindow.fetch = async (...args) => {
let [resource, config] = args;
if (resource.includes("/subjects/review") && config != null && config.method === "POST") {
let payload = JSON.parse(config.body);
// Check if submitted item is a custom item
if(payload.counts && payload.counts[0].id < 0) {
// Update custom item SRS
activePackProfile.submitReview(payload.counts[0].id, payload.counts[0].meaning, payload.counts[0].reading);
return new Response("{}", { status: 200 });
} else {
return originalFetch(...args);
}
// Catch subject info fetch and return custom item details if the number at the end of the url is negative
} else if (resource.includes("/subject_info/") && config && config.method === "get" && resource.split("/").pop() < 0) {
// Submit original fetch but to different URL to get usable headers
args[0] = "https://www.wanikani.com/subject_info/1";
let response = await originalFetch(...args);
let subjectId = resource.split("/").pop();
let subjectInfo = activePackProfile.getSubjectInfo(subjectId);
return new Response(subjectInfo, {
status: response.status,
headers: response.headers
});
} else {
return originalFetch(...args);
}
};
// ----------- If on lessons page -----------
} else if (window.location.pathname.includes("/lessons")) {
// TODO
// ----------- If on dashboard page -----------
} else if (window.location.pathname.includes("/dashboard") || window.location.pathname === "/") {
// Catch lesson / review count fetch and update it with custom item count
const { fetch: originalFetch } = unsafeWindow;
unsafeWindow.fetch = async (...args) => {
let [resource, config] = args;
if (resource.includes("lesson-and-review-count") && config != null && config.method === "get") {
let response = await originalFetch(...args);
let data = await response.text();
data = parseHTML(data);
let reviewCountElement = data.querySelector("a[href='/subjects/review'] .lesson-and-review-count__count");
// If reviewCountElement is null, replace the span .lesson-and-review-count__item with some custom HTML
if(reviewCountElement === null && activePackProfile.getNumActiveReviews() > 0) {
let reviewTile = data.querySelector(".lesson-and-review-count__item:nth-child(2)");
let customHTML = `
<a class="lesson-and-review-count__item" target="_top" href="/subjects/review">
<div class="lesson-and-review-count__count">${activePackProfile.getNumActiveReviews()}</div>
<div class="lesson-and-review-count__label">Reviews</div>
</a>
`;
reviewTile.outerHTML = customHTML;
} else {
reviewCountElement.innerHTML = parseInt(reviewCountElement.innerHTML) + activePackProfile.getNumActiveReviews();
}
// Convert the DocumentFragment back to a string and return it as a Response
let serializedData = (new XMLSerializer()).serializeToString(data);
let res = new Response(serializedData, {
status: response.status,
headers: response.headers
});
return res;
} else {
return originalFetch(...args);
}
};
// Catch document load to edit review count on dashboard
document.addEventListener("DOMContentLoaded", () => {
let reviewNumberElement = document.querySelector(".reviews-dashboard .reviews-dashboard__count-text span");
reviewNumberElement.innerHTML = parseInt(reviewNumberElement.innerHTML) + activePackProfile.getNumActiveReviews();
// Enable review button if 0 WaniKani reviews
let reviewTile = document.querySelector("div.reviews-dashboard");
if(reviewTile.querySelector(".reviews-dashboard__buttons") === null && activePackProfile.getNumActiveReviews() > 0) {
let buttonHTML = `
<div class="reviews-dashboard__buttons">
<div class="reviews-dashboard__button reviews-dashboard__button--start">
<a href="/subjects/review" class="wk-button wk-button--modal-primary" data-turbo="false">
<span class="wk-button__text">Start Reviews</span>
<span class="wk-button__icon wk-button__icon--after"><i class="wk-icon fa-solid fa-chevron-right" aria-hidden="true"></i></span>
</a>
</div>
</div>`;
reviewTile.innerHTML += buttonHTML;
}
});
}
// ----------- UTILITIES -----------
function parseHTML(html) {
var t = document.createElement('template');
t.innerHTML = html;
return t.content;
}
})();