您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add custom word packs to WaniKani!
当前为
// ==UserScript== // @name WK Custom SRS // @namespace leohumnew.wk // @version 0.5.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 // @grant GM_xmlhttpRequest // @run-at document-start // @require https://gf.qytechs.cn/scripts/489759-wk-custom-icons/code/CustomIcons.js?version=1367265 // ==/UserScript== (async() => { const srsNames = ["Lesson", "Apprentice 1", "Apprentice 2", "Apprentice 3", "Apprentice 4", "Guru 1", "Guru 2", "Master", "Enlightened", "Burned"]; const time = 106; if(window.location.pathname.includes("/dashboard") || window.location.pathname === "/") { let tempVar = {}; // Temporary variable for multiple things // ------------------------ Define and create custom HTML structures ------------------------ // --------- 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; } .dashboard .lessons-and-reviews__section:nth-child(3) { margin-top: 12px; margin-right: 16px; } .dashboard .lessons-and-reviews__section:last-child { margin-top: 12px } /* Main popup styling */ #overview-popup { background-color: var(--color-menu, white); width: 60%; max-width: 50rem; height: 70%; max-height: 45rem; 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 } .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 overview tab */ #tab-1__content > div { display: grid; grid-template-columns: 1fr 1fr; column-gap: 1.5rem; text-align: center; p { text-align: left } #custom-srs-progress .content-box { background-color: var(--color-menu, white); margin-bottom: 0.6rem; h3 { margin-top: 0 } .progress-bar { display: flex; justify-content: start; height: 0.5rem; border-radius: 0.3rem; margin-bottom: 0.6rem; background-color: var(--color-wk-panel-background, lightgray); div { border-radius: 0.3rem } div:nth-child(1) { background-color: var(--color-guru, #2ecc71) } div:nth-child(2) { background-color: color-mix(in srgb, var(--color-apprentice, #3daee9) 80%, lightgray); } div:nth-child(3) { background-color: var(--color-apprentice, #3daee9) } div:nth-child(4) { background-color: color-mix(in srgb, var(--color-apprentice, #3daee9) 80%, black); } div:nth-child(5) { background-color: color-mix(in srgb, var(--color-apprentice, #3daee9) 60%, black); } } } } /* 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 } &:has(#pack-select [value="import"]:checked) .pack-box { display: none } &:has(#pack-select [value="import"]:checked) .import-box { display: grid !important } &:has(#pack-lvl-type [value="internal"]:checked) .pack-lvl-specific { display: grid !important } &: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 { hr { margin: 0; border-color: var(--color-wk-panel-background, gray) } i { opacity: 0.5 } .ctx-sentence-div { display: flex; justify-content: space-between; margin-bottom: 0.5rem; input { margin-right: 0.5rem } } .item-info-edit-container { padding: 1rem; border-radius: 3px; background-color: var(--color-wk-panel-content-background, white); button { margin-left: auto } label { margin-right: 0.5rem } } .component-div { display: grid; grid-template-columns: 1fr 0.2fr; } &:has(#item-type [value="Radical"]:checked) .item-radical-specific { display: grid !important } &:has(#item-type [value="Kanji"]:checked) .item-kanji-specific { display: grid !important } &:has(#item-type [value="Vocabulary"]:checked) .item-vocab-specific { display: grid !important } &:has(#item-type [value="KanaVocabulary"]:checked) .item-kanavocab-specific { display: grid !important } &:has(#component-type [value="internal"]:checked), &:has(#component-type [value="wk"]:checked) { #component-type-container { display: none !important } #component-id-container { display: block !important } } } #tab-4__content .content-box, #tab-4__content .content-box > div, #tab-3__content > .content-box, #tab-3__content > .content-box > div, #tab-5__content .content-box { 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; } .component-div { display: grid; grid-template-columns: 1fr 0.2fr; padding: 1rem; border-radius: 3px; background-color: var(--color-wk-panel-content-background, white); } } `; // Add custom review buttons let extraButtons = /*html*/ ` <div class="lessons-and-reviews__section"> <div class="reviews-dashboard"> <div class="reviews-dashboard__content"> <div class="reviews-dashboard__title" style="color: var(--color-todays-lessons-text)"> <div class="reviews-dashboard__title-text">Conjugations</div> </div> <div class="reviews-dashboard__button reviews-dashboard__button--start"> <a href="/subjects/review?conjugations&question_order=reading_first" class="wk-button wk-button--modal-primary"> <span class="wk-button__text">Start</span> <span class="wk-button__icon wk-button__icon--after"> ${Icons.customIconTxt("chevron-right")} </span> </a> </div> </div> </div> </div> <div class="lessons-and-reviews__section"> <div class="reviews-dashboard"> <div class="reviews-dashboard__content"> <div class="reviews-dashboard__title" style="color: var(--color-todays-lessons-text)"> <div class="reviews-dashboard__title-text">Audio Quiz</div> </div> <div class="reviews-dashboard__button reviews-dashboard__button--start"> <a href="/subjects/extra_study?queue_type=burned_items&question_order=meaning_first&audio" class="wk-button wk-button--modal-primary"> <span class="wk-button__text">Start</span> <span class="wk-button__icon wk-button__icon--after"> ${Icons.customIconTxt("chevron-right")} </span> </a> </div> </div> </div> </div> `; // Add content to the Custom SRS popup 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" id="overview-lessons"> <h2>Lessons</h2> <h2>--</h2> </div> <div class="content-box" id="overview-reviews"> <h2>Reviews</h2> <h2>--</h2> </div> <div class="content-box" style="grid-column: span 2; margin-top: 1rem"> <h2>Custom SRS Progress</h2> <div id="custom-srs-progress"></div> </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-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> <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-info-edit-container 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> <!----------- Optional elements -----------> <h3 style="grid-column: 1 / span 2">Optional</h3> <label for="item-level">Item Unlock Level:</label> <input id="item-level" type="number"> <div class="item-vocab-specific item-kanavocab-specific" style="display: none; grid-column: 1 / span 2"> <label for="item-word-function">Item Word Functions (e.g. noun, の adjective):</label> <input id="item-word-function" type="text"> </div> <div class="item-info-edit-container" style="grid-column: 1 / span 2"> <p style="grid-column: 1 / span 2"><i>In explanations you can use tags to highlight <r>radicals</r>, <k>kanji</k>, <v>vocabulary</v>, <me>meanings</me>, and <re>readings</re>.</i></p> <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; grid-template-columns: 1fr 1fr"> <label for="item-reading-explanation">Reading Explanation:</label> <input id="item-reading-explanation" type="text"> </div> </div> <div class="item-info-edit-container item-kanavocab-specific item-vocab-specific" style="display: none; grid-column: 1 / span 2"> <p>Context Sentences</p> <button id="ctx-add-btn">${Icons.customIconTxt("plus")}</button> <div id="item-context-sentences-container" style="grid-column: 1 / span 2"></div> </div> <div class="item-info-edit-container item-vocab-specific item-radical-specific item-kanji-specific" style="display: none; grid-column: 1 / span 2"> <p style="grid-column: 1 / span 2"> <span class="item-radical-specific" style="display: none">Kanji This Radical Is In</span> <span class="item-vocab-specific" style="display: none">Kanji Components</span> <span class="item-kanji-specific" style="display: none">Radical Components</span> </p> <p style="grid-column: 1 / span 2"><i>If custom item, enter the character. If WaniKani item, enter the ID found on the WK item page.</i></p> <span> <span id="component-type-container"> <label for="component-type" style="float: left">Type:</label> <select id="component-type"> <option value=""><i>Select type</i></option> <option value="internal">This Pack</option> <option value="wk">WaniKani</option> </select> </span> <span id="component-id-container" style="display: none"> <label id="component-id-label" for="component-id" style="float: left"> <span class="item-radical-specific item-vocab-specific" style="display: none;">Kanji</span> <span class="item-kanji-specific" style="display: none;">Radical</span> </label> <input id="component-id" type="text"> </span> </span> <button id="component-add-btn">${Icons.customIconTxt("plus")}</button> <p style="display: none; grid-column: 1 / span 2; color: red"><i>Failed to find component.</i></p> <hr style="grid-column: 1 / span 2"> <div id="components-container" style="grid-column: 1 / span 2"></div> </div> <div class="item-info-edit-container" style="grid-column: 1 / span 2"> <label for="item-meaning-whitelist">Meaning Whitelist:</label> <input id="item-meaning-whitelist" type="text"> <label for="item-meaning-blacklist">Meaning Blacklist:</label> <input id="item-meaning-blacklist" type="text"> <div class="item-kanji-specific item-vocab-specific" style="display: none; grid-column: 1 / span 2; grid-template-columns: 1fr 1fr"> <label for="item-reading-whitelist">Reading Whitelist:</label> <input id="item-reading-whitelist" type="text"> <label for="item-reading-blacklist">Reading Blacklist:</label> <input id="item-reading-blacklist" type="text"> </div> </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"> <div class="content-box"> <h2 style="grid-column: span 2">General</h2> <label for="settingsShowDueTime">Show item due times</label> <input type="checkbox" id="settingsShowDueTime" checked> <label for="settingsExportSRSData">Include SRS data in exports</label> <input type="checkbox" id="settingsExportSRSData"> <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> <label for="settingsEnabledConjGrammar">Enable Conjugations and Audio Quiz</label> <input type="checkbox" id="settingsEnabledConjGrammar" checked> <label for="settingsConjGrammarSessionLength">Conjugation session length (item num.)</label> <input type="number" id="settingsConjGrammarSessionLength" value="10"> <label style="grid-column: span 2">Active Conjugations:</label> <div id="settingsActiveConj" style="grid-column: span 2"></div> <h2 style="grid-column: span 2">Network Settings</h2> <label for="settingsWKAPIKey">WaniKani API Key</label> <input type="text" id="settingsWKAPIKey" placeholder="API key"> <h2 style="grid-column: span 2">Experimental Settings</h2> <p style="grid-column: span 2"><i>These settings are experimental and may not work as intended. I would recommend backing up any item packs you care about (download and save the pack JSON from the packs tab - enable the "Include SRS data in exports"!) just in case.</i></p> <div class="component-div" style="grid-column: span 2; grid-template-columns: 1fr 0.8fr"> <label for="settingsSyncEnabled">Enable Cross-Device Sync</label> <input type="checkbox" id="settingsSyncEnabled"> <p id="lastSync"> Last sync: <span>Never</span></p> <span style="margin-left: auto"> <button id="syncNowPull">Force Pull</button> <button id="syncNowPush">Force Push</button> </span> </div> </div> </div> </div> `; // --------- Popup open button --------- let overviewPopupButton, buttonLI; 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); if(CustomSRSSettings.userSettings.enabledConjGrammar) document.querySelector(".lessons-and-reviews").innerHTML += extraButtons; 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.getElementById("pack-select").onchange = () => { loadPackEditDetails(document.getElementById("pack-select").value) }; document.getElementById("new-item-button").onclick = () => { changeTab(4, null) }; // Add popup button to page if (window.location.pathname.includes("/dashboard") || window.location.pathname === "/") { document.getElementById("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("#overview-reviews h2:last-child").innerText = activePackProfile.getNumActiveReviews(); // Fill in the progress section with the current progress for each pack let progressDiv = document.getElementById("custom-srs-progress"); progressDiv.innerHTML = ""; for(let pack of activePackProfile.customPacks) { if(pack.active) progressDiv.innerHTML += packProgressHTML(pack); } } function packProgressHTML(pack) { return /*html*/ ` <div class="content-box"> <h3>${pack.name} - Author: ${pack.author}</h3> ${pack.getProgressHTML()} </div> `; } function updatePacksTab() { let packsTab = document.getElementById("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", true); 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.getElementById("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) { tempVar.components = []; 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"; document.getElementById("ctx-add-btn").onclick = (e) => { e.preventDefault(); document.getElementById("item-context-sentences-container").appendChild(buildContextSentenceEditHTML("", "")); }; document.getElementById("component-add-btn").onclick = (e) => { // Handle adding kanji components e.preventDefault(); let type = document.getElementById("component-type").value; let id = parseInt(document.getElementById("component-id").value); let subjectType = document.getElementById("item-type").value == "Kanji" ? "Radical" : "Kanji"; if(type === "" || id === "") return; // Check if component exists. When type is internal id is the item character to search for switch(type) { case "internal": { let itemID = activePackProfile.customPacks[document.getElementById("pack-select").value].getItemID(subjectType, document.getElementById("component-id").value); if(itemID) { let itemFromID = activePackProfile.customPacks[document.getElementById("pack-select").value].getItem(itemID); tempVar.components.push({id: itemID, pack: parseInt(document.getElementById("pack-select").value), type: subjectType, characters: itemFromID.info.characters, meanings: itemFromID.info.meanings, readings: itemFromID.info.readings || itemFromID.info.onyomi?.concat(itemFromID.info.kunyomi).concat(itemFromID.info.nanori) || null}); document.getElementById("component-add-btn").nextElementSibling.style.display = "none"; document.getElementById("components-container").appendChild(buildComponentEditHTML(tempVar.components[tempVar.components.length - 1])); document.getElementById("component-type").value = ""; document.getElementById("component-id").value = ""; } else { document.getElementById("component-add-btn").nextElementSibling.style.display = "block"; } break; } case "wk": { if(isNaN(id)) { document.getElementById("component-add-btn").nextElementSibling.innerText = "Please enter the ID found on this item's details page." document.getElementById("component-add-btn").nextElementSibling.style.display = "block"; return; } else document.getElementById("component-add-btn").nextElementSibling.innerText = "Failed to find component."; // Fetch wk api item to check it's valid Utils.wkAPIRequest("subjects/" + id).then((response) => { if(response) { tempVar.components.push({id: id, pack: -1, type: subjectType, characters: response.data.characters, meanings: response.data.meanings.map(m => m.meaning), readings: response.data.readings?.map(r => r.reading) || response.data.onyomi?.concat(response.data.kunyomi).concat(response.data.nanori) || null}); document.getElementById("component-add-btn").nextElementSibling.style.display = "none"; document.getElementById("components-container").appendChild(buildComponentEditHTML(tempVar.components[tempVar.components.length - 1])); document.getElementById("component-type").value = ""; document.getElementById("component-id").value = ""; } else { document.getElementById("component-add-btn").nextElementSibling.style.display = "block"; } }); break; } } }; // Clear old data and set new data ["item-reading-explanation", "item-meaning-explanation", "item-characters", "item-meanings", "item-readings", "kanji-onyomi", "kanji-kunyomi", "item-level", "kanji-nanori", "item-word-function", "item-meaning-whitelist", "item-meaning-blacklist", "item-reading-whitelist", "item-reading-blacklist"].forEach(s => { document.getElementById(s).value = ""; }); document.getElementById("item-context-sentences-container").innerHTML = ""; document.getElementById("components-container").innerHTML = ""; tempVar.components = []; if(editItem !== null) { let editItemInfo = activePackProfile.customPacks[document.getElementById("pack-select").value].getItem(editItem).info; document.getElementById("item-srs-stage").value = editItemInfo.srs_lvl; document.getElementById("item-type").value = editItemInfo.type; document.getElementById("item-characters").value = editItemInfo.characters; document.getElementById("item-meanings").value = editItemInfo.meanings.join(", "); if(editItemInfo.lvl) document.getElementById("item-level").value = editItemInfo.lvl; if(editItemInfo.meaning_expl) document.getElementById("item-meaning-explanation").value = editItemInfo.meaning_expl; if(editItemInfo.readings) document.getElementById("item-readings").value = editItemInfo.readings.join(", "); if(editItemInfo.primary_reading_type) document.getElementById("kanji-primary-reading").value = editItemInfo.primary_reading_type; if(editItemInfo.onyomi) document.getElementById("kanji-onyomi").value = editItemInfo.onyomi.join(", "); if(editItemInfo.kunyomi) document.getElementById("kanji-kunyomi").value = editItemInfo.kunyomi.join(", "); if(editItemInfo.nanori) document.getElementById("kanji-nanori").value = editItemInfo.nanori.join(", "); if(editItemInfo.reading_expl) document.getElementById("item-reading-explanation").value = editItemInfo.reading_expl; if(editItemInfo.func) document.getElementById("item-word-function").value = editItemInfo.func; if(editItemInfo.meaning_wl) document.getElementById("item-meaning-whitelist").value = editItemInfo.meaning_wl.join(", "); if(editItemInfo.meaning_bl) document.getElementById("item-meaning-blacklist").value = editItemInfo.meaning_bl.join(", "); if(editItemInfo.reading_wl) document.getElementById("item-reading-whitelist").value = editItemInfo.reading_wl.join(", "); if(editItemInfo.reading_bl) document.getElementById("item-reading-blacklist").value = editItemInfo.reading_bl.join(", "); if(editItemInfo.ctx_jp) { let ctxContainer = document.getElementById("item-context-sentences-container"); ctxContainer.innerHTML = ""; editItemInfo.ctx_jp.forEach((s, i) => { ctxContainer.appendChild(buildContextSentenceEditHTML(s, editItemInfo.ctx_en[i])); }); } if(editItemInfo.kanji || editItemInfo.radicals) { tempVar.components = editItemInfo.kanji || editItemInfo.radicals; let container = document.getElementById("components-container"); container.innerHTML = ""; tempVar.components.forEach(k => { container.appendChild(buildComponentEditHTML(k)); }); } else tempVar.components = []; document.querySelector("#tab-4__content button[type='submit']").innerText = "Save"; } else { 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.getElementById("item-type").value; let infoStruct = { type: itemType, characters: document.getElementById("item-characters").value, meanings: document.getElementById("item-meanings").value.split(",").map(s => s.trim()), srs_lvl: document.getElementById("item-srs-stage").value }; if(document.getElementById("item-meaning-explanation").value != "") infoStruct.meaning_expl = document.getElementById("item-meaning-explanation").value; if(document.getElementById("item-level").value != "") infoStruct.lvl = parseInt(document.getElementById("item-level").value); if(document.getElementById("item-meaning-whitelist").value != "") infoStruct.meaning_wl = document.getElementById("item-meaning-whitelist").value.split(",").map(s => s.trim()); if(document.getElementById("item-meaning-blacklist").value != "") infoStruct.meaning_bl = document.getElementById("item-meaning-blacklist").value.split(",").map(s => s.trim()); let pack = activePackProfile.customPacks[document.getElementById("pack-select").value]; let ctxDivs = document.getElementById("item-context-sentences-container").children; // Add or edit item switch(itemType) { case "Radical": infoStruct.category = infoStruct.type; if(tempVar.components.length > 0) infoStruct.kanji = tempVar.components; break; case "Kanji": infoStruct.category = infoStruct.type; infoStruct.primary_reading_type = document.getElementById("kanji-primary-reading").value; if(document.getElementById("kanji-" + infoStruct.primary_reading_type.toLowerCase()).value == "") { alert("Primary reading must be set"); return; } if(document.getElementById("kanji-onyomi").value != "") infoStruct.onyomi = document.getElementById("kanji-onyomi").value.split(",").map(s => s.trim()); if(document.getElementById("kanji-kunyomi").value != "") infoStruct.kunyomi = document.getElementById("kanji-kunyomi").value.split(",").map(s => s.trim()); if(document.getElementById("kanji-nanori").value != "") infoStruct.nanori = document.getElementById("kanji-nanori").value.split(",").map(s => s.trim()); if(document.getElementById("item-reading-explanation").value != "") infoStruct.reading_expl = document.getElementById("item-reading-explanation").value; if(document.getElementById("item-reading-whitelist").value != "") infoStruct.reading_wl = document.getElementById("item-reading-whitelist").value.split(",").map(s => s.trim()); if(document.getElementById("item-reading-blacklist").value != "") infoStruct.reading_bl = document.getElementById("item-reading-blacklist").value.split(",").map(s => s.trim()); if(tempVar.components.length > 0) infoStruct.radicals = tempVar.components; break; case "Vocabulary": infoStruct.category = infoStruct.type; infoStruct.readings = document.getElementById("item-readings").value.split(",").map(s => s.trim()); if(document.getElementById("item-reading-explanation").value != "") infoStruct.reading_expl = document.getElementById("item-reading-explanation").value; if(document.getElementById("item-word-function").value != "") infoStruct.func = document.getElementById("item-word-function").value; if(document.getElementById("item-context-sentences-container").children.length > 0) { infoStruct.ctx_jp = []; infoStruct.ctx_en = []; for(let i = 0; i < ctxDivs.length; i++) { let ctxDiv = ctxDivs[i]; infoStruct.ctx_jp.push(ctxDiv.children[0].value); infoStruct.ctx_en.push(ctxDiv.children[1].value); } } if(document.getElementById("item-reading-whitelist").value != "") infoStruct.reading_wl = document.getElementById("item-reading-whitelist").value.split(",").map(s => s.trim()); if(document.getElementById("item-reading-blacklist").value != "") infoStruct.reading_bl = document.getElementById("item-reading-blacklist").value.split(",").map(s => s.trim()); if(tempVar.components.length > 0) infoStruct.kanji = tempVar.components; break; case "KanaVocabulary": infoStruct.category = "Vocabulary"; infoStruct.readings = document.getElementById("item-readings").value.split(",").map(s => s.trim()); if(document.getElementById("item-word-function").value != "") infoStruct.func = document.getElementById("item-word-function").value; if(document.getElementById("item-context-sentences-container").children.length > 0) { infoStruct.ctx_jp = []; infoStruct.ctx_en = []; for(let i = 0; i < ctxDivs.length; i++) { let ctxDiv = ctxDivs[i]; infoStruct.ctx_jp.push(ctxDiv.children[0].value); infoStruct.ctx_en.push(ctxDiv.children[1].value); } } 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.getElementById("pack-select").value); StorageManager.savePackProfile(activePackProfile, "main"); changeTab(3, document.getElementById("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() { const settings = CustomSRSSettings.userSettings; const updateSetting = (elementId, property, isCheckbox = false, needsReload = false) => { const element = document.getElementById(elementId); if(isCheckbox) element.checked = settings[property]; else element.value = settings[property]; element.onchange = () => { settings[property] = isCheckbox ? element.checked : element.value; StorageManager.saveSettings(); if(elementId == "settingsSyncEnabled") { if(element.checked) SyncManager.checkIfAuthed(); else { if(confirm("Are you sure you want to disable sync?")) SyncManager.disableSync(); else { element.checked = true; settings[property] = true; StorageManager.saveSettings(); return; } } } if(needsReload) window.location.reload(); }; }; updateSetting("settingsShowDueTime", "showItemDueTime", true); updateSetting("settingsItemQueueMode", "itemQueueMode"); updateSetting("settingsExportSRSData", "exportSRSData", true); updateSetting("settingsWKAPIKey", "apiKey", false, true); updateSetting("settingsEnabledConjGrammar", "enabledConjGrammar", true, true); updateSetting("settingsConjGrammarSessionLength", "conjGrammarSessionLength"); updateSetting("settingsSyncEnabled", "syncEnabled", true); document.getElementById("settingsActiveConj").innerHTML = ""; document.getElementById("settingsActiveConj").appendChild(Conjugations.getSettingsHTML()); document.querySelector("#lastSync span").innerText = new Date(CustomSRSSettings.savedData.lastSynced).toLocaleString(); document.getElementById("syncNowPull").onclick = async () => { activePackProfile = await StorageManager.loadPackProfile("main", true); }; document.getElementById("syncNowPush").onclick = () => { StorageManager.savePackProfile(activePackProfile, "main", true, true); }; } // ---------- Tabs details ---------- function loadPackEditDetails(i) { let packNameInput = document.getElementById("pack-name"); let packAuthorInput = document.getElementById("pack-author"); let packVersionInput = document.getElementById("pack-version"); let packLvlTypeInput = document.getElementById("pack-lvl-type"); let packLvlInput = document.getElementById("pack-lvl"); let packItems = document.getElementById("pack-items"); let importBox = document.getElementById("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 savePack(); 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(); savePack(); 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", true); 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", true); changeTab(2); } } }; function savePack() { 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", true); } } // ---------- Item info procedural edit structures ---------- function buildComponentEditHTML(item) { let template = document.createElement('template'); template.innerHTML = /*html*/ ` <div class="component-div"> <p>${item.pack < 0 ? "WaniKani" : "This Pack"} ${item.type}. Character: ${item.characters} | Meaning: ${item.meanings[0]}</p> <button class="delete-component" title="Delete Component">${Icons.customIconTxt("cross")}</button> </div> `; template.content.querySelector(".delete-component").onclick = function() { tempVar.components.splice(tempVar.components.findIndex(c => c.id == item.id && c.pack == item.pack), 1); this.parentElement.remove(); }; return template.content; } function buildContextSentenceEditHTML(jp, en) { let contextSentence = document.createElement('div'); contextSentence.classList = "ctx-sentence-div"; contextSentence.innerHTML = /*html*/ ` <input type="text" value="${jp}" placeholder="Japanese" required> <input type="text" value="${en}" placeholder="English" required> <button class="delete-sentence" title="Delete Sentence" onclick="this.parentElement.remove()">${Icons.customIconTxt("cross")}</button> `; return contextSentence; } } else { // Add custom CSS let customReviewCSS = document.createElement("style"); customReviewCSS.innerHTML = /* css */ ` .character-header__characters { font-size: 25px !important; text-align: center; width: 100%; } .character-header__characters::first-line { font-size: 50px } @container(min-width: 768px) { .character-header__characters { font-size: 50px !important } .character-header__characters::first-line { font-size: 100px } } `; document.head.appendChild(customReviewCSS); // ---------- Item details HTML and formatting ---------- function buildKanjiComponentHTML(item) { return /*html*/ ` <li class="subject-character-grid__item"> <a class="subject-character subject-character--${item.type.toLowerCase()} subject-character--grid" data-turbo-frame="_blank" ${item.pack < 0 ? 'href="https://www.wanikani.com/' + item.type.toLowerCase() + '/' + item.characters + '"' : ""}> <div class="subject-character__content"> <span class="subject-character__characters" lang="ja">${item.characters}</span> <div class="subject-character__info"> <span class="subject-character__reading">${item.readings ? item.readings[0] : item.primary_reading_type == "onyomi" ? item.onyomi[0] : item.primary_reading_type == "kunyomi" ? item.kunyomi[0] : item.primary_reading_type == "nanori" ? item.nanori[0] : "-"}</span> <span class="subject-character__meaning">${item.meanings[0]}</span> </div> </div> </a> </li> `; } function buildRadicalComponentHTML(item) { return /*html*/ ` <li class="subject-list__item"> <a class="subject-character subject-character--radical subject-character--small-with-meaning subject-character--expandable" data-turbo-frame="_blank" ${item.pack < 0 ? 'href="https://www.wanikani.com/radicals/' + item.meanings[0].toLowerCase() + '"' : ""}> <div class="subject-character__content"> <span class="subject-character__characters" lang="ja">${item.characters}</span> <div class="subject-character__info"> <span class="subject-character__meaning">${item.meanings[0]}</span> </div> </div> </a> </li> `; } function buildVocabComponentHTML(item) { return /*html*/ ` <li class="subject-character-grid__item"> <a class="subject-character subject-character--vocabulary subject-character--grid subject-character--unlocked" href="https://www.wanikani.com/vocabulary/${item.ogChar}" data-turbo-frame="_blank" target="_blank"> <div class="subject-character__content"> <span class="subject-character__characters" lang="ja">${item.ogChar}</span> <div class="subject-character__info"> <span class="subject-character__reading">${item.ogReading}</span> <span class="subject-character__meaning">${item.meanings[0]}</span> </div> </div> </a> </li> `; } function buildContextSentencesHTML(ctxArrayJP, ctxArrayEN) { let out = ""; for(let i = 0; i < ctxArrayJP.length; i++) { out += ` <div class="subject-section__text subject-section__text--grouped"> <p lang="ja">${ctxArrayJP[i]}</p> <p>${ctxArrayEN[i]}</p> </div> `; } return out; } function explFormat(expl) { if(!expl) return null; return expl.replace(/<r>(.*?)<\/r>/g, "<mark title='Radical' class='radical-highlight'>$1</mark>").replace(/<k>(.*?)<\/k>/g, "<mark title='Kanji' class='kanji-highlight'>$1</mark>").replace(/<v>(.*?)<\/v>/g, "<mark title='Vocabulary' class='vocabulary-highlight'>$1</mark>").replace(/<me>(.*?)<\/me>/g, "<mark title='Meaning' class='meaning-highlight'>$1</mark>").replace(/<re>(.*?)<\/re>/g, "<mark title='Reading' class='reading-highlight'>$1</mark>"); } 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">${explFormat(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"> ${item.info.kanji?.map(k => buildKanjiComponentHTML(k)).join('') || "No found in kanji set."} </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"> ${item.info.radicals?.map(k => buildRadicalComponentHTML(k)).join('') || "No radical components set."} </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">${explFormat(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.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.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.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">${explFormat(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>--> ${item.info.func ? `<div class="subject-section__meanings"> <h2 class="subject-section__meanings-title">Word Type</h2> <p class="subject-section__meanings-items">${item.info.func}</p> </div>` : ''} </section> <section class="subject-section__subsection"> <h3 class='subject-section__subtitle'>Explanation</h3> <p class="subject-section__text">${explFormat(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">${explFormat(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.ctx_jp ? buildContextSentencesHTML(item.info.ctx_jp, item.info.ctx_en) : "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"> ${item.info.kanji?.map(k => buildKanjiComponentHTML(k)).join('') || "No kanji components set."} </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>--> ${item.info.func ? `<div class="subject-section__meanings"> <h2 class="subject-section__meanings-title">Word Type</h2> <p class="subject-section__meanings-items">${item.info.func}</p> </div>` : ''} </section> <section class="subject-section__subsection"> <h3 class='subject-section__subtitle'>Explanation</h3> <p class="subject-section__text">${explFormat(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.ctx_jp ? buildContextSentencesHTML(item.info.ctx_jp, item.info.ctx_en) : "No context sentences set."} </section> </section> </section> </div> </turbo-frame> `; } } function makeDetailsHTMLConjugation(item, conjName, conjDesc) { return /*html*/ ` <turbo-frame class="subject-info" id="subject-info"> <div class="container"> <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='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'>Conjugation Info</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-section__meanings'> <h2 class='subject-section__meanings-title'>Conjugated</h2> <div class="subject-section__meanings-items" lang='ja'>${item.readings[0].reading}</div> </div> <div class='subject-section__meanings'> <h2 class='subject-section__meanings-title'>Conjugation</h2> <p class='subject-section__meanings-items'>${conjName} form</p> </div> <div class="subject-section__meanings"> <h2 class="subject-section__meanings-title">Verb Type</h2> <p class="subject-section__meanings-items">${item.verbType.charAt(0).toUpperCase() + item.verbType.slice(1)}</p> </div> </section> <section class="subject-section__subsection"> <h3 class='subject-section__subtitle'>Explanation</h3> <p class="subject-section__text">${conjDesc}</p> </section> <section class="subject-section__subsection"> <h3 class='subject-section__subtitle'>Info</h3> <p class="subject-section__text"><i>This is one of many "conjugations" for this verb. Depending on the type of verb, we remove or modify the last kana and then append the ending corresponding to this conjugation.</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":["reading"]}"> <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'>Verb Details</span> </a> </h2> <section id="section-amalgamations" class="subject-section__content" data-toggle-target="content" hidden="hidden"> <div class="subject-character-grid subject-character-grid--single-column"> <ol class="subject-character-grid__items"> ${buildVocabComponentHTML(item)} </ol> </div> </section> </section> </div> </turbo-frame> `; } } const a = "UFRYcVJzRWlKMnQyUkIyRXphMk53YXAxcjlOUw=="; 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 || ctx_jp, ctx_en, reading_expl, kanji // KanaVocabulary: || crx_jp, ctx_en info; constructor(id, info) { this.id = id; this.info = info; this.last_reviewed_at = Date.now(); } isReadyForReview(levelingType, level) { // 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" && this.info.lvl && CustomSRSSettings.userSettings.lastKnownLevel < this.info.lvl)) { 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) { let aux_meanings = [], aux_readings = []; if(this.info.meaning_wl) aux_meanings = aux_meanings.concat(this.info.meaning_wl.map(m => ({"type": "whitelist", "meaning": m}))); if(this.info.meaning_bl) aux_meanings = aux_meanings.concat(this.info.meaning_bl.map(m => ({"type": "blacklist", "meaning": m}))); if(this.info.reading_wl) aux_readings = aux_readings.concat(this.info.reading_wl.map(r => ({"type": "whitelist", "reading": r}))); if(this.info.reading_bl) aux_readings = aux_readings.concat(this.info.reading_bl.map(r => ({"type": "blacklist", "reading": r}))); 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: 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: aux_meanings, primary_reading_type: this.info.primary_reading_type, onyomi: this.info.onyomi || [], kunyomi: this.info.kunyomi || [], nanori: this.info.nanori || [], auxiliary_readings: 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: aux_meanings, readings: this.info.readings.map(reading => ({"reading": reading, "pronunciations": []})), auxiliary_readings: 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: aux_meanings, readings: this.info.readings.map(reading => ({"reading": reading, "pronunciations": []})) }; } } static fromObject(object) { try { if(object.info.kanji && object.info.kanji[0] && !object.info.kanji[0].characters) delete object.info.kanji; // Remove old component format TODO: remove after a few weeks let item = new CustomItem(object.id, object.info); if(item.info.context_sentences) { // Convert context_sentences to ctx_jp and ctx_en TODO: remove after a few weeks for(let i = 0; i < item.info.context_sentences.length; i++) { item.info.ctx_jp = []; item.info.ctx_en = []; if(i % 2 == 0) item.info.ctx_jp.push(item.info.context_sentences[i]); else item.info.ctx_en.push(item.info.context_sentences[i]); } delete item.info.context_sentences; } item.last_reviewed_at = object.last_reviewed_at; return item; } catch(e) { alert("Error loading item, please let me know what error you're getting (unless you haven't used this script since it was first released): " + e); if(confirm("Clear all custom SRS data to fix this issue? (should only be necessary if you haven't used the script since it was first released, otherwise message me on WK forum)")) { StorageManager.deletePackProfile("main"); } return null; } } } 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); } getItemID(itemType, itemChar) { let item = this.items.find(item => item.info.characters === itemChar && item.info.type === itemType); if(item) return item.id; else return null; } 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); } getProgressHTML() { if(!this.active) return; let progressByLevel = {}; this.items.forEach(item => { if(this.lvlType == "none" || !item.info.lvl) { if(!progressByLevel["noLevel"]) progressByLevel["noLevel"] = []; if(item.info.srs_lvl > 4) progressByLevel["noLevel"][5] = (progressByLevel["noLevel"][5] || 0) + 1; else progressByLevel["noLevel"][item.info.srs_lvl] = (progressByLevel["noLevel"][item.info.srs_lvl] || 0) + 1; progressByLevel["noLevel"][6] = (progressByLevel["noLevel"][6] || 0) + 1; } else if(this.lvlType == "internal" && item.info.lvl > this.lvl) progressByLevel["locked"] = (progressByLevel["locked"] || 0) + 1; else if(this.lvlType == "wk" && item.info.lvl > CustomSRSSettings.userSettings.lastKnownLevel) progressByLevel["locked"] = (progressByLevel["locked"] || 0) + 1; else { if(!progressByLevel[item.info.lvl]) progressByLevel[item.info.lvl] = []; if(item.info.srs_lvl > 4) progressByLevel[item.info.lvl][5] = (progressByLevel[item.info.lvl][5] || 0) + 1; else progressByLevel[item.info.lvl][item.info.srs_lvl] = (progressByLevel[item.info.lvl][item.info.srs_lvl] || 0) + 1; progressByLevel[item.info.lvl][6] = (progressByLevel[item.info.lvl][6] || 0) + 1; } }); let progressHTML = ""; for(let level in progressByLevel) { if(level != "locked") { progressHTML += "<p>Level " + (level == "noLevel" ? "--" : level) + "</p><div class='progress-bar'>"; for(let i = 5; i > 0; i--) { progressHTML += "<div style='width: " + (progressByLevel[level][i] || 0) / progressByLevel[level][6]*100 + "%' title='" + (i == 5 ? "Guru+" : srsNames[i]) + " (" + progressByLevel[level][i] + "/" + progressByLevel[level][6] + ")'></div>"; } progressHTML += "</div>"; } } return progressHTML; } static fromObject(object) { let pack = new CustomItemPack(object.name, object.author, object.version, (object.lvlType || "none"), (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) return; } pack.lvl++; StorageManager.savePackProfile(this, "main"); StorageManager.needsPushSync = true; } } } static fromObject(object) { let packProfile = new CustomPackProfile(); packProfile.customPacks = object.customPacks.map(pack => CustomItemPack.fromObject(pack)); return packProfile; } } const b = "Z3F0ZWZlZzZnc" + String.fromCharCode(time) + "I2NHNwaGxhdDJxaWFvNDdrdmxtaTY="; // ------------------- Conjugations ------------------- class Conjugations { static verbIDs; static levelVerbCount; static activeQueue; static rootEnds = [ ["わ", "か", "さ", "た", "な", "ば", "ま", "ら", "が"], ["い", "き", "し", "ち", "に", "び", "み", "り", "ぎ"], ["う", "く", "す", "つ", "ぬ", "ぶ", "む", "る", "ぐ"], ["え", "け", "せ", "て", "ね", "べ", "め", "れ", "げ"], ["お", "こ", "そ", "と", "の", "ぼ", "も", "ろ", "ご"], ["って", "いて", "して", "って", "んで", "んで", "んで", "って", "いで"], ["った", "いた", "した", "った", "んだ", "んだ", "んだ", "った", "いだ"] ]; static conjugations = { // Name : [godan kana row, general ending, pretty name, explanation, ichidan ending {optional}] "te": [5, "て", "-te", "This is the te-form of the verb, used for connecting the verb to a word or clause that follows it."], "ta": [6, "た", "past", "This is the ta-form of the verb, used for past tense."], "masu": [1, "ます", "formal", "This is the present/future keigo form of the verb, used in polite speech."], "mashita": [1, "ました", "past formal", "This is the past keigo form of the verb, used in polite speech."], "masen": [1, "ません", "negative formal", "This is the negative keigo form of the verb, used in polite speech."], "masendeshita": [1, "ませんでした", "past negative formal", "This is the negative past keigo form of the verb, used in polite speech."], "tai": [1, "たい", "'want'", "This is the 'want to do' form of the verb."], "nai": [0, "ない", "negative", "This is the standard negative form of the verb."], "reru": [0, "れる", "receptive", "This is the receptive (similar to passive) form of the verb, used when something is done to the subject.", "られる"], "seru": [0, "せる", "causative", "This is the causative form of the verb, used when allowing, making, or causing something to happen.", "させる"] }; static irregularVerbs = { "する": {"gen": "し", "reru": "さ", "seru": "さ"}, "来る": {"gen": "き", "nai": "こ", "reru": "こら", "seru": "こさ"}, "有る": {"nai": ""}, "行く": {"te": "いって", "ta": "いった"} }; static init() { this.verbIDs = new Uint16Array(1045); this.verbIDs = [2480,2490,2495,2492,2557,2572,2586,2576,2603,2606,2609,2614,2641,8945,2578,2645,2671,2697,2699,2706,2723,2729,2733,8660,8961,2481,2491,2494,2556,2577,2587,2598,2613,2698,2740,2742,2750,2756,2765,2775,2787,2805,2824,2838,2840,2601,2724,2776,2868,2874,2901,2902,2914,2923,2926,3406,3418,3496,3817,4378,7477,7671,7681,8697,2599,2720,2903,2937,2941,2971,4070,4071,7677,8738,2700,2966,2992,2994,2997,3007,3015,3018,3025,3052,3054,3065,3074,3434,3435,3453,3461,3462,3463,3464,3465,3467,3468,7530,8938,2816,3088,3091,3106,3112,3130,3142,3144,3148,3429,3466,3475,3476,3479,3523,3807,4147,4377,7478,7741,9261,9279,3097,3113,3159,3162,3167,3171,3172,3174,3177,3180,3183,3185,3189,3192,3208,3215,3217,3222,3445,3488,3491,3492,3493,3494,3506,3707,7575,7621,7686,9240,9250,3149,3238,3245,3252,3267,3277,3278,3281,3284,3285,3287,3288,3291,3305,3310,3316,3490,3505,3507,3508,3509,4148,4217,7497,7514,7626,8932,8939,8951,3098,3128,3333,3337,3352,3391,3393,3424,3556,3557,3558,3561,3562,3563,3565,3566,3582,3925,4073,7748,8933,8946,3394,3524,3554,3571,3576,3577,3578,3579,3580,3583,3586,3587,3594,3605,3624,3628,3639,3888,7495,7496,8943,9245,3129,3489,3662,3666,3684,3689,3700,3705,3798,3880,3881,3883,3884,3885,3886,3887,3889,3902,3944,3946,6946,8954,3575,3732,3755,3756,3767,3776,3904,3905,3906,5927,5983,7540,7697,7754,8952,9244,9249,9269,3720,3826,3828,3838,3843,3849,3856,3866,3870,3920,3922,3929,3930,3931,7755,8947,9262,9276,9294,3797,3823,3845,3976,3985,3994,4005,7588,7589,7700,7759,4065,4082,4091,4102,4105,4107,4111,4115,4146,4152,7479,7645,7646,8710,8852,8927,8997,9021,9247,2634,4166,4189,4198,4210,4220,4222,4225,4226,4243,4367,4372,6301,7550,8944,9295,9304,4181,4204,4221,4254,4261,4278,4304,4317,4334,4345,4370,8953,9027,9085,9253,4122,4309,4381,4384,4388,4391,4392,4393,4407,4409,4417,4425,4426,4443,4451,4454,4456,4458,4464,4472,4474,6457,8712,8888,8998,9086,9283,4389,4480,4482,4488,4492,4497,4499,4502,4514,4526,4533,4552,4561,4562,4582,4841,4843,4844,4845,4852,7502,7597,8867,4390,4577,4578,4581,4607,4613,4617,4625,4627,4631,4642,4648,4652,4853,4854,4855,4856,4857,4858,5937,7507,7765,7779,8931,8936,4655,4657,4660,4668,4687,4689,4695,4700,4710,4714,4725,4741,4752,4755,4865,4866,4869,4870,6235,7711,3800,4747,4763,4764,4766,4767,4768,4769,4773,4777,4779,4782,4788,4792,4797,4805,4812,4822,4827,4830,4834,4836,4837,4878,4879,4885,7600,7724,8680,8716,8809,8868,8905,9257,4012,4778,4838,4883,4895,4901,4903,4907,4908,4910,4912,4924,4925,4948,4949,4960,4966,4967,4977,4978,4994,4998,7160,7769,9072,4771,4810,5001,5002,5003,5004,5018,5019,5021,5025,5027,5029,5036,5069,5070,5072,5079,5082,5084,5088,5093,5096,5103,5197,7656,7770,8541,8940,8949,9307,5028,5039,5092,5109,5124,5152,5160,5161,5163,5178,5190,5200,5262,5284,5388,5751,8757,9252,5009,5031,5076,5081,5213,5216,5220,5242,5249,5255,5263,5266,5273,5280,5282,5288,5290,5396,5397,6295,7772,8684,9045,9296,5283,5297,5300,5308,5310,5316,5317,5320,5330,5333,5336,5338,5343,5356,5373,5376,5380,5381,5383,5386,5399,5504,5701,6456,8842,8875,8929,8930,8934,9080,9263,5337,5403,5404,5406,5407,5410,5414,5421,5433,5436,5443,5447,5449,5466,5467,5483,5491,5497,5702,5703,5704,5705,5706,8935,5508,5511,5512,5516,5517,5526,5560,5575,5582,5585,5596,5600,5606,5608,5609,5610,5708,8723,8876,8956,4277,5611,5613,5618,5629,5640,5643,5648,5653,5657,5665,5672,5681,5691,5709,5710,5711,5712,5713,5721,5722,5726,5728,5729,5730,5732,5735,5736,5738,5742,5757,5763,5784,5789,5790,5795,5796,5801,5813,5816,5820,5829,5834,8725,8878,3207,5725,5837,5839,5840,5841,5845,5847,5850,5851,5857,5858,5859,5863,5867,5877,5917,7776,8917,9059,5562,5940,5957,5970,5974,5981,5986,5988,6001,6006,6009,6015,6028,6238,9266,6038,6040,6041,6043,6050,6055,6063,6067,6073,6075,6079,6080,6081,6084,6098,6102,6108,6109,6120,6123,6128,6130,6131,6137,6078,6142,6143,6144,6146,6153,6162,6163,6166,6168,6169,6174,6178,6179,6182,6191,6196,6203,6213,6216,6219,6222,6223,6229,6230,6232,6233,8983,9272,9297,5022,6241,6243,6245,6246,6247,6250,6255,6258,6267,6268,6289,6303,6305,6306,6308,6318,6322,7511,6340,6341,6342,6344,6345,6348,6349,6350,6352,6357,6360,6366,6369,6380,6386,6393,6395,6405,6413,6424,6433,6441,6444,8928,9154,6454,6455,6469,6475,6476,6488,6495,6514,6526,6540,6551,6554,6557,6560,6561,6562,6583,6589,6592,6594,6595,6598,6617,6618,6623,6627,6644,6936,6937,6938,6939,6940,6942,7558,7725,6658,6661,6681,6699,6711,6718,6732,6740,6944,6945,8942,6731,6753,6759,6760,6761,6766,6767,6783,6801,6812,6815,6935,6941,6949,8926,6763,6765,6844,6846,6852,6855,6856,6886,6890,6892,6897,6899,6908,6923,6925,6931,6933,6955,9008,9034,6988,7024,7031,7038,7041,7044,7782,6721,7007,7056,7060,7064,7103,7105,7107,7109,7120,7143,7144,9060,7057,7123,7163,7171,7172,7185,7195,7198,7205,7208,7213,7220,7229,7232,7245,7251,8937,7267,7281,7288,7290,7306,7311,7314,7317,7326,7336,7341,7349,7434,8890,8920,8921,8941,8948,4974,7358,7385,7392,7397,7404,7436,7438,7440,7444,7667,8950,7787,7789,7796,7798,7799,7802,7818,7832,7857,8813,9001,7863,7878,7892,7916,7930,7939,7946,7968,7976,7988,7989,7994,7995,8021,8043,8044,8073,8081,8086,8094,8137,8146,8153,8179,8190,8200,8206,8213,8246,8262,8288,8316,8318,8328,8339,8359,8381,8417,8424,8425,9053,8437,8441,8463,8470,8477,8504,8510,8518,8521,8527,8558,8568,8579,8581,8602,8618,8632,8635,9116,9117]; this.levelVerbCount = [3,7,14,25,45,64,74,99,121,152,181,203,225,247,265,284,295,314,331,346,373,396,421,441,475,500,530,548,572,603,627,647,666,692,712,727,751,781,800,825,838,860,871,886,906,913,926,943,961,973,984,991,1000,1007,1013,1019,1025,1032,1039,1045]; } static async getRandomVerbInfo(quantity) { let verbInfo = []; for(let i = 0; i < quantity; i++) { let verbID = this.verbIDs[Math.floor(Math.random() * this.levelVerbCount[(CustomSRSSettings.userSettings.lastKnownLevel == 60) ? 59 : CustomSRSSettings.userSettings.lastKnownLevel - 3])]; verbInfo.push(verbID); } // Make one request to get all verb info from WK API let verbInfoString = verbInfo.join(","); let response = await Utils.wkAPIRequest("subjects?ids=" + verbInfoString); for(let i = 0; i < verbInfo.length; i++) { verbInfo[i] = response.data.find(item => item.id === verbInfo[i]); } return verbInfo; } static conjugateVerb(verb, type, form, characters) { switch(type) { case "godan": { if(this.irregularVerbs[characters]?.[form]) verb = this.irregularVerbs[characters][form]; // Check for single verb irregularities else { let lastKanaColumn = this.rootEnds[2].indexOf(verb.slice(-1)); verb = verb.slice(0, -1); verb += this.rootEnds[this.conjugations[form][0]][lastKanaColumn]; } if(form != "te" && form != "ta") verb += this.conjugations[form][1]; break; } case "ichidan": verb = verb.slice(0, -1); verb += this.conjugations[form][4] || this.conjugations[form][1]; break; case "irregular": if(characters.includes("する")) verb = verb.slice(0, -2) + (this.irregularVerbs["する"][form] || this.irregularVerbs["する"].gen); else verb = this.irregularVerbs[characters][form] || this.irregularVerbs[characters].gen; verb += this.conjugations[form][1]; break; } return verb; } static getConjugationQueueItem(item) { let conjugationKeys = Object.keys(this.conjugations).filter(c => !CustomSRSSettings.userSettings.inactiveConjugations.includes(c)); if(conjugationKeys.length == 0) { alert("No conjugations selected, please select some in the settings."); window.location.href = "/dashboard"; } const conjugationType = conjugationKeys[Math.floor(Math.random() * conjugationKeys.length)]; const partsOfSpeech = item.data.parts_of_speech.join(" "); const verbType = partsOfSpeech.includes("ichidan") ? "ichidan" : partsOfSpeech.includes("godan") ? "godan" : (this.irregularVerbs[item.data.characters] || item.data.characters.includes("する")) ? "irregular" : "ichidan"; // Determine verb type return { id: -item.id, type: "Vocabulary", subject_category: "Vocabulary", characters: item.data.characters + "\n" + this.conjugations[conjugationType][2] + " form", meanings: item.data.meanings.map(m => m.meaning) || [], auxiliary_meanings: item.data.auxiliary_meanings || [], readings: [{"reading": this.conjugateVerb(item.data.readings[0].reading, verbType, conjugationType, item.data.characters), "pronunciations": []}], auxiliary_readings: item.data.auxiliary_readings || [], kanji: [], verbType: verbType, conjugationType: conjugationType, ogChar: item.data.characters, ogReading: item.data.readings[0].reading }; } static async getConjugationSessionItems(quantity) { if(CustomSRSSettings.userSettings.lastKnownLevel < 3) { alert("WK level too low, conjugation practice requires at least level 3."); window.location.href = "/dashboard"; } Conjugations.init(); let items = await Conjugations.getRandomVerbInfo(quantity); if(items.length < quantity) { alert("Not enough verbs unlocked yet, try again once you've increased your WK level!"); window.location.href = "/dashboard"; return; } // Set up items let srsItems = [], verbItems = []; for(let item of items) { srsItems.push([-item.id, 0]); verbItems.push(this.getConjugationQueueItem(item)); } this.activeQueue = verbItems; return [verbItems, srsItems]; } static getSubjectInfo(subjectID) { // Get details of item for review page details display let item = this.activeQueue.find(item => item.id === parseInt(subjectID)); return makeDetailsHTMLConjugation(item, this.conjugations[item.conjugationType][2], this.conjugations[item.conjugationType][3]); } static getSettingsHTML() { let container = document.createElement("div"); container.classList.add("component-div"); container.style.gridColumn = "span 2"; for(let conjugation in this.conjugations) { let label = document.createElement("label"); label.innerHTML = this.conjugations[conjugation][2] + " form"; let checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = !CustomSRSSettings.userSettings.inactiveConjugations.includes(conjugation); checkbox.onchange = function() { if(checkbox.checked) CustomSRSSettings.userSettings.inactiveConjugations = CustomSRSSettings.userSettings.inactiveConjugations.filter(c => c != conjugation); else CustomSRSSettings.userSettings.inactiveConjugations.push(conjugation); StorageManager.saveSettings(); }; container.append(label, checkbox); } return container; } } // ------------------- Audio Quiz ------------------- class AudioQuiz { static activeItemLink; static addAudioQuizItem(id, audioURL) { this.activeQueueLinks[id] = audioURL; } static async playActiveQuizItemAudio() { if(!this.activeItemLink) return; let audio = new Audio(this.activeItemLink); audio.play(); } static setUpAudioQuizHTML() { let soundIcon = Icons.customIcon("sound-on"); soundIcon.classList.add("sound-icon"); soundIcon.style.width = "100%"; soundIcon.style.height = "7.5rem"; soundIcon.onclick = () => this.playActiveQuizItemAudio(); document.getElementById("custom-srs-header-style").innerHTML += ".character-header .character-header__characters { height: 0 } .character-header__content { cursor: pointer }"; let audioButton = document.querySelector("a.additional-content__item.additional-content__item--audio"); audioButton.onclick = () => this.playActiveQuizItemAudio(); audioButton.style.cursor = "pointer"; window.addEventListener("willShowNextQuestion", (e) => { this.activeItemLink = e.detail.subject?.readings[0].pronunciation.sources[0]?.url; this.playActiveQuizItemAudio(); }); return soundIcon; } } // ------------------- 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) { Utils.log("Error getting controller " + name); } Utils.log("Waiting for controller " + name); await new Promise(r => setTimeout(r, 50)); } return controller; } static async setMeaningsOnly() { let controller = await Utils.get_controller("quiz-queue"); controller = controller.quizQueue.stats; const originalGet = controller.get.bind(controller); controller.get = function(t) { let stat = originalGet(t); stat.reading.complete = true; return stat; }; } static async setReadingsOnly() { let controller = await Utils.get_controller("quiz-queue"); controller = controller.quizQueue.stats; const originalGet = controller.get.bind(controller); controller.get = function(t) { let stat = originalGet(t); stat.meaning.complete = true; return stat; }; } static async wkAPIRequest(endpoint, method = "GET", data = null) { if(!CustomSRSSettings.userSettings.apiKey) { console.error("CustomSRS: No API key set"); return; } 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(); } static log(message) { if(typeof message === "object") console.log(message); else console.log("Custom SRS: " + message); } } class CustomSRSSettings { static defaultUserSettings = { showItemDueTime: true, itemQueueMode: "weighted-start", exportSRSData: false, lastKnownLevel: 0, apiKey: null, enabledConjGrammar: true, conjGrammarSessionLength: 10, inactiveConjugations: [], syncEnabled: false }; static userSettings = this.defaultUserSettings; static defaultSavedData = { capturedWKReview: null, lastSynced: 0 }; static savedData = this.defaultSavedData; static validateSettings() { for(let setting in this.defaultUserSettings) { if(this.userSettings[setting] === undefined) this.userSettings[setting] = this.defaultUserSettings[setting]; } // Prompt user for API key if not set if(!this.userSettings.apiKey) { if(confirm("Custom SRS: No API key set, would you like to set it now? CustomSRS will not work properly without it. It can be found in your WaniKani account settings > API Keys.")) { let apiKey = prompt("Please enter your WaniKani API key:"); if(apiKey) { this.userSettings.apiKey = apiKey; StorageManager.saveSettings(); } else { alert("Custom SRS: No API key set, Custom SRS will not work properly without it."); console.error("Custom SRS: No API key set"); } } else { alert("Custom SRS: No API key set, Custom SRS will not work properly without it. Reload the page or go to the CustomSRS settings tab to set it."); console.error("Custom SRS: No API key set"); } } for(let setting in this.defaultSavedData) { if(this.savedData[setting] === undefined) this.savedData[setting] = this.defaultSavedData[setting]; } } } class StorageManager { static downloadedPackProfile = null; static needsPushSync = false; // Get custom packs saved in GM storage static async loadPackProfile(profileName, forceSync = false) { if(this.needsPushSync) { this.needsPushSync = false; await this.savePackProfile(null, profileName, true, true); Utils.log("Synced data to drive after review session"); return; } if(CustomSRSSettings.userSettings.syncEnabled && (window.location.pathname.includes("dashboard") || window.location.pathname === "/") && (forceSync || new Date().getTime() > CustomSRSSettings.savedData.lastSynced + 120000)) { await SyncManager.loadDataFromDrive(profileName, forceSync); let maxWaitCount = 150; while(!this.downloadedPackProfile && maxWaitCount > 0) { await new Promise(r => setTimeout(r, 100)); Utils.log("Waiting for pack profile to download"); maxWaitCount--; } console.log(this.downloadedPackProfile); if(this.downloadedPackProfile != null && this.downloadedPackProfile != -1) { GM.setValue("customPackProfile_" + profileName, this.downloadedPackProfile); this.downloadedPackProfile = CustomPackProfile.fromObject(this.downloadedPackProfile); return this.downloadedPackProfile; } } let savedPackProfile; try { savedPackProfile = CustomPackProfile.fromObject(await GM.getValue("customPackProfile_" + profileName, new CustomPackProfile())); } catch(e) { Utils.log("Error loading pack profile: " + e); savedPackProfile = new CustomPackProfile(); } this.downloadedPackProfile = null; return savedPackProfile; } // Save custom packs to GM storage static async savePackProfile(packProfile, profileName, shouldSync = false, forceSync = false) { if(!packProfile) packProfile = CustomPackProfile.fromObject(await GM.getValue("customPackProfile_" + profileName, new CustomPackProfile())); if(!packProfile) return; GM.setValue("customPackProfile_" + profileName, packProfile); if(CustomSRSSettings.userSettings.syncEnabled && shouldSync && (new Date().getTime() > CustomSRSSettings.savedData.lastSynced + 40000 || forceSync)) SyncManager.saveDataToDrive(packProfile, profileName); } static async deletePackProfile(profileName) { GM.deleteValue("customPackProfile_" + profileName); } // Settings static async saveSettings() { GM.setValue("custom_srs_user_data", CustomSRSSettings.userSettings); GM.setValue("custom_srs_saved_data", CustomSRSSettings.savedData); } static async updateLastSynced() { CustomSRSSettings.savedData.lastSynced = Date.now(); GM.setValue("custom_srs_saved_data", CustomSRSSettings.savedData); } static async loadSettings() { CustomSRSSettings.userSettings = await GM.getValue("custom_srs_user_data", CustomSRSSettings.userSettings); CustomSRSSettings.savedData = await GM.getValue("custom_srs_saved_data", CustomSRSSettings.savedData); CustomSRSSettings.validateSettings(); } 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); } } await StorageManager.loadSettings(); let stopRemainingScriptLoad = false; // Class with utilities for performing the OAuth2.0 flow and for making requests to the Google Drive API. class SyncManager { static #c = atob("NTA4Njc3MDM4NTU5") + "-" + atob(b) + ".apps.googleusercontent.com"; static #s = atob("R09DU1BY") + "-" + atob(a); static #r = "https://www.wanikani.com"; static beginAuth() { Utils.log("beginAuth"); let builtAuthURL = `https://accounts.google.com/o/oauth2/auth?client_id=${this.#c}&redirect_uri=${this.#r}&response_type=code&scope=https://www.googleapis.com/auth/drive.appdata&access_type=offline&prompt=consent`; window.open(builtAuthURL, "Log in", "resizeable, scrollbars, status, toolbar, dependent, width=480,height=660"); } static async checkIfAuthed() { if(!CustomSRSSettings.userSettings.syncEnabled) return; Utils.log("checkIfAuthed"); let url = new URL(window.location.href); let authCode = url.searchParams.get("code"); if(authCode) { stopRemainingScriptLoad = true; // If authenticating, cover the page with a loading screen let loadingScreen = "<div style='position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: var(--color-wk-panel-background, white); z-index: 1000;'><h1 id='loadingScreenText' style='position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 80%'>Loading...</h1></div>"; Utils.log("Auth code found. Getting access token."); document.addEventListener("DOMContentLoaded", () => { document.body.innerHTML += loadingScreen; this.getAccessToken(authCode); }); } else if(await GM.getValue("customSrsAccessToken")) { Utils.log("Google Drive Access Token found"); if(await GM.getValue("customSrsDidReview")) { Utils.log("Review was done, saving data to Google Drive"); this.saveDataToDrive(activePackProfile, "main"); GM.deleteValue("customSrsDidReview"); } } else { Utils.log("No auth code / access token found. Beginning auth."); SyncManager.beginAuth(); } } static getLoadingScreenText() { return unsafeWindow.document.getElementById("loadingScreenText"); } static getAccessToken(authCode) { let bodyTxt = `client_id=${encodeURIComponent(this.#c)}&` + atob("Y2xpZW50X3NlY3JldA==") + `=${encodeURIComponent(this.#s)}&` + `code=${encodeURIComponent(authCode)}&` + `grant_type=authorization_code&` + `redirect_uri=${encodeURIComponent(this.#r)}&` + `access_type=offline`; GM_xmlhttpRequest({ method: "POST", url: "https://oauth2.googleapis.com/token", headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: bodyTxt, onload: function(response) { if(response.status != 200) { Utils.log("Error getting access token"); Utils.log(response); CustomSRS_getLoadingScreenText().innerText = "Error getting access token"; return; } let responseJSON = JSON.parse(response.responseText); GM.setValue("customSrsAccessToken", responseJSON.access_token); GM.setValue("customSrsRefreshToken", responseJSON.refresh_token); GM.setValue("customSrsTokenExpires", Date.now() + responseJSON.expires_in * 1000); Utils.log("Google Drive Access Token received: " + responseJSON.access_token + ", expires in " + responseJSON.expires_in + " seconds, refresh token: " + responseJSON.refresh_token); CustomSRS_selectMasterSource(responseJSON.access_token); }, onerror: function(response) { Utils.log("Error getting access token"); Utils.log(response); CustomSRS_getLoadingScreenText().innerText = "Error getting access token"; } }); } static async selectMasterSource(accessToken) { let packProfile = await StorageManager.loadPackProfile("main"); GM_xmlhttpRequest({ method: "GET", url: "https://www.googleapis.com/drive/v3/files?spaces=appDataFolder&q=name='packProfiles__main.json'&fields=files(id,modifiedTime)", headers: { "Authorization": "Bearer " + accessToken }, onload: function(response) { let files = JSON.parse(response.responseText).files; if(!files || files.length == 0) CustomSRS_getLoadingScreenText().innerText = "Please close this window and then refresh WaniKani to begin syncing."; else { CustomSRS_getLoadingScreenText().innerHTML = ` <h2>Select Source</h2> <p style="font-size: 40%">Choose which version of your data to keep - whichever you choose will overwrite the other. Whicever you do not choose will be permanently deleted.</p> <button style="width: 100%" id="driveButton">Google Drive<br><small>Last modified: ${new Date(files[0].modifiedTime).toLocaleString()}</small></button> <button style="width: 100%" id="localButton">Local</button>`; document.getElementById("driveButton").addEventListener("click", () => { GM.setValue("customSrsDriveFileId", files[0].id); SyncManager.loadDataFromDrive("main", true); // Wait for the data to be downloaded let interval = setInterval(() => { if(StorageManager.downloadedPackProfile != null) { clearInterval(interval); if(StorageManager.downloadedPackProfile == -1) { CustomSRS_getLoadingScreenText().innerText = "Error downloading file from Google Drive, please close this window and then refresh WaniKani, then go to settings and click 'force pull' to try again."; return; } CustomSRS_getLoadingScreenText().innerText = "Please close this window and then refresh WaniKani to begin syncing."; } }, 100); }); document.getElementById("localButton").addEventListener("click", () => { SyncManager.saveDataToDrive(packProfile, "main").then(() => { CustomSRS_getLoadingScreenText().innerText = "Please close this window and then refresh WaniKani"; }); }); } }, onerror: function(response) { Utils.log("Error getting file metadata from Google Drive"); Utils.log(response); CustomSRS_getLoadingScreenText().innerText = "Error getting file metadata from Google Drive"; } }); } static async refreshToken() { let refreshToken = await GM.getValue("customSrsRefreshToken") Utils.log(refreshToken); if(!refreshToken) { Utils.log("No refresh token found, please re-authenticate"); return; } let bodyTxt = `client_id=${encodeURIComponent(this.#c)}&` + atob("Y2xpZW50X3NlY3JldA==") + `=${encodeURIComponent(this.#s)}&` + `refresh_token=${encodeURIComponent(refreshToken)}&` + `grant_type=refresh_token`; GM_xmlhttpRequest({ method: "POST", url: "https://oauth2.googleapis.com/token", headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: bodyTxt, onload: function(response) { console.log(response); let responseJSON = JSON.parse(response.responseText); GM.setValue("customSrsAccessToken", responseJSON.access_token); GM.setValue("customSrsTokenExpires", Date.now() + responseJSON.expires_in * 1000); Utils.log("Google Drive Access Token refreshed to " + responseJSON.access_token); }, onerror: function(response) { Utils.log("Error refreshing access token"); Utils.log(response); } }); } static async saveDataToDrive(data, fileSuffix) { // Check if review was done less than 3 seconds ago if(CustomSRSSettings.savedData.lastSynced > Date.now() - 3000) { Utils.log("Review was done less than 3 seconds ago, preventing API spam"); return; } // Check if access token is expired let tokenExpires = await GM.getValue("customSrsTokenExpires"); if (Date.now() > tokenExpires) { await this.refreshToken(); } let accessToken = await GM.getValue("customSrsAccessToken"); // Convert data object to JSON string let dataString = JSON.stringify(data); let fileId = await GM.getValue("customSrsDriveFileId"); // Create file metadata let metadata = fileId ? { modifiedTime: new Date().toISOString() } : { name: 'packProfiles__' + fileSuffix + '.json', mimeType: 'application/json', parents: ['appDataFolder'], modifiedTime: new Date().toISOString() }; // Save file to Google Drive GM_xmlhttpRequest({ method: fileId ? "PATCH" : "POST", url: "https://www.googleapis.com/upload/drive/v3/files" + (fileId ? "/" + fileId : "") + "?uploadType=multipart", headers: { "Authorization": "Bearer " + accessToken, "Content-Type": "multipart/related; boundary=foo_bar_baz" }, data: "--foo_bar_baz\r\n" + "Content-Type: application/json; charset=UTF-8\r\n\r\n" + JSON.stringify(metadata) + "\r\n" + "--foo_bar_baz\r\n" + "Content-Type: application/json\r\n\r\n" + dataString + "\r\n" + "--foo_bar_baz--", onload: function(response) { if(response.status != 200) { Utils.log("Error saving file to Google Drive"); Utils.log(response); return; } Utils.log("File saved to Google Drive"); if(fileId && fileId != JSON.parse(response.responseText).id) alert("File saved incorrectly - fileID mismatch. Please report this to the developer."); GM.setValue("customSrsDriveFileId", JSON.parse(response.responseText).id); StorageManager.updateLastSynced(); }, onerror: function(response) { Utils.log("Error saving file to Google Drive"); Utils.log(response); } }); } static async loadDataFromDrive(fileSuffix, forceSync = false) { StorageManager.downloadedPackProfile = null; let accessToken = await GM.getValue("customSrsAccessToken"); if(!accessToken) { StorageManager.downloadedPackProfile = -1; return; } // Check if access token is expired let tokenExpires = await GM.getValue("customSrsTokenExpires"); if (Date.now() > tokenExpires) await this.refreshToken(); let lastSync = CustomSRSSettings.savedData.lastSynced; Utils.log(fileSuffix); // Get file metadata GM_xmlhttpRequest({ method: "GET", url: "https://www.googleapis.com/drive/v3/files?spaces=appDataFolder&q=name='packProfiles__" + fileSuffix + ".json'&fields=files(id,modifiedTime)", headers: { "Authorization": "Bearer " + accessToken }, onload: function(response) { let files = JSON.parse(response.responseText).files; if(!files) { Utils.log("Error getting file metadata from Google Drive"); Utils.log(response); } else if (files.length > 0) { let fileId = files[0].id; let fileModifiedTime = new Date(files[0].modifiedTime).getTime(); Utils.log("File modified time: " + new Date(files[0].modifiedTime)); if (forceSync || fileModifiedTime > lastSync) { // File has been modified, download the updated data response = GM_xmlhttpRequest({ method: "GET", url: "https://www.googleapis.com/drive/v3/files/" + fileId + "?alt=media", headers: { "Authorization": "Bearer " + accessToken }, onload: function(response) { if(response.status != 200) { Utils.log("Error downloading file from Google Drive"); Utils.log(response); StorageManager.downloadedPackProfile = -1; return; } let data = JSON.parse(response.responseText); StorageManager.downloadedPackProfile = data; StorageManager.updateLastSynced(); Utils.log("File downloaded from Google Drive"); } }); } else { Utils.log("No updates found"); StorageManager.downloadedPackProfile = -1; StorageManager.updateLastSynced(); } } else { Utils.log("File not found on Google Drive, syncing for the first time"); StorageManager.downloadedPackProfile = -1; StorageManager.savePackProfile(null, "main", true, true); } } }); } static disableSync() { GM.deleteValue("customSrsAccessToken"); GM.deleteValue("customSrsRefreshToken"); GM.deleteValue("customSrsTokenExpires"); GM.deleteValue("customSrsDriveFileId"); } static setDidReview() { GM.setValue("customSrsDidReview", true); } } window.CustomSRS_selectMasterSource = SyncManager.selectMasterSource; window.CustomSRS_getLoadingScreenText = SyncManager.getLoadingScreenText; if(stopRemainingScriptLoad) return; let activePackProfile = await StorageManager.loadPackProfile("main"); let quizStatsController; // ----------- If on review page ----------- if (window.location.pathname.includes("/review") || (window.location.pathname.includes("/extra_study") && window.location.search.includes("audio"))) { let urlParams = new URLSearchParams(window.location.search); if(urlParams.has("conjugations") || urlParams.has("audio") || activePackProfile.getNumActiveReviews() !== 0) { // Add style to root to prevent header flash let headerStyle = document.createElement("style"); headerStyle.id = "custom-srs-header-style"; headerStyle.innerHTML = ` .character-header .character-header__characters { opacity: 0 } .character-header--loaded .character-header__characters { opacity: 1; transition: opacity 0.1s; } `; document.head.append(headerStyle); } // Add custom items to the quiz queue and update captured WK review document.addEventListener("DOMContentLoaded", async () => { let changedFirstItem = false; let queueEl = document.getElementById('quiz-queue'); let parentEl = queueEl.parentElement; queueEl.remove(); let cloneEl = queueEl.cloneNode(true); let SRSElement, queueElement; // Switch based on review session type if(urlParams.has("conjugations")) { let verbs = await Conjugations.getConjugationSessionItems(CustomSRSSettings.userSettings.conjGrammarSessionLength); queueElement = verbs[0]; SRSElement = verbs[1]; } else if(urlParams.has("audio")) { queueElement = JSON.parse(cloneEl.querySelector("script[data-quiz-queue-target='subjects']").innerHTML); SRSElement = JSON.parse(cloneEl.querySelector("script[data-quiz-queue-target='subjectIds']").innerHTML); for(let i = queueElement.length - 1; i >= 0; i--) { if(queueElement[i].subject_category !== "Vocabulary" && queueElement[i].subject_category !== "KanaVocabulary") { queueElement.splice(i, 1); SRSElement.splice(i, 1); } } if(queueElement.length === 0) { alert("No vocabulary items found in burned items. Please try again after burning some vocabulary items."); window.location.href = "https://www.wanikani.com/dashboard"; return; } document.querySelector(".character-header__content").appendChild(AudioQuiz.setUpAudioQuizHTML()); } else { queueElement = JSON.parse(cloneEl.querySelector("script[data-quiz-queue-target='subjects']").innerHTML); 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; Utils.log("Captured first item from queue."); } else { CustomSRSSettings.savedData.capturedWKReview = queueElement[1]; queueElement.splice(1, 1); SRSElement.splice(1, 1); Utils.log("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; } } StorageManager.saveSettings(); } cloneEl.querySelector("script[data-quiz-queue-target='subjects']").innerHTML = JSON.stringify(queueElement); if(!urlParams.has("audio")) cloneEl.querySelector("script[data-quiz-queue-target='subjectIdsWithSRS']").innerHTML = JSON.stringify(SRSElement); else cloneEl.querySelector("script[data-quiz-queue-target='subjectIds']").innerHTML = JSON.stringify(SRSElement); parentEl.appendChild(cloneEl); if(changedFirstItem) { let headerElement = document.querySelector(".character-header"); for(let className of headerElement.classList) { // Fix header colour issues if(className.includes("character-header--")) { setTimeout(() => { document.querySelector(".quiz-input__input").setAttribute("placeholder", (document.querySelector(".quiz-input__question-type").innerText.includes("reading") ? "答え" : "Your Response")); headerElement.classList.add("character-header--loaded"); }, 400); break; } } } if(urlParams.has("conjugations")) { await Utils.setReadingsOnly(); Utils.log("Controller set up for conjugations."); setTimeout(() => { document.querySelector(".character-header").classList.add("character-header--loaded"); }, 200); } else { if(urlParams.has("audio")) Utils.setMeaningsOnly(); 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) { // Check if url includes ?conjugations if(window.location.search.includes("conjugations")) { return new Response("{}", { status: 200 }); } else { // Update custom item SRS activePackProfile.submitReview(payload.counts[0].id, payload.counts[0].meaning, payload.counts[0].reading); SyncManager.setDidReview(); 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; if(window.location.search.includes("conjugations")) subjectInfo = Conjugations.getSubjectInfo(subjectId); else 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 === "/") { await SyncManager.checkIfAuthed(); // 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); Utils.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); } }; // Add event listener for turbo:load, then check if the site address includes /radicals /kanji or /vocabulary document.addEventListener("turbo:frame-load", () => { if(window.location.pathname.includes("/radicals/") || window.location.pathname.includes("/kanji/") || window.location.pathname.includes("/vocabulary/")) { // Use the meta tag in the header with name subject_id to get the subject ID let itemHeader = document.querySelector("header.page-header"); if(itemHeader.querySelector("h3")) itemHeader.querySelector("h3").innerText = "ID: " + document.querySelector("meta[name='subject_id']").content; else itemHeader.innerHTML += "<h3> ID: " + document.querySelector("meta[name='subject_id']").content + "</h3>"; } }); } // ----------- 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) + (CustomSRSSettings.savedData.capturedWKReview ? -1 : 0) + ((new URLSearchParams(window.location.search)).has("conjugations") ? CustomSRSSettings.userSettings.conjGrammarSessionLength : activePackProfile.getNumActiveReviews()); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址