您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add custom word packs to WaniKani!
当前为
// ==UserScript== // @name WK Custom SRS // @namespace leohumnew.wk // @version 0.3.5 // @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 // @grant GM_xmlhttpRequest // @run-at document-start // @require https://gf.qytechs.cn/scripts/489759-wk-custom-icons/code/CustomIcons.js?version=1344498 // ==/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: xx-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 } #tab-3__content:has(#pack-lvl-type [value="internal"]:checked) .pack-lvl-specific { display: grid !important } #tab-3__content:has(#pack-lvl-type [value="wk"]:checked) .wk-lvl-warn { 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="KanaVocabulary"]: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, #tab-3__content > .content-box > div { display: grid; gap: 0.5rem; grid-template-columns: 1fr 1fr; align-items: center; & input, & select { justify-self: end } } /* Styling for the settings tab */ #tab-5__content { & label { margin-right: 1rem; float: left; } } `; overviewPopup.innerHTML = /*html*/ ` <header> <h1>WaniKani Custom SRS</h1> <button class="close-button" onclick="document.getElementById('overview-popup').close();">${Icons.customIconTxt("cross")}</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"> <label for="pack-lvl-type">Pack Levelling Type:</label> <select id="pack-lvl-type" required> <option value="none">No Levels</option> <option value="internal">Pack Levels</option> <option value="wk">WaniKani Levels</option> </select> <div class="wk-lvl-warn" style="display: none; grid-column: 1 / span 2; margin: 0; color: red"> <p style="grid-column: 1 / span 2"><i>Warning: Make sure API key is set in Custom SRS settings.</i></p> </div> <div class="pack-lvl-specific" style="display: none; grid-column: 1 / span 2; margin: 0"> <label for="pack-lvl">Pack Level (start at 1 recommended):</label> <input id="pack-lvl" required type="number"> </div> <div style="grid-column: 1 / span 2"> <p>Items:</p> <button id="new-item-button" title="Add Item" type="button" style="margin-left: auto">${Icons.customIconTxt("plus")}</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="KanaVocabulary">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"> <div class="item-vocab-specific item-kanavocab-specific" style="display: none; grid-column: 1 / span 2"> <label for="item-readings">Readings (comma separated):</label> <input id="item-readings" type="text"> </div> <div class="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> <p style="grid-column: 1 / span 2"><i>Please enter at least one of the three readings:</i></p> <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"> </div> <label for="item-srs-stage">SRS Stage:</label> <select id="item-srs-stage"> <option value="0">Lesson</option> <option value="1">Apprentice 1</option> <option value="2">Apprentice 2</option> <option value="3">Apprentice 3</option> <option value="4">Apprentice 4</option> <option value="5">Guru 1</option> <option value="6">Guru 2</option> <option value="7">Master</option> <option value="8">Enlightened</option> <option value="9">Burned</option> </select> <h3 style="grid-column: 1 / span 2">Optional</h3> <!-- Optional elements --> <label for="item-level">Item Unlock Level:</label> <input id="item-level" type="number"> <label for="item-meaning-explanation">Meaning Explanation:</label> <input id="item-meaning-explanation" type="text"> <div class="item-kanji-specific item-vocab-specific" style="display: none; grid-column: 1 / span 2"> <label for="item-reading-explanation">Reading Explanation:</label> <input id="item-reading-explanation" type="text"> </div> <div class="item-kanavocab-specific item-vocab-specific" style="display: none; grid-column: 1 / span 2"> <label for="item-context-sentences">Context Sentences (comma separated - each jp,en):</label> <input id="item-context-sentences" 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 id="tab-5__content"> <label for="settingsShowDueTime">Show item due times</label> <input type="checkbox" id="settingsShowDueTime" checked><br> <label for="settingsExportSRSData">Include SRS data in exports</label> <input type="checkbox" id="settingsExportSRSData"><br> <label for="settingsItemQueueMode">Position to insert custom items in reviews</label> <select id="settingsItemQueueMode"> <option value="start">Start</option> <option value="weighted-start">Random, weighted towards start</option> <option value="random">Random</option> </select><br> <label for="settingsWKAPIKey">WaniKani API Key</label> <input type="text" id="settingsWKAPIKey" placeholder="API key"> </div> </div> `; // --------- Popup open button --------- let overviewPopupButton, buttonLI; 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("/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; case 5: updateSettingsTab(); 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 = /*html*/ ` <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" title="Edit Pack">${Icons.customIconTxt("edit")}</button> <button class="export-pack" title="Export Pack">${Icons.customIconTxt("download")}</button> <button class="delete-pack" title="Delete Pack">${Icons.customIconTxt("cross")}</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 editItemInfo = activePackProfile.customPacks[document.querySelector("#pack-select").value].getItem(editItem).info; document.querySelector("#item-srs-stage").value = editItemInfo.srs_lvl; document.querySelector("#item-type").value = editItemInfo.type; document.querySelector("#item-characters").value = editItemInfo.characters; document.querySelector("#item-meanings").value = editItemInfo.meanings.join(", "); if(editItemInfo.lvl) document.querySelector("#item-level").value = editItemInfo.lvl; if(editItemInfo.meaning_expl) document.querySelector("#item-meaning-explanation").value = editItemInfo.meaning_expl; if(editItemInfo.readings) document.querySelector("#item-readings").value = editItemInfo.readings.join(", "); if(editItemInfo.primary_reading_type) document.querySelector("#kanji-primary-reading").value = editItemInfo.primary_reading_type; if(editItemInfo.onyomi) document.querySelector("#kanji-onyomi").value = editItemInfo.onyomi.join(", "); if(editItemInfo.kunyomi) document.querySelector("#kanji-kunyomi").value = editItemInfo.kunyomi.join(", "); if(editItemInfo.nanori) document.querySelector("#kanji-nanori").value = editItemInfo.nanori.join(", "); if(editItemInfo.reading_expl) document.querySelector("#item-reading-explanation").value = editItemInfo.reading_expl; if(editItemInfo.context_sentences) document.querySelector("#item-context-sentences").value = editItemInfo.context_sentences.join(", "); document.querySelector("#tab-4__content button[type='submit']").innerText = "Save"; } else { ["item-context-sentences", "item-reading-explanation", "item-meaning-explanation", "item-characters", "item-meanings", "item-readings", "kanji-onyomi", "kanji-kunyomi", "item-level", "kanji-nanori"].forEach((s) => { document.getElementById(s).value = ""; }); document.querySelector("#tab-4__content button[type='submit']").innerText = "Add"; document.querySelector("#item-srs-stage").value = "0"; } // Add event listener to form document.querySelector("#tab-4__content form").onsubmit = (e) => { e.preventDefault(); let itemType = document.querySelector("#item-type").value; let infoStruct = { type: itemType, characters: document.querySelector("#item-characters").value, meanings: document.querySelector("#item-meanings").value.split(",").map(s => s.trim()), srs_lvl: document.querySelector("#item-srs-stage").value }; if(document.querySelector("#item-meaning-explanation").value != "") infoStruct.meaning_expl = document.querySelector("#item-meaning-explanation").value; if(document.querySelector("#item-level").value != "") infoStruct.lvl = parseInt(document.querySelector("#item-level").value); let pack = activePackProfile.customPacks[document.querySelector("#pack-select").value]; // Add or edit item switch(itemType) { case "Radical": infoStruct.category = infoStruct.type; break; case "Kanji": infoStruct.category = infoStruct.type; infoStruct.primary_reading_type = document.querySelector("#kanji-primary-reading").value; if(document.querySelector("#kanji-onyomi").value != "") infoStruct.onyomi = document.querySelector("#kanji-onyomi").value.split(",").map(s => s.trim()); if(document.querySelector("#kanji-kunyomi").value != "") infoStruct.kunyomi = document.querySelector("#kanji-kunyomi").value.split(",").map(s => s.trim()); if(document.querySelector("#kanji-nanori").value != "") infoStruct.nanori = document.querySelector("#kanji-nanori").value.split(",").map(s => s.trim()); if(document.querySelector("#item-reading-explanation").value != "") infoStruct.reading_expl = document.querySelector("#item-reading-explanation").value; break; case "Vocabulary": infoStruct.category = infoStruct.type; infoStruct.readings = document.querySelector("#item-readings").value.split(",").map(s => s.trim()); if(document.querySelector("#item-reading-explanation").value != "") infoStruct.reading_expl = document.querySelector("#item-reading-explanation").value; if(document.querySelector("#item-context-sentences").value != "") infoStruct.context_sentences = document.querySelector("#item-context-sentences").value.split(",").map(s => s.trim()); break; case "KanaVocabulary": infoStruct.category = "Vocabulary"; infoStruct.readings = document.querySelector("#item-readings").value.split(",").map(s => s.trim()); if(document.querySelector("#item-context-sentences").value != "") infoStruct.context_sentences = document.querySelector("#item-context-sentences").value.split(",").map(s => s.trim()); break; default: console.error("Invalid item type"); return; } if(editItem !== null) pack.editItem(editItem, infoStruct); else pack.addItem(infoStruct); 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"; } } function updateSettingsTab() { document.querySelector("#settingsShowDueTime").checked = CustomSRSSettings.userSettings.showItemDueTime; document.querySelector("#settingsShowDueTime").onchange = () => { CustomSRSSettings.userSettings.showItemDueTime = document.querySelector("#settingsShowDueTime").checked; StorageManager.saveSettings(); }; document.querySelector("#settingsItemQueueMode").value = CustomSRSSettings.userSettings.itemQueueMode ? CustomSRSSettings.userSettings.itemQueueMode : "start"; document.querySelector("#settingsItemQueueMode").onchange = () => { CustomSRSSettings.userSettings.itemQueueMode = document.querySelector("#settingsItemQueueMode").value; StorageManager.saveSettings(); }; document.querySelector("#settingsExportSRSData").checked = CustomSRSSettings.userSettings.exportSRSData; document.querySelector("#settingsExportSRSData").onchange = () => { CustomSRSSettings.userSettings.exportSRSData = document.querySelector("#settingsExportSRSData").checked; StorageManager.saveSettings(); }; document.querySelector("#settingsWKAPIKey").value = CustomSRSSettings.userSettings.apiKey; document.querySelector("#settingsWKAPIKey").onchange = () => { CustomSRSSettings.userSettings.apiKey = document.querySelector("#settingsWKAPIKey").value; StorageManager.saveSettings(); }; } // ---------- Tabs details ---------- function loadPackEditDetails(i) { let packNameInput = document.querySelector("#pack-name"); let packAuthorInput = document.querySelector("#pack-author"); let packVersionInput = document.querySelector("#pack-version"); let packLvlTypeInput = document.querySelector("#pack-lvl-type"); let packLvlInput = document.querySelector("#pack-lvl"); 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; packLvlTypeInput.value = "none"; packLvlInput.value = 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; packLvlTypeInput.value = pack.lvlType; packLvlInput.value = pack.lvl; 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.info.characters} - ${item.info.meanings[0]} - ${item.info.type} ${CustomSRSSettings.userSettings.showItemDueTime ? "- Due: " + pack.getItemTimeUntilReview(j) : ""} <div> <button class="edit-item" title="Edit Item" type="button">${Icons.customIconTxt("edit")}</button> <button class="delete-item" title="Delete Item" type="button">${Icons.customIconTxt("cross")}</button> </div> `; itemElement.querySelector(".edit-item").onclick = () => { // Item edit button changeTab(4, item.id); }; itemElement.querySelector(".delete-item").onclick = () => { // Item delete button pack.removeItem(j); loadPackEditDetails(i); }; packItems.appendChild(itemElement); } } document.querySelector("#tab-3__content form.pack-box").onsubmit = (e) => { // Pack save button e.preventDefault(); if(i === "new") { let pack = new CustomItemPack(packNameInput.value, packAuthorInput.value, packVersionInput.value, packLvlTypeInput.value, parseInt(packLvlInput.value)); activePackProfile.addPack(pack); changeTab(3, activePackProfile.customPacks.length - 1); } else { activePackProfile.customPacks[i].name = packNameInput.value; activePackProfile.customPacks[i].author = packAuthorInput.value; activePackProfile.customPacks[i].version = packVersionInput.value; activePackProfile.customPacks[i].lvlType = packLvlTypeInput.value; activePackProfile.customPacks[i].lvl = packLvlInput.value; } 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 buildContextSentencesHTML(ctxArray) { let out = ""; for(let i = 0; i < ctxArray.length; i += 2) { out += ` <div class="subject-section__text subject-section__text--grouped"> <p lang="ja">${ctxArray[i]}</p> <p>${ctxArray[i+1]}</p> </div> `; } return out; } function makeDetailsHTML(item) { switch(item.info.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"> <span class="subject-section__toggle-icon">${Icons.customIconTxt("chevron-right")}</span> <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.info.meanings[0]}</p> </div> ${item.info.meanings.length > 1 ? ` <div class="subject-section__meanings"> <h2 class="subject-section__meanings-title">Alternatives</h2> <p class="subject-section__meanings-items">${item.info.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.info.meaning_expl ? item.info.meaning_expl : "This item does not have a meaning explanation. Good luck!"}</p> <!--<aside class="subject-hint"> <h3 class="subject-hint__title"> <i class="subject-hint__title-icon" aria-hidden="true">${Icons.customIconTxt("circle-info")}</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"> <span class="subject-section__toggle-icon">${Icons.customIconTxt("chevron-right")}</span> <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"> <span class="subject-section__toggle-icon" aria-hidden="true">${Icons.customIconTxt("chevron-right")}</span> <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"> <span class="subject-section__toggle-icon">${Icons.customIconTxt("chevron-right")}</span> <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.info.meanings[0]}</p> </div> ${item.info.meanings.length > 1 ? ` <div class="subject-section__meanings"> <h2 class="subject-section__meanings-title">Alternative</h2> <p class="subject-section__meanings-items">${item.info.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.info.meaning_expl ? item.info.meaning_expl : "This item does not have a reading explanation. Good luck!"}</p> <!--<aside class="subject-hint"> <h3 class="subject-hint__title"> <i class="subject-hint__title-icon" aria-hidden="true">${Icons.customIconTxt("circle-info")}</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"> <span class="subject-section__toggle-icon">${Icons.customIconTxt("chevron-right")}</span> <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.info.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.info.onyomi && item.info.onyomi.length > 0 ? item.info.onyomi.join(', ') : "None"} </p> </div> <div class="subject-readings__reading ${item.info.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.info.kunyomi && item.info.kunyomi.length > 0 ? item.info.kunyomi.join(', ') : "None"} </p> </div> <div class="subject-readings__reading ${item.info.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.info.nanori && item.info.nanori.length > 0 ? item.info.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.info.reading_expl ? item.info.reading_expl : "This item does not have a reading explanation. Good luck!"}</p> <!--<aside class="subject-hint"> <h3 class="subject-hint__title"> <i class="subject-hint__title-icon" aria-hidden="true">${Icons.customIconTxt("circle-info")}</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"> <span class="subject-section__toggle-icon" aria-hidden="true">${Icons.customIconTxt("chevron-right")}</span> <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"> <span class="subject-section__toggle-icon">${Icons.customIconTxt("chevron-right")}</span> <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.info.meanings[0]}</p> </div> ${item.info.meanings.length > 1 ? ` <div class="subject-section__meanings"> <h2 class="subject-section__meanings-title">Alternatives</h2> <p class="subject-section__meanings-items">${item.info.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.info.meaning_expl ? item.info.meaning_expl : "This item does not have a meaning explanation. Good luck!"}</p> <!--<aside class="subject-hint"> <h3 class="subject-hint__title"> <i class="subject-hint__title-icon" aria-hidden="true">${Icons.customIconTxt("circle-info")}</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"> <span class="subject-section__toggle-icon">${Icons.customIconTxt("chevron-right")}</span> <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.info.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.info.reading_expl ? item.info.reading_expl : "This item does not have a reading explanation. Good luck!"}</p> <!--<aside class="subject-hint"> <h3 class="subject-hint__title"> <i class="subject-hint__title-icon" aria-hidden="true">${Icons.customIconTxt("circle-info")}</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"> <span class="subject-section__toggle-icon" aria-hidden="true">${Icons.customIconTxt("chevron-right")}</span> <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> ${item.info.context_sentences && item.info.context_sentences.length > 0 ? buildContextSentencesHTML(item.info.context_sentences) : "No context sentences set."} </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"> <span class="subject-section__toggle-icon" aria-hidden="true">${Icons.customIconTxt("chevron-right")}</span> <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"> <span class="subject-section__toggle-icon">${Icons.customIconTxt("chevron-right")}</span> <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.info.meanings[0]}</p> </div> ${item.info.meanings.length > 1 ? ` <div class="subject-section__meanings"> <h2 class="subject-section__meanings-title">Alternatives</h2> <p class="subject-section__meanings-items">${item.info.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.info.meaning_expl ? item.info.meaning_expl : "This item does not have a meaning explanation. Good luck!"}</p> <!--<aside class="subject-hint"> <h3 class="subject-hint__title"> <i class="subject-hint__title-icon" aria-hidden="true">${Icons.customIconTxt("circle-info")}</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"> <span class="subject-section__toggle-icon">${Icons.customIconTxt("chevron-right")}</span> <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.info.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"> <span class="subject-section__toggle-icon" aria-hidden="true">${Icons.customIconTxt("chevron-right")}</span> <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> ${item.info.context_sentences && item.info.context_sentences.length > 0 ? buildContextSentencesHTML(item.info.context_sentences) : "No context sentences set."} </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 { // Root variables id; last_reviewed_at = 0; // Item main info. Should always contain at least: // type (KanaVocabulary, Vocabulary, Kanji, Radical), category (Vocabulary, Kanji, Radical), srs_lvl, characters, meanings, aux_meanings // Optional: meaning_expl, lvl // Radicals: -- // Kanji: primary_reading_type, onyomi, kunyomi, nanori || reading_expl // Vocabulary: readings, aux_readings || context_sentences, reading_expl // KanaVocabulary: || context_sentences info; constructor(id, info) { this.id = id; this.info = info; this.last_reviewed_at = Date.now(); } isReadyForReview(levelingType = "none", level = 0) { // levelingType: none, internal, wk if(this.last_reviewed_at < Date.now() - srsGaps[this.info.srs_lvl] && this.info.srs_lvl > -1) { // TODO: Change SRS stage check to > 0 once lessons are implemented if(this.info.srs_lvl > 0) return true; // If item is already in SRS, ignore levels else if(levelingType == "none") return true; else if(levelingType == "internal" && (!this.info.lvl || level >= this.info.lvl)) return true; else if(levelingType == "wk" && (!this.info.lvl || CustomSRSSettings.userSettings.lastKnownLevel >= this.info.lvl)) return true; } return false; } getTimeUntilReview(levelingType, level) { // In hours, rounded to integer if(this.isReadyForReview(levelingType, level)) { return "Now"; } else { if((levelingType == "internal" && this.info.lvl && level < this.info.lvl) || (levelingType == "wk" && level < CustomSRSSettings.userSettings.lastKnownLevel)) { return "Locked"; } else return Math.round((srsGaps[this.info.srs_lvl] - (Date.now() - this.last_reviewed_at)) / (60*60*1000)) + "h"; } } incrementSRS() { if(this.info.srs_lvl < 9) this.info.srs_lvl++; this.last_reviewed_at = Date.now(); StorageManager.savePackProfile(activePackProfile, "main"); } decrementSRS() { if(this.info.srs_lvl > 1) { if(this.info.srs_lvl < 5) this.info.srs_lvl--; else this.info.srs_lvl -= 2; } this.last_reviewed_at = Date.now(); StorageManager.savePackProfile(activePackProfile, "main"); } getSRS(packID) { return [Utils.cantorNumber(packID, this.id), parseInt(this.info.srs_lvl)]; } getQueueItem(packID) { switch(this.info.type) { case "Radical": return { id: Utils.cantorNumber(packID, this.id), type: this.info.type, subject_category: this.info.category, characters: this.info.characters, meanings: this.info.meanings, auxiliary_meanings: this.info.aux_meanings || [], kanji: this.info.kanji || [] }; case "Kanji": return { id: Utils.cantorNumber(packID, this.id), type: this.info.type, subject_category: this.info.category, characters: this.info.characters, meanings: this.info.meanings, auxiliary_meanings: this.info.aux_meanings || [], primary_reading_type: this.info.primary_reading_type, onyomi: this.info.onyomi || [], kunyomi: this.info.kunyomi || [], nanori: this.info.nanori || [], auxiliary_readings: this.info.aux_readings || [], radicals: this.info.radicals || [], vocabulary: this.info.vocabulary || [] }; case "Vocabulary": return { id: Utils.cantorNumber(packID, this.id), type: this.info.type, subject_category: this.info.category, characters: this.info.characters, meanings: this.info.meanings, auxiliary_meanings: this.info.aux_meanings || [], readings: this.info.readings.map(reading => ({"reading": reading, "pronunciations": []})), auxiliary_readings: this.info.aux_readings || [], kanji: this.info.kanji || [] }; case "KanaVocabulary": return { id: Utils.cantorNumber(packID, this.id), type: this.info.type, subject_category: this.info.category, characters: this.info.characters, meanings: this.info.meanings, auxiliary_meanings: this.info.aux_meanings || [], readings: this.info.readings.map(reading => ({"reading": reading, "pronunciations": []})) }; } } static fromObject(object) { let item; if(!object.info) { // If item from before update let newInfo = {}; newInfo.type = object.type; newInfo.category = object.subject_category; newInfo.srs_lvl = object.srs_stage; newInfo.characters = object.characters; newInfo.meanings = object.meanings; if(object.readings) newInfo.readings = object.readings; if(object.auxiliary_readings && object.auxiliary_readings.length > 0) newInfo.aux_readings = object.auxiliary_readings; if(object.auxiliary_meanings && object.auxiliary_meanings.length > 0) newInfo.aux_meanings = object.auxiliary_meanings; if(object.meaning_explanation) newInfo.meaning_expl = object.meaning_explanation; if(object.reading_explanation && (object.type == "Vocabulary" || object.type == "Kanji")) newInfo.reading_expl = object.reading_explanation; if(object.primary_reading_type) newInfo.primary_reading_type = object.primary_reading_type; if(object.onyomi) newInfo.onyomi = object.onyomi; if(object.kunyomi) newInfo.kunyomi = object.kunyomi; if(object.nanori) newInfo.nanori = object.nanori; item = new CustomItem(object.id, newInfo); } else item = new CustomItem(object.id, object.info); item.last_reviewed_at = object.last_reviewed_at; return item; } } class CustomItemPack { name; author; version; items = []; active = true; nextID = 0; lvlType = "none"; // "none", "internal", "wk" lvl = 1; constructor(name, author, version, lvlType, lvl = 1) { this.name = name; this.author = author; this.version = version; this.lvlType = lvlType; this.lvl = lvl; } getItem(id) { return this.items.find(item => item.id === id); } addItem(itemInfo) { let id = this.nextID++; let item = new CustomItem(id, itemInfo); this.items.push(item); } editItem(id, itemInfo) { let item = this.getItem(id); delete item.info; item.info = itemInfo; } removeItem(position) { this.items.splice(position, 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(this.lvlType, this.lvl)).map(item => item.getQueueItem(packID)); } getActiveReviewsSRS(packID) { if(!this.active) return []; return this.items.filter(item => item.isReadyForReview(this.lvlType, this.lvl)).map(item => item.getSRS(packID)); } getNumActiveReviews() { if(!this.active) return 0; let num = 0; for(let item of this.items) { if(item.isReadyForReview(this.lvlType, this.lvl)) num++; } return num; } getItemTimeUntilReview(itemIndex) { return this.items[itemIndex].getTimeUntilReview(this.lvlType, this.lvl); } static fromObject(object) { let pack = new CustomItemPack(object.name, object.author, object.version, (object.lvlType ? object.lvlType : "none"), (object.lvl ? object.lvl : 1)); // TODO: Remove lvlType and lvl checks after a few weeks pack.items = object.items.map(item => CustomItem.fromObject(item)); pack.active = object.active; pack.nextID = (object.nextID || 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.info.srs_lvl = oldItem.info.srs_lvl; 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].getActiveReviewsSRS(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].getItem(itemID); if(meaningIncorrectNum > 0 || readingIncorrectNum > 0) { item.decrementSRS(); } else { item.incrementSRS(); // Check if pack should level up let pack = this.customPacks[packID]; if(pack.lvlType == "internal") { for(let item of pack.items) { if((!item.info.lvl || item.info.lvl <= pack.lvl) && item.info.srs_lvl < 5) break; } pack.lvl++; } } } 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 async get_controller(name) { let controller; while(!controller) { try { controller = Stimulus.getControllerForElementAndIdentifier(document.querySelector(`[data-controller~="${name}"]`),name); } catch(e) { console.log("Waiting for controller " + name); } await new Promise(r => setTimeout(r, 50)); } return controller; } static async wkAPIRequest(endpoint, method = "GET", data = null) { if(!CustomSRSSettings.userSettings.apiKey) console.error("CustomSRS: No API key set"); let url = "https://api.wanikani.com/v2/" + endpoint; let headers = new Headers({ Authorization: "Bearer " + CustomSRSSettings.userSettings.apiKey, }); let apiRequest = new Request(url, { method: method, headers: headers }); if(data) apiRequest.body = JSON.stringify(data); let response = await fetch(apiRequest); return response.json(); } } class CustomSRSSettings { static defaultUserSettings = { showItemDueTime: true, itemQueueMode: "start", exportSRSData: false, lastKnownLevel: 0, apiKey: null }; static userSettings = this.defaultUserSettings; static savedData = { capturedWKReview: null }; static validateSettings() { for(let setting in this.defaultUserSettings) { if(this.userSettings[setting] === undefined) this.userSettings[setting] = this.defaultUserSettings[setting]; } } } class StorageManager { // Get custom packs saved in GM storage static async loadPackProfile(profileName) { let savedPackProfile = CustomPackProfile.fromObject(await GM.getValue("customPackProfile_" + profileName, new CustomPackProfile())); return savedPackProfile; } // Save custom packs to GM storage static async savePackProfile(packProfile, profileName) { GM.setValue("customPackProfile_" + profileName, packProfile); } // Settings static async saveSettings() { GM.setValue("custom_srs_user_data", CustomSRSSettings.userSettings); GM.setValue("custom_srs_saved_data", CustomSRSSettings.savedData); } static async loadSettings() { CustomSRSSettings.userSettings = await GM.getValue("custom_srs_user_data", CustomSRSSettings.userSettings); CustomSRSSettings.validateSettings(); CustomSRSSettings.savedData = await GM.getValue("custom_srs_saved_data", CustomSRSSettings.savedData); } static packFromJSON(json) { let pack = CustomItemPack.fromObject(json); return pack; } static packToJSON(pack) { let packJSON = JSON.parse(JSON.stringify(pack)); if(!CustomSRSSettings.userSettings.exportSRSData) { packJSON.items.forEach(item => { item.last_reviewed_at = 0; item.info.srs_lvl = 0; }); } return JSON.stringify(packJSON); } } let activePackProfile = await StorageManager.loadPackProfile("main"); await StorageManager.loadSettings(); let quizStatsController; // ----------- If on review page ----------- if (window.location.pathname.includes("/review")) { if(activePackProfile.getNumActiveReviews() !== 0) { // Add style to root to prevent header flash let headerStyle = document.createElement("style"); headerStyle.innerHTML = ` .character-header__characters { transition: opacity 0.15s; } .character-header__loading .character-header__characters { opacity: 0; } `; document.head.append(headerStyle); } // Add custom items to the quiz queue and update captured WK review document.addEventListener("DOMContentLoaded", () => { let changedFirstItem = false; let queueEl = document.getElementById('quiz-queue'); let parentEl = queueEl.parentElement; queueEl.remove(); let cloneEl = queueEl.cloneNode(true); let queueElement = JSON.parse(cloneEl.querySelector("script[data-quiz-queue-target='subjects']").innerHTML); let SRSElement = JSON.parse(cloneEl.querySelector("script[data-quiz-queue-target='subjectIdsWithSRS']").innerHTML); // Remove captured WK review from queue if(queueElement.length === 1 || (CustomSRSSettings.savedData.capturedWKReview && queueElement[1].id === CustomSRSSettings.savedData.capturedWKReview.id)) { CustomSRSSettings.savedData.capturedWKReview = queueElement.shift(); SRSElement.shift(); changedFirstItem = true; console.log("CustomSRS: Captured first item from queue."); } else { CustomSRSSettings.savedData.capturedWKReview = queueElement[1]; queueElement.splice(1, 1); SRSElement.splice(1, 1); console.log("CustomSRS: Captured second item from queue."); } // Add custom items to queue if(activePackProfile.getNumActiveReviews() !== 0) { switch(CustomSRSSettings.userSettings.itemQueueMode) { case "weighted-start": let reviewsToAddW = activePackProfile.getActiveReviews(); let reviewsSRSToAddW = activePackProfile.getActiveReviewsSRS(); for(let i = 0; i < reviewsToAddW.length; i++) { let pos = Math.floor(Math.random() * queueElement.length / 4); if(pos === 0) changedFirstItem = true; queueElement.splice(pos, 0, reviewsToAddW[i]); SRSElement.splice(pos, 0, reviewsSRSToAddW[i]); } break; case "random": let reviewsToAdd = activePackProfile.getActiveReviews(); let reviewsSRSToAdd = activePackProfile.getActiveReviewsSRS(); for(let i = 0; i < reviewsToAdd.length; i++) { let pos = Math.floor(Math.random() * queueElement.length); if(pos === 0) changedFirstItem = true; queueElement.splice(pos, 0, reviewsToAdd[i]); SRSElement.splice(pos, 0, reviewsSRSToAdd[i]); } break; case "start": changedFirstItem = true; queueElement = activePackProfile.getActiveReviews().concat(queueElement); SRSElement = activePackProfile.getActiveReviewsSRS().concat(SRSElement); break; } } cloneEl.querySelector("script[data-quiz-queue-target='subjects']").innerHTML = JSON.stringify(queueElement); cloneEl.querySelector("script[data-quiz-queue-target='subjectIdsWithSRS']").innerHTML = JSON.stringify(SRSElement); parentEl.appendChild(cloneEl); StorageManager.saveSettings(); if(changedFirstItem) { let headerElement = document.querySelector(".character-header"); headerElement.classList.add("character-header__loading"); for(let className of headerElement.classList) { // Fix header colour issues if(className.includes("character-header--")) { headerElement.classList.remove(className); headerElement.classList.add("character-header--" + activePackProfile.getActiveReviews()[0].subject_category.toLowerCase()); setTimeout(() => { headerElement.classList.remove("character-header__loading"); }, 500); break; } } } loadControllers(); }); // 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 { if(payload.counts[0].id == CustomSRSSettings.savedData.capturedWKReview.id) { // Check if somehow the captured WK review is being submitted CustomSRSSettings.savedData.capturedWKReview = null; StorageManager.saveSettings(); } 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(); let res = new Response(updateLessonReviewCountData(data), { 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() + (CustomSRSSettings.savedData.capturedWKReview ? -1 : 0); console.log("Captured review item: " + (CustomSRSSettings.savedData.capturedWKReview ? CustomSRSSettings.savedData.capturedWKReview.id : "none")); let reviewTile = document.querySelector("div.reviews-dashboard"); if(reviewTile.querySelector(".reviews-dashboard__buttons") === null && activePackProfile.getNumActiveReviews() > 0) { // If failed to catch WK review and custom items are due, display error message reviewTile.querySelector(".reviews-dashboard__text .wk-text").innerHTML = "CustomSRS Error. Please wait for WK review item to be available."; } else if(parseInt(reviewTile.querySelector(".count-bubble").innerHTML) === 0) { // If no custom items are due, update review tile to remove buttons reviewTile.querySelector(".reviews-dashboard__buttons").remove(); reviewTile.classList.add("reviews-dashboard--complete"); reviewTile.querySelector(".reviews-dashboard__text .wk-text").innerHTML = "There are no more reviews to do right now."; } }); // Update the stored user level let response = await Utils.wkAPIRequest("user"); if(response && response.data && response.data.level) { CustomSRSSettings.userSettings.lastKnownLevel = response.data.level; StorageManager.saveSettings(); } } else { // 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(); let res = new Response(updateLessonReviewCountData(data), { status: response.status, headers: response.headers }); return res; } else { return originalFetch(...args); } }; } // ----------- UTILITIES ----------- function parseHTML(html) { var t = document.createElement('template'); t.innerHTML = html; return t.content; } function updateLessonReviewCountData(data) { 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 let numActiveReviews = activePackProfile.getNumActiveReviews(); if(reviewCountElement === null && numActiveReviews > 0) { let reviewTile = data.querySelector(".lesson-and-review-count__item:nth-child(2)"); reviewTile.outerHTML = ` <a class="lesson-and-review-count__item" target="_top" href="/subjects/review"> <div class="lesson-and-review-count__count">${numActiveReviews}</div> <div class="lesson-and-review-count__label">Reviews</div> </a> `; } else { if(numActiveReviews > 0 || (!CustomSRSSettings.savedData.capturedWKReview && parseInt(reviewCountElement.innerHTML) > 0) || parseInt(reviewCountElement.innerHTML) > 1) reviewCountElement.innerHTML = parseInt(reviewCountElement.innerHTML) + numActiveReviews + (CustomSRSSettings.savedData.capturedWKReview ? -1 : 0); else { let reviewTile = data.querySelector(".lesson-and-review-count__item:nth-child(2)"); reviewTile.outerHTML = ` <span class="lesson-and-review-count__item" target="_top"> <div class="lesson-and-review-count__count lesson-and-review-count__count--zero">0</div> <div class="lesson-and-review-count__label">Reviews</div> </span> `; } } // Convert the DocumentFragment back to a string and return it as a Response return (new XMLSerializer()).serializeToString(data); } async function loadControllers() { quizStatsController = await Utils.get_controller('quiz-statistics'); quizStatsController.remainingCountTarget.innerText = parseInt(quizStatsController.remainingCountTarget.innerText) + activePackProfile.getNumActiveReviews() + (CustomSRSSettings.savedData.capturedWKReview ? -1 : 0); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址