New User Script: Common Vocabulary Indicator

Most scripts have a dedicated thread with some instructions in the top post. You may locate the thread for the script with the search feature in the upper right corner of the screen. Just search for the script name. If the information you seek is not there chances are that it doesn’t exist.

I don’t know about Override but a common alternative is the Double-Check script. It has the features of Override and a few more. If Override has stopped working you may want to check this one.

1 Like

Thank you!

Could you post your fork since the original creator doesn’t seem to have responded? Also, any chance you could have the script listen for when the activeQueue changes and whenever it does, automatically fetch data for all new words in the activeQueue without waiting for them to become the currentQuizItem?

1 Like

I was thinkings about Houhou SRS, a dictionary app with SRS that shows the “Ranking” of most used words in each word you search. Could it be possible to make a Script that showed that “rank” instead of “commom/uncomomon”?

The app literally shows like: おねがいします (143th). Thats just a example btw, not real data.

Ah sorry I haven’t been active on this community. Here is the updated fork [Userscript] Wanikani Common Vocab Indicator - Updated

Do shoot me a message if you have any questions.

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:

  1. 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.)
  2. Makes console logging controllable by a variable and defaults it to off.
  3. Automatic deletion of expired cache items.
  4. Improved handling of corner cases involving making requests for the same item while an existing request for that item is still pending.
  5. fixed an issue where a method called resolve when something is null but did not return, causing a null-related exception.
  6. 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();

Hey sorry for the late reply. I’ll definitely take a look at it sometime when I am free. Let’s discuss on the main thread:

Thanks for making this! It’s quite useful as I’m constantly pondering if I’m stressing out over something useless, like the four words for neighborhood and five ways of saying investigation that I’m accumulating

Is this plugin still maintained?

1 Like