This is a very nice script! I have a patch I was wondering whether you might consider. I have also pasted the full code below in case you prefer that format.
What it does:
- Preloads the info for the items in the activeQueue so there should usually be no delay in the indicator showing, even for items that have never been displayed before. (You’ll still see the very first item load though because the preloading hasn’t finished at that point.)
- Makes console logging controllable by a variable and defaults it to off.
- Automatic deletion of expired cache items.
- Improved handling of corner cases involving making requests for the same item while an existing request for that item is still pending.
- fixed an issue where a method called resolve when something is null but did not return, causing a null-related exception.
- added a couple extra null checks to the jStorage event dispatching code
@@ -6,10 +6,12 @@
// @run-at document-end
// @include https://www.wanikani.com/review/session
// @include https://www.wanikani.com/lesson/session
-// @version 0.0.5
+// @version 0.0.6
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
+// @grant GM_deleteValue
+// @grant GM_listValues
// @connect jisho.org
// @homepage https://github.com/kevinta893/wanikani-common-vocab-indicator
// @homepageURL https://github.com/kevinta893/wanikani-common-vocab-indicator
@@ -27,12 +29,19 @@
var commonIndicatorUi = new CommonIndicatorUi();
var commonIndicatorController = new CommonIndicatorController(commonIndicatorUi, isCommonRepository, isCommonCache);
- console.log('WK Common Vocab Indicator started');
+ log('WK Common Vocab Indicator started');
// To clear the cache in Tampermonkey,
// use Developer > Factory Reset in the script editor
}
+var verbose = false;
+function log() {
+ if (verbose) {
+ console.log(...arguments);
+ }
+}
+
//====================================================
//UI
@@ -115,18 +124,41 @@
/**
* Binds handler to event when the wanikani app changes item
* @param {function} handler Calls back the handler, with the current
- * WaniKani item displayed
+ * WaniKani item displayed. The call is a microtask.
*/
bindItemChangedEvent(handler) {
var itemChangedHandler = function (key) {
var wanikaniItem = $.jStorage.get(key);
- handler(wanikaniItem);
+ if (wanikaniItem) {
+ queueMicrotask(() => handler(wanikaniItem));
+ }
};
$.jStorage.listenKeyChange('currentItem', itemChangedHandler);
$.jStorage.listenKeyChange('l/currentLesson', itemChangedHandler);
}
+ /**
+ * Binds handler to event when the wanikani app changes the active quiz queue items
+ * @param {function} handler. Calls back the handler multiple times, once for each
+ * new item that has entered the queue. Each call is a microtask.
+ */
+ bindNewActiveEvent(handler) {
+ let handled = new WeakSet();
+ var activeChangedHandler = function (key) {
+ var activeQueue = $.jStorage.get(key);
+ for (const wanikaniItem of activeQueue) {
+ if (wanikaniItem && !handled.has(wanikaniItem)) {
+ handled.add(wanikaniItem);
+ queueMicrotask(() => handler(wanikaniItem));
+ }
+ }
+ };
+
+ $.jStorage.listenKeyChange('activeQueue', activeChangedHandler);
+ $.jStorage.listenKeyChange('l/activeQueue', activeChangedHandler);
+ }
+
hideIndicator() {
this.setClassAndText(this.indicatorClasses.hide);
}
@@ -173,35 +205,55 @@
this.isCommonRepository = isCommonRepository;
this.isCommonCache = isCommonCache;
+ // events execute in the same order they are bound
+ this.commonIndicatorView.bindNewActiveEvent((key) => {
+ this.newActiveEvent(key);
+ });
this.commonIndicatorView.bindItemChangedEvent((key) => {
this.itemChangedEvent(key);
});
+
+
}
- itemChangedEvent(currentItem) {
- // Item not vocab, hide indicator
- if (!currentItem.hasOwnProperty('voc')) {
+ newActiveEvent(newItem) {
+ // Item not vocab, nothing to do
+ if (!newItem.hasOwnProperty('voc')) {
this.commonIndicatorView.hideIndicator();
return;
}
// Is vocab, lookup is common
- var vocab = currentItem.voc;
+ var vocab = newItem.voc;
- // Check the cache if we already have data
- var cacheValue = this.isCommonCache.get(vocab);
- if (cacheValue != null) {
- this.commonIndicatorView.setCommonIndicator(cacheValue);
+ // cache if not already cached/pending
+ if (!this.isCommonCache.has(vocab)) {
+ var promise = this.isCommonRepository.getIsCommon(vocab);
+ this.isCommonCache.putPromise(vocab, promise);
+ }
+ }
+
+ async itemChangedEvent(currentItem) {
+ // Item not vocab, hide indicator
+ if (!currentItem.hasOwnProperty('voc')) {
+ this.commonIndicatorView.hideIndicator();
return;
}
- // No data, lookup in repository
+ // Is vocab, lookup is common
+ var vocab = currentItem.voc;
+
+ // show fetching indicator until fetch completes
+ // if in cache, indicator will be removed
+ // later before next frame is rendered so it won't
+ // display at all
this.commonIndicatorView.setFetchingIndicator();
- this.isCommonRepository.getIsCommon(vocab).then((isCommon) => {
- this.isCommonCache.put(vocab, isCommon);
- this.commonIndicatorView.setCommonIndicator(isCommon);
- });
+ await this.newActiveEvent(currentItem); // make sure item is in cache if tab
+ // was open for more than 28 days
+ var cacheValue = await this.isCommonCache.get(vocab); // Get data from cache
+
+ this.commonIndicatorView.setCommonIndicator(cacheValue);
}
}
@@ -221,7 +273,7 @@
//Unknown, assign default
if (isCommon == null) {
var defaultCommon = false;
- console.log('Vocab not found, defaulting to is_common=false for: ' + requestedVocab);
+ log('Vocab not found, defaulting to is_common=false for: ' + requestedVocab);
return defaultCommon;
}
@@ -264,6 +316,7 @@
response.response.data[0] == null;
if (hasNoData) {
resolve(null);
+ return; // resolve does not stop function execution
}
var isCommon = response.response.data[0].is_common;
@@ -292,6 +345,8 @@
constructor(namespaceKey, cacheTtlMillis) {
this.namespaceKey = namespaceKey == null ? "" : namespaceKey;
this.cacheTtlMillis = cacheTtlMillis;
+ this.pending = new Map(); // map of pending promises waiting to be cached when they resolve
+ this.clearExpired();
}
put(key, val) {
@@ -300,24 +355,60 @@
GM_setValue(storageKey, { val: val, exp: this.cacheTtlMillis, time: new Date().getTime() })
}
- get(key) {
+ putPromise(key, promiseForVal) {
+ this.pending.set(key, promiseForVal);
+ promiseForVal.then(function(val) {
+ this.put(key, val);
+ this.pending.delete(key);
+ }.bind(this));
+ }
+
+ has(key) {
+ if (this.pending.has(key)) {
+ return true;
+ }
var storageKey = this.generateStorageKey(key);
var info = GM_getValue(storageKey);
if (!info) {
- //Not cached
- return null;
+ return false;
}
if (new Date().getTime() - info.time > info.exp) {
//Cache expired
- return null;
+ return true;
}
- //Cached value
- return info.val;
+ return true;
+ }
+
+ async get(key) {
+ if (!this.has(key)) {
+ return null
+ }
+
+ if (this.pending.has(key)) {
+ //Addition to cache pending
+ return await this.pending.get(key);
+ }
+
+ var storageKey = this.generateStorageKey(key);
+ return GM_getValue(storageKey).val; //Cached value
}
generateStorageKey(key) {
return `${this.namespaceKey}/${key}`;
}
+
+ isStorageKey(storageKey) {
+ return storageKey.startsWith(this.generateStorageKey(''));
+ }
+
+ clearExpired() {
+ for (var storageKey of GM_listValues()) {
+ var info = GM_getValue(storageKey);
+ if (this.isStorageKey(storageKey) && new Date().getTime() - info.time > info.exp) {
+ GM_deleteValue(storageKey);
+ }
+ }
+ }
}
//====================================================
// ==UserScript==
// @name Wanikani Common Vocab Indicator
// @namespace kevinta893
// @author kevinta893
// @description Show whether the vocabulary word is common or not according to Jisho.org . Original Script by dtwigs
// @run-at document-end
// @include https://www.wanikani.com/review/session
// @include https://www.wanikani.com/lesson/session
// @version 0.0.6
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @connect jisho.org
// @homepage https://github.com/kevinta893/wanikani-common-vocab-indicator
// @homepageURL https://github.com/kevinta893/wanikani-common-vocab-indicator
// ==/UserScript==
function init() {
const CACHE_TTL_MILLIS = 1000 * 60 * 60 * 24 * 28; //28 day cache expiry
const IS_COMMON_NAMESPACE = 'IsCommonCache';
var isCommonCache = new IsCommonCacher(IS_COMMON_NAMESPACE, CACHE_TTL_MILLIS);
var isCommonRequester = new JishoIsCommonRequester();
var isCommonRepository = new IsCommonRepository(isCommonRequester);
var commonIndicatorUi = new CommonIndicatorUi();
var commonIndicatorController = new CommonIndicatorController(commonIndicatorUi, isCommonRepository, isCommonCache);
log('WK Common Vocab Indicator started');
// To clear the cache in Tampermonkey,
// use Developer > Factory Reset in the script editor
}
var verbose = false;
function log() {
if (verbose) {
console.log(...arguments);
}
}
//====================================================
//UI
class CommonIndicatorUi {
constructor() {
this.css = `
.common-indicator-item {
position: absolute;
padding: 0px 5px 2px;
top: 40px;
right: 20px;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
z-index: 99;
letter-spacing: 0;
opacity: 0.8;
text-decoration: none;
}
.common-indicator-item.hide {
background-color: transparent;
color: transparent;
}
.common-indicator-item.fetching {
background-color: white;
opacity: 0.4;
color: #a100f1;
}
.common-indicator-item.common {
background-color: white;
color: #a100f1;
}
.common-indicator-item.uncommon {
background-color: transparent;
color: white;
opacity: 0.5;
visibility: hidden;
}
`;
this.indicatorClasses = {
hide: {
klass: 'hide',
text: '',
},
fetching: {
klass: 'fetching',
text: '...',
},
common: {
klass: 'common',
text: 'common'
},
uncommon: {
klass: 'uncommon',
text: 'not common',
}
};
this.questionIndicatorHtml = `
<div id="common-indicator" class="common-indicator-item"></div>
`;
this.lessonIndicatorHtml = `
<div id="common-indicator" class="common-indicator-item"></div>
`;
//Add indicator UI
this.addStyle(this.css);
$('#question').append(this.questionIndicatorHtml);
$('#lessons').append(this.lessonIndicatorHtml);
this.commonIndicator = $('#common-indicator');
}
/**
* Binds handler to event when the wanikani app changes item
* @param {function} handler Calls back the handler, with the current
* WaniKani item displayed. The call is a microtask.
*/
bindItemChangedEvent(handler) {
var itemChangedHandler = function (key) {
var wanikaniItem = $.jStorage.get(key);
if (wanikaniItem) {
queueMicrotask(() => handler(wanikaniItem));
}
};
$.jStorage.listenKeyChange('currentItem', itemChangedHandler);
$.jStorage.listenKeyChange('l/currentLesson', itemChangedHandler);
}
/**
* Binds handler to event when the wanikani app changes the active quiz queue items
* @param {function} handler. Calls back the handler multiple times, once for each
* new item that has entered the queue. Each call is a microtask.
*/
bindNewActiveEvent(handler) {
let handled = new WeakSet();
var activeChangedHandler = function (key) {
var activeQueue = $.jStorage.get(key);
for (const wanikaniItem of activeQueue) {
if (wanikaniItem && !handled.has(wanikaniItem)) {
handled.add(wanikaniItem);
queueMicrotask(() => handler(wanikaniItem));
}
}
};
$.jStorage.listenKeyChange('activeQueue', activeChangedHandler);
$.jStorage.listenKeyChange('l/activeQueue', activeChangedHandler);
}
hideIndicator() {
this.setClassAndText(this.indicatorClasses.hide);
}
setFetchingIndicator() {
this.setClassAndText(this.indicatorClasses.fetching);
}
setCommonIndicator(isCommon) {
if (isCommon) {
this.setClassAndText(this.indicatorClasses.common);
} else {
this.setClassAndText(this.indicatorClasses.uncommon);
}
}
setClassAndText(aObj) {
for (var klass in this.indicatorClasses) {
this.commonIndicator.removeClass(klass);
}
this.commonIndicator.text(aObj.text).addClass(aObj.klass);
}
addStyle(aCss) {
var head, style;
head = document.getElementsByTagName('head')[0];
if (head) {
style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = aCss;
head.appendChild(style);
}
}
}
//====================================================
// Common indicator Controller
class CommonIndicatorController {
constructor(commonIndicatorView, isCommonRepository, isCommonCache) {
this.commonIndicatorView = commonIndicatorView;
this.isCommonRepository = isCommonRepository;
this.isCommonCache = isCommonCache;
// events execute in the same order they are bound
this.commonIndicatorView.bindNewActiveEvent((key) => {
this.newActiveEvent(key);
});
this.commonIndicatorView.bindItemChangedEvent((key) => {
this.itemChangedEvent(key);
});
}
newActiveEvent(newItem) {
// Item not vocab, nothing to do
if (!newItem.hasOwnProperty('voc')) {
this.commonIndicatorView.hideIndicator();
return;
}
// Is vocab, lookup is common
var vocab = newItem.voc;
// cache if not already cached/pending
if (!this.isCommonCache.has(vocab)) {
var promise = this.isCommonRepository.getIsCommon(vocab);
this.isCommonCache.putPromise(vocab, promise);
}
}
async itemChangedEvent(currentItem) {
// Item not vocab, hide indicator
if (!currentItem.hasOwnProperty('voc')) {
this.commonIndicatorView.hideIndicator();
return;
}
// Is vocab, lookup is common
var vocab = currentItem.voc;
// show fetching indicator until fetch completes
// if in cache, indicator will be removed
// later before next frame is rendered so it won't
// display at all
this.commonIndicatorView.setFetchingIndicator();
await this.newActiveEvent(currentItem); // make sure item is in cache if tab
// was open for more than 28 days
var cacheValue = await this.isCommonCache.get(vocab); // Get data from cache
this.commonIndicatorView.setCommonIndicator(cacheValue);
}
}
//====================================================
// Jisho repository
class IsCommonRepository {
constructor(isCommonRequester) {
this.isCommonRequester = isCommonRequester;
}
async getIsCommon(requestedVocab, callback) {
try {
var isCommon = await this.isCommonRequester.getJishoIsCommon(requestedVocab);
//Unknown, assign default
if (isCommon == null) {
var defaultCommon = false;
log('Vocab not found, defaulting to is_common=false for: ' + requestedVocab);
return defaultCommon;
}
//Has isCommon data
return isCommon;
}
catch (error) {
console.error(error);
}
}
}
//====================================================
//Jisho API requester
class JishoIsCommonRequester {
constructor() {
this.jishoApiUrl = "https://jisho.org/api/v1/search/words?keyword=";
}
/**
* Determines if a vocab work is common or not using the Jisho API
* @param {*} vocab The vocab to lookup the is_common data for
* Promise is called back with (string vocab, nullable<bool> isCommon).
* isCommon is True if common, false otherwise.
* If no data (unknown word), then undefined is returned.
*/
getJishoIsCommon(vocab) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'get',
url: this.jishoApiUrl + vocab,
responseType: 'json',
onload: function (response) {
//No jisho data
var hasNoData = response.response.data.length == 0 ||
response.response.data[0] == null;
if (hasNoData) {
resolve(null);
return; // resolve does not stop function execution
}
var isCommon = response.response.data[0].is_common;
resolve(isCommon);
},
onerror: function (error) {
console.error('Jisho error: ', error);
reject(error);
},
ontimeout: function (error) {
console.error('Jisho timeout error: ', error);
reject(error);
}
});
});
}
}
//====================================================
// Cacher
class IsCommonCacher {
constructor(namespaceKey, cacheTtlMillis) {
this.namespaceKey = namespaceKey == null ? "" : namespaceKey;
this.cacheTtlMillis = cacheTtlMillis;
this.pending = new Map(); // map of pending promises waiting to be cached when they resolve
this.clearExpired();
}
put(key, val) {
//expiry time is in milliseconds
var storageKey = this.generateStorageKey(key);
GM_setValue(storageKey, { val: val, exp: this.cacheTtlMillis, time: new Date().getTime() })
}
putPromise(key, promiseForVal) {
this.pending.set(key, promiseForVal);
promiseForVal.then(function(val) {
this.put(key, val);
this.pending.delete(key);
}.bind(this));
}
has(key) {
if (this.pending.has(key)) {
return true;
}
var storageKey = this.generateStorageKey(key);
var info = GM_getValue(storageKey);
if (!info) {
return false;
}
if (new Date().getTime() - info.time > info.exp) {
//Cache expired
return true;
}
return true;
}
async get(key) {
if (!this.has(key)) {
return null
}
if (this.pending.has(key)) {
//Addition to cache pending
return await this.pending.get(key);
}
var storageKey = this.generateStorageKey(key);
return GM_getValue(storageKey).val; //Cached value
}
generateStorageKey(key) {
return `${this.namespaceKey}/${key}`;
}
isStorageKey(storageKey) {
return storageKey.startsWith(this.generateStorageKey(''));
}
clearExpired() {
for (var storageKey of GM_listValues()) {
var info = GM_getValue(storageKey);
if (this.isStorageKey(storageKey) && new Date().getTime() - info.time > info.exp) {
GM_deleteValue(storageKey);
}
}
}
}
//====================================================
init();