您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Show SRS and leech breakdown on dashboard, with ability to expand and view detailed lists.
// ==UserScript== // @name Araigoshi's Wanikani Stage Breakdown // @namespace https://www.wanikani.com // @description Show SRS and leech breakdown on dashboard, with ability to expand and view detailed lists. // @author araigoshi // @version 1.2.1 // @match https://www.wanikani.com/* // @license MIT // @grant none // ==/UserScript== (async function () { 'use strict'; /* global wkof */ if (!window.wkof) { let response = confirm('Dashboard Stage Breakdown requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.'); if (response) { window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549'; } return; } const SCRIPT_ID = 'araistages'; const STAGE_NAMES = { 0: 'Unlearned', 1: 'Apprentice I', 2: 'Apprentice II', 3: 'Apprentice III', 4: 'Apprentice IV', 5: 'Guru I', 6: 'Guru II', 7: 'Master', 8: 'Enlightened', 9: 'Burned', }; const TYPE_LABELS = { 'radical': 'Radical', 'kanji': 'Kanji', 'vocabulary': 'Vocab', 'kana_vocabulary': 'Vocab' }; const ROMAN_NUMERALS = [0, 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']; const CSS_TEXT = ` .dashboard section.srs-progress .srs-progress-details-group { display: flex; margin: 0; list-style-type: none; gap: 0.3em; justify-content: center; padding-top: var(--spacing-xtight); font-size: var(--font-size-xsmall); color: var(--color-text); } .araistages-items-compact { .srs-progress__subject-types { display: none !important; } } .araistages-items-expanded { .araistages-types-group { display: none !important; } } .araistages-hide-leeches-with-details { .srs-progress__stage--apprentice, .srs-progress__stage--guru { .leeches-group { display: none !important; } } } .dashboard section.srs-progress .srs-progress-details-group:has(+ .leeches-group:hover), .dashboard section.srs-progress .srs-progress-details-group:has(+ .srs-progress-details-group + .leeches-group:hover) { .group-item-leeches { display: initial; } .group-item-value { display: none; } } .dashboard section.srs-progress { li.grouped-list { padding: var(--spacing-xtight) 0.3em; border-radius: var(--border-radius-tight); background-color: var(--color-srs-progress-subject-type-background, white); flex-grow: 1; flex-basis: 0; display: flex; align-items: center; justify-content: space-between; &:first-child { padding-left: var(--spacing-xtight); } &:last-child { padding-right: var(--spacing-xtight); } &:hover { background-color: #f88; } .group-item-label { flex-grow: 1; } .group-item-value { font-weight: bold; } &:hover .group-item-value { display: none; } .group-item-leeches { font-weight: bold; display: none; } &:hover .group-item-leeches { display: initial; } } .srs-progress__stage--apprentice li.grouped-list { .group-item-value, .group-item-leeches { color: var(--color-srs-progress-apprentice); } } .srs-progress__stage--guru li.grouped-list { .group-item-value, .group-item-leeches { color: var(--color-srs-progress-guru); } } .srs-progress__stage--master li.grouped-list { .group-item-value, .group-item-leeches { color: var(--color-srs-progress-master); } } .srs-progress__stage--enlightened li.grouped-list { .group-item-value, .group-item-leeches { color: var(--color-srs-progress-enlightened); } } } #araistages-info-dialog { width: 800px; max-height: 80vh; flex-direction: column; border-radius: var(--border-radius-normal); padding: var(--spacing-tight); .close-button { border-radius: var(--border-radius-normal); border-style: solid; } &[open] { display: flex; } header { display: flex; gap: 2em; align-items: center; justify-content: space-between; margin: var(--spacing-xtight) 0; h2 { font-size: var(--font-size-xlarge); } button { border-style: solid; border-radius: var(--border-radius-normal); font-size: 20px; padding: 5px; font-weight: bold; } } .table-wrapper { flex-grow: 1; overflow: auto; } table { width: 100%; border-radius: var(--border-radius-normal); a { color: var(--color-link); text-decoration: none; } thead { th { background-color: var(--color-character-grid-header-background, #d5d5d5); } th:first-child { border-radius: var(--border-radius-normal) 0 0 0; } th:last-child { border-radius: 0 var(--border-radius-normal) 0 0; } } tbody, tbody a { color: var(--color-character-text); } th { text-align: left; font-variant: small-caps; } th, td { padding: 0.7em 1em; } wk-character-image { width: 14px; display: inline-block; } .row-kanji td { background-color: var(--color-kanji); } .row-kana_vocabulary td, .row-vocabulary td { background-color: var(--color-vocabulary); } .row-radical td { background-color: var(--color-radical); } tbody tr:last-child td:first-child { border-radius: 0 0 0 var(--border-radius-normal); } tbody tr:last-child td:last-child { border-radius: 0 0 var(--border-radius-normal) 0; } } .options { display: flex; align-items: center; justify-content: space-between; gap: 2em; margin: var(--spacing-xtight) 0; select, input { width: fit-content; } .option { display: flex; align-items: baseline; gap: 0.5em; } } }` const state = { dialog: createDialog(), itemsBySrs: null, }; window.araistages_state = state; const requiredWkofModules = 'Menu,ItemData,Settings'; wkof.include(requiredWkofModules); await wkof.ready(requiredWkofModules); wkof.Settings.load(SCRIPT_ID, { compactItemTypes: true, leechThreshold: 1, alwaysShowTotalLeeches: false, useRMSForLeechScore: false, forceClearCache: false, tableSortOrder: 'wanikani', tablePageSize: 20, tableGroupType: 'all', tableGroup: 'all', tableItemType: 'all', }); loadStylesheet(); await loadData(); document.documentElement.addEventListener("turbo:load", async (evt) => { const path = new URL(evt.detail.url).pathname; if (shouldLoadOnPage(path)) { await loadData(true); } renderer(path); }); renderer(document.location.pathname); function loadStylesheet() { if (document.getElementById('araistages-styles') === null) { let styleSheet = document.createElement('style'); styleSheet.id = 'araistages-styles'; styleSheet.textContent = CSS_TEXT; document.body.appendChild(styleSheet); } } function shouldLoadOnPage(path) { return path === '/dashboard' || path === '/'; } function renderer(path) { if (shouldLoadOnPage(path)) { setTimeout(() => updatePage(state.itemsBySrs), 0); } } async function loadData(clearCache) { if (clearCache && wkof.settings[SCRIPT_ID]?.forceClearCache) { wkof.Apiv2.clear_cache(); } const allItems = await wkof.ItemData.get_items({ wk_items: { options: { review_statistics: true, assignments: true } } }); const filteredItems = allItems.filter(item => item?.assignments?.srs_stage > 0); state.itemsBySrs = mapItemsToSrs(filteredItems); } function insertMenu() { wkof.Menu.insert_script_link({ name: `${SCRIPT_ID}_settings_open`, submenu: 'Settings', title: "Araigoshi's Dashboard Stage Breakdown", on_click() { new wkof.Settings({ script_id: SCRIPT_ID, title: "Araigoshi's Dashboard Stage Breakdown", content: { compactItemTypes: { type: 'checkbox', label: 'Compact Item Types', hover_tip: 'Display all item types on one line. Turn off to use the Wanikani default item type breakdown.' }, alwaysShowTotalLeeches: { type: 'checkbox', label: 'Always show total leeches', hover_tip: 'Display the total leeches as its own row, even for apprentice and guru', }, useRMSForLeechScore: { type: 'checkbox', label: 'Use the Root-Mean-Square method for getting final leech score', hover_tip: 'Takes the Root-Mean-Square of the reading leech score and meaning leech score instead of the maximum', }, forceClearCache: { type: 'checkbox', label: 'Clear Cache On Navigation', html: 'Useful if your review sessions last under a minute' }, leechThreshold: { type: 'number', label: 'Leech Threshold', min: 1, hover_tip: 'How high the leech score needs to be to consider an item a leech.' }, leechNote: { type: 'html', html: 'Note: Leeches will be recalculated after a page refresh.' } }, on_save() { setStagesClasses(); } }).open() } }); } const sortFunctions = { wanikani(itemA, itemB) { const levelSort = itemA.data.level - itemB.data.level; return levelSort != 0 ? levelSort : itemA.data.lesson_position - itemB.data.lesson_position; }, nextReview(itemA, itemB) { return Date.parse(itemA.assignments.available_at) - Date.parse(itemB.assignments.available_at); }, leechScore(itemA, itemB) { return getLeechScore(itemB) - getLeechScore(itemA); } } function mapItemsToSrs(items) { const itemsBySrs = [1, 2, 3, 4, 5, 6, 7, 8, 9].reduce((result, srs) => { result[srs] = { totals: { all: 0, radical: 0, vocabulary: 0, kanji: 0, }, leeches: { all: 0, radical: 0, vocabulary: 0, kanji: 0, }, items: [], }; return result; }, {}); for (let item of items) { const srsStage = item.assignments.srs_stage; let subjectType = item.assignments.subject_type; if (subjectType === 'kana_vocabulary') { subjectType = 'vocabulary' } itemsBySrs[srsStage].totals[subjectType]++; itemsBySrs[srsStage].totals.all++; if (isLeech(item)) { itemsBySrs[srsStage].leeches[subjectType]++; itemsBySrs[srsStage].leeches.all++; } itemsBySrs[srsStage].items.push(item); } return itemsBySrs; } function rmsOfPair(num1, num2) { return Math.sqrt((Math.pow(num1, 2) + Math.pow(num2, 2)) / 2); } function getLeechScore(item) { if (item.review_statistics === undefined) { return 0; } const reviewStats = item.review_statistics; const meaningScore = calculateLeechScore(reviewStats.meaning_incorrect, reviewStats.meaning_current_streak); const readingScore = calculateLeechScore(reviewStats.reading_incorrect, reviewStats.reading_current_streak); return wkof.settings[SCRIPT_ID]?.useRMSForLeechScore ? rmsOfPair(meaningScore, readingScore) : Math.max(meaningScore, readingScore); } function isLeech(item) { return getLeechScore(item) > (wkof.settings[SCRIPT_ID]?.leechThreshold || 1); } function calculateLeechScore(incorrect, currentStreak) { return incorrect / Math.pow((currentStreak || 0.5), 1.5); } function stagesForSection(section) { switch (section) { case 'apprentice': return [1, 2, 3, 4]; case 'guru': return [5, 6]; case 'master': return [7]; case 'enlightened': return [8]; case 'burned': return [9]; } } function setStagesClasses() { const el = document.querySelector('.dashboard__srs-progress'); if(!el) { // Settings accessed from non-dashboard page return; } el.classList.remove('araistages-items-expanded', 'araistages-items-compact', 'araistages-always-leeches') const itemTypesCls = wkof.settings[SCRIPT_ID]?.compactItemTypes ? 'compact' : 'expanded'; el.classList.toggle('araistages-hide-leeches-with-details', !wkof.settings[SCRIPT_ID]?.alwaysShowTotalLeeches); el.classList.add(`araistages-items-${itemTypesCls}`); } function updatePage(itemsBySrs) { insertMenu(); setStagesClasses(); for (var section of ['apprentice', 'guru', 'master', 'enlightened']) { addTypes(section, itemsBySrs); addSubStages(section, itemsBySrs); addTotalLeeches(section, itemsBySrs); } addTypes('burned', itemsBySrs); } function makeGroupedListItem(labelText, valueCount, leechesCount, dataProperties) { const li = document.createElement('li'); li.classList.add('grouped-list'); for (const property of Object.keys(dataProperties)) { li.dataset[property] = dataProperties[property]; } const label = document.createElement('span'); label.classList.add('group-item-label'); label.textContent = labelText; li.appendChild(label); const value = document.createElement('span'); value.classList.add('group-item-value'); value.textContent = valueCount; li.appendChild(value); const leeches = document.createElement('span'); leeches.textContent = leechesCount; leeches.classList.add('group-item-leeches'); li.appendChild(leeches); li.addEventListener('click', handleGroupClick); return li; } function selectorForSection(srsSectionId) { return `.srs-progress__stage--${srsSectionId}`; } function addSubStages(srsSectionId, itemsBySrs) { const stages = stagesForSection(srsSectionId); if (stages.length <= 1) { return; } const items = stages.map((srs, i) => makeGroupedListItem( ROMAN_NUMERALS[i + 1], itemsBySrs[srs].totals.all, itemsBySrs[srs].leeches.all, { groupType: 'stage', group: srs, itemType: 'all' } )); document.querySelector(selectorForSection(srsSectionId)).appendChild(makeGroupList(items, 'stages-group')); } function addTotalLeeches(srsSectionId, itemsBySrs) { const totalLeechCount = sumAll(itemsBySrs, srsSectionId, 'leeches', 'all'); document.querySelector(selectorForSection(srsSectionId)).appendChild( makeGroupList( [makeGroupedListItem( 'Leeches', totalLeechCount, totalLeechCount, { groupType: 'section', group: srsSectionId, itemType: 'all' } )], 'leeches-group' ), ); } function sumAll(itemsBySrs, srsSectionId, group, field) { return stagesForSection(srsSectionId).reduce((n, stage) => n + itemsBySrs[stage][group][field], 0); } function makeTypeListItem(itemsBySrs, srsSectionId, abbr, itemType) { return makeGroupedListItem( abbr, sumAll(itemsBySrs, srsSectionId, 'totals', itemType), sumAll(itemsBySrs, srsSectionId, 'leeches', itemType), { groupType: 'section', group: srsSectionId, itemType } ); } function makeGroupList(items, className) { const wrapper = document.createElement('ol'); wrapper.classList.add('srs-progress-details-group', className); for (const item of items) { wrapper.appendChild(item); }; return wrapper; } function addTypes(srsSectionId, itemsBySrs) { const items = [ makeTypeListItem(itemsBySrs, srsSectionId, 'R', 'radical'), makeTypeListItem(itemsBySrs, srsSectionId, 'K', 'kanji'), makeTypeListItem(itemsBySrs, srsSectionId, 'V', 'vocabulary'), ]; document.querySelector(selectorForSection(srsSectionId)).appendChild(makeGroupList(items, 'araistages-types-group')); } function renderSubject(item) { if (item.object !== 'radical') { return item.data.characters; } const primaryMeaning = item.data.meanings.find(meaning => meaning.primary === true).meaning; if (item.data.characters !== null) { return `${item.data.characters} (${primaryMeaning})`; } else { const imageUrl = item.data.character_images.find(image => image.content_type === 'image/svg+xml').url; return `<wk-character-image class="radical-image" src="${imageUrl}" aria-label=${primaryMeaning} alt="${primaryMeaning} radical"></wk-character-image> (${primaryMeaning})`; } } function rerenderDialogTable(groupType, group, itemType) { const resolvedGroupType = groupType ?? wkof.settings[SCRIPT_ID].tableGroupType; const resolvedGroup = group ?? wkof.settings[SCRIPT_ID].tableGroup; const resolvedItemType = itemType ?? wkof.settings[SCRIPT_ID].tableItemType; const items = getItems(resolvedGroupType, resolvedGroup, resolvedItemType); const tableRows = items.map(item => { const leechScore = new Intl.NumberFormat().format(getLeechScore(item)); const assignAvailable = item.assignments.available_at; const nextReview = assignAvailable === null ? 'Never' : new Date(item.assignments.available_at) .toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); const subject = renderSubject(item); return `<tr class="row-${item.object}"> <td><a href="${item.data.document_url}">${subject}</a></td> <td>${TYPE_LABELS[item.object]}</td> <td>${STAGE_NAMES[item.assignments.srs_stage]}</td> <td>${item.data.level}</td> <td>${leechScore}</td> <td>${nextReview}</td> </tr>`; }); state.dialog.querySelector('tbody').innerHTML = tableRows.join(''); } function createDialog() { const dialogEl = document.createElement('dialog'); dialogEl.innerHTML = ` <header> <h2>Item Stage Breakdown</h2> <button class="close-button">X</button> </header> <div class="options"> <div class="option"> <label for="araistages-table-group">Stage</label> <select id="araistages-table-group"> <option selected value="all-all">All</option> <hr> <option value="section-apprentice">Apprentice</option> <option value="stage-1">-- Apprentice I</option> <option value="stage-2">-- Apprentice II</option> <option value="stage-3">-- Apprentice III</option> <option value="stage-4">-- Apprentice IV</option> <hr> <option value="section-guru">Guru</option> <option value="stage-5">-- Guru I</option> <option value="stage-6">-- Guru II</option> <hr> <option value="section-master">Master</option> <hr> <option value="section-enlightened">Enlightened</option> <hr> <option value="section-burned">Burned</option> </select> </div> <div class="option"> <label for="araistages-table-type">Type</label> <select id="araistages-table-type"> <option value="all">All</option> <option value="radical">- Radicals</option> <option value="kanji">- Kanji</option> <option value="vocabulary">- Vocab</option> </select> </div> <div class="option"> <label for="araistages-sort-order">Sort</label> <select id="araistages-sort-order"> <option selected value="wanikani">WK Order</option> <option value="nextReview">Next Review</option> <option value="leechScore">Leech Score</option> </select> </div> <div class="option"> <label for="araistages-page-size">Max Items</label> <select id="araistages-page-size"> <option selected value="20">20</option> <option value="50">50</option> <option value="100">100</option> <option value="250">250</option> <option value="9999">All</option> </select> </div> </div> <div class="table-wrapper"> <table> <thead> <tr> <th>Item</th> <th>Type</th> <th>Stage</th> <th>Level</th> <th>Leech Score</th> <th>Next Review</th> </tr> </thead> <tbody> </tbody> </table> </div> `; dialogEl.id = 'araistages-info-dialog'; dialogEl.addEventListener('change', (evt) => { console.log(evt); }); dialogEl.querySelector('.close-button').addEventListener('click', () => { dialogEl.close(); }); dialogEl.querySelector('#araistages-sort-order').addEventListener('change', (evt) => { wkof.settings[SCRIPT_ID].tableSortOrder = evt.target.value; rerenderDialogTable(); }); dialogEl.querySelector('#araistages-page-size').addEventListener('change', (evt) => { wkof.settings[SCRIPT_ID].tablePageSize = parseInt(evt.target.value, 10); rerenderDialogTable(); }); dialogEl.querySelector('#araistages-table-group').addEventListener('change', (evt) => { const [groupType, group] = evt.target.value.split('-'); wkof.settings[SCRIPT_ID].tableGroupType = groupType; wkof.settings[SCRIPT_ID].tableGroup = group; rerenderDialogTable(groupType, group); }); dialogEl.querySelector('#araistages-table-type').addEventListener('change', (evt) => { wkof.settings[SCRIPT_ID].tableItemType = evt.target.value; rerenderDialogTable(); }); document.body.appendChild(dialogEl); return dialogEl; } function handleGroupClick(evt) { const data = evt.currentTarget.dataset; showDialog(data.groupType, data.group, data.itemType); } function getItems(group, option, itemType) { let stages = Object.keys(state.itemsBySrs).map(x => parseInt(x, 10)); if(group === 'stage') { stages = [parseInt(option, 10)]; } if(group === 'section') { stages = stagesForSection(option); } let items = []; for (const stage of stages) { items = items.concat(state.itemsBySrs[stage].items); } if(itemType !== 'all') { items = items.filter(item => item.object === itemType || (itemType === 'vocabulary' && item.object === 'kana_vocabulary')); } const pageSize = wkof.settings[SCRIPT_ID]?.tablePageSize || 20; items.sort(sortFunctions[wkof.settings[SCRIPT_ID].tableSortOrder]); return items.slice(0, pageSize); } function showDialog(groupType, group, itemType) { rerenderDialogTable(groupType, group, itemType); state.dialog.querySelector('#araistages-table-group').value = `${groupType}-${group}`; state.dialog.querySelector('#araistages-table-type').value = itemType; wkof.settings[SCRIPT_ID].tableGroupType = groupType; wkof.settings[SCRIPT_ID].tableGroup = group; wkof.settings[SCRIPT_ID].tableItemType = itemType; state.dialog.showModal(); } })();