您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Formerly named "Wanikani Anime Sentences 2". Adds example sentences from anime, dramas, games, literature, and news for vocabulary from https://www.immersionkit.com.
// ==UserScript== // @name WaniKani Media Context Sentences // @description Formerly named "Wanikani Anime Sentences 2". Adds example sentences from anime, dramas, games, literature, and news for vocabulary from https://www.immersionkit.com. // @version 4.0.4 // @author Inserio // @namespace https://greasyfork.org/en/users/11878 // @match https://www.wanikani.com/* // @match https://preview.wanikani.com/* // @require https://greasyfork.org/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1492607 // @copyright 2021+, Paul Connolly // @copyright 2024-2025, Brian Shenk // @license MIT; http://opensource.org/licenses/MIT // @run-at document-body // @grant none // ==/UserScript== /* jshint esversion: 11 */ /* eslint-disable no-irregular-whitespace */ // noinspection CssUnusedSymbol,CssInvalidPropertyValue,CssUnresolvedCustomProperty,JSUnusedGlobalSymbols,JSNonASCIINames /* global wkItemInfo */ (() => { 'use strict'; const {wkof} = window, script = { id: 'media-context-sentences', name: 'Media Context Sentences', secondarySortingKeyName: 'secondary', version: '4.0.4' }; script.styleSheetName = `${script.id}-style`; script.regex = { anyUrl: new RegExp(), // default "empty" regex. Equivalent to `/(?:)/` exampleLimitSearch: new RegExp(`(#${script.id} \\.example:nth-child)(\\(n\\+\\d+\\))?`), // /(#media-context-sentences \.example:nth-child)(\(n\+\d+\))?/ maxHeightSearch: new RegExp(`(#${script.id}\\s*{[^}]*?max-height:).*?;`), // /(#media-context-sentences\s*{[^}]*?max-height:) *[\d.] *+\w*;/ validCssUnit: /^((\d*\.)?\d+)((px)|(em)|(%)|(ex)|(ch)|(rem)|(vw)|(vh)|(vmin)|(vmax)|(cm)|(mm)|(in)|(pt)|(pc))$/i }; Object.freeze(script); const state = { classNames: { audioButton: 'audio-btn', audioIdle: 'audio-idle', audioPlaying: 'audio-play', }, // Used for working with the settings dialog and determining which sentences to show content: { deckIndex: {}, selections: {}, allContent: new Map(), keyTitleMap: new Map(), titleKeyMap: new Map(), anime: {}, drama: {}, games: {}, literature: {}, news: {} }, // Default options for configuring the content in the settings dialog (memoized) get contentOptions() { return lazyProperty(this, 'contentOptions', () => ({ whenShowText: {always: 'Always', onhover: 'On Hover', onclick: 'On Click'}, whenShowFurigana: {always: 'Always', onhover: 'On Hover', never: 'Never'}, jlpt: {0: 'No Filter', 1: 'N1', 2: 'N2', 3: 'N3', 4: 'N4', 5: 'N5'}, immersionKitAPIVersions: {'1': 'v1', '2': 'v2'}, sortingMethods: { default: 'Default', category: 'Category (anime, drama, etc.)', source: 'Source Title', shortness: 'Shortest sentences first', longness: 'Longest sentences first', position: 'Position of keyword in sentence', }, secondarySortingMethodsToHide(primarySortingMethod) { const keysToHide = []; switch (primarySortingMethod) { case 'category': keysToHide.push('category'); break; case 'longness': case 'shortness': keysToHide.push('shortness'); keysToHide.push('longness'); break; case 'source': keysToHide.push('category'); keysToHide.push('source'); break; case 'position': keysToHide.push('position'); break; case 'default': default: keysToHide.push('category'); keysToHide.push('source'); keysToHide.push('shortness'); keysToHide.push('longness'); keysToHide.push('position'); break; } return keysToHide; }, secondarySortingMethods(primarySortingMethod) { const sortingMethodsCopy = Object.assign({}, this.sortingMethods); for (const oldKey of this.secondarySortingMethodsToHide(primarySortingMethod)) { // we do a little cheeky attribute injection for wkof's list generation... const newKey = `${oldKey}" class="hidden`; sortingMethodsCopy[newKey] = sortingMethodsCopy[oldKey]; delete sortingMethodsCopy[oldKey]; } return sortingMethodsCopy; }, }), {enumerable: true}); }, // Referenced for quick access to elements used in this script elements: { // The base node base: null, // The audio nodes audio: {}, // The element holding all the sentences (referenced so that sentences can be re-rendered after settings change) sentences: null, // The style sheet element styleSheet: null, }, handlers: { onPlay(button) {return ({currentTarget: el}) => { if (el == null) return; const {classNames:{audioIdle, audioPlaying}, settings:{general:{playback:{restartAudioOnPause}}}} = state; for (const [key, {audio}] of Object.entries(state.elements.audio)) { if (key !== button.id && !audio.paused) audio.pause(); if (restartAudioOnPause) audio.currentTime = 0; } button.classList.replace(audioIdle, audioPlaying); button.textContent = '🔊'; };}, onStop(button) {return ({currentTarget: el}) => { if (el == null) return; const {audioIdle, audioPlaying} = state.classNames; button.classList.replace(audioPlaying, audioIdle); button.textContent = '🔈'; el.remove(); };}, }, // Container for other Immersion Kit stuff (memoized) get immersionKit() { return lazyProperty(this, 'immersionKit', ()=> ({ api: { '1': { version: 1, origin: 'https://api.immersionkit.com', endpoints: { query: { pathname: '/look_up_dictionary', getSearch(keyword, {exactSearch, exampleLimit, jlptLevel, primarySorting, tags, waniKaniLevel}) { keyword = keyword.replace('〜', ''); // for "counter" kanji if (exactSearch) keyword = `「${keyword}」`; let sentenceSorting = ''; switch (primarySorting) { case 'shortness': case 'longness': sentenceSorting = `&sort=${sentenceSorting}`; } const jlpt = Number(jlptLevel) > 0 ? `&jlpt=${jlptLevel}` : ''; const wk = waniKaniLevel ? `&wk=${state.userLevel}` : ''; const tag = tags.length > 0 ? `&tags=${tags}` : ''; const limit = Number(exampleLimit) > 0 ? `&limit=${exampleLimit}` : ''; return `?keyword=${keyword}${jlpt}${wk}${tag}${sentenceSorting}${limit}`; }, }, }, }, '2': { endpoints: { index: {pathname: '/index_meta'}, query: { pathname: '/search', getSearch(keyword, {exactSearch, jlptLevel, primarySorting, tags, waniKaniLevel}) { keyword = keyword.replace('〜', ''); // for "counter" kanji let sentenceSorting = ''; switch (primarySorting) { case 'longness': sentenceSorting = '&sort=sentence_length:desc'; break; case 'shortness': sentenceSorting = '&sort=sentence_length:asc'; break; } const exact = exactSearch ? '&exactMatch=true' : ''; const jlpt = Number(jlptLevel) > 0 ? `&jlpt=${jlptLevel}` : ''; const wk = waniKaniLevel ? `&wk=${state.userLevel}` : ''; const tag = tags.length > 0 ? `&tags=${tags}` : ''; return `?q=${keyword}${exact}${jlpt}${wk}${tag}${sentenceSorting}`; }, }, }, origin: 'https://apiv2.immersionkit.com', version: 2, }, }, baseContentUrl: 'https://us-southeast-1.linodeobjects.com/immersionkit/media/', // Cached to aid in determining whether retries should be done currentSearchUrl: null, cache: { key: `${script.id}.immersion-kit-data`, // Cached so sentences can be re-rendered after settings change and to persist lookups between sessions urls: {}, }, // Cache for the number of fetches done for any given url fetchCount: {}, getSearchOptions({general: {appearance: {exampleLimit=0}}, sorting: {primary: primarySorting='shortness'}, filters: {exactSearch=false, jlptLevel=0, tags='', waniKaniLevel=false}}={}) { if (Array.isArray(tags)) tags = tags.join(','); return {exactSearch, exampleLimit, jlptLevel, primarySorting, tags, waniKaniLevel}; }, })); }, // The settings object settings: { // Sentence Filtering Options filters: { // Filters the results to only those with sentences exactly matching the keyword (i.e., this filters after the results are found) // TODO: Make the kanji (i.e., not the okurigana) required by default for non-kana-only vocab exactMatch: false, // Wraps the search term in Japanese quotes (i.e., 「term」) before sending it to Immersion Kit exactSearch: false, // If greater than 0, tells Immersion Kit to filter out results that are not at the selected JLPT level or easier. jlptLevel: 0, // Mapping of the content title to the enabled state. // All content is enabled by default. // Titles are taken from https://www.immersionkit.com/information and modified after testing a few example search results. // Including the full lists for v1 compatability lists: { anime: { [`Alya Sometimes Hides Her Feelings in Russian`]: true, [`Angel Beats!`]: true, [`Anohana the flower we saw that day`]: true, [`Assassination Classroom Season 1`]: true, [`Bakemonogatari`]: true, [`Boku no Hero Academia Season 1`]: true, [`Bunny Drop`]: true, [`Cardcaptor Sakura`]: true, [`Castle in the sky`]: true, [`Chobits`]: true, [`Clannad After Story`]: true, [`Clannad`]: true, [`Code Geass Season 1`]: true, [`Daily Lives of High School Boys`]: true, [`Death Note`]: true, [`Demon Slayer - Kimetsu no Yaiba`]: true, [`Durarara!!`]: true, [`Erased`]: true, [`Fairy Tail`]: true, [`Fate Stay Night Unlimited Blade Works`]: true, [`Fate Zero`]: true, [`From Up on Poppy Hill`]: true, [`From the New World`]: true, [`Fruits Basket Season 1`]: true, [`Fullmetal Alchemist Brotherhood`]: true, [`Girls Band Cry`]: true, [`God's Blessing on this Wonderful World!`]: true, [`Grave of the Fireflies`]: true, [`Haruhi Suzumiya`]: true, [`Howl's Moving Castle`]: true, [`Hunter × Hunter`]: true, [`Hyouka`]: true, [`Is The Order a Rabbit`]: true, [`K-On!`]: true, [`Kakegurui`]: true, [`Kanon (2006)`]: true, [`Kiki's Delivery Service`]: true, [`Kill la Kill`]: true, [`Kino's Journey`]: true, [`Kokoro Connect`]: true, [`Little Witch Academia`]: true, [`Lucky Star`]: true, [`Mahou Shoujo Madoka Magica`]: true, [`Mononoke`]: true, [`My Little Sister Can't Be This Cute`]: true, [`My Neighbor Totoro`]: true, [`New Game!`]: true, [`Nisekoi`]: true, [`No Game No Life`]: true, [`Noragami`]: true, [`One Week Friends`]: true, [`Only Yesterday`]: true, [`Princess Mononoke`]: true, [`Psycho Pass`]: true, [`Re Zero − Starting Life in Another World`]: true, [`ReLIFE`]: true, [`Shirokuma Cafe`]: true, [`Sound! Euphonium`]: true, [`Spirited Away`]: true, [`Steins Gate`]: true, [`Sword Art Online`]: true, [`The Cat Returns`]: true, [`The Garden of Words`]: true, [`The Girl Who Leapt Through Time`]: true, [`The Pet Girl of Sakurasou`]: true, [`The Secret World of Arrietty`]: true, [`The Wind Rises`]: true, [`The World God Only Knows`]: true, [`Toradora!`]: true, [`Wandering Witch The Journey of Elaina`]: true, [`Weathering with You`]: true, [`When Marnie Was There`]: true, [`Whisper of the Heart`]: true, [`Wolf Children`]: true, [`Your Lie in April`]: true, [`Your Name`]: true, }, drama: { [`1 Litre of Tears`]: true, [`Border`]: true, [`Good Morning Call`]: true, // Exists in the APIv2 list, but APIv1 splits it into two [`Good Morning Call Season 1`]: true, // Exists in the APIv1 list, but not in the APIv2 list [`Good Morning Call Season 2`]: true, [`I am Mita, Your Housekeeper`]: true, [`I'm Taking the Day Off`]: true, [`Legal High Season 1`]: true, [`Million Yen Woman`]: true, [`Mob Psycho 100`]: true, [`Overprotected Kahoko`]: true, [`Quartet`]: true, [`Sailor Suit and Machine Gun (2006)`]: true, [`Smoking`]: true, [`The Journalist`]: true, [`Weakest Beast`]: true, }, games: { [`Cyberpunk 2077`]: true, [`Skyrim`]: true, [`Witcher 3`]: true, // The following are currently not queryable via the API (but maybe they will be someday?) // [`NieR: Automata`]: true, [`NieR Re[in]carnation`]: true, [`Zelda: Breath of the Wild`]: true, }, literature: { [`黒猫`]: true, [`おおかみと七ひきのこどもやぎ`]: true, [`マッチ売りの少女`]: true, [`サンタクロースがやってきた`]: true, [`君死にたまふことなかれ`]: true, [`蝉`]: true, [`胡瓜`]: true, [`若鮎について`]: true, [`黒足袋`]: true, [`柿`]: true, [`お母さんの思ひ出`]: true, [`砂をかむ`]: true, [`虻のおれい`]: true, [`がちゃがちゃ`]: true, [`犬のいたずら`]: true, [`犬と人形`]: true, [`懐中時計`]: true, [`きのこ会議`]: true, [`お金とピストル`]: true, [`梅のにおい`]: true, [`純真`]: true, [`声と人柄`]: true, [`心の調べ`]: true, [`愛`]: true, [`期待と切望`]: true, [`空の美`]: true, [`いちょうの実`]: true, [`虔十公園林`]: true, [`クねずみ`]: true, [`おきなぐさ`]: true, [`さるのこしかけ`]: true, [`セロ弾きのゴーシュ`]: true, [`ざしき童子のはなし`]: true, [`秋の歌`]: true, [`赤い船とつばめ`]: true, [`赤い蝋燭と人魚`]: true, [`赤い魚と子供`]: true, [`秋が きました`]: true, [`青いボタン`]: true, [`ある夜の星たちの話`]: true, [`いろいろな花`]: true, [`からすとかがし`]: true, [`片田舎にあった話`]: true, [`金魚売り`]: true, [`小鳥と兄妹`]: true, [`おじいさんが捨てたら`]: true, [`おかめどんぐり`]: true, [`お母さん`]: true, [`お母さんのお乳`]: true, [`おっぱい`]: true, [`少年と秋の日`]: true, [`金のくびかざり`]: true, [`愛よ愛`]: true, [`気の毒な奥様`]: true, [`新茶`]: true, [`初夏に座す`]: true, [`三角と四角`]: true, [`赤い蝋燭`]: true, [`赤とんぼ`]: true, [`飴だま`]: true, [`あし`]: true, [`がちょうのたんじょうび`]: true, [`ごん狐`]: true, [`蟹のしょうばい`]: true, [`カタツムリノ ウタ`]: true, [`木の祭り`]: true, [`こぞうさんのおきょう`]: true, [`去年の木`]: true, [`おじいさんのランプ`]: true, [`王さまと靴屋`]: true, [`落とした一銭銅貨`]: true, [`サルト サムライ`]: true, [`里の春、山の春`]: true, [`ウサギ 新美 南吉`]: true, [`あひるさん と 時計`]: true, [`川へおちた玉ねぎさん`]: true, [`小ぐまさんのかんがへちがひ`]: true, [`お鍋とお皿とカーテン`]: true, [`お鍋とおやかんとフライパンのけんくわ`]: true, [`ひらめの学校`]: true, [`狐物語`]: true, [`桜の樹の下には`]: true, [`瓜子姫子`]: true, [`ああしんど`]: true, [`葬式の行列`]: true, [`風`]: true, [`子どものすきな神さま`]: true, [`喫茶店にて`]: true, [`子供に化けた狐`]: true, [`顔`]: true, [`四季とその折々`]: true, }, news: { [`平成30年阿蘇神社で甘酒の仕込み始まる`]: true, [`フレッシュマン!5月号阿蘇広域行政事務組合`]: true, [`フレッシュマン!7月号春工房、そば処ゆう雀`]: true, [`フレッシュマン!11月号内牧保育園`]: true, [`山田小学校で最後の稲刈り`]: true, }, }, // Not implemented (If implemented, would filter the results to only those matching the provided category tags) - TODO: Priority low tags: '', // Tells Immersion Kit to filter out results containing words more than 1 level higher than the current WaniKani level // (possibly inaccurate, due to frequent changes in WK level contents) waniKaniLevel: false, }, // General general: { // Appearance Options appearance: { // 0 = No limit exampleLimit: 0, // Options for highlighting the keyword in the sentence: exact|fuzzy // TODO: Implement highlighting: 'exact', // The maximum height of the container box. If no unit type is provided, px (pixels) is automatically appended. maxBoxHeight: '320px', // Options for when to show English translation: always|onhover|onclick showEnglish: 'onhover', // Options for when to show Furigana: always|onhover|never showFurigana: 'onhover', // Options for when to show Japanese text: always|onhover|onclick showJapanese: 'always', // Allows the box to appear in the Examples tab for kanji as well showOnKanji: false, }, // Playback Options playback: { // If true, will restart the audio track from the beginning when the user pauses. // If false, will save the current position and resume from there the next time that sentence is played. restartAudioOnPause: true, // Playback speed in percent = (playbackRate * 2) playbackRate: 50, // Playback volume in percent playbackVolume: 100, }, // Immersion Kit Data Fetching Options dataFetching: { // If greater than 0, will attempt to retry fetching results if none were found. retryCount: 0, // Milliseconds to wait before retrying fetch retryDelay: 5000, // Whether to check the Last-Modified header to see if the data is still up to date or not. // If a Last-Modified header is not present, the data is assumed to be out-of-date and will be re-fetched checkLastModified: false, }, // Advanced Options advanced: { // Enables debugging statements to find and help remedy bugs when they occur. debugging: false, // This works as a "fail-early" measure and will not show any normal results when an issue is found. failWhenHidden: false, // Immersion Kit API Version immersionKitAPIVersion: '2', } }, // Sentence Sorting Options sorting: { // Options for primary sentence sorting: default|category|shortness|longness|position primary: 'default', // Options for secondary sentence sorting: default(none)|category|shortness|longness|position [script.secondarySortingKeyName]: 'default', }, // The current version of the stored settings version: script.version, }, get settingsDialog() { return ({ script_id: script.id, title: script.name, on_save: onSettingsSaved, on_close: onSettingsClosed, content: { general: { type: 'page', label: 'General', content: { generalDescription: { type: 'section', label: 'Changes to settings in this tab can be previewed in real-time.', }, appearanceOptions: { type: 'group', label: 'Appearance Options', content: { showOnKanji: { type: 'checkbox', label: 'Show on Kanji Items', default: state.settings.general.appearance.showOnKanji, hover_tip: 'Allows the box to appear in the Examples tab for kanji in addition to vocabulary.', on_change: onAppearanceOptionChanged, path: '@general.appearance.showOnKanji', }, maxBoxHeight: { type: 'text', label: 'Box Height', step: 1, min: 0, default: state.settings.general.appearance.maxBoxHeight, hover_tip: 'Set the maximum height of the container box.\nIf no unit type is provided, px (pixels) is automatically appended.', on_change: onAppearanceOptionChanged, validate: validateMaxHeight, path: '@general.appearance.maxBoxHeight', }, exampleLimit: { type: 'number', label: 'Example Limit', step: 1, min: 0, default: state.settings.general.appearance.exampleLimit, hover_tip: 'Limit the number of entries that may appear.\nSet to 0 to show as many as possible (note that this can really lag the list generation when there are a very large number of matches).', on_change: onAppearanceOptionChanged, path: '@general.appearance.exampleLimit', }, showJapanese: { type: 'dropdown', label: 'Show Japanese', default: state.settings.general.appearance.showJapanese, content: state.contentOptions.whenShowText, hover_tip: 'When to show Japanese text.\nHover enables transcribing a sentences first (play audio by clicking the image to avoid seeing the answer).', on_change: onAppearanceOptionChanged, path: '@general.appearance.showJapanese', }, showFurigana: { type: 'dropdown', label: 'Show Furigana', default: state.settings.general.appearance.showFurigana, content: state.contentOptions.whenShowFurigana, hover_tip: 'These have been autogenerated so there may be mistakes.', on_change: onAppearanceOptionChanged, path: '@general.appearance.showFurigana', }, showEnglish: { type: 'dropdown', label: 'Show English', default: state.settings.general.appearance.showEnglish, content: state.contentOptions.whenShowText, hover_tip: 'Hover or click allows testing your understanding before seeing the answer.', on_change: onAppearanceOptionChanged, path: '@general.appearance.showEnglish', }, }, }, playbackOptions: { type: 'group', label: 'Playback Options', content: { playbackRate: { type: 'input', subtype: 'range', label: 'Playback Speed', default: state.settings.general.playback.playbackRate, hover_tip: 'Speed to play back audio. (10% - 200%)', on_change: onPlaybackOptionChanged, validate: validatePlaybackRate, path: '@general.playback.playbackRate', }, playbackVolume: { type: 'input', subtype: 'range', label: 'Playback Volume', default: state.settings.general.playback.playbackVolume, hover_tip: 'Volume to play back audio. (0% - 100%)', on_change: onPlaybackOptionChanged, validate: validatePlaybackVolume, path: '@general.playback.playbackVolume', }, restartAudioOnPause: { type: 'checkbox', label: 'Restart Audio on Pause', default: state.settings.general.playback.restartAudioOnPause, hover_tip: 'If true, will restart the audio track from the beginning whenever a sentence is played.\nIf false, will save the current position and resume from there the next time that sentence is played.', on_change: onPlaybackOptionChanged, path: '@general.playback.restartAudioOnPause', } }, }, immersionKitDataFetchingOptions: { type: 'group', label: 'Immersion Kit Data Fetching Options', content: { fetchRetryCount: { type: 'number', label: 'Fetch Retry Count', step: 1, min: 0, default: state.settings.general.dataFetching.retryCount, hover_tip: 'Set how many times you would like to allow retrying the fetch for sentences (to workaround backend issues).', on_change: onFetchOptionChanged, path: '@general.dataFetching.retryCount', }, fetchRetryDelay: { type: 'number', label: 'Fetch Retry Delay (ms)', step: 1, min: 0, default: state.settings.general.dataFetching.retryDelay, hover_tip: 'Set the delay in milliseconds between each retry attempt.', on_change: onFetchOptionChanged, path: '@general.dataFetching.retryDelay', }, checkLastModified: { type: 'checkbox', label: 'Compare Last-Modified Date', default: state.settings.general.dataFetching.checkLastModified, hover_tip: 'If true, will check the last modified date of the sentences before fetching.\nIf false, will always fetch the sentences.\n\nNote that as of the time of writing, Immersion Kit does not send a Last-Modified header, so checking this option will mean that the cache will always be ignored.', on_change: onFetchOptionChanged, path: '@general.dataFetching.checkLastModified', } }, }, advancedOptions: { type: 'group', label: 'Advanced Options', content: { debugging: { type: 'checkbox', label: 'Debugging', default: state.settings.general.advanced.debugging, hover_tip: 'Show additional debugging information in the console.', on_change: onAdvancedOptionChanged, path: '@general.advanced.debugging', }, failWhenHidden: { type: 'checkbox', label: 'Fail When Hidden', default: state.settings.general.advanced.failWhenHidden, hover_tip: 'Immediately fail to display the sentences if any are available but hidden, instead showing a message including the list of hidden sentences.\nNote: Toggling this option will immediately cause a rerender of the sentences.', on_change: onAdvancedOptionChanged, path: '@general.advanced.failWhenHidden', }, immersionKitAPIVersion: { type: 'dropdown', label: 'Immersion Kit API Version', default: state.settings.general.advanced.immersionKitAPIVersion, content: state.contentOptions.immersionKitAPIVersions, hover_tip: 'Select the Immersion Kit API version to use.', on_change: onAdvancedOptionChanged, path: '@general.advanced.immersionKitAPIVersion', }, flushCachedData: { type: 'button', label: 'Cached Data', text: 'Flush', hover_tip: 'Immediately flush all cached Immersion Kit data.', on_click: onFlushCachedDataClicked, }, }, }, }, }, sorting: { type: 'page', label: 'Sorting', content: { sentenceSortOptions: { type: 'group', label: 'Sentence Sorting Options', content: { primary: { type: 'dropdown', label: 'Primary Sorting Method', default: state.settings.sorting.primary, content: state.contentOptions.sortingMethods, hover_tip: 'Choose in what order the sentences will be presented.\nDefault = Exactly as retrieved from Immersion Kit', on_change: onPrimarySortOptionChanged, path: '@sorting.primary', }, [script.secondarySortingKeyName]: { type: 'dropdown', label: 'Secondary Sorting Method', default: state.settings.sorting[script.secondarySortingKeyName], content: state.contentOptions.secondarySortingMethods(state.settings.sorting.primary), hover_tip: 'Choose how you would like to sort equivalencies in the primary sorting method.\nDefault = No secondary sorting', path: `@sorting.${script.secondarySortingKeyName}`, }, }, }, }, }, filters: { type: 'page', label: 'Filters', content: { sentenceFilteringOptions: { type: 'group', label: 'Sentence Filtering Options', content: { filterExactMatch: { type: 'checkbox', label: 'Exact Match', default: state.settings.filters.exactMatch, hover_tip: 'Text must match term exactly, i.e., this filters out conjugations/inflections.\nChecking this for a word with kanji means it will not match if the sentence has it only in kana form and vice-versa for kana-only vocabulary.\n\nThis filtering is done after the results are retrieved from Immersion Kit and may yield different results than the "Exact Search" option (below) when the latter is not used.', path: '@filters.exactMatch', }, filterAnime: { type: 'list', label: 'Anime', multi: true, size: 10, default: state.content.selections.anime, content: state.content.anime, hover_tip: 'Select the anime that can be included in the examples.', path: '@filters.lists.anime', }, filterDrama: { type: 'list', label: 'Drama', multi: true, size: 6, default: state.content.selections.drama, content: state.content.drama, hover_tip: 'Select the dramas that can be included in the examples.', path: '@filters.lists.drama', }, filterGames: { type: 'list', label: 'Games', multi: true, size: 3, default: state.content.selections.games, content: state.content.games, hover_tip: 'Select the video games that can be included in the examples.', path: '@filters.lists.games', }, filterLiterature: { type: 'list', label: 'Literature', multi: true, size: 6, default: state.content.selections.literature, content: state.content.literature, hover_tip: 'Select the pieces of literature that can be included in the examples.', path: '@filters.lists.literature', }, filterNews: { type: 'list', label: 'News', multi: true, size: 6, default: state.content.selections.news, content: state.content.news, hover_tip: 'Select the news sources that can be included in the examples.', path: '@filters.lists.news', }, }, }, immersionKitSearchOptions: { type: 'group', label: 'Immersion Kit Search Options', content: { immersionKitSearchDescription: {type: 'section', label: 'Changes here cause an API request unless already cached.'}, filterExactSearch: { type: 'checkbox', label: 'Exact Search', default: state.settings.filters.exactSearch, hover_tip: 'Text must match term exactly, i.e., this filters out conjugations/inflections.\nChecking this for a word with kanji means it will not match if the sentence has it only in kana form and vice-versa for kana-only vocabulary.', path: '@filters.exactSearch', }, filterWaniKaniLevel: { type: 'checkbox', label: 'WaniKani Level', default: state.settings.filters.waniKaniLevel, hover_tip: 'Only show sentences with maximum 1 word outside of your current WaniKani level.', path: '@filters.waniKaniLevel', }, filterJLPTLevel: { type: 'dropdown', label: 'JLPT Level', default: state.settings.filters.jlptLevel, content: state.contentOptions.jlpt, hover_tip: 'Only show sentences matching a particular JLPT Level or easier.', path: '@filters.jlptLevel', }, }, }, }, }, credits: { type: 'html', label: 'Powered by', html: '<a href="https://www.immersionkit.com" style="vertical-align:middle;vertical-align:-webkit-baseline-middle;vertical-align:-moz-middle-with-baseline;">https://www.immersionkit.com</a>', }, }, }); }, // Current user's WaniKani level get userLevel() { return wkof ? wkof.user.level : 0; }, // Whether certain operations are pending to be done. pending: { // Whether an update of the desired shows is pending to be done. updateDesiredShows: false, updateIndexUrl: false, // Whether the current Immersion Kit search URL is pending to be updated. updateSearchUrl: false, // Whether a rerender of the sentences is pending to be done. renderSentences: false, }, // Used for modifying the current WK Item Info Injector listeners wkItemInfo: { handlers: { kanji: null, vocabulary: null, }, // Current item from wkItemInfoInjector item: null, }, }; Object.defineProperties(state, { classNames: {enumerable: true, writable: false, configurable: false}, content: {enumerable: true, writable: false, configurable: false}, elements: {enumerable: true, writable: false, configurable: false}, handlers: {enumerable: false, writable: false, configurable: false}, settings: {enumerable: true, writable: false, configurable: true}, pending: {enumerable: true, writable: false, configurable: false}, wkItemInfo: {enumerable: true, writable: false, configurable: false}, }); Promise.resolve().then(async () => await init()); // END SCRIPT async function init() { updateKeyMapForTitles(); await restoreCachedImmersionKitData(); const decksIndex = await fetchImmersionKitDecksIndex(); mergeImmersionKitDeckDataIntoContent(decksIndex); if (wkof) { await wkof.include('Apiv2,Settings,Menu'); // Apiv2 needed in order to set wkof.user.level // document.documentElement.addEventListener('turbo:load', () => setTimeout(() => wkof.ready('Menu').then(installMenu), 0)); await wkof.ready('Settings'); // await createContentListsForSettings(); await loadSettings(); await migrateSettingsVersion(); addMissingEntriesToContent(); await Promise.all([wkof.ready('Apiv2'), addStyle(), onImmersionKitAPIVersionOptionChanged(state.settings.general.advanced.immersionKitAPIVersion)]); await updateDesiredShows(); wkof.on_pageload(script.regex.anyUrl, () => wkof.ready('Menu').then(installMenu)); } else { console.warn(`${script.name}: You are not using Wanikani Open Framework which this script utilizes to provide the settings dialog for the script. You can still use ${script.name} normally though`); await Promise.all([addStyle(), updateDesiredShows()]); } window.mediaContextSentences = state; setWaniKaniItemInfoListener(); } function setWaniKaniItemInfoListener() { if (state.wkItemInfo.handlers.vocabulary == null) state.wkItemInfo.handlers.vocabulary = wkItemInfo.forType('vocabulary,kanaVocabulary').under('examples').notify(onExamplesVisible); if (state.settings.general.appearance.showOnKanji) { if (state.wkItemInfo.handlers.kanji == null) state.wkItemInfo.handlers.kanji = wkItemInfo.forType('kanji').under('examples').notify(onExamplesVisible); else state.wkItemInfo.handlers.kanji.renew(); } else { state.wkItemInfo.handlers.kanji?.remove(); state.wkItemInfo.handlers.kanji = null; } } async function addContextSentences() { state.elements.base = Object.assign(document.createElement('div'), {id: `${script.id}-container`}); state.elements.sentences = Object.assign(document.createElement('div'), { id: `${script.id}`, textContent: 'Loading...', }); const titleEl = Object.assign(document.createElement('span'), {textContent: script.name}); const header = [], additionalSettings = {sectionName: script.name, under: 'examples'}; header.push(titleEl); if (wkof) { const settingsBtn = Object.assign(document.createElement('span'), { textContent: '⚙️', className: `${script.id}-settings-btn`, onclick: openSettings, }); header.push(settingsBtn); } state.elements.base.append(state.elements.sentences); if (state.wkItemInfo.item.injector) state.wkItemInfo.item.injector.appendSubsection(header, state.elements.base, additionalSettings); await renderSentences(); } function getNewImmersionKitUrl(keyword, settings=state.settings) { const immersionKit = state.immersionKit; const api = immersionKit.api[state.settings.general.advanced.immersionKitAPIVersion]; const options = immersionKit.getSearchOptions(settings); const url = `${(api.origin)}${api.endpoints.query.pathname}${api.endpoints.query.getSearch(keyword, options)}`; state.pending.updateSearchUrl = false; return url; } async function restoreCachedImmersionKitData() {if (state.immersionKit.cache.key in wkof.file_cache.dir) state.immersionKit.cache.urls = await wkof.file_cache.load(state.immersionKit.cache.key);} async function deleteCachedImmersionKitData() {if (state.immersionKit.cache.key in wkof.file_cache.dir) await wkof.file_cache.delete(state.immersionKit.cache.key); state.immersionKit.cache.urls = {};} async function saveCachedImmersionKitData() {await wkof.file_cache.save(state.immersionKit.cache.key, state.immersionKit.cache.urls);} function mergeImmersionKitDeckDataIntoContent(data) { const errorList = []; for (let [key, entry] of Object.entries(data)) { state.content.deckIndex[key] = entry; const {title, category, tags} = entry; if (!state.content.keyTitleMap.has(key)) { errorList.push({error: 'Key not found in key-title mappings', key}); state.content.keyTitleMap.set(key, title); state.content.titleKeyMap.set(title, key); } state.content.allContent.set(title, {title, category, tags, enabled: true}); // Default to enabled if (category in state.content) { state.content[category][title] = title; } else { errorList.push({error: 'Category not found in state.content', category}); } } if (errorList.length > 0) console.warn(`Error(s) during mergeImmersionKitDeckDataIntoContent:`, errorList); } // Temporary workaround for entries not listed in the index_meta list (likely only matters for API v1) function addMissingEntriesToContent() { const errorList = []; let currentCount = 0; for (const category of ['anime', 'drama', 'games', 'literature', 'news']) { if (!(category in state.content)) state.content[category] = {}; if (Object.keys(state.content[category]).length < Object.keys(state.settings.filters.lists[category]).length) { for (let title of Object.keys(state.settings.filters.lists[category])) { if (title in state.content[category]) continue; state.content[category][title] = title; state.content.allContent.set(title, {title, category, tags: [], enabled: true}); errorList.push(title); } if (errorList.length > currentCount) sortObjectPropertiesInPlace(state.content[category]); currentCount = errorList.length; } } if (state.settings.general.advanced.debugging && errorList.length > 0) { console.warn(`Added the following ${errorList.length} "missing" entries to content`, errorList); } } // Update the map to be able to look up the title from the key function updateKeyMapForTitles() { const englishTitles = Object.assign({}, state.settings.filters.lists.anime, state.settings.filters.lists.drama, state.settings.filters.lists.games); const japaneseTitles = Object.assign({}, state.settings.filters.lists.literature, state.settings.filters.lists.news); for (let title of Object.keys(englishTitles)) { const key = normalize(title).toLocaleLowerCase(); state.content.keyTitleMap.set(key, title); state.content.titleKeyMap.set(title, key); } for (let title of Object.keys(japaneseTitles)) { state.content.keyTitleMap.set(title, title); state.content.titleKeyMap.set(title, title); } } async function fetchImmersionKitDecksIndex() { try { const api = state.immersionKit.api['2']; const indexUrl = `${api.origin}${api.endpoints.index.pathname}`; let prevModified = state.immersionKit.cache.urls[indexUrl]?.lastModified; const response = prevModified ? await fetch(indexUrl, {headers: {'If-Modified-Since': prevModified}}) : await fetch(indexUrl); if (response.status === 304) return state.immersionKit.cache.urls[indexUrl].data; // Return cached data const json = await response.json(); const data = json.data; let lastModified = response.headers.get('Last-Modified') || json.lastUpdatedTimestamp; if (data != null) { if (prevModified !== lastModified) await deleteCachedImmersionKitData(); for (let key of Object.keys(data).sort()) { const value = data[key]; delete data[key]; data[key] = value; } } state.immersionKit.cache.urls[indexUrl] = {data, lastModified}; state.pending.updateIndexUrl = false; return data; } catch(e) { throw Error('Error fetching Immersion Kit deck list', {cause: e}); } } async function fetchImmersionKitData() { if (state.immersionKit.currentSearchUrl == null) state.immersionKit.currentSearchUrl = getNewImmersionKitUrl(state.wkItemInfo.item.characters); const url1 = state.immersionKit.currentSearchUrl; const url2 = getNewImmersionKitUrl(state.wkItemInfo.item.characters, Object.assign({}, state.settings, {filters: {exactSearch: !state.settings.filters.exactSearch}})); let url = url1; try { for (;;) { if (state.immersionKit.cache.urls[url1] != null) { if (!state.settings.general.dataFetching.checkLastModified) return state.immersionKit.cache.urls[url1].data; const lastModified = state.immersionKit.cache.urls[url1].lastModified; const response = await fetch(url1, {headers: {'If-Modified-Since': lastModified}}); if (response.status === 304) return state.immersionKit.cache.urls[url1].data; // Return cached data } else if (state.wkItemInfo.item.type === 'kanji' && state.immersionKit.fetchCount[url1] > 0 && state.immersionKit.cache.urls[url2] != null) { if (!state.settings.general.dataFetching.checkLastModified) return state.immersionKit.cache.urls[url2].data; const lastModified = state.immersionKit.cache.urls[url2].lastModified; const response = await fetch(url2, {headers: {'If-Modified-Since': lastModified}}); if (response.status === 304) return state.immersionKit.cache.urls[url2].data; // Return cached data } state.immersionKit.fetchCount[url] = (state.immersionKit.fetchCount[url] ?? 0) + 1; state.elements.sentences.textContent = 'Fetching...'; if (state.settings.general.advanced.debugging) console.log(`Fetching Immersion Kit data from ${url}`); const response = await fetch(url), lastModified = response.headers.get('Last-Modified'); let data = await response.json(); if (state.settings.general.advanced.immersionKitAPIVersion === '1') data = data.data[0]; if (data?.examples?.length > 0) { state.immersionKit.cache.urls[url] = {data, lastModified}; await saveCachedImmersionKitData(); return data; } else if (state.wkItemInfo.item.type === 'kanji' && !state.immersionKit.fetchCount[url2]) { url = url2; continue; } else if (state.immersionKit.fetchCount[url] > state.settings.general.dataFetching.retryCount) return data; else url = url1; const seconds = Math.round(state.settings.general.dataFetching.retryDelay / 100) / 10; // round to nearest first decimal state.elements.sentences.textContent = `Retrying in ${seconds} second${seconds !== 1 ? 's' : ''}`; await sleep(state.settings.general.dataFetching.retryDelay); } } catch(e) { throw Error('Error fetching Immersion Kit data', {cause: e}); } } async function onExamplesVisible(item) { state.wkItemInfo.item = item; // current vocab item state.immersionKit.currentSearchUrl = getNewImmersionKitUrl(item.characters); try { await addContextSentences(item); } catch(e) { throw Error(`Error while adding ${script.name} section: ${e.message}`, {cause: e}); } } function sortSentences(sentences, primarySorting, secondarySorting) { const categoryCompare = (a, b) => a.category.localeCompare(b.category); const sourceCompare = (a, b) => a.title.localeCompare(b.title); const shortnessCompare = (a, b) => a.sentence.length - b.sentence.length; const longnessCompare = (a, b) => b.sentence.length - a.sentence.length; const positionCompare = (a, b) => a.furiganaObject.getFirstKeywordIndex() - b.furiganaObject.getFirstKeywordIndex(); const sort = (primaryOrder, a, b) => { if (primaryOrder !== 0) return primaryOrder; switch (secondarySorting) { case primarySorting: return primaryOrder; case 'category': return categoryCompare(a, b); case 'source': return sourceCompare(a, b); case 'shortness': return shortnessCompare(a, b); case 'longness': return longnessCompare(a, b); case 'position': return positionCompare(a, b); case 'default': default: return primaryOrder; } }; switch (primarySorting) { case 'category': sentences.sort((a, b) => sort(categoryCompare(a, b), a, b)); break; case 'longness': sentences.sort((a, b) => sort(longnessCompare(a, b), a, b)); break; case 'shortness': sentences.sort((a, b) => sort(shortnessCompare(a, b), a, b)); break; case 'source': sentences.sort((a, b) => sort(sourceCompare(a, b), a, b)); break; case 'position': sentences.sort((a, b) => sort(positionCompare(a, b), a, b)); break; case 'default': default: break; } } function getItemInfo(item) { const {allContent, keyTitleMap, titleKeyMap} = state.content; let key = null, title = null, category = null; switch (state.settings.general.advanced.immersionKitAPIVersion) { case '1': if (titleKeyMap.has(item.deck_name)) { key = titleKeyMap.get(item.deck_name); title = item.title = item.deck_name; category = item.category; } else if (titleKeyMap.has(item.deck_name_japanese)) { key = titleKeyMap.get(item.deck_name_japanese); title = item.title = item.deck_name_japanese; category = item.category; } else if (keyTitleMap.has(item.deck_name)) { key = item.deck_name; title = item.title = keyTitleMap.get(item.deck_name); category = item.category; } else if (keyTitleMap.has(item.deck_name_japanese)) { key = item.deck_name_japanese; title = item.title = keyTitleMap.get(item.deck_name_japanese); category = item.category; } else if (allContent.has(item.deck_name)) { key = item.deck_name; const matchingItem = allContent.get(item.deck_name); title = item.title = matchingItem.title; category = matchingItem.category; } else if (allContent.has(item.deck_name_japanese)) { key = item.deck_name_japanese; const matchingItem = allContent.get(item.deck_name_japanese); title = item.title = matchingItem.title; category = matchingItem.category; } else { console.warn('No matching title found for item:', item, allContent, keyTitleMap.keys()); } break; case '2': if (keyTitleMap.has(item.title)) { key = item.title; title = keyTitleMap.get(item.title); category = item.category; } else if (titleKeyMap.has(item.title)) { key = titleKeyMap.get(item.title); title = item.title; category = item.category; } else if (allContent.has(item.title)) { key = item.title; const matchingItem = allContent.get(item.title); title = matchingItem.title; category = matchingItem.category; } else { console.warn('No matching title found for item:', item, allContent, keyTitleMap.keys()); } break; } return {key, title, category}; } function setKeywordRegexForItem(item, itemKeyword) { const keywordSet = new Set(); // use a set to prevent duplicates from being added. switch (state.settings.general.advanced.immersionKitAPIVersion) { case '1': for (let i = 0; i < item.word_index.length; i++) keywordSet.add(item.word_list[item.word_index[i]]); break; case '2': for (let i = 0; i < item.matched_indexes.length; i++) keywordSet.add(item.word_list[item.matched_indexes[i].index]); break; } const sentenceKeywords = Array.from(keywordSet); // Default to the keyword from the item if word_list is empty, // and intersperse whitespace quantifier to match awkwardly spaced out sentences. // Otherwise, use the keywords from the sentence data if they exist properly // and use alternation when using the example's word_list (which will end up creating tags around each "word"). const regexExpression = (sentenceKeywords.length === 0 ? itemKeyword.split('').join('\\s*') : sentenceKeywords.join('|')); item.furiganaObject.setKeyword(regexExpression); } // Called from Immersion Kit response and on settings save. // This function starts by calling fetchImmersionKitData() to get the data. async function renderSentences() { const data = await fetchImmersionKitData(); const {content, elements, immersionKit, settings, wkItemInfo} = state; Object.values(elements.audio).forEach(el => {el.audio.remove(); el = null;}); elements.audio = {}; if (data == null) return (elements.sentences.textContent = 'Error fetching examples from Immersion Kit.'); const exampleCount = data.examples?.length ?? 0; if (exampleCount === 0) return (elements.sentences.textContent = `${settings.retryCount > 0 ? 'Retry limit reached. ' : ''}No sentences found.`); elements.sentences.textContent = 'Loading...'; const debugList = new Map(); const errorList = []; const sentencesToDisplay = []; // Exclude non-selected titles for (let i = 0; i < exampleCount; i++) { const example = data.examples[i]; const {title} = getItemInfo(example); if (!title || !content.allContent.has(title)) { const error = !title ? 'No title' : `No matching title found for "${title}"`; errorList.push({error, index: `${i + 1} of ${exampleCount}:`, example}); continue; } const matchingEntry = content.allContent.get(title); const enabled = matchingEntry?.enabled ?? false; if (!enabled) { if (settings.debugging) debugList.set(title, (debugList.get(title) ?? 0) + 1); continue; } example.category = matchingEntry.category; const baseUrl = `${immersionKit.baseContentUrl}${matchingEntry.category}/${matchingEntry.title}/media/`; // Normalize or create image and sound URLs example.image_url = example.image_url || `${baseUrl}${example.image}`; example.sound_url = example.sound_url || `${baseUrl}${example.sound}`; // Strip directional formatting and other non-displaying characters from sentences (...how they got there in the first place, who knows...) const directionalFormattingCharsRegex = /[\u202A-\u202E\u2066-\u2069\uE4C6]/g; example.sentence = example.sentence.replace(directionalFormattingCharsRegex,''); example.sentence_with_furigana = example.sentence_with_furigana.replace(directionalFormattingCharsRegex,''); example.furiganaObject = new Furigana(example.sentence, example.sentence_with_furigana); const itemKeyword = wkItemInfo.item.characters.replace('〜', ''); if (settings.filters.exactMatch && !example.sentence.includes(itemKeyword)) { if (settings.debugging) debugList.set(title, (debugList.get(title) ?? 0) + 1); // if (settings.debugging) console.log(`Excluded: Title="${title}"; ExactMatch=true`); continue; } setKeywordRegexForItem(example, itemKeyword); sentencesToDisplay.push(example); } if (errorList.length > 0) console.warn(`Error(s) found while rendering sentences:`, errorList); if (settings.debugging && debugList.size > 0) console.log('Currently hidden titles:', debugList); if (sentencesToDisplay.length === 0 || settings.failWhenHidden && debugList.size > 0) { const deckCountsAsJson = JSON.stringify(data.deck_count, undefined, '\t'); const preElement = Object.assign(document.createElement('pre'), { innerHTML: `${sentencesToDisplay.length>0 ? sentencesToDisplay.length : 'No'} sentences found for the selected filters (${exampleCount-sentencesToDisplay.length} are available but hidden; see below for details and entry counts).`, }); if (settings.failWhenHidden) preElement.innerHTML += `<br><br>Currently hidden titles: {<br>\t${Array.from(debugList).map(([title, count]) => `"${title}": ${count}`).join('<br>\t')}<br>}<br>`; preElement.innerHTML += `<br>Complete deck count data: ${deckCountsAsJson}`; elements.sentences.replaceChildren(preElement); return; } const fragment = document.createDocumentFragment(); sortSentences(sentencesToDisplay, settings.sorting.primary, settings.sorting[script.secondarySortingKeyName]); for (let i = 0; i < sentencesToDisplay.length; i++) { const example = sentencesToDisplay[i]; const exampleElement = await createExampleElement(example); fragment.appendChild(exampleElement); } elements.sentences.replaceChildren(fragment); state.pending.renderSentences = false; } async function createExampleElement(example) { const {title} = getItemInfo(example); const parentEl = Object.assign(document.createElement('div'), {className: 'example'}), imgEl = Object.assign(document.createElement('img'), { src: example.image_url ?? '', decoding: 'auto', alt: '', }), textParentEl = Object.assign(document.createElement('div'), {className: 'example-text'}), textTitleEl = Object.assign(document.createElement('div'), { className: 'title', title: example.id, // TODO: Consider removing/moving elsewhere textContent: state.content.allContent.get(title).title, }), audioButtonEl = Object.assign(document.createElement('button'), { id: `audio-button-${example.id}`, type: 'button', className: `${state.classNames.audioButton} ${state.classNames.audioIdle}`, title: 'Play Audio', textContent: '🔈', }), jaEl = Object.assign(document.createElement('div'), {className: 'ja'}), jaFuriganaSpanEl = Object.assign(document.createElement('span'), { className: 'furigana', innerHTML: example.furiganaObject.getFuriganaHtml(), }), enEl = Object.assign(document.createElement('div'), {className: 'en'}), enSpanEl = Object.assign(document.createElement('span'), {textContent: example.translation}), // TODO: Perhaps this should be moved to a separate function or the for loop removed elements = [ {element: jaFuriganaSpanEl, classListUpdates: [{name: 'showJapanese', value: state.settings.general.appearance.showJapanese}, {name: 'showFurigana', value: state.settings.general.appearance.showFurigana}], clickListener: {name: 'showFurigana', value: state.settings.general.appearance.showFurigana}}, {element: enSpanEl, classListUpdates: [{name: 'showEnglish', value: state.settings.general.appearance.showEnglish}], clickListener: {name: 'showEnglish', value: state.settings.general.appearance.showEnglish}}, ], promises = []; for (const {element, classListUpdates, clickListener} of elements) { for (const {name, value} of classListUpdates) promises.push(updateClassListForSpanElement(element, name, value)); const {name, value} = clickListener; promises.push(updateOnClickListenerForSpanElement(element, name, value)); } await Promise.all(promises); attachAudioOnClickListener(parentEl); configureAudioElement(audioButtonEl, example); parentEl.append(imgEl); textTitleEl.append(audioButtonEl); textParentEl.append(textTitleEl); jaEl.append(jaFuriganaSpanEl); enEl.append(enSpanEl); textParentEl.append(jaEl); textParentEl.append(enEl); parentEl.append(textParentEl); return parentEl; } // ---------------------------------------------------------------------------------------------------------------- // // ----------------------------------------------------HELPERS----------------------------------------------------- // // ---------------------------------------------------------------------------------------------------------------- // function lazyProperty(obj, prop, value, {enumerable=true, writable=false, configurable=false}={}) { value = typeof value === 'function' ? value.call(obj) : value; return Object.defineProperty(obj, prop, {enumerable, writable, configurable, value})[prop]; } function sortObjectPropertiesInPlace(obj) { if (typeof obj !== 'object') return; Object.keys(obj).sort().forEach(function(key) { const value = obj[key]; delete obj[key]; obj[key] = value; }); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function normalize(str){return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replaceAll('×','x').replace(/[^a-z0-9]/gi,'_');} // .replace(/[\s,“”"`'.?!;:()[\]{}\-−/+*=&]/g, '_') // Adapted from WKOF Core.js // If `currentVersion` is newer than otherVersion, return 1; if `currentVersion` is older than otherVersion, return -1; otherwise, return 0 function compareVersions(currentVersion, otherVersion) { const currentVer = currentVersion?.split('.').map(d => Number(d)) || []; const otherVer = otherVersion?.split('.').map(d => Number(d)) || []; const len = Math.max(currentVer.length, otherVer.length); for (let idx = 0; idx < len; idx++) { const v1 = currentVer[idx] || 0; const v2 = otherVer[idx] || 0; if (v1 !== v2) return v1 - v2; } return 0; } function arrayValuesEqual(a, b) { if (a === b) return true; if (a == null || b == null) return false; let aValues = Object.values(a), bValues = Object.values(b); if (aValues.length !== bValues.length) return false; for (let i = 0; i < aValues.length; ++i) { if (aValues[i] !== bValues[i]) return false; } return true; } // ----------------------------------------------ELEMENT MANIPULATION---------------------------------------------- // function configureAudioElement(element, example) { element.addEventListener('click', async function(e) { e.stopPropagation(); // prevent this click from triggering twice in some scenarios const {elements: {audio: audioEls, base}, handlers, settings} = state; const {audio} = audioEls[element.id] = (audioEls[element.id] || { button: element, audio: Object.assign(document.createElement('audio'), { src: example.sound_url, playbackRate: settings.general.playback.playbackRate * 2 / 100, volume: settings.general.playback.playbackVolume / 100, onplay: handlers.onPlay(element), onpause: handlers.onStop(element), onended: handlers.onStop(element), onabort: handlers.onStop(element), }), }); if (!audio.paused) { audio.pause(); return; } base.append(audio); await audio.play()?.catch(() => {}); }, {passive: true}); } async function updateClassListForSpanElement(element, name, value) { switch (name) { case 'showEnglish': case 'showJapanese': element.classList.toggle('show-on-click', value === 'onclick'); element.classList.toggle('show-on-hover', value === 'onhover'); break; case 'showFurigana': if (element.classList.contains('base')) { element.classList.toggle('hidden', value !== 'never'); } else if (element.classList.contains('furigana')) { element.classList.toggle('show-ruby-on-hover', value === 'onhover'); element.classList.toggle('hide-ruby', value === 'never'); } break; } } // ----------------------------------------------------ON CLICK---------------------------------------------------- // async function updateOnClickListenerForSpanElement(element, name, value) { switch (value) { case 'always': case 'onhover': case 'never': if (name !== 'showFurigana') removeOnClickEventListener(element); break; case 'onclick': attachShowOnClickEventListener(element); break; default: return; } } function onAudioElementClick(event) { if (event.target.classList.contains('show-on-click')) return; const button = event.currentTarget.getElementsByClassName(state.classNames.audioButton)[0]; button?.click(); } function attachAudioOnClickListener(element) { // Click anywhere plays the audio element.addEventListener('click', onAudioElementClick, {passive: true}); } function onShowOnClick(event) { event.stopPropagation(); // prevent this click from triggering the audio to play event.target.classList.toggle('show-on-click'); } function attachShowOnClickEventListener(element) { // Assign an onclick function to toggle the .show-on-click class element.addEventListener('click', onShowOnClick, {passive: true}); } function removeOnClickEventListener(element) { element.removeEventListener('click', onShowOnClick, {passive: true}); } // ---------------------------------------------------------------------------------------------------------------- // // ----------------------------------------------------SETTINGS---------------------------------------------------- // // ---------------------------------------------------------------------------------------------------------------- // /** Installs the `options` button in the menu */ function installMenu() { const config = { name: script.id, submenu: 'Settings', title: script.name, on_click: openSettings, }; wkof.Menu.insert_script_link(config); } async function loadSettings() { try { return mergeSettings(await wkof.Settings.load(script.id, state.settings)); } catch(e) { throw Error('Error loading settings from WaniKani Open Framework', {cause: e}); } } // Merges the given settings into the state.settings object, using a deep copy and Object.assign() to avoid updating the state.settings object byref. // Returns the updated state.settings object function mergeSettings(settings) { return Object.assign(state.settings, window.structuredClone(settings)); } // Deletes settings when the current version is older than the last major update async function migrateSettingsVersion() { const majorUpdateVersion = '4.0.0'; if (compareVersions(state.settings.version, majorUpdateVersion) >= 0) return; if (state.settings.general.advanced.debugging) console.log(`Migrating settings from ${state.settings.version} to ${majorUpdateVersion}`); const settingsKey = `wkof.settings.${script.id}`; if (settingsKey in wkof.file_cache.dir) await wkof.file_cache.delete(settingsKey); await deleteCachedImmersionKitData(); await loadSettings(); wkof.settings[script.id].version = script.version; try { await wkof.Settings.save(script.id); } catch (e) { throw Error('Error migrating old settings from WaniKani Open Framework', {cause: e}); } } // Called whenever the settings dialog is closed (e.g., Save/Cancel) async function onSettingsClosed(settings) { // Revert any modifications that were unsaved or finalize any that were. const {appearance, playback, dataFetching, advanced} = settings.general; await Promise.all([ onAppearanceOptionChanged('showOnKanji', appearance.showOnKanji), onAppearanceOptionChanged('exampleLimit', appearance.exampleLimit), onAppearanceOptionChanged('maxBoxHeight', appearance.maxBoxHeight), onAppearanceOptionChanged('showJapanese', appearance.showJapanese), onAppearanceOptionChanged('showFurigana', appearance.showFurigana), onAppearanceOptionChanged('showEnglish', appearance.showEnglish), onPlaybackOptionChanged('playbackRate', playback.playbackRate), onPlaybackOptionChanged('playbackVolume', playback.playbackVolume), onPlaybackOptionChanged('restartAudioOnPause', playback.restartAudioOnPause), onFetchOptionChanged('retryCount', dataFetching.retryCount), onFetchOptionChanged('retryDelay', dataFetching.retryDelay), onFetchOptionChanged('checkLastModified', dataFetching.checkLastModified), onAdvancedOptionChanged('debugging', advanced.debugging), onAdvancedOptionChanged('failWhenHidden', advanced.failWhenHidden), onAdvancedOptionChanged('immersionKitAPIVersion', advanced.immersionKitAPIVersion), ]); } // Called when the user clicks the Save button on the Settings dialog. async function onSettingsSaved(updatedSettings) { const {pending} = state; const { filters: { exactMatch, exactSearch, jlptLevel, waniKaniLevel, // lists: {anime: filterAnime, drama: filterDrama, games: filterGames, literature: filterLiterature, news: filterNews}, }, general: { /* appearance: { exampleLimit, // changes handled in onSettingsClosed // highlighting, // Not implemented // maxBoxHeight, // changes handled in onSettingsClosed // showEnglish, // changes handled in onSettingsClosed // showFurigana, // changes handled in onSettingsClosed // showJapanese, // changes handled in onSettingsClosed // showOnKanji, // changes handled in onSettingsClosed },*/ /* playback: { restartAudioOnPause, // changes handled in onSettingsClosed playbackRate, // changes handled in onSettingsClosed playbackVolume, // changes handled in onSettingsClosed },*/ /* dataFetching: { retryCount, // changes handled in onSettingsClosed retryDelay, // changes handled in onSettingsClosed },*/ advanced: { // debugging, // changes handled in onSettingsClosed // failWhenHidden, // changes handled in onSettingsClosed immersionKitAPIVersion }, }, sorting: { primary: primarySorting, [script.secondarySortingKeyName]: secondarySorting, }, } = state.settings; const { filters: { exactMatch: exactMatchNew, exactSearch: exactSearchNew, jlptLevel: jlptLevelNew, waniKaniLevel: waniKaniLevelNew, // lists: {anime: filterAnimeNew, drama: filterDramaNew, games: filterGamesNew, literature: filterLiteratureNew, news: filterNewsNew} }, general: { /* appearance: { // exampleLimit: exampleLimitNew, // highlighting: highlightingNew },*/ advanced: {immersionKitAPIVersion: immersionKitAPIVersionNew}}, sorting: { primary: primarySortingNew, [script.secondarySortingKeyName]: secondarySortingNew, }, } = updatedSettings; for (const listKey of Object.keys(state.settings.filters.lists)) { if (!arrayValuesEqual(state.settings.filters.lists[listKey], updatedSettings.filters.lists[listKey])) { pending.updateDesiredShows = pending.renderSentences = true; break; } } mergeSettings(updatedSettings); // if (showOnKanji !== showOnKanjiNew) setWaniKaniItemInfoListener(showOnKanjiNew); if (exactSearch !== exactSearchNew || jlptLevel !== jlptLevelNew || waniKaniLevel !== waniKaniLevelNew) { // Immersion Kit search options changed pending.updateSearchUrl = pending.renderSentences = true; } if (immersionKitAPIVersion !== immersionKitAPIVersionNew) { pending.updateDesiredShows = pending.renderSentences = true; } else if (exactMatch !== exactMatchNew || primarySorting !== primarySortingNew || secondarySorting !== secondarySortingNew) { pending.renderSentences = true; } if (pending.updateIndexUrl) await fetchImmersionKitDecksIndex(); if (pending.updateSearchUrl) state.immersionKit.currentSearchUrl = getNewImmersionKitUrl(state.wkItemInfo.item.characters, updatedSettings); if (pending.updateDesiredShows) await updateDesiredShows(); if (pending.renderSentences) { await renderSentences(); } } function onPrimarySortOptionChanged(name, value) { // TODO: This method is a somewhat cursed way of handling this and should be replaced by a natively available method via WKOF if/when I can figure one out. const options = document.getElementById(`${script.id}_${script.secondarySortingKeyName}`)?.options; if (options === null) return; const keysToHide = state.contentOptions.secondarySortingMethodsToHide(value); for (let i = 0; i < options.length; i++) { const option = options[i], optionName = option.getAttribute('name'); const shouldHide = keysToHide.includes(optionName); option.classList.toggle('hidden', shouldHide); if (options.selectedIndex === i && shouldHide) options.selectedIndex = 0; } } function openSettings(e) { e.stopPropagation(); (new wkof.Settings(state.settingsDialog)).open(); } async function onAppearanceOptionChanged(name, value) { if (value === state.settings.general.appearance[name]) return; state.settings.general.appearance[name] = value; let selector; switch (name) { case 'exampleLimit': { // Adjust the example limit with CSS to avoid recreating the list const replacement = Number(value) === 0 ? '$1' : `$1(n+${value + 1})`; state.elements.styleSheet.innerHTML = state.elements.styleSheet.innerHTML.replace(script.regex.exampleLimitSearch, replacement); state.pending.updateSearchUrl = state.pending.renderSentences = true; return; } case 'maxBoxHeight': { if (!Number.isNaN(Number(value))) { value += 'px'; state.settings.general.appearance[name] = wkof.settings[script.id].general.appearance.maxBoxHeight = value; } const replacement = `$1 ${value};`; state.elements.styleSheet.innerHTML = state.elements.styleSheet.innerHTML.replace(script.regex.maxHeightSearch, replacement); return; } case 'showOnKanji': setWaniKaniItemInfoListener(); return; case 'showEnglish': selector = '.example-text .en > span'; break; case 'showFurigana': case 'showJapanese': selector = '.example-text .ja > span'; break; default: return; } // On reached for the text displaying options const exampleEls = state.elements.sentences.querySelectorAll(selector); const promises = []; for (let i = 0; i < exampleEls.length; i++) { const el = exampleEls[i]; promises.push(updateClassListForSpanElement(el, name, value)); promises.push(updateOnClickListenerForSpanElement(el, name, value)); } await Promise.all(promises); } async function onAdvancedOptionChanged(name, value) { if (value === state.settings.general.advanced[name]) return; state.settings.general.advanced[name] = value; switch (name) { case 'debugging': break; case 'failWhenHidden': await renderSentences(); break; case 'immersionKitAPIVersion': await onImmersionKitAPIVersionOptionChanged(value); break; } } // Called by any setting listener to verify the setting is different from the existing value async function onPlaybackOptionChanged(name, value) { if (value === state.settings.general.playback[name]) return; state.settings.general.playback[name] = value; const audioContainer = state.elements.base?.querySelector('audio'); if (audioContainer === null) return; switch (name) { case 'playbackRate': audioContainer.playbackRate = value * 2 / 100; break; case 'playbackVolume': audioContainer.volume = value / 100; break; case 'restartAudioOnPause': break; } } async function onFetchOptionChanged(name, value) { if (value === state.settings.general.dataFetching[name]) return; let prevRetryCount = state.settings.general.dataFetching.retryCount; state.settings.general.dataFetching[name] = value; switch (name) { case 'retryCount': // TODO: Possibly make this not affect the fetch count when the dialog was canceled instead of saved if (state.elements.sentences.childElementCount === 0 && value > prevRetryCount && value >= (state.immersionKit.fetchCount[state.immersionKit.currentSearchUrl] ?? 0)) await renderSentences(); break; case 'retryDelay': case 'checkLastModified': break; } } // Called when the flush cached data button is clicked async function onFlushCachedDataClicked(name, value, on_change) { await deleteCachedImmersionKitData(); if (state.settings.general.advanced.debugging) console.log('Immersion Kit data has been deleted.'); state.pending.updateIndexUrl = state.pending.updateSearchUrl = state.pending.updateDesiredShows = state.pending.renderSentences = true; await on_change(); } async function onImmersionKitAPIVersionOptionChanged(value) { let title; // Hardcoded the values that need to be swapped based on the API as of version 4.0.0 of this script switch (value) { case '1': for (const title of ['Good Morning Call Season 1', 'Good Morning Call Season 2']) state.content.drama[title] = title; title = 'Good Morning Call'; if (title in state.content.drama) delete state.content.drama[title]; sortObjectPropertiesInPlace(state.settings.filters.lists.drama); sortObjectPropertiesInPlace(state.content.drama); break; case '2': for (const title of ['Good Morning Call Season 1', 'Good Morning Call Season 2']) { if (!(title in state.content.drama)) continue; delete state.content.drama[title]; } title = 'Good Morning Call'; state.content.drama[title] = title; sortObjectPropertiesInPlace(state.content.drama); break; default: return; } updateKeyMapForTitles(); state.pending.updateSearchUrl = state.pending.updateDesiredShows = state.pending.renderSentences = true; } async function updateDesiredShows() { // Combine settings objects to a single set containing the desired titles const errors = []; for (const [category, values] of Object.entries(state.settings.filters.lists)) { for (const [title, value] of Object.entries(values)) { // Always use the full title as the key let entry = {category, enabled: value, tags: [], title}; if (state.content.allContent.has(title)) { // Push the settings value to the corresponding entry in the content object entry = state.content.allContent.get(title); entry.enabled = value; // Update the selection set for the settings dialog if (!state.content.selections[entry.category]) state.content.selections[entry.category] = {}; state.content.selections[entry.category][title] = value; } else if (typeof value === 'boolean') { errors.push(Object.assign({error: `"title" from "settings.filters.lists.${category}" not found in "allContent"`}, entry)); } else { state.content.allContent.set(title, entry); errors.push(Object.assign({error: 'Unresolved Error. Added "title" to "allContent"'}, entry)); } } } if (errors.length > 0) console.debug(`Error(s) found during updateDesiredShows:`, errors); state.pending.updateDesiredShows = false; } function validateMaxHeight(value) { return value === undefined || value === null || value === '' || script.regex.validCssUnit.test(value) || 'Number and (optional) valid unit type only'; } function validatePlaybackRate(value) { return {valid: value >= 5, msg: `${value * 2}%`}; } function validatePlaybackVolume(value) { return {valid: true, msg: `${value}%`}; } // ---------------------------------------------------------------------------------------------------------------- // // -----------------------------------------------------STYLES----------------------------------------------------- // // ---------------------------------------------------------------------------------------------------------------- // async function addStyle() { if (document.getElementById(script.styleSheetName)) return; const {classNames:{audioIdle, audioButton}, elements, settings:{general:{appearance:{maxBoxHeight, exampleLimit}}}} = state; elements.styleSheet = Object.assign(document.createElement('style'), { id: script.styleSheetName, type: 'text/css', // language=CSS textContent: ` #${script.id}_dialog button { background-color: #555555; border: 2px solid #555555; color: white; padding: 0 10px; text-align: center; text-decoration: none; display: inline-block; margin: 0 2px; transition-duration: 0.1s; cursor: pointer; } #${script.id}_dialog button:hover { background-color: gray; } #${script.id}_dialog button:active { background-color: gray; transform: translate(0, 3px); } #${script.id} { max-height: ${maxBoxHeight}; overflow-y: auto; } #${script.id} .example:nth-child${exampleLimit===0?'':`(n+${exampleLimit+1})`} { display: none; } .${script.id}-settings-btn { font-size: 14px; cursor: pointer; vertical-align: middle; margin-left: 10px; } #${script.id}-container { border: none; font-size: 100%; } #${script.id} pre { white-space: pre-wrap; white-space: -moz-pre-wrap; white-space: -pre-wrap; white-space: -o-pre-wrap; word-wrap: break-word; } #${script.id} .example { display: flex; align-items: center; margin-bottom: 1em; cursor: pointer; } #${script.id} .example > * { flex-grow: 1; flex-shrink: 1; flex-basis: min-content; } #${script.id} .example img { padding-right: 1em; max-width: 200px; } #${script.id} .example .${audioButton} { background-color: transparent; margin-left: 0.25em; } #${script.id} .example .${audioButton}.${audioIdle} { opacity: 50%; } #${script.id} .example-text { display: table; white-space: normal; } #${script.id} .example-text .title { font-weight: var(--font-weight-bold); } #${script.id} .example-text .ja { font-size: var(--font-size-xlarge); } /* Set the default and on-hover appearance */ #${script.id} .show-on-hover:hover, #${script.id} .show-ruby-on-hover:hover ruby rt { background-color: inherit; color: inherit; visibility: visible; } /* Set the color/appearance of the marked keyword */ #${script.id} mark, #${script.id} .show-on-hover:hover mark { background-color: inherit; color: darkcyan; } /* Set the appearance for show-on-hover and show-on-click elements when trigger state is inactive */ #${script.id} .show-on-hover, #${script.id} .show-on-hover mark, #${script.id} .show-on-click, #${script.id} .show-on-click mark { background-color: #ccc; color: transparent; text-shadow: none; } /* Set the appearance for hidden and show-ruby-on-hover elements when trigger state is inactive */ #${script.id} .show-ruby-on-hover ruby rt { visibility: hidden; } #${script.id} .hide, #${script.id} .hide-ruby ruby rt { display: none; } `.replaceAll(/(\n|^ {2,})/mg, ''), }); document.getElementsByTagName('head')[0].append(elements.styleSheet); } // ---------------------------------------------------------------------------------------------------------------- // // ----------------------------------------------------FURIGANA---------------------------------------------------- // // ---------------------------------------------------------------------------------------------------------------- // function Furigana(expression, expressionWithFurigana) { this.expression = expression; this.expressionWithFurigana = expressionWithFurigana; this.keyword = this.keywordRegex = this.firstKeywordIndex = null; this.keywordTag = 'mark'; this.furiganaSegments = this.parseFurigana(expressionWithFurigana); } Furigana.prototype.setKeyword = function(keyword) { this.keyword = keyword ?? null; this.keywordRegex = keyword !== null ? new RegExp(keyword, 'g') : null; this.firstKeywordIndex = null; // Reset keyword index }; Furigana.prototype.getExpressionHtml = function() { return this.keywordRegex === null ? this.expression : this.expression.replaceAll(this.keywordRegex, `<${this.keywordTag}>$&</${this.keywordTag}>`); }; Furigana.prototype.getFuriganaHtml = function() { const normalizedSegments = this.normalizeSegments(this.furiganaSegments, this.expression); let html = ''; for (let i = 0; i < normalizedSegments.length; i++) { const {base, furigana} = normalizedSegments[i]; html += furigana !== null ? `<ruby>${base}<rp>[</rp><rt>${furigana}</rt><rp>]</rp></ruby>` : base; } return html; }; Furigana.prototype.getFirstKeywordIndex = function() { if (this.keyword === null) return -1; if (this.firstKeywordIndex !== null) return this.firstKeywordIndex; return (this.firstKeywordIndex = this.expression.search(this.keywordRegex)); }; Furigana.prototype.parseFurigana = function(expressionWithFurigana) { const segments = [], regex = /([\u3001-\u303F\u3041-\u3096\u30A0-\u30FF\u3400-\u4DB5\u4E00-\u9FCB\uF900-\uFA6A\uFF01-\uFF5E\uFF5F-\uFF9F]+|[^[\]<> \u4E00-\u9FCB]+)(?:\[([^[\]]+)])?/g; let match; while ((match = regex.exec(expressionWithFurigana)) !== null) { segments.push({base: match[1], furigana: (match[2] ?? null)}); } return segments; }; Furigana.prototype.normalizeSegments = function(segments, expression) { const normalizedSegments = [], keywordRegex = this.keywordRegex, keywordTag = this.keywordTag; let nextIndex = 0, markStart = 0, markRemaining = 0; const keywordMatches = keywordRegex !== null ? Array.from(expression.matchAll(keywordRegex)) : []; for (let i = 0; i < segments.length; i++) { const curIndex = nextIndex; if (i === segments.length - 1) { nextIndex = expression.length; } else { nextIndex = expression.indexOf(segments[i + 1].base[0], nextIndex); if (nextIndex === -1) nextIndex = curIndex + segments[i].base.length; } let matchingSection = expression.substring(curIndex, nextIndex); let offset = 0; for (let j = 0; j < keywordMatches.length; j++){ const match = keywordMatches[j]; if (match.index === undefined) continue; const [start, end] = [match.index, match.index + match[0].length]; if (this.firstKeywordIndex === null && start >= 0) this.firstKeywordIndex = start; if (start >= curIndex && start < nextIndex) { markStart = offset + start - curIndex; markRemaining = offset + end - curIndex - markStart; } if (markRemaining > 0) { const segmentLength = matchingSection.length; const markEnd = Math.min(markStart + markRemaining, segmentLength); const precedingSection = matchingSection.substring(0, markStart); const markedSegment = matchingSection.substring(markStart, markEnd); const remainingSegment = matchingSection.substring(markEnd); matchingSection = `${precedingSection}<${keywordTag}>${markedSegment}</${keywordTag}>${remainingSegment}`; markStart = 0; markRemaining -= Math.min(markRemaining, markedSegment.length); if (remainingSegment.length === 0) break; offset += (keywordTag.length * 2) + 5; // 5 = '<></>'.length } } normalizedSegments.push({ base: matchingSection, furigana: segments[i].furigana }); } return normalizedSegments; }; })();