您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Shows unlock information for vocab and the recommended number of vocab/day to clear the queue on level up
// ==UserScript== // @name Daily Vocab Planner // @namespace wyverex // @version 1.2.2 // @description Shows unlock information for vocab and the recommended number of vocab/day to clear the queue on level up // @author Andreas Krügersen-Clark // @match https://www.wanikani.com/ // @match https://www.wanikani.com/dashboard // @grant none // @license MIT // ==/UserScript== (function () { if (!window.wkof) { alert('"Daily Vocab Planner" script requires Wanikani Open Framework.\nYou will now be forwarded to installation instructions.'); window.location.href = "https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549"; return; } const DayMillis = 24 * 3600 * 1000; const WeekMillis = 7 * DayMillis; const wkof = window.wkof; const shared = { settings: {}, wffSettings: {}, items: {}, // SRS system id -> [seconds to pass] timings: {}, // SubjectId -> projected pass time millis passTimes: {}, // SubjectId -> projected unlock time millis unlockTimes: {}, // SubjectIds of locked vocab items for the current level lockedVocabIds: [], // Number of vocab items on stage 0 availableCount: 0, vocabUnlocks: {}, levelUpTimeMillis: 0, numVocabLearnedToday: 0, recommendedVocabPerDay: 0, unlocksElement: null, lessonBarElement: null, }; const todayFormatter = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "numeric", }); const shortFormatter = new Intl.DateTimeFormat(undefined, { weekday: "short", hour: "numeric", minute: "numeric", }); const longFormatter = new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "2-digit", hour: "numeric", minute: "numeric", }); wkof.include("Apiv2,ItemData,Menu,Settings"); wkof .ready("document,Apiv2,ItemData,Menu,Settings") .then(load_settings) .then(() => wkof.Apiv2.get_endpoint("spaced_repetition_systems").then(calculateSrsTimings)) .then(startup) .catch(loadError); function loadError(e) { console.error('Failed to load data from WKOF for "Daily Vocab Planner"', e); } function installCSS() { // prettier-ignore const content = ` .vocab-progress-bar { background-color: var(--color-level-progress-bar-background); color: var(--color-level-progress-bar-text); border-radius: 18px; height: 18px; box-shadow: inset 0px 2px 0 0 var(--color-level-progress-bar-shadow); display: flex; overflow: hidden; margin-top: 15px; } .vocab-progress-bar__progress { background-color: var(--color-vocabulary); color: var(--color-level-progress-bar-progress-text); border-radius: 18px; height: 18px; display: flex; justify-content: flex-end; min-width: 18px; transition: width 0.5s ease-in-out; } `; $("head").append("<style>" + content + "</style>"); } function load_settings() { let defaults = { showUnlocks: true, showNumRecommendedVocab: true, useWFFData: true, includeLevelUpDay: true, radicalsPerDay: 5, kanjiPerDay: 5, lessonHour: 9, }; return wkof.Settings.load("vocab_planner", defaults).then(() => { shared.settings = wkof.settings.vocab_planner; shared.wffSettings = wkof.settings["wk-word-frequency-filter"]; }); } function startup() { installCSS(); installMenu(); const config = { wk_items: { options: { subjects: true, assignments: true }, filters: { level: "-1..+0", item_type: "rad, kan, voc, kana_voc", }, }, }; wkof.ItemData.get_items(config).then(processData); } // ==================================================================================== function installMenu() { wkof.Menu.insert_script_link({ name: "vocab_planner", submenu: "Settings", title: "Daily Vocab Planner", on_click: openSettings, }); } // prettier-ignore function openSettings() { let config = { script_id: 'vocab_planner', title: 'Daily Vocab Planner', on_save: settingsSaved, on_change: settingsSaved, content: { display: { type: "group", label: "Display", content: { showUnlocks: { type: "checkbox", label: "Vocabulary unlocks", default: true }, showNumRecommendedVocab: { type: "checkbox", label: "Lesson recommendation", default: true, hover_tip: "Displays a progress bar in the lesson panel whenever there's vocabulary available, which displays how many vocabulary items you should learn today to clear your vocabulary queue by the time you level up" }, useWFFData: { type: "checkbox", label: "Use Word Frequency Filter", default: true, hover_tip: "Requires \"Word Frequency Filter\" to be installed. The number of vocab to learn is based on the frequency cutoff set in WFF." }, } }, config: { type: "group", label: "Configuration", content: { includeLevelUpDay: { type: 'checkbox', label: 'Learn vocab on level-up day', default: true, hover_tip: "If set, the day you're going to level up is considered a full day to learn vocabulary." }, radicalsPerDay: { type: "number", label: "Radicals/Day", default: 5, min: 0, hover_tip: "How many radicals do you intend to learn per day? Set to 0 if you learn all available radicals as a batch as soon as they are unlocked." }, kanjiPerDay: { type: "number", label: "Kanji/Day", default: 5, min: 0, hover_tip: "How many kanji do you intend to learn per day? Set to 0 if you learn all available kanji as a batch as soon as they are unlocked." }, lessonHour: { type: "number", label: "Daily lesson hour (24h format)", default: 9, min: 0, max: 23, hover_tip: "Roughly at which hour do you usually learn radicals/kanji each day? This is used to make the unlock predictions a bit more accurate." }, } } } }; let dialog = new wkof.Settings(config); dialog.open(); } function settingsSaved() { if (shared.unlocksElement) { shared.unlocksElement.remove(); shared.unlocksElement = null; } if (shared.lessonBarElement) { shared.lessonBarElement.remove(); shared.lessonBarElement = null; } shared.unlockTimes = {}; shared.passTimes = {}; updateData(); } // ==================================================================================== function calculateSrsTimings(data) { let result = {}; const entries = Object.entries(data); for (let i = 0; i < entries.length; ++i) { const id = entries[i][1].id; const system = entries[i][1].data; const passingStage = system.passing_stage_position; const stages = system.stages; let timings = []; for (let k = 0; k < passingStage; ++k) { timings.push(0); } timings[passingStage - 1] = getSrsIntervalInSeconds(stages[passingStage - 1]); for (let k = passingStage - 2; k >= 0; --k) { timings[k] = timings[k + 1] + getSrsIntervalInSeconds(stages[k]); } result[id] = timings; } shared.timings = result; } function getSrsIntervalInSeconds(stage) { if (stage.interval_unit === "milliseconds") { return stage.interval / 1000; } if (stage.interval_unit === "seconds") { return stage.interval; } if (stage.interval_unit === "minutes") { return stage.interval * 60; } if (stage.interval_unit === "hours") { return stage.interval * 3600; } if (stage.interval_unit === "days") { return stage.interval * 3600 * 24; } if (stage.interval_unit === "weeks") { return stage.interval * 3600 * 24 * 7; } return 0; } // ==================================================================================== function processData(items) { shared.items = items; updateData(); } function updateData() { const byType = wkof.ItemData.get_index(shared.items, "item_type"); const allVocab = [...(byType.vocabulary || []), ...(byType.kana_vocabulary || [])]; const filtered = filterVocabByWFF(allVocab); const subjectsById = wkof.ItemData.get_index(shared.items, "subject_id"); const vocabByStage = wkof.ItemData.get_index(filtered, "srs_stage"); const lockedVocab = vocabByStage[-1]; shared.lockedVocabIds = lockedVocab.map((item) => item.id); shared.availableCount = vocabByStage[0] ? vocabByStage[0].length : 0; const nowMillis = Date.now(); const now = new Date(nowMillis); const context = { numRadicalsLearnedToday: getNumItemsLearnedToday(byType.radical, now), numKanjiLearnedToday: getNumItemsLearnedToday(byType.kanji, now), }; context.radicalLessonsPerDay = [context.numRadicalsLearnedToday]; context.kanjiLessonsPerDay = [context.numKanjiLearnedToday]; for (let i = 0; i < lockedVocab.length; ++i) { projectPassTimeForItem(lockedVocab[i], nowMillis, subjectsById, context); } shared.vocabUnlocks = groupByTime(shared.lockedVocabIds, shared.unlockTimes, nowMillis); const thisLevelKanjiIds = getThisLevelItems(byType.kanji).map((item) => item.id); // Kanji for which all the current level vocab has already been unlocked haven't been projected yet. // For Kanji with locked vocab this is simply a lookup to a cached value. for (let i = 0; i < thisLevelKanjiIds.length; ++i) { projectPassTimeForItem(subjectsById[thisLevelKanjiIds[i]], nowMillis, subjectsById, context); } shared.levelUpTimeMillis = projectLevelUpTime(thisLevelKanjiIds); shared.numVocabLearnedToday = getNumItemsLearnedToday(filtered, now); calculateRecommendedVocabPerDay(now); addUnlockOverview(); addVocabLessonBar(); } function filterVocabByWFF(allVocab) { if (shared.settings.useWFFData && shared.wffSettings && window.wff.FrequencyData) { const cutoff = shared.wffSettings.rankCutoff; const filtered = allVocab.filter((v) => { const slug = v.data.slug; const freqData = window.wff.FrequencyData[slug]; const rank = window.wff.getRank(freqData); const isMarked = window.wff.isMarked(v.id); return rank <= cutoff || isMarked; }); return filtered; } return allVocab; } function getThisLevelItems(items) { return items.filter((item) => item.data.level == wkof.user.level); } function projectLessonDate(passesPerDay, nowMillis, availableAt, perDay) { const now = new Date(nowMillis); const dayOffset = new Date(availableAt).getDate() - now.getDate(); let tryOffset = dayOffset; while (true) { while (passesPerDay.length < tryOffset + 1) { passesPerDay.push(0); } if (passesPerDay[tryOffset] < perDay) { passesPerDay[tryOffset]++; if (tryOffset == 0) { // Available today, assume we're learning it now return nowMillis; } const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, shared.settings.lessonHour); return tomorrow.getTime() + (tryOffset - 1) * DayMillis; } ++tryOffset; } } function projectPassTimeForItem(item, nowMillis, subjectsById, context) { if (shared.passTimes[item.id] !== undefined) { return shared.passTimes[item.id]; } if (item.assignments) { // Item is unlocked or learned let availableAt = nowMillis; if (item.assignments.available_at) { // Item was already learned availableAt = Date.parse(item.assignments.available_at); } else { if (item.object == "radical" && shared.settings.radicalsPerDay > 0) { availableAt = projectLessonDate(context.radicalLessonsPerDay, nowMillis, availableAt, shared.settings.radicalsPerDay); } else if (item.object == "kanji" && shared.settings.kanjiPerDay > 0) { availableAt = projectLessonDate(context.kanjiLessonsPerDay, nowMillis, availableAt, shared.settings.kanjiPerDay); } } const passTime = getPassTimeMillis(item.data.spaced_repetition_system_id, item.assignments.srs_stage, availableAt); shared.passTimes[item.id] = passTime; return passTime; } // Item is still locked behind at least one component if (shared.unlockTimes[item.id] === undefined) { // Get latest pass time of its components to determine the unlock time let unlockTimeMillis = 0; const components = item.data.component_subject_ids; for (let i = 0; i < components.length; ++i) { const id = components[i]; if (shared.passTimes[id] === undefined) { const component = subjectsById[id]; if (component === undefined) { // Component not in the level range, it must have passed on an earlier level continue; } projectPassTimeForItem(component, nowMillis, subjectsById, context); } if (shared.passTimes[id] > unlockTimeMillis) { unlockTimeMillis = shared.passTimes[id]; } } shared.unlockTimes[item.id] = unlockTimeMillis; } let availableAt = shared.unlockTimes[item.id]; if (item.object == "kanji" && shared.settings.kanjiPerDay > 0) { availableAt = projectLessonDate(context.kanjiLessonsPerDay, nowMillis, availableAt, shared.settings.kanjiPerDay); } const passTime = getPassTimeMillis(item.data.spaced_repetition_system_id, 0, availableAt); shared.passTimes[item.id] = passTime; return passTime; } function getPassTimeMillis(systemId, srsStage, lessonOrReviewTimeMillis) { const timings = shared.timings[systemId]; const passingStage = timings.length; if (srsStage >= passingStage) { return 0; } if (srsStage == passingStage - 1) { return lessonOrReviewTimeMillis; } const secondsToPass = timings[srsStage + 1]; return lessonOrReviewTimeMillis + secondsToPass * 1000; } // Given itemIds and a map of Id -> time, returns an array of {timeMilis, count} objects, sorted by timeMillis function groupByTime(itemIds, timeSource, groupCutoff) { const byTime = {}; for (let i = 0; i < itemIds.length; ++i) { const timeMillis = timeSource[itemIds[i]]; if (byTime[timeMillis] === undefined) { byTime[timeMillis] = 0; } byTime[timeMillis]++; } let byTimeArray = Object.entries(byTime); byTimeArray.sort((lhs, rhs) => lhs[0] - rhs[0]); // Group up entries that are below the cutoff if (groupCutoff) { while (byTimeArray.length > 1 && parseInt(byTimeArray[1][0]) <= groupCutoff) { byTimeArray[0][1] += byTimeArray[1][1]; byTimeArray = [byTimeArray[0], ...byTimeArray.slice(2)]; } } return byTimeArray.map((entry) => { return { timeMillis: entry[0], count: entry[1] }; }); } // ==================================================================================== function projectLevelUpTime(kanjiIds) { const kanjiPasses = groupByTime(kanjiIds, shared.passTimes); const numKanjisToLevelUp = Math.ceil(kanjiIds.length * 0.9); let remaining = kanjiIds.length - numKanjisToLevelUp; for (let i = kanjiPasses.length - 1; i >= 0; --i) { remaining -= kanjiPasses[i].count; if (remaining <= 0) { return parseInt(kanjiPasses[i].timeMillis); } } // Should never happen return 0; } function getNumItemsLearnedToday(items, now) { let numLearnedToday = 0; for (let i = 0; i < items.length; ++i) { const item = items[i]; if (item.assignments && item.assignments.started_at) { const startedAt = new Date(Date.parse(item.assignments.started_at)); if (startedAt.getDate() == now.getDate() && startedAt.getMonth() == now.getMonth()) { ++numLearnedToday; } } } return numLearnedToday; } function getNumVocabUntilLevelUp() { let numVocab = shared.availableCount; for (let i = 0; i < shared.vocabUnlocks.length; ++i) { if (shared.vocabUnlocks[i].timeMillis >= shared.levelUpTimeMillis) { break; } numVocab += shared.vocabUnlocks[i].count; } return numVocab; } function calculateRecommendedVocabPerDay(now) { const numUntilLevelUp = getNumVocabUntilLevelUp() + shared.numVocabLearnedToday; const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const levelUpDate = new Date(shared.levelUpTimeMillis); const levelUpDay = new Date(levelUpDate.getFullYear(), levelUpDate.getMonth(), levelUpDate.getDate()); const numDays = (levelUpDay.getTime() - today.getTime()) / (24 * 3600 * 1000) + (shared.settings.includeLevelUpDay ? 1 : 0); shared.recommendedVocabPerDay = Math.ceil(numUntilLevelUp / numDays); return shared.recommendedVocabPerDay; } // ==================================================================================== function createDiv(parent, className, style = undefined, innerHTML = undefined) { const div = document.createElement("div"); div.className = className; if (style !== undefined) { div.style = style; } if (innerHTML !== undefined) { div.innerHTML = innerHTML; } $(parent).append(div); return div; } function addUnlockOverview() { if (!shared.settings.showUnlocks) { return; } const root = document.getElementsByClassName("wk-panel--review-forecast")[0]; if (root === undefined) { console.log("Review forecast panel not found, can't add Vocab Panel"); return; } const contentRoot = root.getElementsByClassName("review-forecast")[0]; if (contentRoot === undefined) { console.log("Review content panel not found, can't add Vocab Panel"); return; } const section = createDiv(contentRoot, "review-forecast__day", "margin-top: 10px"); shared.unlocksElement = section; createDiv(section, "review-forecast__day-title", "padding-bottom: 5px", "Vocabulary unlocks"); const content = createDiv(section, "review-forecast__day-content"); const maxCount = Math.max(shared.availableCount, Math.max(...shared.vocabUnlocks.map((entry) => entry.count))); if (shared.availableCount > 0) { addUnlockRow(content, "Unlocked", shared.availableCount, shared.availableCount, maxCount, false); } let unlockCounter = shared.availableCount; let levelUpAdded = false; const levelUpDate = new Date(shared.levelUpTimeMillis); const now = new Date(Date.now()); for (let i = 0; i < shared.vocabUnlocks.length; ++i) { const date = new Date(parseInt(shared.vocabUnlocks[i].timeMillis)); if (!levelUpAdded && levelUpDate <= date) { const levelUpTimeStr = getTimeStr(levelUpDate, now); addLevelUpRow(content, levelUpTimeStr); levelUpAdded = true; } const timeStr = getTimeStr(date, now); const count = shared.vocabUnlocks[i].count; unlockCounter += count; addUnlockRow(content, timeStr, count, unlockCounter, maxCount); } if (!levelUpAdded) { const levelUpTimeStr = getTimeStr(levelUpDate, now); addLevelUpRow(content, levelUpTimeStr); } } function getTimeStr(date, now) { const diff = date - now; if (diff > WeekMillis) { return longFormatter.format(date); } else if (diff < DayMillis && date.getDate() == now.getDate()) { if (date.getTime() <= now.getTime()) { return "After reviews"; } return "Today " + todayFormatter.format(date); } return shortFormatter.format(date); } function addLevelUpRow(root, timeStr) { const container = createDiv( root, "review-forecast__day-content", "border-bottom: 1px solid var(--color-review-forecast-divider); padding-bottom: var(--spacing-xxtight);" ); const row = createDiv(container, "review-forecast__hour"); createDiv(row, "review-forecast__hour-title", "flex: 0 0 120px", timeStr); createDiv(row, "review-forecast__increase-indicator", "font-weight: var(--font-weight-bold); text-align: center;", "Level Up!"); } function addUnlockRow(root, timeStr, count, runningTotal, maxCount, showIncrease = true) { const row = createDiv(root, "review-forecast__hour"); createDiv(row, "review-forecast__hour-title", "flex: 0 0 120px", timeStr); const barRoot = createDiv(row, "review-forecast__increase-indicator"); createDiv(barRoot, "review-forecast__increase-bar", `width: ${(count / maxCount) * 100}%; background-color: var(--color-vocabulary);`); if (showIncrease) { createDiv(row, "review-forecast__hour-increase review-forecast__increase", undefined, count); } else { createDiv(row, "review-forecast__hour-increase", "flex: 0 0 56px"); } createDiv(row, "review-forecast__hour-total review-forecast__total", undefined, runningTotal); } function addVocabLessonBar() { if (!shared.settings.showNumRecommendedVocab) { return; } if (shared.recommendedVocabPerDay == 0 || (shared.availableCount == 0 && shared.numVocabLearnedToday == 0)) { return; } const root = document.getElementsByClassName("todays-lessons")[0]; if (root === undefined) { console.log("Lesson panel not found, can't add vocab bar"); return; } const percentage = Math.min(1.0, shared.numVocabLearnedToday / shared.recommendedVocabPerDay); const outer = createDiv(root, "vocab-progress-bar"); shared.lessonBarElement = outer; const bar = createDiv(outer, "vocab-progress-bar__progress", `width: ${percentage * 100}%`); if (percentage > 0.5) { createDiv( bar, "level-progress-bar__label level-progress-bar__label--inside", "line-height: 18px; font-size: 14px", `${shared.numVocabLearnedToday}/${shared.recommendedVocabPerDay} vocab` ); } else { createDiv( outer, "level-progress-bar__label level-progress-bar__label", "line-height: 18px; font-size: 14px", `${shared.numVocabLearnedToday}/${shared.recommendedVocabPerDay} vocab` ); } createDiv(bar, "level-progress-bar__icon", "flex: 0 0 18px"); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址