From 4da4827bcbcdd1ef163f635d9b29416ff272b0bb Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 12:48:14 -0500
Subject: Add JSDoc type annotations to project (rebased)

---
 ext/js/language/deinflector.js                     |   39 +-
 ext/js/language/dictionary-database.js             |  255 +++-
 .../language/dictionary-importer-media-loader.js   |   10 +-
 ext/js/language/dictionary-importer.js             |  338 ++++-
 ext/js/language/dictionary-worker-handler.js       |   38 +-
 ext/js/language/dictionary-worker-media-loader.js  |   16 +-
 ext/js/language/dictionary-worker.js               |  110 +-
 ext/js/language/sandbox/dictionary-data-util.js    |  161 ++-
 ext/js/language/sandbox/japanese-util.js           | 1371 +++++++++++---------
 ext/js/language/text-scanner.js                    |  534 +++++++-
 ext/js/language/translator.js                      |  788 ++++++++---
 11 files changed, 2655 insertions(+), 1005 deletions(-)

(limited to 'ext/js/language')

diff --git a/ext/js/language/deinflector.js b/ext/js/language/deinflector.js
index 3012c29a..b7a235d0 100644
--- a/ext/js/language/deinflector.js
+++ b/ext/js/language/deinflector.js
@@ -17,10 +17,18 @@
  */
 
 export class Deinflector {
+    /**
+     * @param {import('deinflector').ReasonsRaw} reasons
+     */
     constructor(reasons) {
+        /** @type {import('deinflector').Reason[]} */
         this.reasons = Deinflector.normalizeReasons(reasons);
     }
 
+    /**
+     * @param {string} source
+     * @returns {import('translation-internal').Deinflection[]}
+     */
     deinflect(source) {
         const results = [this._createDeinflection(source, 0, [])];
         for (let i = 0; i < results.length; ++i) {
@@ -46,13 +54,25 @@ export class Deinflector {
         return results;
     }
 
+    /**
+     * @param {string} term
+     * @param {import('translation-internal').DeinflectionRuleFlags} rules
+     * @param {string[]} reasons
+     * @returns {import('translation-internal').Deinflection}
+     */
     _createDeinflection(term, rules, reasons) {
         return {term, rules, reasons};
     }
 
+    /**
+     * @param {import('deinflector').ReasonsRaw} reasons
+     * @returns {import('deinflector').Reason[]}
+     */
     static normalizeReasons(reasons) {
+        /** @type {import('deinflector').Reason[]} */
         const normalizedReasons = [];
         for (const [reason, reasonInfo] of Object.entries(reasons)) {
+            /** @type {import('deinflector').ReasonVariant[]} */
             const variants = [];
             for (const {kanaIn, kanaOut, rulesIn, rulesOut} of reasonInfo) {
                 variants.push([
@@ -67,6 +87,10 @@ export class Deinflector {
         return normalizedReasons;
     }
 
+    /**
+     * @param {string[]} rules
+     * @returns {import('translation-internal').DeinflectionRuleFlags}
+     */
     static rulesToRuleFlags(rules) {
         const ruleTypes = this._ruleTypes;
         let value = 0;
@@ -79,13 +103,14 @@ export class Deinflector {
     }
 }
 
+/** @type {Map<string, import('translation-internal').DeinflectionRuleFlags>} */
 // eslint-disable-next-line no-underscore-dangle
 Deinflector._ruleTypes = new Map([
-    ['v1',    0b00000001], // Verb ichidan
-    ['v5',    0b00000010], // Verb godan
-    ['vs',    0b00000100], // Verb suru
-    ['vk',    0b00001000], // Verb kuru
-    ['vz',    0b00010000], // Verb zuru
-    ['adj-i', 0b00100000], // Adjective i
-    ['iru',   0b01000000] // Intermediate -iru endings for progressive or perfect tense
+    ['v1',    /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00000001)], // Verb ichidan
+    ['v5',    /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00000010)], // Verb godan
+    ['vs',    /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00000100)], // Verb suru
+    ['vk',    /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00001000)], // Verb kuru
+    ['vz',    /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00010000)], // Verb zuru
+    ['adj-i', /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00100000)], // Adjective i
+    ['iru',   /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b01000000)] // Intermediate -iru endings for progressive or perfect tense
 ]);
diff --git a/ext/js/language/dictionary-database.js b/ext/js/language/dictionary-database.js
index da365da7..c47e1e90 100644
--- a/ext/js/language/dictionary-database.js
+++ b/ext/js/language/dictionary-database.js
@@ -21,29 +21,45 @@ import {Database} from '../data/database.js';
 
 export class DictionaryDatabase {
     constructor() {
+        /** @type {Database<import('dictionary-database').ObjectStoreName>} */
         this._db = new Database();
+        /** @type {string} */
         this._dbName = 'dict';
-        this._schemas = new Map();
+        /** @type {import('dictionary-database').CreateQuery<string>} */
         this._createOnlyQuery1 = (item) => IDBKeyRange.only(item);
+        /** @type {import('dictionary-database').CreateQuery<import('dictionary-database').DictionaryAndQueryRequest>} */
         this._createOnlyQuery2 = (item) => IDBKeyRange.only(item.query);
+        /** @type {import('dictionary-database').CreateQuery<import('dictionary-database').TermExactRequest>} */
         this._createOnlyQuery3 = (item) => IDBKeyRange.only(item.term);
+        /** @type {import('dictionary-database').CreateQuery<import('dictionary-database').MediaRequest>} */
         this._createOnlyQuery4 = (item) => IDBKeyRange.only(item.path);
+        /** @type {import('dictionary-database').CreateQuery<string>} */
         this._createBoundQuery1 = (item) => IDBKeyRange.bound(item, `${item}\uffff`, false, false);
+        /** @type {import('dictionary-database').CreateQuery<string>} */
         this._createBoundQuery2 = (item) => { item = stringReverse(item); return IDBKeyRange.bound(item, `${item}\uffff`, false, false); };
-        this._createTermBind1 = this._createTerm.bind(this, 'term', 'exact');
-        this._createTermBind2 = this._createTerm.bind(this, 'sequence', 'exact');
+        /** @type {import('dictionary-database').CreateResult<import('dictionary-database').TermExactRequest, import('dictionary-database').DatabaseTermEntryWithId, import('dictionary-database').TermEntry>} */
+        this._createTermBind1 = this._createTermExact.bind(this);
+        /** @type {import('dictionary-database').CreateResult<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').DatabaseTermEntryWithId, import('dictionary-database').TermEntry>} */
+        this._createTermBind2 = this._createTermSequenceExact.bind(this);
+        /** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseTermMeta, import('dictionary-database').TermMeta>} */
         this._createTermMetaBind = this._createTermMeta.bind(this);
+        /** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseKanjiEntry, import('dictionary-database').KanjiEntry>} */
         this._createKanjiBind = this._createKanji.bind(this);
+        /** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseKanjiMeta, import('dictionary-database').KanjiMeta>} */
         this._createKanjiMetaBind = this._createKanjiMeta.bind(this);
+        /** @type {import('dictionary-database').CreateResult<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent, import('dictionary-database').Media>} */
         this._createMediaBind = this._createMedia.bind(this);
     }
 
+    /** */
     async prepare() {
         await this._db.open(
             this._dbName,
             60,
-            [
-                {
+            /** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>[]} */
+            ([
+                /** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>} */
+                ({
                     version: 20,
                     stores: {
                         terms: {
@@ -63,7 +79,7 @@ export class DictionaryDatabase {
                             indices: ['title', 'version']
                         }
                     }
-                },
+                }),
                 {
                     version: 30,
                     stores: {
@@ -108,18 +124,25 @@ export class DictionaryDatabase {
                         }
                     }
                 }
-            ]
+            ])
         );
     }
 
+    /** */
     async close() {
         this._db.close();
     }
 
+    /**
+     * @returns {boolean}
+     */
     isPrepared() {
         return this._db.isOpen();
     }
 
+    /**
+     * @returns {Promise<boolean>}
+     */
     async purge() {
         if (this._db.isOpening()) {
             throw new Error('Cannot purge database while opening');
@@ -138,14 +161,13 @@ export class DictionaryDatabase {
         return result;
     }
 
+    /**
+     * @param {string} dictionaryName
+     * @param {number} progressRate
+     * @param {import('dictionary-database').DeleteDictionaryProgressCallback} onProgress
+     */
     async deleteDictionary(dictionaryName, progressRate, onProgress) {
-        if (typeof progressRate !== 'number') {
-            progressRate = 1;
-        }
-        if (typeof onProgress !== 'function') {
-            onProgress = () => {};
-        }
-
+        /** @type {[objectStoreName: import('dictionary-database').ObjectStoreName, key: string][][]} */
         const targetGroups = [
             [
                 ['kanji', 'dictionary'],
@@ -165,6 +187,7 @@ export class DictionaryDatabase {
             storeCount += targets.length;
         }
 
+        /** @type {import('dictionary-database').DeleteDictionaryProgressData} */
         const progressData = {
             count: 0,
             processed: 0,
@@ -172,6 +195,10 @@ export class DictionaryDatabase {
             storesProcesed: 0
         };
 
+        /**
+         * @param {IDBValidKey[]} keys
+         * @returns {IDBValidKey[]}
+         */
         const filterKeys = (keys) => {
             ++progressData.storesProcesed;
             progressData.count += keys.length;
@@ -197,8 +224,15 @@ export class DictionaryDatabase {
         }
     }
 
+    /**
+     * @param {string[]} termList
+     * @param {import('dictionary-database').DictionarySet} dictionaries
+     * @param {import('dictionary-database').MatchType} matchType
+     * @returns {Promise<import('dictionary-database').TermEntry[]>}
+     */
     findTermsBulk(termList, dictionaries, matchType) {
         const visited = new Set();
+        /** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseTermEntryWithId>} */
         const predicate = (row) => {
             if (!dictionaries.has(row.dictionary)) { return false; }
             const {id} = row;
@@ -224,54 +258,106 @@ export class DictionaryDatabase {
         return this._findMultiBulk('terms', indexNames, termList, createQuery, predicate, createResult);
     }
 
+    /**
+     * @param {import('dictionary-database').TermExactRequest[]} termList
+     * @param {import('dictionary-database').DictionarySet} dictionaries
+     * @returns {Promise<import('dictionary-database').TermEntry[]>}
+     */
     findTermsExactBulk(termList, dictionaries) {
+        /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').TermExactRequest, import('dictionary-database').DatabaseTermEntry>} */
         const predicate = (row, item) => (row.reading === item.reading && dictionaries.has(row.dictionary));
         return this._findMultiBulk('terms', ['expression'], termList, this._createOnlyQuery3, predicate, this._createTermBind1);
     }
 
+    /**
+     * @param {import('dictionary-database').DictionaryAndQueryRequest[]} items
+     * @returns {Promise<import('dictionary-database').TermEntry[]>}
+     */
     findTermsBySequenceBulk(items) {
+        /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').DatabaseTermEntry>} */
         const predicate = (row, item) => (row.dictionary === item.dictionary);
         return this._findMultiBulk('terms', ['sequence'], items, this._createOnlyQuery2, predicate, this._createTermBind2);
     }
 
+    /**
+     * @param {string[]} termList
+     * @param {import('dictionary-database').DictionarySet} dictionaries
+     * @returns {Promise<import('dictionary-database').TermMeta[]>}
+     */
     findTermMetaBulk(termList, dictionaries) {
+        /** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseTermMeta>} */
         const predicate = (row) => dictionaries.has(row.dictionary);
         return this._findMultiBulk('termMeta', ['expression'], termList, this._createOnlyQuery1, predicate, this._createTermMetaBind);
     }
 
+    /**
+     * @param {string[]} kanjiList
+     * @param {import('dictionary-database').DictionarySet} dictionaries
+     * @returns {Promise<import('dictionary-database').KanjiEntry[]>}
+     */
     findKanjiBulk(kanjiList, dictionaries) {
+        /** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseKanjiEntry>} */
         const predicate = (row) => dictionaries.has(row.dictionary);
         return this._findMultiBulk('kanji', ['character'], kanjiList, this._createOnlyQuery1, predicate, this._createKanjiBind);
     }
 
+    /**
+     * @param {string[]} kanjiList
+     * @param {import('dictionary-database').DictionarySet} dictionaries
+     * @returns {Promise<import('dictionary-database').KanjiMeta[]>}
+     */
     findKanjiMetaBulk(kanjiList, dictionaries) {
+        /** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseKanjiMeta>} */
         const predicate = (row) => dictionaries.has(row.dictionary);
         return this._findMultiBulk('kanjiMeta', ['character'], kanjiList, this._createOnlyQuery1, predicate, this._createKanjiMetaBind);
     }
 
+    /**
+     * @param {import('dictionary-database').DictionaryAndQueryRequest[]} items
+     * @returns {Promise<(import('dictionary-database').Tag|undefined)[]>}
+     */
     findTagMetaBulk(items) {
+        /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').Tag>} */
         const predicate = (row, item) => (row.dictionary === item.dictionary);
         return this._findFirstBulk('tagMeta', 'name', items, this._createOnlyQuery2, predicate);
     }
 
-    findTagForTitle(name, title) {
+    /**
+     * @param {string} name
+     * @param {string} dictionary
+     * @returns {Promise<?import('dictionary-database').Tag>}
+     */
+    findTagForTitle(name, dictionary) {
         const query = IDBKeyRange.only(name);
-        return this._db.find('tagMeta', 'name', query, (row) => (row.dictionary === title), null, null);
+        return this._db.find('tagMeta', 'name', query, (row) => (/** @type {import('dictionary-database').Tag} */ (row).dictionary === dictionary), null, null);
     }
 
+    /**
+     * @param {import('dictionary-database').MediaRequest[]} items
+     * @returns {Promise<import('dictionary-database').Media[]>}
+     */
     getMedia(items) {
+        /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent>} */
         const predicate = (row, item) => (row.dictionary === item.dictionary);
         return this._findMultiBulk('media', ['path'], items, this._createOnlyQuery4, predicate, this._createMediaBind);
     }
 
+    /**
+     * @returns {Promise<import('dictionary-importer').Summary[]>}
+     */
     getDictionaryInfo() {
         return new Promise((resolve, reject) => {
             const transaction = this._db.transaction(['dictionaries'], 'readonly');
             const objectStore = transaction.objectStore('dictionaries');
-            this._db.getAll(objectStore, null, resolve, reject);
+            this._db.getAll(objectStore, null, resolve, reject, null);
         });
     }
 
+    /**
+     * @param {string[]} dictionaryNames
+     * @param {boolean} getTotal
+     * @returns {Promise<import('dictionary-database').DictionaryCounts>}
+     */
     getDictionaryCounts(dictionaryNames, getTotal) {
         return new Promise((resolve, reject) => {
             const targets = [
@@ -290,10 +376,11 @@ export class DictionaryDatabase {
                 return {objectStore, index};
             });
 
+            /** @type {import('database').CountTarget[]} */
             const countTargets = [];
             if (getTotal) {
                 for (const {objectStore} of databaseTargets) {
-                    countTargets.push([objectStore, null]);
+                    countTargets.push([objectStore, void 0]);
                 }
             }
             for (const dictionaryName of dictionaryNames) {
@@ -303,18 +390,23 @@ export class DictionaryDatabase {
                 }
             }
 
+            /**
+             * @param {number[]} results
+             */
             const onCountComplete = (results) => {
                 const resultCount = results.length;
                 const targetCount = targets.length;
+                /** @type {import('dictionary-database').DictionaryCountGroup[]} */
                 const counts = [];
                 for (let i = 0; i < resultCount; i += targetCount) {
+                    /** @type {import('dictionary-database').DictionaryCountGroup} */
                     const countGroup = {};
                     for (let j = 0; j < targetCount; ++j) {
                         countGroup[targets[j][0]] = results[i + j];
                     }
                     counts.push(countGroup);
                 }
-                const total = getTotal ? counts.shift() : null;
+                const total = getTotal ? /** @type {import('dictionary-database').DictionaryCountGroup} */ (counts.shift()) : null;
                 resolve({total, counts});
             };
 
@@ -322,22 +414,47 @@ export class DictionaryDatabase {
         });
     }
 
+    /**
+     * @param {string} title
+     * @returns {Promise<boolean>}
+     */
     async dictionaryExists(title) {
         const query = IDBKeyRange.only(title);
         const result = await this._db.find('dictionaries', 'title', query, null, null, void 0);
         return typeof result !== 'undefined';
     }
 
+    /**
+     * @template {import('dictionary-database').ObjectStoreName} T
+     * @param {T} objectStoreName
+     * @param {import('dictionary-database').ObjectStoreData<T>[]} items
+     * @param {number} start
+     * @param {number} count
+     * @returns {Promise<void>}
+     */
     bulkAdd(objectStoreName, items, start, count) {
         return this._db.bulkAdd(objectStoreName, items, start, count);
     }
 
     // Private
 
+    /**
+     * @template [TRow=unknown]
+     * @template [TItem=unknown]
+     * @template [TResult=unknown]
+     * @param {import('dictionary-database').ObjectStoreName} objectStoreName
+     * @param {string[]} indexNames
+     * @param {TItem[]} items
+     * @param {import('dictionary-database').CreateQuery<TItem>} createQuery
+     * @param {import('dictionary-database').FindPredicate<TItem, TRow>} predicate
+     * @param {import('dictionary-database').CreateResult<TItem, TRow, TResult>} createResult
+     * @returns {Promise<TResult[]>}
+     */
     _findMultiBulk(objectStoreName, indexNames, items, createQuery, predicate, createResult) {
         return new Promise((resolve, reject) => {
             const itemCount = items.length;
             const indexCount = indexNames.length;
+            /** @type {TResult[]} */
             const results = [];
             if (itemCount === 0 || indexCount === 0) {
                 resolve(results);
@@ -352,6 +469,10 @@ export class DictionaryDatabase {
             }
             let completeCount = 0;
             const requiredCompleteCount = itemCount * indexCount;
+            /**
+             * @param {TRow[]} rows
+             * @param {import('dictionary-database').FindMultiBulkData<TItem>} data
+             */
             const onGetAll = (rows, data) => {
                 for (const row of rows) {
                     if (predicate(row, data.item)) {
@@ -366,15 +487,28 @@ export class DictionaryDatabase {
                 const item = items[i];
                 const query = createQuery(item);
                 for (let j = 0; j < indexCount; ++j) {
-                    this._db.getAll(indexList[j], query, onGetAll, reject, {item, itemIndex: i, indexIndex: j});
+                    /** @type {import('dictionary-database').FindMultiBulkData<TItem>} */
+                    const data = {item, itemIndex: i, indexIndex: j};
+                    this._db.getAll(indexList[j], query, onGetAll, reject, data);
                 }
             }
         });
     }
 
+    /**
+     * @template [TRow=unknown]
+     * @template [TItem=unknown]
+     * @param {import('dictionary-database').ObjectStoreName} objectStoreName
+     * @param {string} indexName
+     * @param {TItem[]} items
+     * @param {import('dictionary-database').CreateQuery<TItem>} createQuery
+     * @param {import('dictionary-database').FindPredicate<TItem, TRow>} predicate
+     * @returns {Promise<(TRow|undefined)[]>}
+     */
     _findFirstBulk(objectStoreName, indexName, items, createQuery, predicate) {
         return new Promise((resolve, reject) => {
             const itemCount = items.length;
+            /** @type {(TRow|undefined)[]} */
             const results = new Array(itemCount);
             if (itemCount === 0) {
                 resolve(results);
@@ -385,6 +519,10 @@ export class DictionaryDatabase {
             const objectStore = transaction.objectStore(objectStoreName);
             const index = objectStore.index(indexName);
             let completeCount = 0;
+            /**
+             * @param {TRow|undefined} row
+             * @param {number} itemIndex
+             */
             const onFind = (row, itemIndex) => {
                 results[itemIndex] = row;
                 if (++completeCount >= itemCount) {
@@ -399,16 +537,47 @@ export class DictionaryDatabase {
         });
     }
 
+    /**
+     * @param {import('dictionary-database').MatchType} matchType
+     * @param {import('dictionary-database').DatabaseTermEntryWithId} row
+     * @param {import('dictionary-database').FindMultiBulkData<string>} data
+     * @returns {import('dictionary-database').TermEntry}
+     */
     _createTermGeneric(matchType, row, data) {
         const matchSourceIsTerm = (data.indexIndex === 0);
         const matchSource = (matchSourceIsTerm ? 'term' : 'reading');
         if ((matchSourceIsTerm ? row.expression : row.reading) === data.item) {
             matchType = 'exact';
         }
-        return this._createTerm(matchSource, matchType, row, data);
+        return this._createTerm(matchSource, matchType, row, data.itemIndex);
+    }
+
+    /**
+     * @param {import('dictionary-database').DatabaseTermEntryWithId} row
+     * @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').TermExactRequest>} data
+     * @returns {import('dictionary-database').TermEntry}
+     */
+    _createTermExact(row, data) {
+        return this._createTerm('term', 'exact', row, data.itemIndex);
     }
 
-    _createTerm(matchSource, matchType, row, {itemIndex: index}) {
+    /**
+     * @param {import('dictionary-database').DatabaseTermEntryWithId} row
+     * @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').DictionaryAndQueryRequest>} data
+     * @returns {import('dictionary-database').TermEntry}
+     */
+    _createTermSequenceExact(row, data) {
+        return this._createTerm('sequence', 'exact', row, data.itemIndex);
+    }
+
+    /**
+     * @param {import('dictionary-database').MatchSource} matchSource
+     * @param {import('dictionary-database').MatchType} matchType
+     * @param {import('dictionary-database').DatabaseTermEntryWithId} row
+     * @param {number} index
+     * @returns {import('dictionary-database').TermEntry}
+     */
+    _createTerm(matchSource, matchType, row, index) {
         const {sequence} = row;
         return {
             index,
@@ -427,7 +596,13 @@ export class DictionaryDatabase {
         };
     }
 
+    /**
+     * @param {import('dictionary-database').DatabaseKanjiEntry} row
+     * @param {import('dictionary-database').FindMultiBulkData<string>} data
+     * @returns {import('dictionary-database').KanjiEntry}
+     */
     _createKanji(row, {itemIndex: index}) {
+        const {stats} = row;
         return {
             index,
             character: row.character,
@@ -435,23 +610,51 @@ export class DictionaryDatabase {
             kunyomi: this._splitField(row.kunyomi),
             tags: this._splitField(row.tags),
             definitions: row.meanings,
-            stats: row.stats,
+            stats: typeof stats === 'object' && stats !== null ? stats : {},
             dictionary: row.dictionary
         };
     }
 
+    /**
+     * @param {import('dictionary-database').DatabaseTermMeta} row
+     * @param {import('dictionary-database').FindMultiBulkData<string>} data
+     * @returns {import('dictionary-database').TermMeta}
+     * @throws {Error}
+     */
     _createTermMeta({expression: term, mode, data, dictionary}, {itemIndex: index}) {
-        return {term, mode, data, dictionary, index};
+        switch (mode) {
+            case 'freq':
+                return {index, term, mode, data, dictionary};
+            case 'pitch':
+                return {index, term, mode, data, dictionary};
+            default:
+                throw new Error(`Unknown mode: ${mode}`);
+        }
     }
 
+    /**
+     * @param {import('dictionary-database').DatabaseKanjiMeta} row
+     * @param {import('dictionary-database').FindMultiBulkData<string>} data
+     * @returns {import('dictionary-database').KanjiMeta}
+     */
     _createKanjiMeta({character, mode, data, dictionary}, {itemIndex: index}) {
-        return {character, mode, data, dictionary, index};
+        return {index, character, mode, data, dictionary};
     }
 
+    /**
+     * @param {import('dictionary-database').MediaDataArrayBufferContent} row
+     * @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').MediaRequest>} data
+     * @returns {import('dictionary-database').Media}
+     */
     _createMedia(row, {itemIndex: index}) {
-        return Object.assign({}, row, {index});
+        const {dictionary, path, mediaType, width, height, content} = row;
+        return {index, dictionary, path, mediaType, width, height, content};
     }
 
+    /**
+     * @param {unknown} field
+     * @returns {string[]}
+     */
     _splitField(field) {
         return typeof field === 'string' && field.length > 0 ? field.split(' ') : [];
     }
diff --git a/ext/js/language/dictionary-importer-media-loader.js b/ext/js/language/dictionary-importer-media-loader.js
index 7d4f798c..a5857dce 100644
--- a/ext/js/language/dictionary-importer-media-loader.js
+++ b/ext/js/language/dictionary-importer-media-loader.js
@@ -22,15 +22,7 @@ import {EventListenerCollection} from '../core.js';
  * Class used for loading and validating media during the dictionary import process.
  */
 export class DictionaryImporterMediaLoader {
-    /**
-     * Attempts to load an image using an ArrayBuffer and a media type to return details about it.
-     * @param {ArrayBuffer} content The binary content for the image, encoded as an ArrayBuffer.
-     * @param {string} mediaType The media type for the image content.
-     * @param {Transferable[]} [transfer] An optional array of data that should be transferred in `postMessage` calls.
-     *   When the resulting promise resolves, this array will contain the `content` object.
-     * @returns {Promise<{content: ArrayBuffer, width: number, height: number}>} Details about the requested image content.
-     * @throws {Error} An error can be thrown if the image fails to load.
-     */
+    /** @type {import('dictionary-importer-media-loader').GetImageDetailsFunction} */
     getImageDetails(content, mediaType, transfer) {
         return new Promise((resolve, reject) => {
             const image = new Image();
diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js
index 791d1a77..aa6d7ae6 100644
--- a/ext/js/language/dictionary-importer.js
+++ b/ext/js/language/dictionary-importer.js
@@ -20,13 +20,27 @@ import * as ajvSchemas from '../../lib/validate-schemas.js';
 import {BlobWriter, TextWriter, Uint8ArrayReader, ZipReader, configure} from '../../lib/zip.js';
 import {stringReverse} from '../core.js';
 import {MediaUtil} from '../media/media-util.js';
+
 export class DictionaryImporter {
+    /**
+     * @param {import('dictionary-importer-media-loader').GenericMediaLoader} mediaLoader
+     * @param {import('dictionary-importer').OnProgressCallback} [onProgress]
+     */
     constructor(mediaLoader, onProgress) {
+        /** @type {import('dictionary-importer-media-loader').GenericMediaLoader} */
         this._mediaLoader = mediaLoader;
+        /** @type {import('dictionary-importer').OnProgressCallback} */
         this._onProgress = typeof onProgress === 'function' ? onProgress : () => {};
-        this._progressData = null;
+        /** @type {import('dictionary-importer').ProgressData} */
+        this._progressData = this._createProgressData();
     }
 
+    /**
+     * @param {DictionaryDatabase} dictionaryDatabase
+     * @param {ArrayBuffer} archiveContent
+     * @param {import('dictionary-importer').ImportDetails} details
+     * @returns {Promise<import('dictionary-importer').ImportResult>}
+     */
     async importDictionary(dictionaryDatabase, archiveContent, details) {
         if (!dictionaryDatabase) {
             throw new Error('Invalid database');
@@ -69,9 +83,9 @@ export class DictionaryImporter {
         }
 
         const dictionaryTitle = index.title;
-        const version = index.format || index.version;
+        const version = typeof index.format === 'number' ? index.format : index.version;
 
-        if (!dictionaryTitle || !index.revision) {
+        if (typeof version !== 'number' || !dictionaryTitle || !index.revision) {
             throw new Error('Unrecognized dictionary format');
         }
 
@@ -80,13 +94,6 @@ export class DictionaryImporter {
             throw new Error('Dictionary is already imported');
         }
 
-        // Data format converters
-        const convertTermBankEntry = (version === 1 ? this._convertTermBankEntryV1.bind(this) : this._convertTermBankEntryV3.bind(this));
-        const convertTermMetaBankEntry = this._convertTermMetaBankEntry.bind(this);
-        const convertKanjiBankEntry = (version === 1 ? this._convertKanjiBankEntryV1.bind(this) : this._convertKanjiBankEntryV3.bind(this));
-        const convertKanjiMetaBankEntry = this._convertKanjiMetaBankEntry.bind(this);
-        const convertTagBankEntry = this._convertTagBankEntry.bind(this);
-
         // Load schemas
         this._progressNextStep(0);
         const dataBankSchemas = this._getDataBankSchemas(version);
@@ -100,11 +107,19 @@ export class DictionaryImporter {
 
         // Load data
         this._progressNextStep(termFiles.length + termMetaFiles.length + kanjiFiles.length + kanjiMetaFiles.length + tagFiles.length);
-        const termList      = await this._readFileSequence(termFiles,      convertTermBankEntry,      dataBankSchemas[0], dictionaryTitle);
-        const termMetaList  = await this._readFileSequence(termMetaFiles,  convertTermMetaBankEntry,  dataBankSchemas[1], dictionaryTitle);
-        const kanjiList     = await this._readFileSequence(kanjiFiles,     convertKanjiBankEntry,     dataBankSchemas[2], dictionaryTitle);
-        const kanjiMetaList = await this._readFileSequence(kanjiMetaFiles, convertKanjiMetaBankEntry, dataBankSchemas[3], dictionaryTitle);
-        const tagList       = await this._readFileSequence(tagFiles,       convertTagBankEntry,       dataBankSchemas[4], dictionaryTitle);
+        const termList = await (
+            version === 1 ?
+            this._readFileSequence(termFiles, this._convertTermBankEntryV1.bind(this), dataBankSchemas[0], dictionaryTitle) :
+            this._readFileSequence(termFiles, this._convertTermBankEntryV3.bind(this), dataBankSchemas[0], dictionaryTitle)
+        );
+        const termMetaList = await this._readFileSequence(termMetaFiles, this._convertTermMetaBankEntry.bind(this), dataBankSchemas[1], dictionaryTitle);
+        const kanjiList = await (
+            version === 1 ?
+            this._readFileSequence(kanjiFiles, this._convertKanjiBankEntryV1.bind(this), dataBankSchemas[2], dictionaryTitle) :
+            this._readFileSequence(kanjiFiles, this._convertKanjiBankEntryV3.bind(this), dataBankSchemas[2], dictionaryTitle)
+        );
+        const kanjiMetaList = await this._readFileSequence(kanjiMetaFiles, this._convertKanjiMetaBankEntry.bind(this), dataBankSchemas[3], dictionaryTitle);
+        const tagList = await this._readFileSequence(tagFiles, this._convertTagBankEntry.bind(this), dataBankSchemas[4], dictionaryTitle);
         this._addOldIndexTags(index, tagList, dictionaryTitle);
 
         // Prefix wildcard support
@@ -119,6 +134,7 @@ export class DictionaryImporter {
         // Extended data support
         this._progressNextStep(termList.length);
         const formatProgressInterval = 1000;
+        /** @type {import('dictionary-importer').ImportRequirement[]} */
         const requirements = [];
         for (let i = 0, ii = termList.length; i < ii; ++i) {
             const entry = termList[i];
@@ -142,6 +158,7 @@ export class DictionaryImporter {
         // Add dictionary descriptor
         this._progressNextStep(termList.length + termMetaList.length + kanjiList.length + kanjiMetaList.length + tagList.length + media.length);
 
+        /** @type {import('dictionary-importer').SummaryCounts} */
         const counts = {
             terms: {total: termList.length},
             termMeta: this._getMetaCounts(termMetaList),
@@ -154,9 +171,15 @@ export class DictionaryImporter {
         dictionaryDatabase.bulkAdd('dictionaries', [summary], 0, 1);
 
         // Add data
+        /** @type {Error[]} */
         const errors = [];
         const maxTransactionLength = 1000;
 
+        /**
+         * @template {import('dictionary-database').ObjectStoreName} T
+         * @param {T} objectStoreName
+         * @param {import('dictionary-database').ObjectStoreData<T>[]} entries
+         */
         const bulkAdd = async (objectStoreName, entries) => {
             const ii = entries.length;
             for (let i = 0; i < ii; i += maxTransactionLength) {
@@ -165,7 +188,7 @@ export class DictionaryImporter {
                 try {
                     await dictionaryDatabase.bulkAdd(objectStoreName, entries, i, count);
                 } catch (e) {
-                    errors.push(e);
+                    errors.push(e instanceof Error ? e : new Error(`${e}`));
                 }
 
                 this._progressData.index += count;
@@ -185,16 +208,27 @@ export class DictionaryImporter {
         return {result: summary, errors};
     }
 
-    _progressReset() {
-        this._progressData = {
+    /**
+     * @returns {import('dictionary-importer').ProgressData}
+     */
+    _createProgressData() {
+        return {
             stepIndex: 0,
             stepCount: 6,
             index: 0,
             count: 0
         };
+    }
+
+    /** */
+    _progressReset() {
+        this._progressData = this._createProgressData();
         this._progress();
     }
 
+    /**
+     * @param {number} count
+     */
     _progressNextStep(count) {
         ++this._progressData.stepIndex;
         this._progressData.index = 0;
@@ -202,17 +236,31 @@ export class DictionaryImporter {
         this._progress();
     }
 
+    /** */
     _progress() {
         this._onProgress(this._progressData);
     }
 
+    /**
+     * @param {string} dictionaryTitle
+     * @param {number} version
+     * @param {import('dictionary-data').Index} index
+     * @param {{prefixWildcardsSupported: boolean, counts: import('dictionary-importer').SummaryCounts}} details
+     * @returns {import('dictionary-importer').Summary}
+     */
     _createSummary(dictionaryTitle, version, index, details) {
+        const indexSequenced = index.sequenced;
+        const {prefixWildcardsSupported, counts} = details;
+
+        /** @type {import('dictionary-importer').Summary} */
         const summary = {
             title: dictionaryTitle,
             revision: index.revision,
-            sequenced: index.sequenced,
+            sequenced: typeof indexSequenced === 'boolean' && indexSequenced,
             version,
-            importDate: Date.now()
+            importDate: Date.now(),
+            prefixWildcardsSupported,
+            counts
         };
 
         const {author, url, description, attribution, frequencyMode} = index;
@@ -222,11 +270,14 @@ export class DictionaryImporter {
         if (typeof attribution === 'string') { summary.attribution = attribution; }
         if (typeof frequencyMode === 'string') { summary.frequencyMode = frequencyMode; }
 
-        Object.assign(summary, details);
-
         return summary;
     }
 
+    /**
+     *
+     * @param schema
+     * @param fileName
+     */
     _formatAjvSchemaError(schema, fileName) {
         const e2 = new Error(`Dictionary has invalid data in '${fileName}'`);
         e2.data = schema.errors;
@@ -234,6 +285,10 @@ export class DictionaryImporter {
         return e2;
     }
 
+    /**
+     *
+     * @param version
+     */
     _getDataBankSchemas(version) {
         const termBank = (
             version === 1 ?
@@ -252,6 +307,13 @@ export class DictionaryImporter {
         return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank];
     }
 
+    /**
+     * @param {import('dictionary-data').TermGlossaryText|import('dictionary-data').TermGlossaryImage|import('dictionary-data').TermGlossaryStructuredContent} data
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     * @param {import('dictionary-importer').ImportRequirement[]} requirements
+     * @returns {import('dictionary-data').TermGlossary}
+     * @throws {Error}
+     */
     _formatDictionaryTermGlossaryObject(data, entry, requirements) {
         switch (data.type) {
             case 'text':
@@ -261,16 +323,32 @@ export class DictionaryImporter {
             case 'structured-content':
                 return this._formatStructuredContent(data, entry, requirements);
             default:
-                throw new Error(`Unhandled data type: ${data.type}`);
+                throw new Error(`Unhandled data type: ${/** @type {import('core').SerializableObject} */ (data).type}`);
         }
     }
 
+    /**
+     * @param {import('dictionary-data').TermGlossaryImage} data
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     * @param {import('dictionary-importer').ImportRequirement[]} requirements
+     * @returns {import('dictionary-data').TermGlossaryImage}
+     */
     _formatDictionaryTermGlossaryImage(data, entry, requirements) {
-        const target = {};
-        requirements.push({type: 'image', target, args: [data, entry]});
+        /** @type {import('dictionary-data').TermGlossaryImage} */
+        const target = {
+            type: 'image',
+            path: '' // Will be populated during requirement resolution
+        };
+        requirements.push({type: 'image', target, source: data, entry});
         return target;
     }
 
+    /**
+     * @param {import('dictionary-data').TermGlossaryStructuredContent} data
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     * @param {import('dictionary-importer').ImportRequirement[]} requirements
+     * @returns {import('dictionary-data').TermGlossaryStructuredContent}
+     */
     _formatStructuredContent(data, entry, requirements) {
         const content = this._prepareStructuredContent(data.content, entry, requirements);
         return {
@@ -279,6 +357,12 @@ export class DictionaryImporter {
         };
     }
 
+    /**
+     * @param {import('structured-content').Content} content
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     * @param {import('dictionary-importer').ImportRequirement[]} requirements
+     * @returns {import('structured-content').Content}
+     */
     _prepareStructuredContent(content, entry, requirements) {
         if (typeof content === 'string' || !(typeof content === 'object' && content !== null)) {
             return content;
@@ -301,12 +385,27 @@ export class DictionaryImporter {
         return content;
     }
 
+    /**
+     * @param {import('structured-content').ImageElement} content
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     * @param {import('dictionary-importer').ImportRequirement[]} requirements
+     * @returns {import('structured-content').ImageElement}
+     */
     _prepareStructuredContentImage(content, entry, requirements) {
-        const target = {};
-        requirements.push({type: 'structured-content-image', target, args: [content, entry]});
+        /** @type {import('structured-content').ImageElement} */
+        const target = {
+            tag: 'img',
+            path: '' // Will be populated during requirement resolution
+        };
+        requirements.push({type: 'structured-content-image', target, source: content, entry});
         return target;
     }
 
+    /**
+     *
+     * @param requirements
+     * @param zipEntriesObject
+     */
     async _resolveAsyncRequirements(requirements, zipEntriesObject) {
         const media = new Map();
         const context = {zipEntriesObject, media};
@@ -320,37 +419,65 @@ export class DictionaryImporter {
         };
     }
 
+    /**
+     * @param {import('dictionary-importer').ImportRequirementContext} context
+     * @param {import('dictionary-importer').ImportRequirement} requirement
+     */
     async _resolveAsyncRequirement(context, requirement) {
-        const {type, target, args} = requirement;
-        let result;
-        switch (type) {
+        switch (requirement.type) {
             case 'image':
-                result = await this._resolveDictionaryTermGlossaryImage(context, ...args);
+                await this._resolveDictionaryTermGlossaryImage(
+                    context,
+                    requirement.target,
+                    requirement.source,
+                    requirement.entry
+                );
                 break;
             case 'structured-content-image':
-                result = await this._resolveStructuredContentImage(context, ...args);
+                await this._resolveStructuredContentImage(
+                    context,
+                    requirement.target,
+                    requirement.source,
+                    requirement.entry
+                );
                 break;
             default:
                 return;
         }
-        Object.assign(target, result);
         ++this._progressData.index;
         this._progress();
     }
 
-    async _resolveDictionaryTermGlossaryImage(context, data, entry) {
-        return await this._createImageData(context, data, entry, {type: 'image'});
-    }
-
-    async _resolveStructuredContentImage(context, content, entry) {
-        const {verticalAlign, sizeUnits} = content;
-        const result = await this._createImageData(context, content, entry, {tag: 'img'});
-        if (typeof verticalAlign === 'string') { result.verticalAlign = verticalAlign; }
-        if (typeof sizeUnits === 'string') { result.sizeUnits = sizeUnits; }
-        return result;
-    }
-
-    async _createImageData(context, data, entry, attributes) {
+    /**
+     * @param {import('dictionary-importer').ImportRequirementContext} context
+     * @param {import('dictionary-data').TermGlossaryImage} target
+     * @param {import('dictionary-data').TermGlossaryImage} source
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     */
+    async _resolveDictionaryTermGlossaryImage(context, target, source, entry) {
+        await this._createImageData(context, target, source, entry);
+    }
+
+    /**
+     * @param {import('dictionary-importer').ImportRequirementContext} context
+     * @param {import('structured-content').ImageElement} target
+     * @param {import('structured-content').ImageElement} source
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     */
+    async _resolveStructuredContentImage(context, target, source, entry) {
+        const {verticalAlign, sizeUnits} = source;
+        await this._createImageData(context, target, source, entry);
+        if (typeof verticalAlign === 'string') { target.verticalAlign = verticalAlign; }
+        if (typeof sizeUnits === 'string') { target.sizeUnits = sizeUnits; }
+    }
+
+    /**
+     * @param {import('dictionary-importer').ImportRequirementContext} context
+     * @param {import('structured-content').ImageElementBase} target
+     * @param {import('structured-content').ImageElementBase} source
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     */
+    async _createImageData(context, target, source, entry) {
         const {
             path,
             width: preferredWidth,
@@ -363,26 +490,37 @@ export class DictionaryImporter {
             background,
             collapsed,
             collapsible
-        } = data;
+        } = source;
         const {width, height} = await this._getImageMedia(context, path, entry);
-        const newData = Object.assign({}, attributes, {path, width, height});
-        if (typeof preferredWidth === 'number') { newData.preferredWidth = preferredWidth; }
-        if (typeof preferredHeight === 'number') { newData.preferredHeight = preferredHeight; }
-        if (typeof title === 'string') { newData.title = title; }
-        if (typeof description === 'string') { newData.description = description; }
-        if (typeof pixelated === 'boolean') { newData.pixelated = pixelated; }
-        if (typeof imageRendering === 'string') { newData.imageRendering = imageRendering; }
-        if (typeof appearance === 'string') { newData.appearance = appearance; }
-        if (typeof background === 'boolean') { newData.background = background; }
-        if (typeof collapsed === 'boolean') { newData.collapsed = collapsed; }
-        if (typeof collapsible === 'boolean') { newData.collapsible = collapsible; }
-        return newData;
-    }
-
+        target.path = path;
+        target.width = width;
+        target.height = height;
+        if (typeof preferredWidth === 'number') { target.preferredWidth = preferredWidth; }
+        if (typeof preferredHeight === 'number') { target.preferredHeight = preferredHeight; }
+        if (typeof title === 'string') { target.title = title; }
+        if (typeof description === 'string') { target.description = description; }
+        if (typeof pixelated === 'boolean') { target.pixelated = pixelated; }
+        if (typeof imageRendering === 'string') { target.imageRendering = imageRendering; }
+        if (typeof appearance === 'string') { target.appearance = appearance; }
+        if (typeof background === 'boolean') { target.background = background; }
+        if (typeof collapsed === 'boolean') { target.collapsed = collapsed; }
+        if (typeof collapsible === 'boolean') { target.collapsible = collapsible; }
+    }
+
+    /**
+     * @param {import('dictionary-importer').ImportRequirementContext} context
+     * @param {string} path
+     * @param {import('dictionary-database').DatabaseTermEntry} entry
+     * @returns {Promise<import('dictionary-database').MediaDataArrayBufferContent>}
+     */
     async _getImageMedia(context, path, entry) {
         const {media} = context;
         const {dictionary} = entry;
 
+        /**
+         * @param {string} message
+         * @returns {Error}
+         */
         const createError = (message) => {
             const {expression, reading} = entry;
             const readingSource = reading.length > 0 ? ` (${reading})`: '';
@@ -437,6 +575,10 @@ export class DictionaryImporter {
         return mediaData;
     }
 
+    /**
+     * @param {string} url
+     * @returns {Promise<unknown>}
+     */
     async _fetchJsonAsset(url) {
         const response = await fetch(url, {
             method: 'GET',
@@ -452,6 +594,11 @@ export class DictionaryImporter {
         return await response.json();
     }
 
+    /**
+     * @param {import('dictionary-data').TermV1} entry
+     * @param {string} dictionary
+     * @returns {import('dictionary-database').DatabaseTermEntry}
+     */
     _convertTermBankEntryV1(entry, dictionary) {
         let [expression, reading, definitionTags, rules, score, ...glossary] = entry;
         expression = this._normalizeTermOrReading(expression);
@@ -459,6 +606,11 @@ export class DictionaryImporter {
         return {expression, reading, definitionTags, rules, score, glossary, dictionary};
     }
 
+    /**
+     * @param {import('dictionary-data').TermV3} entry
+     * @param {string} dictionary
+     * @returns {import('dictionary-database').DatabaseTermEntry}
+     */
     _convertTermBankEntryV3(entry, dictionary) {
         let [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] = entry;
         expression = this._normalizeTermOrReading(expression);
@@ -466,40 +618,75 @@ export class DictionaryImporter {
         return {expression, reading, definitionTags, rules, score, glossary, sequence, termTags, dictionary};
     }
 
+    /**
+     * @param {import('dictionary-data').TermMeta} entry
+     * @param {string} dictionary
+     * @returns {import('dictionary-database').DatabaseTermMeta}
+     */
     _convertTermMetaBankEntry(entry, dictionary) {
         const [expression, mode, data] = entry;
-        return {expression, mode, data, dictionary};
+        return /** @type {import('dictionary-database').DatabaseTermMeta} */ ({expression, mode, data, dictionary});
     }
 
+    /**
+     * @param {import('dictionary-data').KanjiV1} entry
+     * @param {string} dictionary
+     * @returns {import('dictionary-database').DatabaseKanjiEntry}
+     */
     _convertKanjiBankEntryV1(entry, dictionary) {
         const [character, onyomi, kunyomi, tags, ...meanings] = entry;
         return {character, onyomi, kunyomi, tags, meanings, dictionary};
     }
 
+    /**
+     * @param {import('dictionary-data').KanjiV3} entry
+     * @param {string} dictionary
+     * @returns {import('dictionary-database').DatabaseKanjiEntry}
+     */
     _convertKanjiBankEntryV3(entry, dictionary) {
         const [character, onyomi, kunyomi, tags, meanings, stats] = entry;
         return {character, onyomi, kunyomi, tags, meanings, stats, dictionary};
     }
 
+    /**
+     * @param {import('dictionary-data').KanjiMeta} entry
+     * @param {string} dictionary
+     * @returns {import('dictionary-database').DatabaseKanjiMeta}
+     */
     _convertKanjiMetaBankEntry(entry, dictionary) {
         const [character, mode, data] = entry;
         return {character, mode, data, dictionary};
     }
 
+    /**
+     * @param {import('dictionary-data').Tag} entry
+     * @param {string} dictionary
+     * @returns {import('dictionary-database').Tag}
+     */
     _convertTagBankEntry(entry, dictionary) {
         const [name, category, order, notes, score] = entry;
         return {name, category, order, notes, score, dictionary};
     }
 
+    /**
+     * @param {import('dictionary-data').Index} index
+     * @param {import('dictionary-database').Tag[]} results
+     * @param {string} dictionary
+     */
     _addOldIndexTags(index, results, dictionary) {
         const {tagMeta} = index;
         if (typeof tagMeta !== 'object' || tagMeta === null) { return; }
-        for (const name of Object.keys(tagMeta)) {
-            const {category, order, notes, score} = tagMeta[name];
+        for (const [name, value] of Object.entries(tagMeta)) {
+            const {category, order, notes, score} = value;
             results.push({name, category, order, notes, score, dictionary});
         }
     }
 
+    /**
+     *
+     * @param zipEntriesObject
+     * @param fileNameFormat
+     */
     _getArchiveFiles(zipEntriesObject, fileNameFormat) {
         const indexPosition = fileNameFormat.indexOf('?');
         const prefix = fileNameFormat.substring(0, indexPosition);
@@ -513,6 +700,13 @@ export class DictionaryImporter {
         return results;
     }
 
+    /**
+     *
+     * @param files
+     * @param convertEntry
+     * @param schemaName
+     * @param dictionaryTitle
+     */
     async _readFileSequence(files, convertEntry, schemaName, dictionaryTitle) {
         const progressData = this._progressData;
         let startIndex = 0;
@@ -534,20 +728,28 @@ export class DictionaryImporter {
             progressData.index = startIndex + 1;
             this._progress();
 
-            for (const entry of entries) {
-                results.push(convertEntry(entry, dictionaryTitle));
+            if (Array.isArray(entries)) {
+                for (const entry of entries) {
+                    results.push(convertEntry(/** @type {TEntry} */ (entry), dictionaryTitle));
+                }
             }
         }
         return results;
     }
 
+    /**
+     * @param {import('dictionary-database').DatabaseTermMeta[]|import('dictionary-database').DatabaseKanjiMeta[]} metaList
+     * @returns {import('dictionary-importer').SummaryMetaCount}
+     */
     _getMetaCounts(metaList) {
+        /** @type {Map<string, number>} */
         const countsMap = new Map();
         for (const {mode} of metaList) {
             let count = countsMap.get(mode);
             count = typeof count !== 'undefined' ? count + 1 : 1;
             countsMap.set(mode, count);
         }
+        /** @type {import('dictionary-importer').SummaryMetaCount} */
         const counts = {total: metaList.length};
         for (const [key, value] of countsMap.entries()) {
             if (Object.prototype.hasOwnProperty.call(counts, key)) { continue; }
@@ -556,6 +758,10 @@ export class DictionaryImporter {
         return counts;
     }
 
+    /**
+     * @param {string} text
+     * @returns {string}
+     */
     _normalizeTermOrReading(text) {
         // Note: this function should not perform String.normalize on the text,
         // as it will characters in an undesirable way.
diff --git a/ext/js/language/dictionary-worker-handler.js b/ext/js/language/dictionary-worker-handler.js
index b8c41b26..8ac342b2 100644
--- a/ext/js/language/dictionary-worker-handler.js
+++ b/ext/js/language/dictionary-worker-handler.js
@@ -23,17 +23,22 @@ import {DictionaryWorkerMediaLoader} from './dictionary-worker-media-loader.js';
 
 export class DictionaryWorkerHandler {
     constructor() {
+        /** @type {DictionaryWorkerMediaLoader} */
         this._mediaLoader = new DictionaryWorkerMediaLoader();
     }
 
+    /** */
     prepare() {
         self.addEventListener('message', this._onMessage.bind(this), false);
     }
 
     // Private
 
-    _onMessage(e) {
-        const {action, params} = e.data;
+    /**
+     * @param {MessageEvent<import('dictionary-worker-handler').Message>} event
+     */
+    _onMessage(event) {
+        const {action, params} = event.data;
         switch (action) {
             case 'importDictionary':
                 this._onMessageWithProgress(params, this._importDictionary.bind(this));
@@ -50,7 +55,15 @@ export class DictionaryWorkerHandler {
         }
     }
 
+    /**
+     * @template [T=unknown]
+     * @param {T} params
+     * @param {(details: T, onProgress: import('dictionary-worker-handler').OnProgressCallback) => Promise<unknown>} handler
+     */
     async _onMessageWithProgress(params, handler) {
+        /**
+         * @param {...unknown} args
+         */
         const onProgress = (...args) => {
             self.postMessage({
                 action: 'progress',
@@ -62,11 +75,16 @@ export class DictionaryWorkerHandler {
             const result = await handler(params, onProgress);
             response = {result};
         } catch (e) {
-            response = {error: serializeError(e)};
+            response = {error: ExtensionError.serialize(e)};
         }
         self.postMessage({action: 'complete', params: response});
     }
 
+    /**
+     * @param {import('dictionary-worker-handler').ImportDictionaryMessageParams} details
+     * @param {import('dictionary-worker-handler').OnProgressCallback} onProgress
+     * @returns {Promise<import('dictionary-worker').MessageCompleteResultSerialized>}
+     */
     async _importDictionary({details, archiveContent}, onProgress) {
         const dictionaryDatabase = await this._getPreparedDictionaryDatabase();
         try {
@@ -74,13 +92,18 @@ export class DictionaryWorkerHandler {
             const {result, errors} = await dictionaryImporter.importDictionary(dictionaryDatabase, archiveContent, details);
             return {
                 result,
-                errors: errors.map((error) => serializeError(error))
+                errors: errors.map((error) => ExtensionError.serialize(error))
             };
         } finally {
             dictionaryDatabase.close();
         }
     }
 
+    /**
+     * @param {import('dictionary-worker-handler').DeleteDictionaryMessageParams} details
+     * @param {import('dictionary-database').DeleteDictionaryProgressCallback} onProgress
+     * @returns {Promise<void>}
+     */
     async _deleteDictionary({dictionaryTitle}, onProgress) {
         const dictionaryDatabase = await this._getPreparedDictionaryDatabase();
         try {
@@ -90,6 +113,10 @@ export class DictionaryWorkerHandler {
         }
     }
 
+    /**
+     * @param {import('dictionary-worker-handler').GetDictionaryCountsMessageParams} details
+     * @returns {Promise<import('dictionary-database').DictionaryCounts>}
+     */
     async _getDictionaryCounts({dictionaryNames, getTotal}) {
         const dictionaryDatabase = await this._getPreparedDictionaryDatabase();
         try {
@@ -99,6 +126,9 @@ export class DictionaryWorkerHandler {
         }
     }
 
+    /**
+     * @returns {Promise<DictionaryDatabase>}
+     */
     async _getPreparedDictionaryDatabase() {
         const dictionaryDatabase = new DictionaryDatabase();
         await dictionaryDatabase.prepare();
diff --git a/ext/js/language/dictionary-worker-media-loader.js b/ext/js/language/dictionary-worker-media-loader.js
index d58e46c5..9e3fd67e 100644
--- a/ext/js/language/dictionary-worker-media-loader.js
+++ b/ext/js/language/dictionary-worker-media-loader.js
@@ -27,12 +27,13 @@ export class DictionaryWorkerMediaLoader {
      * Creates a new instance of the media loader.
      */
     constructor() {
+        /** @type {Map<string, {resolve: (result: import('dictionary-worker-media-loader').ImageDetails) => void, reject: (reason?: import('core').RejectionReason) => void}>} */
         this._requests = new Map();
     }
 
     /**
      * Handles a response message posted to the worker thread.
-     * @param {{id: string, error: object|undefined, result: any|undefined}} params Details of the response.
+     * @param {import('dictionary-worker-media-loader').HandleMessageParams} params Details of the response.
      */
     handleMessage(params) {
         const {id} = params;
@@ -41,24 +42,19 @@ export class DictionaryWorkerMediaLoader {
         this._requests.delete(id);
         const {error} = params;
         if (typeof error !== 'undefined') {
-            request.reject(deserializeError(error));
+            request.reject(ExtensionError.deserialize(error));
         } else {
             request.resolve(params.result);
         }
     }
 
-    /**
-     * Attempts to load an image using an ArrayBuffer and a media type to return details about it.
-     * @param {ArrayBuffer} content The binary content for the image, encoded as an ArrayBuffer.
-     * @param {string} mediaType The media type for the image content.
-     * @returns {Promise<{content: ArrayBuffer, width: number, height: number}>} Details about the requested image content.
-     * @throws {Error} An error can be thrown if the image fails to load.
-     */
+    /** @type {import('dictionary-importer-media-loader').GetImageDetailsFunction} */
     getImageDetails(content, mediaType) {
         return new Promise((resolve, reject) => {
             const id = generateId(16);
             this._requests.set(id, {resolve, reject});
-            self.postMessage({
+            // This is executed in a Worker context, so the self needs to be force cast
+            /** @type {Worker} */ (/** @type {unknown} */ (self)).postMessage({
                 action: 'getImageDetails',
                 params: {id, content, mediaType}
             }, [content]);
diff --git a/ext/js/language/dictionary-worker.js b/ext/js/language/dictionary-worker.js
index 18c300af..b9d0236c 100644
--- a/ext/js/language/dictionary-worker.js
+++ b/ext/js/language/dictionary-worker.js
@@ -16,37 +16,64 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {deserializeError, serializeError} from '../core.js';
 import {DictionaryImporterMediaLoader} from './dictionary-importer-media-loader.js';
 
 export class DictionaryWorker {
     constructor() {
+        /** @type {DictionaryImporterMediaLoader} */
         this._dictionaryImporterMediaLoader = new DictionaryImporterMediaLoader();
     }
 
+    /**
+     * @param {ArrayBuffer} archiveContent
+     * @param {import('dictionary-importer').ImportDetails} details
+     * @param {?import('dictionary-worker').ImportProgressCallback} onProgress
+     * @returns {Promise<import('dictionary-importer').ImportResult>}
+     */
     importDictionary(archiveContent, details, onProgress) {
         return this._invoke(
             'importDictionary',
             {details, archiveContent},
             [archiveContent],
             onProgress,
-            this._formatimportDictionaryResult.bind(this)
+            this._formatImportDictionaryResult.bind(this)
         );
     }
 
+    /**
+     * @param {string} dictionaryTitle
+     * @param {?import('dictionary-worker').DeleteProgressCallback} onProgress
+     * @returns {Promise<void>}
+     */
     deleteDictionary(dictionaryTitle, onProgress) {
-        return this._invoke('deleteDictionary', {dictionaryTitle}, [], onProgress);
+        return this._invoke('deleteDictionary', {dictionaryTitle}, [], onProgress, null);
     }
 
+    /**
+     * @param {string[]} dictionaryNames
+     * @param {boolean} getTotal
+     * @returns {Promise<import('dictionary-database').DictionaryCounts>}
+     */
     getDictionaryCounts(dictionaryNames, getTotal) {
-        return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal}, [], null);
+        return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal}, [], null, null);
     }
 
     // Private
 
+    /**
+     * @template [TParams=import('core').SerializableObject]
+     * @template [TResponseRaw=unknown]
+     * @template [TResponse=unknown]
+     * @param {string} action
+     * @param {TParams} params
+     * @param {Transferable[]} transfer
+     * @param {?(arg: import('core').SafeAny) => void} onProgress
+     * @param {?(result: TResponseRaw) => TResponse} formatResult
+     */
     _invoke(action, params, transfer, onProgress, formatResult) {
         return new Promise((resolve, reject) => {
             const worker = new Worker('/js/language/dictionary-worker-main.js', {type: 'module'});
+            /** @type {import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>} */
             const details = {
                 complete: false,
                 worker,
@@ -56,20 +83,29 @@ export class DictionaryWorker {
                 onProgress,
                 formatResult
             };
-            const onMessage = this._onMessage.bind(this, details);
+            // Ugly typecast below due to not being able to explicitly state the template types
+            /** @type {(event: MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>) => void} */
+            const onMessage = /** @type {(details: import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>, event: MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>) => void} */ (this._onMessage).bind(this, details);
             details.onMessage = onMessage;
             worker.addEventListener('message', onMessage);
             worker.postMessage({action, params}, transfer);
         });
     }
 
-    _onMessage(details, e) {
+    /**
+     * @template [TResponseRaw=unknown]
+     * @template [TResponse=unknown]
+     * @param {import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>} details
+     * @param {MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>} event
+     */
+    _onMessage(details, event) {
         if (details.complete) { return; }
-        const {action, params} = e.data;
+        const {action, params} = event.data;
         switch (action) {
             case 'complete':
                 {
                     const {worker, resolve, reject, onMessage, formatResult} = details;
+                    if (worker === null || onMessage === null || resolve === null || reject === null) { return; }
                     details.complete = true;
                     details.worker = null;
                     details.resolve = null;
@@ -86,50 +122,82 @@ export class DictionaryWorker {
                 this._onMessageProgress(params, details.onProgress);
                 break;
             case 'getImageDetails':
-                this._onMessageGetImageDetails(params, details.worker);
+                {
+                    const {worker} = details;
+                    if (worker === null) { return; }
+                    this._onMessageGetImageDetails(params, worker);
+                }
                 break;
         }
     }
 
+    /**
+     * @template [TResponseRaw=unknown]
+     * @template [TResponse=unknown]
+     * @param {import('dictionary-worker').MessageCompleteParams<TResponseRaw>} params
+     * @param {(result: TResponse) => void} resolve
+     * @param {(reason?: import('core').RejectionReason) => void} reject
+     * @param {?(result: TResponseRaw) => TResponse} formatResult
+     */
     _onMessageComplete(params, resolve, reject, formatResult) {
         const {error} = params;
         if (typeof error !== 'undefined') {
-            reject(deserializeError(error));
+            reject(ExtensionError.deserialize(error));
         } else {
-            let {result} = params;
-            try {
-                if (typeof formatResult === 'function') {
-                    result = formatResult(result);
+            const {result} = params;
+            if (typeof formatResult === 'function') {
+                let result2;
+                try {
+                    result2 = formatResult(result);
+                } catch (e) {
+                    reject(e);
+                    return;
                 }
-            } catch (e) {
-                reject(e);
-                return;
+                resolve(result2);
+            } else {
+                // If formatResult is not provided, the response is assumed to be the same type
+                resolve(/** @type {TResponse} */ (/** @type {unknown} */ (result)));
             }
-            resolve(result);
         }
     }
 
+    /**
+     * @param {import('dictionary-worker').MessageProgressParams} params
+     * @param {?(...args: unknown[]) => void} onProgress
+     */
     _onMessageProgress(params, onProgress) {
         if (typeof onProgress !== 'function') { return; }
         const {args} = params;
         onProgress(...args);
     }
 
+    /**
+     * @param {import('dictionary-worker').MessageGetImageDetailsParams} params
+     * @param {Worker} worker
+     */
     async _onMessageGetImageDetails(params, worker) {
         const {id, content, mediaType} = params;
+        /** @type {Transferable[]} */
         const transfer = [];
         let response;
         try {
             const result = await this._dictionaryImporterMediaLoader.getImageDetails(content, mediaType, transfer);
             response = {id, result};
         } catch (e) {
-            response = {id, error: serializeError(e)};
+            response = {id, error: ExtensionError.serialize(e)};
         }
         worker.postMessage({action: 'getImageDetails.response', params: response}, transfer);
     }
 
-    _formatimportDictionaryResult(result) {
-        result.errors = result.errors.map((error) => deserializeError(error));
-        return result;
+    /**
+     * @param {import('dictionary-worker').MessageCompleteResultSerialized} response
+     * @returns {import('dictionary-worker').MessageCompleteResult}
+     */
+    _formatImportDictionaryResult(response) {
+        const {result, errors} = response;
+        return {
+            result,
+            errors: errors.map((error) => ExtensionError.deserialize(error))
+        };
     }
 }
diff --git a/ext/js/language/sandbox/dictionary-data-util.js b/ext/js/language/sandbox/dictionary-data-util.js
index 1b71346a..a54b043b 100644
--- a/ext/js/language/sandbox/dictionary-data-util.js
+++ b/ext/js/language/sandbox/dictionary-data-util.js
@@ -17,6 +17,10 @@
  */
 
 export class DictionaryDataUtil {
+    /**
+     * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
+     * @returns {import('dictionary-data-util').TagGroup[]}
+     */
     static groupTermTags(dictionaryEntry) {
         const {headwords} = dictionaryEntry;
         const headwordCount = headwords.length;
@@ -27,8 +31,8 @@ export class DictionaryDataUtil {
             const {tags} = headwords[i];
             for (const tag of tags) {
                 if (uniqueCheck) {
-                    const {name, category, notes, dictionary} = tag;
-                    const key = this._createMapKey([name, category, notes, dictionary]);
+                    const {name, category, content, dictionaries} = tag;
+                    const key = this._createMapKey([name, category, content, dictionaries]);
                     const index = resultsIndexMap.get(key);
                     if (typeof index !== 'undefined') {
                         const existingItem = results[index];
@@ -45,11 +49,16 @@ export class DictionaryDataUtil {
         return results;
     }
 
+    /**
+     * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
+     * @returns {import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').TermFrequency>[]}
+     */
     static groupTermFrequencies(dictionaryEntry) {
-        const {headwords, frequencies} = dictionaryEntry;
+        const {headwords, frequencies: sourceFrequencies} = dictionaryEntry;
 
+        /** @type {import('dictionary-data-util').TermFrequenciesMap1} */
         const map1 = new Map();
-        for (const {headwordIndex, dictionary, hasReading, frequency, displayValue} of frequencies) {
+        for (const {headwordIndex, dictionary, hasReading, frequency, displayValue} of sourceFrequencies) {
             const {term, reading} = headwords[headwordIndex];
 
             let map2 = map1.get(dictionary);
@@ -68,12 +77,30 @@ export class DictionaryDataUtil {
 
             frequencyData.values.set(this._createMapKey([frequency, displayValue]), {frequency, displayValue});
         }
-        return this._createFrequencyGroupsFromMap(map1);
+
+        const results = [];
+        for (const [dictionary, map2] of map1.entries()) {
+            const frequencies = [];
+            for (const {term, reading, values} of map2.values()) {
+                frequencies.push({
+                    term,
+                    reading,
+                    values: [...values.values()]
+                });
+            }
+            results.push({dictionary, frequencies});
+        }
+        return results;
     }
 
-    static groupKanjiFrequencies(frequencies) {
+    /**
+     * @param {import('dictionary').KanjiFrequency[]} sourceFrequencies
+     * @returns {import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').KanjiFrequency>[]}
+     */
+    static groupKanjiFrequencies(sourceFrequencies) {
+        /** @type {import('dictionary-data-util').KanjiFrequenciesMap1} */
         const map1 = new Map();
-        for (const {dictionary, character, frequency, displayValue} of frequencies) {
+        for (const {dictionary, character, frequency, displayValue} of sourceFrequencies) {
             let map2 = map1.get(dictionary);
             if (typeof map2 === 'undefined') {
                 map2 = new Map();
@@ -88,9 +115,25 @@ export class DictionaryDataUtil {
 
             frequencyData.values.set(this._createMapKey([frequency, displayValue]), {frequency, displayValue});
         }
-        return this._createFrequencyGroupsFromMap(map1);
+
+        const results = [];
+        for (const [dictionary, map2] of map1.entries()) {
+            const frequencies = [];
+            for (const {character, values} of map2.values()) {
+                frequencies.push({
+                    character,
+                    values: [...values.values()]
+                });
+            }
+            results.push({dictionary, frequencies});
+        }
+        return results;
     }
 
+    /**
+     * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
+     * @returns {import('dictionary-data-util').DictionaryGroupedPronunciations[]}
+     */
     static getGroupedPronunciations(dictionaryEntry) {
         const {headwords, pronunciations} = dictionaryEntry;
 
@@ -101,6 +144,7 @@ export class DictionaryDataUtil {
             allReadings.add(reading);
         }
 
+        /** @type {Map<string, import('dictionary-data-util').GroupedPronunciationInternal[]>} */
         const groupedPronunciationsMap = new Map();
         for (const {headwordIndex, dictionary, pitches} of pronunciations) {
             const {term, reading} = headwords[headwordIndex];
@@ -118,9 +162,7 @@ export class DictionaryDataUtil {
                         position,
                         nasalPositions,
                         devoicePositions,
-                        tags,
-                        exclusiveTerms: [],
-                        exclusiveReadings: []
+                        tags
                     };
                     dictionaryGroupedPronunciationList.push(groupedPronunciation);
                 }
@@ -128,27 +170,39 @@ export class DictionaryDataUtil {
             }
         }
 
+        /** @type {import('dictionary-data-util').DictionaryGroupedPronunciations[]} */
+        const results2 = [];
         const multipleReadings = (allReadings.size > 1);
-        for (const dictionaryGroupedPronunciationList of groupedPronunciationsMap.values()) {
+        for (const [dictionary, dictionaryGroupedPronunciationList] of groupedPronunciationsMap.entries()) {
+            /** @type {import('dictionary-data-util').GroupedPronunciation[]} */
+            const pronunciations2 = [];
             for (const groupedPronunciation of dictionaryGroupedPronunciationList) {
-                const {terms, reading, exclusiveTerms, exclusiveReadings} = groupedPronunciation;
-                if (!this._areSetsEqual(terms, allTerms)) {
-                    exclusiveTerms.push(...this._getSetIntersection(terms, allTerms));
-                }
+                const {terms, reading, position, nasalPositions, devoicePositions, tags} = groupedPronunciation;
+                const exclusiveTerms = !this._areSetsEqual(terms, allTerms) ? this._getSetIntersection(terms, allTerms) : [];
+                const exclusiveReadings = [];
                 if (multipleReadings) {
                     exclusiveReadings.push(reading);
                 }
-                groupedPronunciation.terms = [...terms];
+                pronunciations2.push({
+                    terms: [...terms],
+                    reading,
+                    position,
+                    nasalPositions,
+                    devoicePositions,
+                    tags,
+                    exclusiveTerms,
+                    exclusiveReadings
+                });
             }
-        }
-
-        const results2 = [];
-        for (const [dictionary, pronunciations2] of groupedPronunciationsMap.entries()) {
             results2.push({dictionary, pronunciations: pronunciations2});
         }
         return results2;
     }
 
+    /**
+     * @param {import('dictionary').Tag[]|import('anki-templates').Tag[]} termTags
+     * @returns {import('dictionary-data-util').TermFrequencyType}
+     */
     static getTermFrequency(termTags) {
         let totalScore = 0;
         for (const {score} of termTags) {
@@ -163,10 +217,19 @@ export class DictionaryDataUtil {
         }
     }
 
+    /**
+     * @param {import('dictionary').TermHeadword[]} headwords
+     * @param {number[]} headwordIndices
+     * @param {Set<string>} allTermsSet
+     * @param {Set<string>} allReadingsSet
+     * @returns {string[]}
+     */
     static getDisambiguations(headwords, headwordIndices, allTermsSet, allReadingsSet) {
         if (allTermsSet.size <= 1 && allReadingsSet.size <= 1) { return []; }
 
+        /** @type {Set<string>} */
         const terms = new Set();
+        /** @type {Set<string>} */
         const readings = new Set();
         for (const headwordIndex of headwordIndices) {
             const {term, reading} = headwords[headwordIndex];
@@ -174,6 +237,7 @@ export class DictionaryDataUtil {
             readings.add(reading);
         }
 
+        /** @type {string[]} */
         const disambiguations = [];
         const addTerms = !this._areSetsEqual(terms, allTermsSet);
         const addReadings = !this._areSetsEqual(readings, allReadingsSet);
@@ -191,6 +255,10 @@ export class DictionaryDataUtil {
         return disambiguations;
     }
 
+    /**
+     * @param {string[]} wordClasses
+     * @returns {boolean}
+     */
     static isNonNounVerbOrAdjective(wordClasses) {
         let isVerbOrAdjective = false;
         let isSuruVerb = false;
@@ -218,19 +286,15 @@ export class DictionaryDataUtil {
 
     // Private
 
-    static _createFrequencyGroupsFromMap(map) {
-        const results = [];
-        for (const [dictionary, map2] of map.entries()) {
-            const frequencies = [];
-            for (const frequencyData of map2.values()) {
-                frequencyData.values = [...frequencyData.values.values()];
-                frequencies.push(frequencyData);
-            }
-            results.push({dictionary, frequencies});
-        }
-        return results;
-    }
-
+    /**
+     * @param {string} reading
+     * @param {number} position
+     * @param {number[]} nasalPositions
+     * @param {number[]} devoicePositions
+     * @param {import('dictionary').Tag[]} tags
+     * @param {import('dictionary-data-util').GroupedPronunciationInternal[]} groupedPronunciationList
+     * @returns {?import('dictionary-data-util').GroupedPronunciationInternal}
+     */
     static _findExistingGroupedPronunciation(reading, position, nasalPositions, devoicePositions, tags, groupedPronunciationList) {
         for (const pitchInfo of groupedPronunciationList) {
             if (
@@ -246,6 +310,12 @@ export class DictionaryDataUtil {
         return null;
     }
 
+    /**
+     * @template [T=unknown]
+     * @param {T[]} array1
+     * @param {T[]} array2
+     * @returns {boolean}
+     */
     static _areArraysEqual(array1, array2) {
         const ii = array1.length;
         if (ii !== array2.length) { return false; }
@@ -255,6 +325,11 @@ export class DictionaryDataUtil {
         return true;
     }
 
+    /**
+     * @param {import('dictionary').Tag[]} tagList1
+     * @param {import('dictionary').Tag[]} tagList2
+     * @returns {boolean}
+     */
     static _areTagListsEqual(tagList1, tagList2) {
         const ii = tagList1.length;
         if (tagList2.length !== ii) { return false; }
@@ -262,7 +337,7 @@ export class DictionaryDataUtil {
         for (let i = 0; i < ii; ++i) {
             const tag1 = tagList1[i];
             const tag2 = tagList2[i];
-            if (tag1.name !== tag2.name || tag1.dictionary !== tag2.dictionary) {
+            if (tag1.name !== tag2.name || !this._areArraysEqual(tag1.dictionaries, tag2.dictionaries)) {
                 return false;
             }
         }
@@ -270,6 +345,12 @@ export class DictionaryDataUtil {
         return true;
     }
 
+    /**
+     * @template [T=unknown]
+     * @param {Set<T>} set1
+     * @param {Set<T>} set2
+     * @returns {boolean}
+     */
     static _areSetsEqual(set1, set2) {
         if (set1.size !== set2.size) {
             return false;
@@ -284,6 +365,12 @@ export class DictionaryDataUtil {
         return true;
     }
 
+    /**
+     * @template [T=unknown]
+     * @param {Set<T>} set1
+     * @param {Set<T>} set2
+     * @returns {T[]}
+     */
     static _getSetIntersection(set1, set2) {
         const result = [];
         for (const value of set1) {
@@ -294,6 +381,10 @@ export class DictionaryDataUtil {
         return result;
     }
 
+    /**
+     * @param {unknown[]} array
+     * @returns {string}
+     */
     static _createMapKey(array) {
         return JSON.stringify(array);
     }
diff --git a/ext/js/language/sandbox/japanese-util.js b/ext/js/language/sandbox/japanese-util.js
index 316b1c2e..f7f20b3b 100644
--- a/ext/js/language/sandbox/japanese-util.js
+++ b/ext/js/language/sandbox/japanese-util.js
@@ -16,710 +16,877 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-export const JapaneseUtil = (() => {
-    const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063;
-    const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3;
-    const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5;
-    const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6;
-    const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
-
-    const HIRAGANA_RANGE = [0x3040, 0x309f];
-    const KATAKANA_RANGE = [0x30a0, 0x30ff];
-
-    const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
-    const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
-
-    const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE];
-
-    const CJK_UNIFIED_IDEOGRAPHS_RANGE = [0x4e00, 0x9fff];
-    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE = [0x3400, 0x4dbf];
-    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE = [0x20000, 0x2a6df];
-    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE = [0x2a700, 0x2b73f];
-    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE = [0x2b740, 0x2b81f];
-    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE = [0x2b820, 0x2ceaf];
-    const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE = [0x2ceb0, 0x2ebef];
-    const CJK_COMPATIBILITY_IDEOGRAPHS_RANGE = [0xf900, 0xfaff];
-    const CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE = [0x2f800, 0x2fa1f];
-    const CJK_IDEOGRAPH_RANGES = [
-        CJK_UNIFIED_IDEOGRAPHS_RANGE,
-        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE,
-        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE,
-        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE,
-        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE,
-        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE,
-        CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE,
-        CJK_COMPATIBILITY_IDEOGRAPHS_RANGE,
-        CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE
-    ];
-
-    // Japanese character ranges, roughly ordered in order of expected frequency
-    const JAPANESE_RANGES = [
-        HIRAGANA_RANGE,
-        KATAKANA_RANGE,
-
-        ...CJK_IDEOGRAPH_RANGES,
-
-        [0xff66, 0xff9f], // Halfwidth katakana
-
-        [0x30fb, 0x30fc], // Katakana punctuation
-        [0xff61, 0xff65], // Kana punctuation
-        [0x3000, 0x303f], // CJK punctuation
-
-        [0xff10, 0xff19], // Fullwidth numbers
-        [0xff21, 0xff3a], // Fullwidth upper case Latin letters
-        [0xff41, 0xff5a], // Fullwidth lower case Latin letters
-
-        [0xff01, 0xff0f], // Fullwidth punctuation 1
-        [0xff1a, 0xff1f], // Fullwidth punctuation 2
-        [0xff3b, 0xff3f], // Fullwidth punctuation 3
-        [0xff5b, 0xff60], // Fullwidth punctuation 4
-        [0xffe0, 0xffee]  // Currency markers
-    ];
-
-    const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ'));
-
-    const HALFWIDTH_KATAKANA_MAPPING = new Map([
-        ['ヲ', 'ヲヺ-'],
-        ['ァ', 'ァ--'],
-        ['ィ', 'ィ--'],
-        ['ゥ', 'ゥ--'],
-        ['ェ', 'ェ--'],
-        ['ォ', 'ォ--'],
-        ['ャ', 'ャ--'],
-        ['ュ', 'ュ--'],
-        ['ョ', 'ョ--'],
-        ['ッ', 'ッ--'],
-        ['ー', 'ー--'],
-        ['ア', 'ア--'],
-        ['イ', 'イ--'],
-        ['ウ', 'ウヴ-'],
-        ['エ', 'エ--'],
-        ['オ', 'オ--'],
-        ['カ', 'カガ-'],
-        ['キ', 'キギ-'],
-        ['ク', 'クグ-'],
-        ['ケ', 'ケゲ-'],
-        ['コ', 'コゴ-'],
-        ['サ', 'サザ-'],
-        ['シ', 'シジ-'],
-        ['ス', 'スズ-'],
-        ['セ', 'セゼ-'],
-        ['ソ', 'ソゾ-'],
-        ['タ', 'タダ-'],
-        ['チ', 'チヂ-'],
-        ['ツ', 'ツヅ-'],
-        ['テ', 'テデ-'],
-        ['ト', 'トド-'],
-        ['ナ', 'ナ--'],
-        ['ニ', 'ニ--'],
-        ['ヌ', 'ヌ--'],
-        ['ネ', 'ネ--'],
-        ['ノ', 'ノ--'],
-        ['ハ', 'ハバパ'],
-        ['ヒ', 'ヒビピ'],
-        ['フ', 'フブプ'],
-        ['ヘ', 'ヘベペ'],
-        ['ホ', 'ホボポ'],
-        ['マ', 'マ--'],
-        ['ミ', 'ミ--'],
-        ['ム', 'ム--'],
-        ['メ', 'メ--'],
-        ['モ', 'モ--'],
-        ['ヤ', 'ヤ--'],
-        ['ユ', 'ユ--'],
-        ['ヨ', 'ヨ--'],
-        ['ラ', 'ラ--'],
-        ['リ', 'リ--'],
-        ['ル', 'ル--'],
-        ['レ', 'レ--'],
-        ['ロ', 'ロ--'],
-        ['ワ', 'ワ--'],
-        ['ン', 'ン--']
-    ]);
-
-    const VOWEL_TO_KANA_MAPPING = new Map([
-        ['a', 'ぁあかがさざただなはばぱまゃやらゎわヵァアカガサザタダナハバパマャヤラヮワヵヷ'],
-        ['i', 'ぃいきぎしじちぢにひびぴみりゐィイキギシジチヂニヒビピミリヰヸ'],
-        ['u', 'ぅうくぐすずっつづぬふぶぷむゅゆるゥウクグスズッツヅヌフブプムュユルヴ'],
-        ['e', 'ぇえけげせぜてでねへべぺめれゑヶェエケゲセゼテデネヘベペメレヱヶヹ'],
-        ['o', 'ぉおこごそぞとどのほぼぽもょよろをォオコゴソゾトドノホボポモョヨロヲヺ'],
-        ['', 'のノ']
-    ]);
-
-    const KANA_TO_VOWEL_MAPPING = (() => {
-        const map = new Map();
-        for (const [vowel, characters] of VOWEL_TO_KANA_MAPPING) {
-            for (const character of characters) {
-                map.set(character, vowel);
-            }
-        }
-        return map;
-    })();
-
-    const DIACRITIC_MAPPING = (() => {
-        const kana = 'うゔ-かが-きぎ-くぐ-けげ-こご-さざ-しじ-すず-せぜ-そぞ-ただ-ちぢ-つづ-てで-とど-はばぱひびぴふぶぷへべぺほぼぽワヷ-ヰヸ-ウヴ-ヱヹ-ヲヺ-カガ-キギ-クグ-ケゲ-コゴ-サザ-シジ-スズ-セゼ-ソゾ-タダ-チヂ-ツヅ-テデ-トド-ハバパヒビピフブプヘベペホボポ';
-        const map = new Map();
-        for (let i = 0, ii = kana.length; i < ii; i += 3) {
-            const character = kana[i];
-            const dakuten = kana[i + 1];
-            const handakuten = kana[i + 2];
-            map.set(dakuten, {character, type: 'dakuten'});
-            if (handakuten !== '-') {
-                map.set(handakuten, {character, type: 'handakuten'});
-            }
+const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063;
+const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3;
+const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5;
+const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6;
+const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
+
+/** @type {import('japanese-util').CodepointRange} */
+const HIRAGANA_RANGE = [0x3040, 0x309f];
+/** @type {import('japanese-util').CodepointRange} */
+const KATAKANA_RANGE = [0x30a0, 0x30ff];
+
+/** @type {import('japanese-util').CodepointRange} */
+const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
+/** @type {import('japanese-util').CodepointRange} */
+const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
+
+/** @type {import('japanese-util').CodepointRange[]} */
+const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE];
+
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_UNIFIED_IDEOGRAPHS_RANGE = [0x4e00, 0x9fff];
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE = [0x3400, 0x4dbf];
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE = [0x20000, 0x2a6df];
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE = [0x2a700, 0x2b73f];
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE = [0x2b740, 0x2b81f];
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE = [0x2b820, 0x2ceaf];
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE = [0x2ceb0, 0x2ebef];
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_COMPATIBILITY_IDEOGRAPHS_RANGE = [0xf900, 0xfaff];
+/** @type {import('japanese-util').CodepointRange} */
+const CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE = [0x2f800, 0x2fa1f];
+/** @type {import('japanese-util').CodepointRange[]} */
+const CJK_IDEOGRAPH_RANGES = [
+    CJK_UNIFIED_IDEOGRAPHS_RANGE,
+    CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE,
+    CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE,
+    CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE,
+    CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE,
+    CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE,
+    CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE,
+    CJK_COMPATIBILITY_IDEOGRAPHS_RANGE,
+    CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE
+];
+
+// Japanese character ranges, roughly ordered in order of expected frequency
+/** @type {import('japanese-util').CodepointRange[]} */
+const JAPANESE_RANGES = [
+    HIRAGANA_RANGE,
+    KATAKANA_RANGE,
+
+    ...CJK_IDEOGRAPH_RANGES,
+
+    [0xff66, 0xff9f], // Halfwidth katakana
+
+    [0x30fb, 0x30fc], // Katakana punctuation
+    [0xff61, 0xff65], // Kana punctuation
+    [0x3000, 0x303f], // CJK punctuation
+
+    [0xff10, 0xff19], // Fullwidth numbers
+    [0xff21, 0xff3a], // Fullwidth upper case Latin letters
+    [0xff41, 0xff5a], // Fullwidth lower case Latin letters
+
+    [0xff01, 0xff0f], // Fullwidth punctuation 1
+    [0xff1a, 0xff1f], // Fullwidth punctuation 2
+    [0xff3b, 0xff3f], // Fullwidth punctuation 3
+    [0xff5b, 0xff60], // Fullwidth punctuation 4
+    [0xffe0, 0xffee]  // Currency markers
+];
+
+const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ'));
+
+const HALFWIDTH_KATAKANA_MAPPING = new Map([
+    ['ヲ', 'ヲヺ-'],
+    ['ァ', 'ァ--'],
+    ['ィ', 'ィ--'],
+    ['ゥ', 'ゥ--'],
+    ['ェ', 'ェ--'],
+    ['ォ', 'ォ--'],
+    ['ャ', 'ャ--'],
+    ['ュ', 'ュ--'],
+    ['ョ', 'ョ--'],
+    ['ッ', 'ッ--'],
+    ['ー', 'ー--'],
+    ['ア', 'ア--'],
+    ['イ', 'イ--'],
+    ['ウ', 'ウヴ-'],
+    ['エ', 'エ--'],
+    ['オ', 'オ--'],
+    ['カ', 'カガ-'],
+    ['キ', 'キギ-'],
+    ['ク', 'クグ-'],
+    ['ケ', 'ケゲ-'],
+    ['コ', 'コゴ-'],
+    ['サ', 'サザ-'],
+    ['シ', 'シジ-'],
+    ['ス', 'スズ-'],
+    ['セ', 'セゼ-'],
+    ['ソ', 'ソゾ-'],
+    ['タ', 'タダ-'],
+    ['チ', 'チヂ-'],
+    ['ツ', 'ツヅ-'],
+    ['テ', 'テデ-'],
+    ['ト', 'トド-'],
+    ['ナ', 'ナ--'],
+    ['ニ', 'ニ--'],
+    ['ヌ', 'ヌ--'],
+    ['ネ', 'ネ--'],
+    ['ノ', 'ノ--'],
+    ['ハ', 'ハバパ'],
+    ['ヒ', 'ヒビピ'],
+    ['フ', 'フブプ'],
+    ['ヘ', 'ヘベペ'],
+    ['ホ', 'ホボポ'],
+    ['マ', 'マ--'],
+    ['ミ', 'ミ--'],
+    ['ム', 'ム--'],
+    ['メ', 'メ--'],
+    ['モ', 'モ--'],
+    ['ヤ', 'ヤ--'],
+    ['ユ', 'ユ--'],
+    ['ヨ', 'ヨ--'],
+    ['ラ', 'ラ--'],
+    ['リ', 'リ--'],
+    ['ル', 'ル--'],
+    ['レ', 'レ--'],
+    ['ロ', 'ロ--'],
+    ['ワ', 'ワ--'],
+    ['ン', 'ン--']
+]);
+
+const VOWEL_TO_KANA_MAPPING = new Map([
+    ['a', 'ぁあかがさざただなはばぱまゃやらゎわヵァアカガサザタダナハバパマャヤラヮワヵヷ'],
+    ['i', 'ぃいきぎしじちぢにひびぴみりゐィイキギシジチヂニヒビピミリヰヸ'],
+    ['u', 'ぅうくぐすずっつづぬふぶぷむゅゆるゥウクグスズッツヅヌフブプムュユルヴ'],
+    ['e', 'ぇえけげせぜてでねへべぺめれゑヶェエケゲセゼテデネヘベペメレヱヶヹ'],
+    ['o', 'ぉおこごそぞとどのほぼぽもょよろをォオコゴソゾトドノホボポモョヨロヲヺ'],
+    ['', 'のノ']
+]);
+
+const KANA_TO_VOWEL_MAPPING = (() => {
+    /** @type {Map<string, string>} */
+    const map = new Map();
+    for (const [vowel, characters] of VOWEL_TO_KANA_MAPPING) {
+        for (const character of characters) {
+            map.set(character, vowel);
         }
-        return map;
-    })();
-
-
-    function isCodePointInRange(codePoint, [min, max]) {
-        return (codePoint >= min && codePoint <= max);
     }
+    return map;
+})();
 
-    function isCodePointInRanges(codePoint, ranges) {
-        for (const [min, max] of ranges) {
-            if (codePoint >= min && codePoint <= max) {
-                return true;
-            }
+const DIACRITIC_MAPPING = (() => {
+    const kana = 'うゔ-かが-きぎ-くぐ-けげ-こご-さざ-しじ-すず-せぜ-そぞ-ただ-ちぢ-つづ-てで-とど-はばぱひびぴふぶぷへべぺほぼぽワヷ-ヰヸ-ウヴ-ヱヹ-ヲヺ-カガ-キギ-クグ-ケゲ-コゴ-サザ-シジ-スズ-セゼ-ソゾ-タダ-チヂ-ツヅ-テデ-トド-ハバパヒビピフブプヘベペホボポ';
+    /** @type {Map<string, {character: string, type: import('japanese-util').DiacriticType}>} */
+    const map = new Map();
+    for (let i = 0, ii = kana.length; i < ii; i += 3) {
+        const character = kana[i];
+        const dakuten = kana[i + 1];
+        const handakuten = kana[i + 2];
+        map.set(dakuten, {character, type: 'dakuten'});
+        if (handakuten !== '-') {
+            map.set(handakuten, {character, type: 'handakuten'});
         }
-        return false;
     }
+    return map;
+})();
+
 
-    function getProlongedHiragana(previousCharacter) {
-        switch (KANA_TO_VOWEL_MAPPING.get(previousCharacter)) {
-            case 'a': return 'あ';
-            case 'i': return 'い';
-            case 'u': return 'う';
-            case 'e': return 'え';
-            case 'o': return 'う';
-            default: return null;
+/**
+ * @param {number} codePoint
+ * @param {import('japanese-util').CodepointRange} range
+ * @returns {boolean}
+ */
+// eslint-disable-next-line no-implicit-globals
+function isCodePointInRange(codePoint, [min, max]) {
+    return (codePoint >= min && codePoint <= max);
+}
+
+/**
+ * @param {number} codePoint
+ * @param {import('japanese-util').CodepointRange[]} ranges
+ * @returns {boolean}
+ */
+// eslint-disable-next-line no-implicit-globals
+function isCodePointInRanges(codePoint, ranges) {
+    for (const [min, max] of ranges) {
+        if (codePoint >= min && codePoint <= max) {
+            return true;
         }
     }
+    return false;
+}
 
+/**
+ * @param {string} previousCharacter
+ * @returns {?string}
+ */
+// eslint-disable-next-line no-implicit-globals
+function getProlongedHiragana(previousCharacter) {
+    switch (KANA_TO_VOWEL_MAPPING.get(previousCharacter)) {
+        case 'a': return 'あ';
+        case 'i': return 'い';
+        case 'u': return 'う';
+        case 'e': return 'え';
+        case 'o': return 'う';
+        default: return null;
+    }
+}
 
-    // eslint-disable-next-line no-shadow
-    class JapaneseUtil {
-        constructor(wanakana=null) {
-            this._wanakana = wanakana;
-        }
 
-        // Character code testing functions
+export class JapaneseUtil {
+    /**
+     * @param {?import('wanakana')} wanakana
+     */
+    constructor(wanakana=null) {
+        /** @type {?import('wanakana')} */
+        this._wanakana = wanakana;
+    }
 
-        isCodePointKanji(codePoint) {
-            return isCodePointInRanges(codePoint, CJK_IDEOGRAPH_RANGES);
-        }
+    // Character code testing functions
 
-        isCodePointKana(codePoint) {
-            return isCodePointInRanges(codePoint, KANA_RANGES);
-        }
+    /**
+     * @param {number} codePoint
+     * @returns {boolean}
+     */
+    isCodePointKanji(codePoint) {
+        return isCodePointInRanges(codePoint, CJK_IDEOGRAPH_RANGES);
+    }
 
-        isCodePointJapanese(codePoint) {
-            return isCodePointInRanges(codePoint, JAPANESE_RANGES);
-        }
+    /**
+     * @param {number} codePoint
+     * @returns {boolean}
+     */
+    isCodePointKana(codePoint) {
+        return isCodePointInRanges(codePoint, KANA_RANGES);
+    }
 
-        // String testing functions
+    /**
+     * @param {number} codePoint
+     * @returns {boolean}
+     */
+    isCodePointJapanese(codePoint) {
+        return isCodePointInRanges(codePoint, JAPANESE_RANGES);
+    }
 
-        isStringEntirelyKana(str) {
-            if (str.length === 0) { return false; }
-            for (const c of str) {
-                if (!isCodePointInRanges(c.codePointAt(0), KANA_RANGES)) {
-                    return false;
-                }
+    // String testing functions
+
+    /**
+     * @param {string} str
+     * @returns {boolean}
+     */
+    isStringEntirelyKana(str) {
+        if (str.length === 0) { return false; }
+        for (const c of str) {
+            if (!isCodePointInRanges(/** @type {number} */ (c.codePointAt(0)), KANA_RANGES)) {
+                return false;
             }
-            return true;
         }
+        return true;
+    }
 
-        isStringPartiallyJapanese(str) {
-            if (str.length === 0) { return false; }
-            for (const c of str) {
-                if (isCodePointInRanges(c.codePointAt(0), JAPANESE_RANGES)) {
-                    return true;
-                }
+    /**
+     * @param {string} str
+     * @returns {boolean}
+     */
+    isStringPartiallyJapanese(str) {
+        if (str.length === 0) { return false; }
+        for (const c of str) {
+            if (isCodePointInRanges(/** @type {number} */ (c.codePointAt(0)), JAPANESE_RANGES)) {
+                return true;
             }
-            return false;
         }
+        return false;
+    }
 
-        // Mora functions
+    // Mora functions
 
-        isMoraPitchHigh(moraIndex, pitchAccentDownstepPosition) {
-            switch (pitchAccentDownstepPosition) {
-                case 0: return (moraIndex > 0);
-                case 1: return (moraIndex < 1);
-                default: return (moraIndex > 0 && moraIndex < pitchAccentDownstepPosition);
-            }
+    /**
+     * @param {number} moraIndex
+     * @param {number} pitchAccentDownstepPosition
+     * @returns {boolean}
+     */
+    isMoraPitchHigh(moraIndex, pitchAccentDownstepPosition) {
+        switch (pitchAccentDownstepPosition) {
+            case 0: return (moraIndex > 0);
+            case 1: return (moraIndex < 1);
+            default: return (moraIndex > 0 && moraIndex < pitchAccentDownstepPosition);
         }
+    }
 
-        getPitchCategory(text, pitchAccentDownstepPosition, isVerbOrAdjective) {
-            if (pitchAccentDownstepPosition === 0) {
-                return 'heiban';
-            }
-            if (isVerbOrAdjective) {
-                return pitchAccentDownstepPosition > 0 ? 'kifuku' : null;
-            }
-            if (pitchAccentDownstepPosition === 1) {
-                return 'atamadaka';
-            }
-            if (pitchAccentDownstepPosition > 1) {
-                return pitchAccentDownstepPosition >= this.getKanaMoraCount(text) ? 'odaka' : 'nakadaka';
-            }
-            return null;
+    /**
+     * @param {string} text
+     * @param {number} pitchAccentDownstepPosition
+     * @param {boolean} isVerbOrAdjective
+     * @returns {?import('japanese-util').PitchCategory}
+     */
+    getPitchCategory(text, pitchAccentDownstepPosition, isVerbOrAdjective) {
+        if (pitchAccentDownstepPosition === 0) {
+            return 'heiban';
+        }
+        if (isVerbOrAdjective) {
+            return pitchAccentDownstepPosition > 0 ? 'kifuku' : null;
+        }
+        if (pitchAccentDownstepPosition === 1) {
+            return 'atamadaka';
         }
+        if (pitchAccentDownstepPosition > 1) {
+            return pitchAccentDownstepPosition >= this.getKanaMoraCount(text) ? 'odaka' : 'nakadaka';
+        }
+        return null;
+    }
 
-        getKanaMorae(text) {
-            const morae = [];
-            let i;
-            for (const c of text) {
-                if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) {
-                    morae[i - 1] += c;
-                } else {
-                    morae.push(c);
-                }
+    /**
+     * @param {string} text
+     * @returns {string[]}
+     */
+    getKanaMorae(text) {
+        const morae = [];
+        let i;
+        for (const c of text) {
+            if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) {
+                morae[i - 1] += c;
+            } else {
+                morae.push(c);
             }
-            return morae;
         }
+        return morae;
+    }
 
-        getKanaMoraCount(text) {
-            let moraCount = 0;
-            for (const c of text) {
-                if (!(SMALL_KANA_SET.has(c) && moraCount > 0)) {
-                    ++moraCount;
-                }
+    /**
+     * @param {string} text
+     * @returns {number}
+     */
+    getKanaMoraCount(text) {
+        let moraCount = 0;
+        for (const c of text) {
+            if (!(SMALL_KANA_SET.has(c) && moraCount > 0)) {
+                ++moraCount;
             }
-            return moraCount;
         }
+        return moraCount;
+    }
 
-        // Conversion functions
+    // Conversion functions
 
-        convertToKana(text) {
-            return this._getWanakana().toKana(text);
-        }
+    /**
+     * @param {string} text
+     * @returns {string}
+     */
+    convertToKana(text) {
+        return this._getWanakana().toKana(text);
+    }
 
-        convertToKanaSupported() {
-            return this._wanakana !== null;
-        }
+    /**
+     * @returns {boolean}
+     */
+    convertToKanaSupported() {
+        return this._wanakana !== null;
+    }
 
-        convertKatakanaToHiragana(text, keepProlongedSoundMarks=false) {
-            let result = '';
-            const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]);
-            for (let char of text) {
-                const codePoint = char.codePointAt(0);
-                switch (codePoint) {
-                    case KATAKANA_SMALL_KA_CODE_POINT:
-                    case KATAKANA_SMALL_KE_CODE_POINT:
-                        // No change
-                        break;
-                    case KANA_PROLONGED_SOUND_MARK_CODE_POINT:
-                        if (!keepProlongedSoundMarks && result.length > 0) {
-                            const char2 = getProlongedHiragana(result[result.length - 1]);
-                            if (char2 !== null) { char = char2; }
-                        }
-                        break;
-                    default:
-                        if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) {
-                            char = String.fromCodePoint(codePoint + offset);
-                        }
-                        break;
-                }
-                result += char;
+    /**
+     * @param {string} text
+     * @param {boolean} [keepProlongedSoundMarks]
+     * @returns {string}
+     */
+    convertKatakanaToHiragana(text, keepProlongedSoundMarks=false) {
+        let result = '';
+        const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]);
+        for (let char of text) {
+            const codePoint = /** @type {number} */ (char.codePointAt(0));
+            switch (codePoint) {
+                case KATAKANA_SMALL_KA_CODE_POINT:
+                case KATAKANA_SMALL_KE_CODE_POINT:
+                    // No change
+                    break;
+                case KANA_PROLONGED_SOUND_MARK_CODE_POINT:
+                    if (!keepProlongedSoundMarks && result.length > 0) {
+                        const char2 = getProlongedHiragana(result[result.length - 1]);
+                        if (char2 !== null) { char = char2; }
+                    }
+                    break;
+                default:
+                    if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) {
+                        char = String.fromCodePoint(codePoint + offset);
+                    }
+                    break;
             }
-            return result;
+            result += char;
         }
+        return result;
+    }
 
-        convertHiraganaToKatakana(text) {
-            let result = '';
-            const offset = (KATAKANA_CONVERSION_RANGE[0] - HIRAGANA_CONVERSION_RANGE[0]);
-            for (let char of text) {
-                const codePoint = char.codePointAt(0);
-                if (isCodePointInRange(codePoint, HIRAGANA_CONVERSION_RANGE)) {
-                    char = String.fromCodePoint(codePoint + offset);
-                }
-                result += char;
+    /**
+     * @param {string} text
+     * @returns {string}
+     */
+    convertHiraganaToKatakana(text) {
+        let result = '';
+        const offset = (KATAKANA_CONVERSION_RANGE[0] - HIRAGANA_CONVERSION_RANGE[0]);
+        for (let char of text) {
+            const codePoint = /** @type {number} */ (char.codePointAt(0));
+            if (isCodePointInRange(codePoint, HIRAGANA_CONVERSION_RANGE)) {
+                char = String.fromCodePoint(codePoint + offset);
             }
-            return result;
+            result += char;
         }
+        return result;
+    }
 
-        convertToRomaji(text) {
-            const wanakana = this._getWanakana();
-            return wanakana.toRomaji(text);
-        }
+    /**
+     * @param {string} text
+     * @returns {string}
+     */
+    convertToRomaji(text) {
+        const wanakana = this._getWanakana();
+        return wanakana.toRomaji(text);
+    }
 
-        convertToRomajiSupported() {
-            return this._wanakana !== null;
-        }
+    /**
+     * @returns {boolean}
+     */
+    convertToRomajiSupported() {
+        return this._wanakana !== null;
+    }
 
-        convertNumericToFullWidth(text) {
-            let result = '';
-            for (const char of text) {
-                let c = char.codePointAt(0);
-                if (c >= 0x30 && c <= 0x39) { // ['0', '9']
-                    c += 0xff10 - 0x30; // 0xff10 = '0' full width
-                    result += String.fromCodePoint(c);
-                } else {
-                    result += char;
-                }
+    /**
+     * @param {string} text
+     * @returns {string}
+     */
+    convertNumericToFullWidth(text) {
+        let result = '';
+        for (const char of text) {
+            let c = /** @type {number} */ (char.codePointAt(0));
+            if (c >= 0x30 && c <= 0x39) { // ['0', '9']
+                c += 0xff10 - 0x30; // 0xff10 = '0' full width
+                result += String.fromCodePoint(c);
+            } else {
+                result += char;
             }
-            return result;
         }
+        return result;
+    }
 
-        convertHalfWidthKanaToFullWidth(text, sourceMap=null) {
-            let result = '';
-
-            // This function is safe to use charCodeAt instead of codePointAt, since all
-            // the relevant characters are represented with a single UTF-16 character code.
-            for (let i = 0, ii = text.length; i < ii; ++i) {
-                const c = text[i];
-                const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c);
-                if (typeof mapping !== 'string') {
-                    result += c;
-                    continue;
-                }
-
-                let index = 0;
-                switch (text.charCodeAt(i + 1)) {
-                    case 0xff9e: // dakuten
-                        index = 1;
-                        break;
-                    case 0xff9f: // handakuten
-                        index = 2;
-                        break;
-                }
-
-                let c2 = mapping[index];
-                if (index > 0) {
-                    if (c2 === '-') { // invalid
-                        index = 0;
-                        c2 = mapping[0];
-                    } else {
-                        ++i;
-                    }
-                }
-
-                if (sourceMap !== null && index > 0) {
-                    sourceMap.combine(result.length, 1);
-                }
-                result += c2;
+    /**
+     * @param {string} text
+     * @param {?TextSourceMap} [sourceMap]
+     * @returns {string}
+     */
+    convertHalfWidthKanaToFullWidth(text, sourceMap=null) {
+        let result = '';
+
+        // This function is safe to use charCodeAt instead of codePointAt, since all
+        // the relevant characters are represented with a single UTF-16 character code.
+        for (let i = 0, ii = text.length; i < ii; ++i) {
+            const c = text[i];
+            const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c);
+            if (typeof mapping !== 'string') {
+                result += c;
+                continue;
             }
 
-            return result;
-        }
+            let index = 0;
+            switch (text.charCodeAt(i + 1)) {
+                case 0xff9e: // dakuten
+                    index = 1;
+                    break;
+                case 0xff9f: // handakuten
+                    index = 2;
+                    break;
+            }
 
-        convertAlphabeticToKana(text, sourceMap=null) {
-            let part = '';
-            let result = '';
-
-            for (const char of text) {
-                // Note: 0x61 is the character code for 'a'
-                let c = char.codePointAt(0);
-                if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']
-                    c += (0x61 - 0x41);
-                } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z']
-                    // NOP; c += (0x61 - 0x61);
-                } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth
-                    c += (0x61 - 0xff21);
-                } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth
-                    c += (0x61 - 0xff41);
-                } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash
-                    c = 0x2d; // '-'
+            let c2 = mapping[index];
+            if (index > 0) {
+                if (c2 === '-') { // invalid
+                    index = 0;
+                    c2 = mapping[0];
                 } else {
-                    if (part.length > 0) {
-                        result += this._convertAlphabeticPartToKana(part, sourceMap, result.length);
-                        part = '';
-                    }
-                    result += char;
-                    continue;
+                    ++i;
                 }
-                part += String.fromCodePoint(c);
             }
 
-            if (part.length > 0) {
-                result += this._convertAlphabeticPartToKana(part, sourceMap, result.length);
+            if (sourceMap !== null && index > 0) {
+                sourceMap.combine(result.length, 1);
             }
-            return result;
+            result += c2;
         }
 
-        convertAlphabeticToKanaSupported() {
-            return this._wanakana !== null;
+        return result;
+    }
+
+    /**
+     * @param {string} text
+     * @param {?TextSourceMap} sourceMap
+     * @returns {string}
+     */
+    convertAlphabeticToKana(text, sourceMap=null) {
+        let part = '';
+        let result = '';
+
+        for (const char of text) {
+            // Note: 0x61 is the character code for 'a'
+            let c = /** @type {number} */ (char.codePointAt(0));
+            if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']
+                c += (0x61 - 0x41);
+            } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z']
+                // NOP; c += (0x61 - 0x61);
+            } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth
+                c += (0x61 - 0xff21);
+            } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth
+                c += (0x61 - 0xff41);
+            } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash
+                c = 0x2d; // '-'
+            } else {
+                if (part.length > 0) {
+                    result += this._convertAlphabeticPartToKana(part, sourceMap, result.length);
+                    part = '';
+                }
+                result += char;
+                continue;
+            }
+            part += String.fromCodePoint(c);
         }
 
-        getKanaDiacriticInfo(character) {
-            const info = DIACRITIC_MAPPING.get(character);
-            return typeof info !== 'undefined' ? {character: info.character, type: info.type} : null;
+        if (part.length > 0) {
+            result += this._convertAlphabeticPartToKana(part, sourceMap, result.length);
         }
+        return result;
+    }
 
-        // Furigana distribution
+    /**
+     * @returns {boolean}
+     */
+    convertAlphabeticToKanaSupported() {
+        return this._wanakana !== null;
+    }
 
-        distributeFurigana(term, reading) {
-            if (reading === term) {
-                // Same
-                return [this._createFuriganaSegment(term, '')];
-            }
+    /**
+     * @param {string} character
+     * @returns {?{character: string, type: import('japanese-util').DiacriticType}}
+     */
+    getKanaDiacriticInfo(character) {
+        const info = DIACRITIC_MAPPING.get(character);
+        return typeof info !== 'undefined' ? {character: info.character, type: info.type} : null;
+    }
 
-            const groups = [];
-            let groupPre = null;
-            let isKanaPre = null;
-            for (const c of term) {
-                const codePoint = c.codePointAt(0);
-                const isKana = this.isCodePointKana(codePoint);
-                if (isKana === isKanaPre) {
-                    groupPre.text += c;
-                } else {
-                    groupPre = {isKana, text: c, textNormalized: null};
-                    groups.push(groupPre);
-                    isKanaPre = isKana;
-                }
-            }
-            for (const group of groups) {
-                if (group.isKana) {
-                    group.textNormalized = this.convertKatakanaToHiragana(group.text);
-                }
+    // Furigana distribution
+
+    /**
+     * @param {string} term
+     * @param {string} reading
+     * @returns {import('japanese-util').FuriganaSegment[]}
+     */
+    distributeFurigana(term, reading) {
+        if (reading === term) {
+            // Same
+            return [this._createFuriganaSegment(term, '')];
+        }
+
+        /** @type {import('japanese-util').FuriganaGroup[]} */
+        const groups = [];
+        /** @type {?import('japanese-util').FuriganaGroup} */
+        let groupPre = null;
+        let isKanaPre = null;
+        for (const c of term) {
+            const codePoint = /** @type {number} */ (c.codePointAt(0));
+            const isKana = this.isCodePointKana(codePoint);
+            if (isKana === isKanaPre) {
+                /** @type {import('japanese-util').FuriganaGroup} */ (groupPre).text += c;
+            } else {
+                groupPre = {isKana, text: c, textNormalized: null};
+                groups.push(groupPre);
+                isKanaPre = isKana;
             }
-
-            const readingNormalized = this.convertKatakanaToHiragana(reading);
-            const segments = this._segmentizeFurigana(reading, readingNormalized, groups, 0);
-            if (segments !== null) {
-                return segments;
+        }
+        for (const group of groups) {
+            if (group.isKana) {
+                group.textNormalized = this.convertKatakanaToHiragana(group.text);
             }
-
-            // Fallback
-            return [this._createFuriganaSegment(term, reading)];
         }
 
-        distributeFuriganaInflected(term, reading, source) {
-            const termNormalized = this.convertKatakanaToHiragana(term);
-            const readingNormalized = this.convertKatakanaToHiragana(reading);
-            const sourceNormalized = this.convertKatakanaToHiragana(source);
-
-            let mainText = term;
-            let stemLength = this._getStemLength(termNormalized, sourceNormalized);
+        const readingNormalized = this.convertKatakanaToHiragana(reading);
+        const segments = this._segmentizeFurigana(reading, readingNormalized, groups, 0);
+        if (segments !== null) {
+            return segments;
+        }
 
-            // Check if source is derived from the reading instead of the term
-            const readingStemLength = this._getStemLength(readingNormalized, sourceNormalized);
-            if (readingStemLength > 0 && readingStemLength >= stemLength) {
-                mainText = reading;
-                stemLength = readingStemLength;
-                reading = `${source.substring(0, stemLength)}${reading.substring(stemLength)}`;
-            }
+        // Fallback
+        return [this._createFuriganaSegment(term, reading)];
+    }
 
-            const segments = [];
-            if (stemLength > 0) {
-                mainText = `${source.substring(0, stemLength)}${mainText.substring(stemLength)}`;
-                const segments2 = this.distributeFurigana(mainText, reading);
-                let consumed = 0;
-                for (const segment of segments2) {
-                    const {text} = segment;
-                    const start = consumed;
-                    consumed += text.length;
-                    if (consumed < stemLength) {
-                        segments.push(segment);
-                    } else if (consumed === stemLength) {
-                        segments.push(segment);
-                        break;
-                    } else {
-                        if (start < stemLength) {
-                            segments.push(this._createFuriganaSegment(mainText.substring(start, stemLength), ''));
-                        }
-                        break;
+    /**
+     * @param {string} term
+     * @param {string} reading
+     * @param {string} source
+     * @returns {import('japanese-util').FuriganaSegment[]}
+     */
+    distributeFuriganaInflected(term, reading, source) {
+        const termNormalized = this.convertKatakanaToHiragana(term);
+        const readingNormalized = this.convertKatakanaToHiragana(reading);
+        const sourceNormalized = this.convertKatakanaToHiragana(source);
+
+        let mainText = term;
+        let stemLength = this._getStemLength(termNormalized, sourceNormalized);
+
+        // Check if source is derived from the reading instead of the term
+        const readingStemLength = this._getStemLength(readingNormalized, sourceNormalized);
+        if (readingStemLength > 0 && readingStemLength >= stemLength) {
+            mainText = reading;
+            stemLength = readingStemLength;
+            reading = `${source.substring(0, stemLength)}${reading.substring(stemLength)}`;
+        }
+
+        const segments = [];
+        if (stemLength > 0) {
+            mainText = `${source.substring(0, stemLength)}${mainText.substring(stemLength)}`;
+            const segments2 = this.distributeFurigana(mainText, reading);
+            let consumed = 0;
+            for (const segment of segments2) {
+                const {text} = segment;
+                const start = consumed;
+                consumed += text.length;
+                if (consumed < stemLength) {
+                    segments.push(segment);
+                } else if (consumed === stemLength) {
+                    segments.push(segment);
+                    break;
+                } else {
+                    if (start < stemLength) {
+                        segments.push(this._createFuriganaSegment(mainText.substring(start, stemLength), ''));
                     }
+                    break;
                 }
             }
+        }
 
-            if (stemLength < source.length) {
-                const remainder = source.substring(stemLength);
-                const segmentCount = segments.length;
-                if (segmentCount > 0 && segments[segmentCount - 1].reading.length === 0) {
-                    // Append to the last segment if it has an empty reading
-                    segments[segmentCount - 1].text += remainder;
-                } else {
-                    // Otherwise, create a new segment
-                    segments.push(this._createFuriganaSegment(remainder, ''));
-                }
+        if (stemLength < source.length) {
+            const remainder = source.substring(stemLength);
+            const segmentCount = segments.length;
+            if (segmentCount > 0 && segments[segmentCount - 1].reading.length === 0) {
+                // Append to the last segment if it has an empty reading
+                segments[segmentCount - 1].text += remainder;
+            } else {
+                // Otherwise, create a new segment
+                segments.push(this._createFuriganaSegment(remainder, ''));
             }
-
-            return segments;
         }
 
-        // Miscellaneous
-
-        collapseEmphaticSequences(text, fullCollapse, sourceMap=null) {
-            let result = '';
-            let collapseCodePoint = -1;
-            const hasSourceMap = (sourceMap !== null);
-            for (const char of text) {
-                const c = char.codePointAt(0);
-                if (
-                    c === HIRAGANA_SMALL_TSU_CODE_POINT ||
-                    c === KATAKANA_SMALL_TSU_CODE_POINT ||
-                    c === KANA_PROLONGED_SOUND_MARK_CODE_POINT
-                ) {
-                    if (collapseCodePoint !== c) {
-                        collapseCodePoint = c;
-                        if (!fullCollapse) {
-                            result += char;
-                            continue;
-                        }
+        return segments;
+    }
+
+    // Miscellaneous
+
+    /**
+     * @param {string} text
+     * @param {boolean} fullCollapse
+     * @param {?TextSourceMap} [sourceMap]
+     * @returns {string}
+     */
+    collapseEmphaticSequences(text, fullCollapse, sourceMap=null) {
+        let result = '';
+        let collapseCodePoint = -1;
+        const hasSourceMap = (sourceMap !== null);
+        for (const char of text) {
+            const c = char.codePointAt(0);
+            if (
+                c === HIRAGANA_SMALL_TSU_CODE_POINT ||
+                c === KATAKANA_SMALL_TSU_CODE_POINT ||
+                c === KANA_PROLONGED_SOUND_MARK_CODE_POINT
+            ) {
+                if (collapseCodePoint !== c) {
+                    collapseCodePoint = c;
+                    if (!fullCollapse) {
+                        result += char;
+                        continue;
                     }
-                } else {
-                    collapseCodePoint = -1;
-                    result += char;
-                    continue;
                 }
+            } else {
+                collapseCodePoint = -1;
+                result += char;
+                continue;
+            }
 
-                if (hasSourceMap) {
-                    sourceMap.combine(Math.max(0, result.length - 1), 1);
-                }
+            if (hasSourceMap) {
+                sourceMap.combine(Math.max(0, result.length - 1), 1);
             }
-            return result;
         }
+        return result;
+    }
 
-        // Private
-
-        _createFuriganaSegment(text, reading) {
-            return {text, reading};
-        }
+    // Private
 
-        _segmentizeFurigana(reading, readingNormalized, groups, groupsStart) {
-            const groupCount = groups.length - groupsStart;
-            if (groupCount <= 0) {
-                return reading.length === 0 ? [] : null;
-            }
+    /**
+     * @param {string} text
+     * @param {string} reading
+     * @returns {import('japanese-util').FuriganaSegment}
+     */
+    _createFuriganaSegment(text, reading) {
+        return {text, reading};
+    }
 
-            const group = groups[groupsStart];
-            const {isKana, text} = group;
-            const textLength = text.length;
-            if (isKana) {
-                const {textNormalized} = group;
-                if (readingNormalized.startsWith(textNormalized)) {
-                    const segments = this._segmentizeFurigana(
-                        reading.substring(textLength),
-                        readingNormalized.substring(textLength),
-                        groups,
-                        groupsStart + 1
-                    );
-                    if (segments !== null) {
-                        if (reading.startsWith(text)) {
-                            segments.unshift(this._createFuriganaSegment(text, ''));
-                        } else {
-                            segments.unshift(...this._getFuriganaKanaSegments(text, reading));
-                        }
-                        return segments;
+    /**
+     * @param {string} reading
+     * @param {string} readingNormalized
+     * @param {import('japanese-util').FuriganaGroup[]} groups
+     * @param {number} groupsStart
+     * @returns {?(import('japanese-util').FuriganaSegment[])}
+     */
+    _segmentizeFurigana(reading, readingNormalized, groups, groupsStart) {
+        const groupCount = groups.length - groupsStart;
+        if (groupCount <= 0) {
+            return reading.length === 0 ? [] : null;
+        }
+
+        const group = groups[groupsStart];
+        const {isKana, text} = group;
+        const textLength = text.length;
+        if (isKana) {
+            const {textNormalized} = group;
+            if (textNormalized !== null && readingNormalized.startsWith(textNormalized)) {
+                const segments = this._segmentizeFurigana(
+                    reading.substring(textLength),
+                    readingNormalized.substring(textLength),
+                    groups,
+                    groupsStart + 1
+                );
+                if (segments !== null) {
+                    if (reading.startsWith(text)) {
+                        segments.unshift(this._createFuriganaSegment(text, ''));
+                    } else {
+                        segments.unshift(...this._getFuriganaKanaSegments(text, reading));
                     }
+                    return segments;
                 }
-                return null;
-            } else {
-                let result = null;
-                for (let i = reading.length; i >= textLength; --i) {
-                    const segments = this._segmentizeFurigana(
-                        reading.substring(i),
-                        readingNormalized.substring(i),
-                        groups,
-                        groupsStart + 1
-                    );
-                    if (segments !== null) {
-                        if (result !== null) {
-                            // More than one way to segmentize the tail; mark as ambiguous
-                            return null;
-                        }
-                        const segmentReading = reading.substring(0, i);
-                        segments.unshift(this._createFuriganaSegment(text, segmentReading));
-                        result = segments;
-                    }
-                    // There is only one way to segmentize the last non-kana group
-                    if (groupCount === 1) {
-                        break;
+            }
+            return null;
+        } else {
+            let result = null;
+            for (let i = reading.length; i >= textLength; --i) {
+                const segments = this._segmentizeFurigana(
+                    reading.substring(i),
+                    readingNormalized.substring(i),
+                    groups,
+                    groupsStart + 1
+                );
+                if (segments !== null) {
+                    if (result !== null) {
+                        // More than one way to segmentize the tail; mark as ambiguous
+                        return null;
                     }
+                    const segmentReading = reading.substring(0, i);
+                    segments.unshift(this._createFuriganaSegment(text, segmentReading));
+                    result = segments;
+                }
+                // There is only one way to segmentize the last non-kana group
+                if (groupCount === 1) {
+                    break;
                 }
-                return result;
-            }
-        }
-
-        _getFuriganaKanaSegments(text, reading) {
-            const textLength = text.length;
-            const newSegments = [];
-            let start = 0;
-            let state = (reading[0] === text[0]);
-            for (let i = 1; i < textLength; ++i) {
-                const newState = (reading[i] === text[i]);
-                if (state === newState) { continue; }
-                newSegments.push(this._createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i)));
-                state = newState;
-                start = i;
             }
-            newSegments.push(this._createFuriganaSegment(text.substring(start, textLength), state ? '' : reading.substring(start, textLength)));
-            return newSegments;
+            return result;
         }
+    }
 
-        _getWanakana() {
-            const wanakana = this._wanakana;
-            if (wanakana === null) { throw new Error('Functions which use WanaKana are not supported in this context'); }
-            return wanakana;
-        }
+    /**
+     * @param {string} text
+     * @param {string} reading
+     * @returns {import('japanese-util').FuriganaSegment[]}
+     */
+    _getFuriganaKanaSegments(text, reading) {
+        const textLength = text.length;
+        const newSegments = [];
+        let start = 0;
+        let state = (reading[0] === text[0]);
+        for (let i = 1; i < textLength; ++i) {
+            const newState = (reading[i] === text[i]);
+            if (state === newState) { continue; }
+            newSegments.push(this._createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i)));
+            state = newState;
+            start = i;
+        }
+        newSegments.push(this._createFuriganaSegment(text.substring(start, textLength), state ? '' : reading.substring(start, textLength)));
+        return newSegments;
+    }
 
-        _convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) {
-            const wanakana = this._getWanakana();
-            const result = wanakana.toHiragana(text);
-
-            // Generate source mapping
-            if (sourceMap !== null) {
-                let i = 0;
-                let resultPos = 0;
-                const ii = text.length;
-                while (i < ii) {
-                    // Find smallest matching substring
-                    let iNext = i + 1;
-                    let resultPosNext = result.length;
-                    while (iNext < ii) {
-                        const t = wanakana.toHiragana(text.substring(0, iNext));
-                        if (t === result.substring(0, t.length)) {
-                            resultPosNext = t.length;
-                            break;
-                        }
-                        ++iNext;
-                    }
+    /**
+     * @returns {import('wanakana')}
+     * @throws {Error}
+     */
+    _getWanakana() {
+        const wanakana = this._wanakana;
+        if (wanakana === null) { throw new Error('Functions which use WanaKana are not supported in this context'); }
+        return wanakana;
+    }
 
-                    // Merge characters
-                    const removals = iNext - i - 1;
-                    if (removals > 0) {
-                        sourceMap.combine(sourceMapStart, removals);
+    /**
+     * @param {string} text
+     * @param {?TextSourceMap} sourceMap
+     * @param {number} sourceMapStart
+     * @returns {string}
+     */
+    _convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) {
+        const wanakana = this._getWanakana();
+        const result = wanakana.toHiragana(text);
+
+        // Generate source mapping
+        if (sourceMap !== null) {
+            let i = 0;
+            let resultPos = 0;
+            const ii = text.length;
+            while (i < ii) {
+                // Find smallest matching substring
+                let iNext = i + 1;
+                let resultPosNext = result.length;
+                while (iNext < ii) {
+                    const t = wanakana.toHiragana(text.substring(0, iNext));
+                    if (t === result.substring(0, t.length)) {
+                        resultPosNext = t.length;
+                        break;
                     }
-                    ++sourceMapStart;
+                    ++iNext;
+                }
 
-                    // Empty elements
-                    const additions = resultPosNext - resultPos - 1;
-                    for (let j = 0; j < additions; ++j) {
-                        sourceMap.insert(sourceMapStart, 0);
-                        ++sourceMapStart;
-                    }
+                // Merge characters
+                const removals = iNext - i - 1;
+                if (removals > 0) {
+                    sourceMap.combine(sourceMapStart, removals);
+                }
+                ++sourceMapStart;
 
-                    i = iNext;
-                    resultPos = resultPosNext;
+                // Empty elements
+                const additions = resultPosNext - resultPos - 1;
+                for (let j = 0; j < additions; ++j) {
+                    sourceMap.insert(sourceMapStart, 0);
+                    ++sourceMapStart;
                 }
-            }
 
-            return result;
+                i = iNext;
+                resultPos = resultPosNext;
+            }
         }
 
-        _getStemLength(text1, text2) {
-            const minLength = Math.min(text1.length, text2.length);
-            if (minLength === 0) { return 0; }
+        return result;
+    }
 
-            let i = 0;
-            while (true) {
-                const char1 = text1.codePointAt(i);
-                const char2 = text2.codePointAt(i);
-                if (char1 !== char2) { break; }
-                const charLength = String.fromCodePoint(char1).length;
-                i += charLength;
-                if (i >= minLength) {
-                    if (i > minLength) {
-                        i -= charLength; // Don't consume partial UTF16 surrogate characters
-                    }
-                    break;
+    /**
+     * @param {string} text1
+     * @param {string} text2
+     * @returns {number}
+     */
+    _getStemLength(text1, text2) {
+        const minLength = Math.min(text1.length, text2.length);
+        if (minLength === 0) { return 0; }
+
+        let i = 0;
+        while (true) {
+            const char1 = /** @type {number} */ (text1.codePointAt(i));
+            const char2 = /** @type {number} */ (text2.codePointAt(i));
+            if (char1 !== char2) { break; }
+            const charLength = String.fromCodePoint(char1).length;
+            i += charLength;
+            if (i >= minLength) {
+                if (i > minLength) {
+                    i -= charLength; // Don't consume partial UTF16 surrogate characters
                 }
+                break;
             }
-            return i;
         }
+        return i;
     }
-
-
-    return JapaneseUtil;
-})();
+}
diff --git a/ext/js/language/text-scanner.js b/ext/js/language/text-scanner.js
index ac7ef3d9..b4d9a642 100644
--- a/ext/js/language/text-scanner.js
+++ b/ext/js/language/text-scanner.js
@@ -16,11 +16,17 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {EventDispatcher, EventListenerCollection, clone, isObject, log, promiseTimeout} from '../core.js';
+import {EventDispatcher, EventListenerCollection, clone, log} from '../core.js';
 import {DocumentUtil} from '../dom/document-util.js';
 import {yomitan} from '../yomitan.js';
 
+/**
+ * @augments EventDispatcher<import('text-scanner').EventType>
+ */
 export class TextScanner extends EventDispatcher {
+    /**
+     * @param {import('text-scanner').ConstructorDetails} details
+     */
     constructor({
         node,
         getSearchContext,
@@ -32,67 +38,121 @@ export class TextScanner extends EventDispatcher {
         searchOnClickOnly=false
     }) {
         super();
+        /** @type {HTMLElement|Window} */
         this._node = node;
+        /** @type {import('text-scanner').GetSearchContextCallback} */
         this._getSearchContext = getSearchContext;
+        /** @type {?(() => Element[])} */
         this._ignoreElements = ignoreElements;
+        /** @type {?((x: number, y: number) => Promise<boolean>)} */
         this._ignorePoint = ignorePoint;
+        /** @type {boolean} */
         this._searchTerms = searchTerms;
+        /** @type {boolean} */
         this._searchKanji = searchKanji;
+        /** @type {boolean} */
         this._searchOnClick = searchOnClick;
+        /** @type {boolean} */
         this._searchOnClickOnly = searchOnClickOnly;
 
+        /** @type {boolean} */
         this._isPrepared = false;
+        /** @type {?string} */
         this._includeSelector = null;
+        /** @type {?string} */
         this._excludeSelector = null;
 
+        /** @type {?import('text-scanner').InputInfo} */
         this._inputInfoCurrent = null;
+        /** @type {?Promise<boolean>} */
         this._scanTimerPromise = null;
+        /** @type {?(value: boolean) => void} */
+        this._scanTimerPromiseResolve = null;
+        /** @type {?import('text-source').TextSource} */
         this._textSourceCurrent = null;
+        /** @type {boolean} */
         this._textSourceCurrentSelected = false;
+        /** @type {boolean} */
         this._pendingLookup = false;
+        /** @type {?import('text-scanner').SelectionRestoreInfo} */
         this._selectionRestoreInfo = null;
 
+        /** @type {boolean} */
         this._deepContentScan = false;
+        /** @type {boolean} */
         this._normalizeCssZoom = true;
+        /** @type {boolean} */
         this._selectText = false;
+        /** @type {number} */
         this._delay = 0;
+        /** @type {boolean} */
         this._touchInputEnabled = false;
+        /** @type {boolean} */
         this._pointerEventsEnabled = false;
+        /** @type {number} */
         this._scanLength = 1;
+        /** @type {boolean} */
         this._layoutAwareScan = false;
+        /** @type {boolean} */
         this._preventMiddleMouse = false;
+        /** @type {boolean} */
         this._matchTypePrefix = false;
+        /** @type {number} */
         this._sentenceScanExtent = 0;
+        /** @type {boolean} */
         this._sentenceTerminateAtNewlines = true;
+        /** @type {Map<string, [includeCharacterAtStart: boolean, includeCharacterAtEnd: boolean]>} */
         this._sentenceTerminatorMap = new Map();
+        /** @type {Map<string, [character: string, includeCharacterAtStart: boolean]>} */
         this._sentenceForwardQuoteMap = new Map();
+        /** @type {Map<string, [character: string, includeCharacterAtEnd: boolean]>} */
         this._sentenceBackwardQuoteMap = new Map();
+        /** @type {import('text-scanner').InputConfig[]} */
         this._inputs = [];
 
+        /** @type {boolean} */
         this._enabled = false;
+        /** @type {boolean} */
         this._enabledValue = false;
+        /** @type {EventListenerCollection} */
         this._eventListeners = new EventListenerCollection();
 
+        /** @type {boolean} */
         this._preventNextClickScan = false;
+        /** @type {?number} */
         this._preventNextClickScanTimer = null;
+        /** @type {number} */
         this._preventNextClickScanTimerDuration = 50;
+        /** @type {() => void} */
         this._preventNextClickScanTimerCallback = this._onPreventNextClickScanTimeout.bind(this);
 
+        /** @type {?number} */
         this._primaryTouchIdentifier = null;
+        /** @type {boolean} */
         this._preventNextContextMenu = false;
+        /** @type {boolean} */
         this._preventNextMouseDown = false;
+        /** @type {boolean} */
         this._preventNextClick = false;
+        /** @type {boolean} */
         this._preventScroll = false;
+        /** @type {0|1|2|3} */
         this._penPointerState = 0; // 0 = not active; 1 = hovering; 2 = touching; 3 = hovering after touching
+        /** @type {Map<number, string>} */
         this._pointerIdTypeMap = new Map();
 
+        /** @type {boolean} */
         this._canClearSelection = true;
 
+        /** @type {?number} */
         this._textSelectionTimer = null;
+        /** @type {boolean} */
         this._yomitanIsChangingTextSelectionNow = false;
+        /** @type {boolean} */
         this._userHasNotSelectedAnythingManually = true;
     }
 
+    /** @type {boolean} */
     get canClearSelection() {
         return this._canClearSelection;
     }
@@ -101,6 +161,7 @@ export class TextScanner extends EventDispatcher {
         this._canClearSelection = value;
     }
 
+    /** @type {?string} */
     get includeSelector() {
         return this._includeSelector;
     }
@@ -109,6 +170,7 @@ export class TextScanner extends EventDispatcher {
         this._includeSelector = value;
     }
 
+    /** @type {?string} */
     get excludeSelector() {
         return this._excludeSelector;
     }
@@ -117,15 +179,22 @@ export class TextScanner extends EventDispatcher {
         this._excludeSelector = value;
     }
 
+    /** */
     prepare() {
         this._isPrepared = true;
         this.setEnabled(this._enabled);
     }
 
+    /**
+     * @returns {boolean}
+     */
     isEnabled() {
         return this._enabled;
     }
 
+    /**
+     * @param {boolean} enabled
+     */
     setEnabled(enabled) {
         this._enabled = enabled;
 
@@ -145,11 +214,13 @@ export class TextScanner extends EventDispatcher {
 
         if (value) {
             this._hookEvents();
-            const selection = window.getSelection();
-            this._userHasNotSelectedAnythingManually = (selection === null) ? true : selection.isCollapsed;
+            this._userHasNotSelectedAnythingManually = this._computeUserHasNotSelectedAnythingManually();
         }
     }
 
+    /**
+     * @param {import('text-scanner').Options} options
+     */
     setOptions({
         inputs,
         deepContentScan,
@@ -200,7 +271,7 @@ export class TextScanner extends EventDispatcher {
         if (typeof sentenceParsingOptions === 'object' && sentenceParsingOptions !== null) {
             const {scanExtent, terminationCharacterMode, terminationCharacters} = sentenceParsingOptions;
             if (typeof scanExtent === 'number') {
-                this._sentenceScanExtent = sentenceParsingOptions.scanExtent;
+                this._sentenceScanExtent = scanExtent;
             }
             if (typeof terminationCharacterMode === 'string') {
                 this._sentenceTerminateAtNewlines = (terminationCharacterMode === 'custom' || terminationCharacterMode === 'newlines');
@@ -229,6 +300,12 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {import('text-source').TextSource} textSource
+     * @param {number} length
+     * @param {boolean} layoutAwareScan
+     * @returns {string}
+     */
     getTextSourceContent(textSource, length, layoutAwareScan) {
         const clonedTextSource = textSource.clone();
 
@@ -243,10 +320,14 @@ export class TextScanner extends EventDispatcher {
         return clonedTextSource.text();
     }
 
+    /**
+     * @returns {boolean}
+     */
     hasSelection() {
         return (this._textSourceCurrent !== null);
     }
 
+    /** */
     clearSelection() {
         if (!this._canClearSelection) { return; }
         if (this._textSourceCurrent !== null) {
@@ -263,15 +344,21 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @returns {?import('text-source').TextSource}
+     */
     getCurrentTextSource() {
         return this._textSourceCurrent;
     }
 
+    /**
+     * @param {?import('text-source').TextSource} textSource
+     */
     setCurrentTextSource(textSource) {
         this._textSourceCurrent = textSource;
-        if (this._selectText && this._userHasNotSelectedAnythingManually) {
+        if (this._selectText && this._userHasNotSelectedAnythingManually && textSource !== null) {
             this._yomitanIsChangingTextSelectionNow = true;
-            this._textSourceCurrent.select();
+            textSource.select();
             if (this._textSelectionTimer !== null) { clearTimeout(this._textSelectionTimer); }
             // This timeout uses a 50ms delay to ensure that the selectionchange event has time to occur.
             // If the delay is 0ms, the timeout will sometimes complete before the event.
@@ -285,6 +372,9 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @returns {Promise<boolean>}
+     */
     async searchLast() {
         if (this._textSourceCurrent !== null && this._inputInfoCurrent !== null) {
             await this._search(this._textSourceCurrent, this._searchTerms, this._searchKanji, this._inputInfoCurrent);
@@ -293,6 +383,11 @@ export class TextScanner extends EventDispatcher {
         return false;
     }
 
+    /**
+     * @param {import('text-source').TextSource} textSource
+     * @param {import('text-scanner').InputInfoDetail} [inputDetail]
+     * @returns {Promise<?import('text-scanner').SearchedEventDetails>}
+     */
     async search(textSource, inputDetail) {
         const inputInfo = this._createInputInfo(null, 'script', 'script', true, [], [], inputDetail);
         return await this._search(textSource, this._searchTerms, this._searchKanji, inputInfo);
@@ -300,6 +395,11 @@ export class TextScanner extends EventDispatcher {
 
     // Private
 
+    /**
+     * @param {import('settings').OptionsContext} baseOptionsContext
+     * @param {import('text-scanner').InputInfo} inputInfo
+     * @returns {import('settings').OptionsContext}
+     */
     _createOptionsContextForInput(baseOptionsContext, inputInfo) {
         const optionsContext = clone(baseOptionsContext);
         const {modifiers, modifierKeys} = inputInfo;
@@ -308,20 +408,33 @@ export class TextScanner extends EventDispatcher {
         return optionsContext;
     }
 
+    /**
+     * @param {import('text-source').TextSource} textSource
+     * @param {boolean} searchTerms
+     * @param {boolean} searchKanji
+     * @param {import('text-scanner').InputInfo} inputInfo
+     * @returns {Promise<?import('text-scanner').SearchedEventDetails>}
+     */
     async _search(textSource, searchTerms, searchKanji, inputInfo) {
+        /** @type {?import('dictionary').DictionaryEntry[]} */
         let dictionaryEntries = null;
+        /** @type {?import('display').HistoryStateSentence} */
         let sentence = null;
+        /** @type {?import('display').PageType} */
         let type = null;
+        /** @type {?Error} */
         let error = null;
         let searched = false;
+        /** @type {?import('settings').OptionsContext} */
         let optionsContext = null;
+        /** @type {?import('text-scanner').SearchResultDetail} */
         let detail = null;
 
         try {
             const inputInfoDetail = inputInfo.detail;
             const selectionRestoreInfo = (
-                (isObject(inputInfoDetail) && inputInfoDetail.restoreSelection) ?
-                (this._inputInfoCurrent === null ? this._createSelectionRestoreInfo() : void 0) :
+                (typeof inputInfoDetail === 'object' && inputInfoDetail !== null && inputInfoDetail.restoreSelection) ?
+                (this._inputInfoCurrent === null ? this._createSelectionRestoreInfo() : null) :
                 null
             );
 
@@ -329,8 +442,11 @@ export class TextScanner extends EventDispatcher {
                 return null;
             }
 
-            ({optionsContext, detail} = await this._getSearchContext());
-            optionsContext = this._createOptionsContextForInput(optionsContext, inputInfo);
+            const getSearchContextPromise = this._getSearchContext();
+            const getSearchContextResult = getSearchContextPromise instanceof Promise ? await getSearchContextPromise : getSearchContextPromise;
+            const {detail: detail2} = getSearchContextResult;
+            if (typeof detail2 !== 'undefined') { detail = detail2; }
+            optionsContext = this._createOptionsContextForInput(getSearchContextResult.optionsContext, inputInfo);
 
             searched = true;
 
@@ -339,9 +455,9 @@ export class TextScanner extends EventDispatcher {
             if (result !== null) {
                 ({dictionaryEntries, sentence, type} = result);
                 valid = true;
-            } else if (textSource !== null && textSource.type === 'element' && await this._hasJapanese(textSource.fullContent)) {
+            } else if (textSource !== null && textSource instanceof TextSourceElement && await this._hasJapanese(textSource.fullContent)) {
                 dictionaryEntries = [];
-                sentence = {sentence: '', offset: 0};
+                sentence = {text: '', offset: 0};
                 type = 'terms';
                 valid = true;
             }
@@ -354,11 +470,12 @@ export class TextScanner extends EventDispatcher {
                 }
             }
         } catch (e) {
-            error = e;
+            error = e instanceof Error ? e : new Error(`A search error occurred: ${e}`);
         }
 
         if (!searched) { return null; }
 
+        /** @type {import('text-scanner').SearchedEventDetails} */
         const results = {
             textScanner: this,
             type,
@@ -374,41 +491,55 @@ export class TextScanner extends EventDispatcher {
         return results;
     }
 
+    /** */
     _resetPreventNextClickScan() {
         this._preventNextClickScan = false;
         if (this._preventNextClickScanTimer !== null) { clearTimeout(this._preventNextClickScanTimer); }
         this._preventNextClickScanTimer = setTimeout(this._preventNextClickScanTimerCallback, this._preventNextClickScanTimerDuration);
     }
 
+    /** */
     _onPreventNextClickScanTimeout() {
         this._preventNextClickScanTimer = null;
     }
 
+    /** */
     _onSelectionChange() {
         if (this._preventNextClickScanTimer !== null) { return; } // Ignore deselection that occurs at the start of the click
         this._preventNextClickScan = true;
     }
 
+    /** */
     _onSelectionChangeCheckUserSelection() {
         if (this._yomitanIsChangingTextSelectionNow) { return; }
-        this._userHasNotSelectedAnythingManually = window.getSelection().isCollapsed;
+        this._userHasNotSelectedAnythingManually = this._computeUserHasNotSelectedAnythingManually();
     }
 
+    /**
+     * @param {MouseEvent} e
+     */
     _onSearchClickMouseDown(e) {
         if (e.button !== 0) { return; }
         this._resetPreventNextClickScan();
     }
 
+    /** */
     _onSearchClickTouchStart() {
         this._resetPreventNextClickScan();
     }
 
+    /**
+     * @param {MouseEvent} e
+     */
     _onMouseOver(e) {
-        if (this._ignoreElements !== null && this._ignoreElements().includes(e.target)) {
+        if (this._ignoreElements !== null && this._ignoreElements().includes(/** @type {Element} */ (e.target))) {
             this._scanTimerClear();
         }
     }
 
+    /**
+     * @param {MouseEvent} e
+     */
     _onMouseMove(e) {
         this._scanTimerClear();
 
@@ -418,6 +549,10 @@ export class TextScanner extends EventDispatcher {
         this._searchAtFromMouseMove(e.clientX, e.clientY, inputInfo);
     }
 
+    /**
+     * @param {MouseEvent} e
+     * @returns {boolean|void}
+     */
     _onMouseDown(e) {
         if (this._preventNextMouseDown) {
             this._preventNextMouseDown = false;
@@ -443,10 +578,15 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /** */
     _onMouseOut() {
         this._scanTimerClear();
     }
 
+    /**
+     * @param {MouseEvent} e
+     * @returns {boolean|void}
+     */
     _onClick(e) {
         if (this._preventNextClick) {
             this._preventNextClick = false;
@@ -460,6 +600,9 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {MouseEvent} e
+     */
     _onSearchClick(e) {
         const preventNextClickScan = this._preventNextClickScan;
         this._preventNextClickScan = false;
@@ -476,10 +619,15 @@ export class TextScanner extends EventDispatcher {
         this._searchAt(e.clientX, e.clientY, inputInfo);
     }
 
+    /** */
     _onAuxClick() {
         this._preventNextContextMenu = false;
     }
 
+    /**
+     * @param {MouseEvent} e
+     * @returns {boolean|void}
+     */
     _onContextMenu(e) {
         if (this._preventNextContextMenu) {
             this._preventNextContextMenu = false;
@@ -489,6 +637,9 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {TouchEvent} e
+     */
     _onTouchStart(e) {
         if (this._primaryTouchIdentifier !== null || e.changedTouches.length === 0) {
             return;
@@ -498,13 +649,20 @@ export class TextScanner extends EventDispatcher {
         this._onPrimaryTouchStart(e, clientX, clientY, identifier);
     }
 
+    /**
+     * @param {TouchEvent|PointerEvent} e
+     * @param {number} x
+     * @param {number} y
+     * @param {number} identifier
+     */
     _onPrimaryTouchStart(e, x, y, identifier) {
         this._preventScroll = false;
         this._preventNextContextMenu = false;
         this._preventNextMouseDown = false;
         this._preventNextClick = false;
 
-        if (DocumentUtil.isPointInSelection(x, y, window.getSelection())) {
+        const selection = window.getSelection();
+        if (selection !== null && DocumentUtil.isPointInSelection(x, y, selection)) {
             return;
         }
 
@@ -513,11 +671,14 @@ export class TextScanner extends EventDispatcher {
         if (this._pendingLookup) { return; }
 
         const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchStart', e);
-        if (inputInfo === null || !inputInfo.input.scanOnTouchPress) { return; }
+        if (inputInfo === null || !(inputInfo.input !== null && inputInfo.input.scanOnTouchPress)) { return; }
 
         this._searchAtFromTouchStart(x, y, inputInfo);
     }
 
+    /**
+     * @param {TouchEvent} e
+     */
     _onTouchEnd(e) {
         if (this._primaryTouchIdentifier === null) { return; }
 
@@ -528,6 +689,12 @@ export class TextScanner extends EventDispatcher {
         this._onPrimaryTouchEnd(e, clientX, clientY, true);
     }
 
+    /**
+     * @param {TouchEvent|PointerEvent} e
+     * @param {number} x
+     * @param {number} y
+     * @param {boolean} allowSearch
+     */
     _onPrimaryTouchEnd(e, x, y, allowSearch) {
         this._primaryTouchIdentifier = null;
         this._preventScroll = false;
@@ -538,11 +705,14 @@ export class TextScanner extends EventDispatcher {
         if (!allowSearch) { return; }
 
         const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchEnd', e);
-        if (inputInfo === null || !inputInfo.input.scanOnTouchRelease) { return; }
+        if (inputInfo === null || !(inputInfo.input !== null && inputInfo.input.scanOnTouchRelease)) { return; }
 
         this._searchAtFromTouchEnd(x, y, inputInfo);
     }
 
+    /**
+     * @param {TouchEvent} e
+     */
     _onTouchCancel(e) {
         if (this._primaryTouchIdentifier === null) { return; }
 
@@ -552,6 +722,9 @@ export class TextScanner extends EventDispatcher {
         this._onPrimaryTouchEnd(e, 0, 0, false);
     }
 
+    /**
+     * @param {TouchEvent} e
+     */
     _onTouchMove(e) {
         if (this._primaryTouchIdentifier === null) { return; }
 
@@ -568,13 +741,18 @@ export class TextScanner extends EventDispatcher {
         const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e);
         if (inputInfo === null) { return; }
 
-        if (inputInfo.input.scanOnTouchMove) {
+        const {input} = inputInfo;
+        if (input !== null && input.scanOnTouchMove) {
             this._searchAt(primaryTouch.clientX, primaryTouch.clientY, inputInfo);
         }
 
         e.preventDefault(); // Disable scroll
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onPointerOver(e) {
         const {pointerType, pointerId, isPrimary} = e;
         if (pointerType === 'pen') {
@@ -584,11 +762,15 @@ export class TextScanner extends EventDispatcher {
         if (!isPrimary) { return; }
         switch (pointerType) {
             case 'mouse': return this._onMousePointerOver(e);
-            case 'touch': return this._onTouchPointerOver(e);
+            case 'touch': return this._onTouchPointerOver();
             case 'pen': return this._onPenPointerOver(e);
         }
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onPointerDown(e) {
         if (!e.isPrimary) { return; }
         switch (this._getPointerEventType(e)) {
@@ -598,6 +780,10 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onPointerMove(e) {
         if (!e.isPrimary) { return; }
         switch (this._getPointerEventType(e)) {
@@ -607,92 +793,144 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onPointerUp(e) {
         if (!e.isPrimary) { return; }
         switch (this._getPointerEventType(e)) {
-            case 'mouse': return this._onMousePointerUp(e);
+            case 'mouse': return this._onMousePointerUp();
             case 'touch': return this._onTouchPointerUp(e);
             case 'pen': return this._onPenPointerUp(e);
         }
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onPointerCancel(e) {
         this._pointerIdTypeMap.delete(e.pointerId);
         if (!e.isPrimary) { return; }
         switch (e.pointerType) {
-            case 'mouse': return this._onMousePointerCancel(e);
+            case 'mouse': return this._onMousePointerCancel();
             case 'touch': return this._onTouchPointerCancel(e);
-            case 'pen': return this._onPenPointerCancel(e);
+            case 'pen': return this._onPenPointerCancel();
         }
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onPointerOut(e) {
         this._pointerIdTypeMap.delete(e.pointerId);
         if (!e.isPrimary) { return; }
         switch (e.pointerType) {
-            case 'mouse': return this._onMousePointerOut(e);
-            case 'touch': return this._onTouchPointerOut(e);
-            case 'pen': return this._onPenPointerOut(e);
+            case 'mouse': return this._onMousePointerOut();
+            case 'touch': return this._onTouchPointerOut();
+            case 'pen': return this._onPenPointerOut();
         }
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onMousePointerOver(e) {
         return this._onMouseOver(e);
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onMousePointerDown(e) {
         return this._onMouseDown(e);
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onMousePointerMove(e) {
         return this._onMouseMove(e);
     }
 
+    /** */
     _onMousePointerUp() {
         // NOP
     }
 
-    _onMousePointerCancel(e) {
-        return this._onMouseOut(e);
+    /**
+     * @returns {boolean|void}
+     */
+    _onMousePointerCancel() {
+        return this._onMouseOut();
     }
 
-    _onMousePointerOut(e) {
-        return this._onMouseOut(e);
+    /**
+     * @returns {boolean|void}
+     */
+    _onMousePointerOut() {
+        return this._onMouseOut();
     }
 
+    /** */
     _onTouchPointerOver() {
         // NOP
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onTouchPointerDown(e) {
         const {clientX, clientY, pointerId} = e;
         this._onPrimaryTouchStart(e, clientX, clientY, pointerId);
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onTouchPointerMove(e) {
         if (!this._preventScroll || !e.cancelable) {
             return;
         }
 
         const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e);
-        if (inputInfo === null || !inputInfo.input.scanOnTouchMove) { return; }
+        if (inputInfo === null || !(inputInfo.input !== null && inputInfo.input.scanOnTouchMove)) { return; }
 
         this._searchAt(e.clientX, e.clientY, inputInfo);
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onTouchPointerUp(e) {
         const {clientX, clientY} = e;
         return this._onPrimaryTouchEnd(e, clientX, clientY, true);
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {boolean|void}
+     */
     _onTouchPointerCancel(e) {
         return this._onPrimaryTouchEnd(e, 0, 0, false);
     }
 
+    /** */
     _onTouchPointerOut() {
         // NOP
     }
 
+    /**
+     * @param {PointerEvent} e
+     */
     _onTouchMovePreventScroll(e) {
         if (!this._preventScroll) { return; }
 
@@ -703,31 +941,45 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {PointerEvent} e
+     */
     _onPenPointerOver(e) {
         this._penPointerState = 1;
         this._searchAtFromPen(e, 'pointerOver', false);
     }
 
+    /**
+     * @param {PointerEvent} e
+     */
     _onPenPointerDown(e) {
         this._penPointerState = 2;
         this._searchAtFromPen(e, 'pointerDown', true);
     }
 
+    /**
+     * @param {PointerEvent} e
+     */
     _onPenPointerMove(e) {
         if (this._penPointerState === 2 && (!this._preventScroll || !e.cancelable)) { return; }
         this._searchAtFromPen(e, 'pointerMove', true);
     }
 
+    /**
+     * @param {PointerEvent} e
+     */
     _onPenPointerUp(e) {
         this._penPointerState = 3;
         this._preventScroll = false;
         this._searchAtFromPen(e, 'pointerUp', false);
     }
 
-    _onPenPointerCancel(e) {
-        this._onPenPointerOut(e);
+    /** */
+    _onPenPointerCancel() {
+        this._onPenPointerOut();
     }
 
+    /** */
     _onPenPointerOut() {
         this._penPointerState = 0;
         this._preventScroll = false;
@@ -736,32 +988,54 @@ export class TextScanner extends EventDispatcher {
         this._preventNextClick = false;
     }
 
+    /**
+     * @returns {Promise<boolean>}
+     */
     async _scanTimerWait() {
         const delay = this._delay;
-        const promise = promiseTimeout(delay, true);
+        const promise = /** @type {Promise<boolean>} */ (new Promise((resolve) => {
+            /** @type {?number} */
+            let timeout = setTimeout(() => {
+                timeout = null;
+                resolve(true);
+            }, delay);
+            this._scanTimerPromiseResolve = (value) => {
+                if (timeout === null) { return; }
+                clearTimeout(timeout);
+                timeout = null;
+                resolve(value);
+            };
+        }));
         this._scanTimerPromise = promise;
         try {
             return await promise;
         } finally {
             if (this._scanTimerPromise === promise) {
                 this._scanTimerPromise = null;
+                this._scanTimerPromiseResolve = null;
             }
         }
     }
 
+    /** */
     _scanTimerClear() {
-        if (this._scanTimerPromise !== null) {
-            this._scanTimerPromise.resolve(false);
-            this._scanTimerPromise = null;
-        }
+        if (this._scanTimerPromiseResolve === null) { return; }
+        this._scanTimerPromiseResolve(false);
+        this._scanTimerPromiseResolve = null;
+        this._scanTimerPromise = null;
     }
 
+    /**
+     * @returns {boolean}
+     */
     _arePointerEventsSupported() {
         return (this._pointerEventsEnabled && typeof PointerEvent !== 'undefined');
     }
 
+    /** */
     _hookEvents() {
         const capture = true;
+        /** @type {import('event-listener-collection').AddEventListenerArgs[]} */
         let eventListenerInfos;
         if (this._searchOnClickOnly) {
             eventListenerInfos = this._getMouseClickOnlyEventListeners(capture);
@@ -779,11 +1053,15 @@ export class TextScanner extends EventDispatcher {
 
         eventListenerInfos.push(this._getSelectionChangeCheckUserSelectionListener());
 
-        for (const args of eventListenerInfos) {
+        for (const [...args] of eventListenerInfos) {
             this._eventListeners.addEventListener(...args);
         }
     }
 
+    /**
+     * @param {boolean} capture
+     * @returns {import('event-listener-collection').AddEventListenerArgs[]}
+     */
     _getPointerEventListeners(capture) {
         return [
             [this._node, 'pointerover', this._onPointerOver.bind(this), capture],
@@ -799,6 +1077,10 @@ export class TextScanner extends EventDispatcher {
         ];
     }
 
+    /**
+     * @param {boolean} capture
+     * @returns {import('event-listener-collection').AddEventListenerArgs[]}
+     */
     _getMouseEventListeners(capture) {
         return [
             [this._node, 'mousedown', this._onMouseDown.bind(this), capture],
@@ -809,6 +1091,10 @@ export class TextScanner extends EventDispatcher {
         ];
     }
 
+    /**
+     * @param {boolean} capture
+     * @returns {import('event-listener-collection').AddEventListenerArgs[]}
+     */
     _getTouchEventListeners(capture) {
         return [
             [this._node, 'auxclick', this._onAuxClick.bind(this), capture],
@@ -820,14 +1106,23 @@ export class TextScanner extends EventDispatcher {
         ];
     }
 
+    /**
+     * @param {boolean} capture
+     * @returns {import('event-listener-collection').AddEventListenerArgs[]}
+     */
     _getMouseClickOnlyEventListeners(capture) {
         return [
             [this._node, 'click', this._onClick.bind(this), capture]
         ];
     }
 
+    /**
+     * @param {boolean} capture
+     * @returns {import('event-listener-collection').AddEventListenerArgs[]}
+     */
     _getMouseClickOnlyEventListeners2(capture) {
         const {documentElement} = document;
+        /** @type {import('event-listener-collection').AddEventListenerArgs[]} */
         const entries = [
             [document, 'selectionchange', this._onSelectionChange.bind(this)]
         ];
@@ -840,10 +1135,18 @@ export class TextScanner extends EventDispatcher {
         return entries;
     }
 
+    /**
+     * @returns {import('event-listener-collection').AddEventListenerArgs}
+     */
     _getSelectionChangeCheckUserSelectionListener() {
         return [document, 'selectionchange', this._onSelectionChangeCheckUserSelection.bind(this)];
     }
 
+    /**
+     * @param {TouchList} touchList
+     * @param {number} identifier
+     * @returns {?Touch}
+     */
     _getTouch(touchList, identifier) {
         for (const touch of touchList) {
             if (touch.identifier === identifier) {
@@ -853,6 +1156,13 @@ export class TextScanner extends EventDispatcher {
         return null;
     }
 
+    /**
+     * @param {import('text-source').TextSource} textSource
+     * @param {boolean} searchTerms
+     * @param {boolean} searchKanji
+     * @param {import('settings').OptionsContext} optionsContext
+     * @returns {Promise<?import('text-scanner').SearchResults>}
+     */
     async _findDictionaryEntries(textSource, searchTerms, searchKanji, optionsContext) {
         if (textSource === null) {
             return null;
@@ -868,6 +1178,11 @@ export class TextScanner extends EventDispatcher {
         return null;
     }
 
+    /**
+     * @param {import('text-source').TextSource} textSource
+     * @param {import('settings').OptionsContext} optionsContext
+     * @returns {Promise<?import('text-scanner').TermSearchResults>}
+     */
     async _findTermDictionaryEntries(textSource, optionsContext) {
         const scanLength = this._scanLength;
         const sentenceScanExtent = this._sentenceScanExtent;
@@ -879,6 +1194,7 @@ export class TextScanner extends EventDispatcher {
         const searchText = this.getTextSourceContent(textSource, scanLength, layoutAwareScan);
         if (searchText.length === 0) { return null; }
 
+        /** @type {import('api').FindTermsDetails} */
         const details = {};
         if (this._matchTypePrefix) { details.matchType = 'prefix'; }
         const {dictionaryEntries, originalTextLength} = await yomitan.api.termsFind(searchText, details, optionsContext);
@@ -898,6 +1214,11 @@ export class TextScanner extends EventDispatcher {
         return {dictionaryEntries, sentence, type: 'terms'};
     }
 
+    /**
+     * @param {import('text-source').TextSource} textSource
+     * @param {import('settings').OptionsContext} optionsContext
+     * @returns {Promise<?import('text-scanner').KanjiSearchResults>}
+     */
     async _findKanjiDictionaryEntries(textSource, optionsContext) {
         const sentenceScanExtent = this._sentenceScanExtent;
         const sentenceTerminateAtNewlines = this._sentenceTerminateAtNewlines;
@@ -925,6 +1246,11 @@ export class TextScanner extends EventDispatcher {
         return {dictionaryEntries, sentence, type: 'kanji'};
     }
 
+    /**
+     * @param {number} x
+     * @param {number} y
+     * @param {import('text-scanner').InputInfo} inputInfo
+     */
     async _searchAt(x, y, inputInfo) {
         if (this._pendingLookup) { return; }
 
@@ -948,11 +1274,13 @@ export class TextScanner extends EventDispatcher {
                 deepContentScan: this._deepContentScan,
                 normalizeCssZoom: this._normalizeCssZoom
             });
-            try {
-                await this._search(textSource, searchTerms, searchKanji, inputInfo);
-            } finally {
-                if (textSource !== null) {
-                    textSource.cleanup();
+            if (textSource !== null) {
+                try {
+                    await this._search(textSource, searchTerms, searchKanji, inputInfo);
+                } finally {
+                    if (textSource !== null) {
+                        textSource.cleanup();
+                    }
                 }
             }
         } catch (e) {
@@ -962,6 +1290,11 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {number} x
+     * @param {number} y
+     * @param {import('text-scanner').InputInfo} inputInfo
+     */
     async _searchAtFromMouseMove(x, y, inputInfo) {
         if (this._pendingLookup) { return; }
 
@@ -975,15 +1308,21 @@ export class TextScanner extends EventDispatcher {
         await this._searchAt(x, y, inputInfo);
     }
 
+    /**
+     * @param {number} x
+     * @param {number} y
+     * @param {import('text-scanner').InputInfo} inputInfo
+     */
     async _searchAtFromTouchStart(x, y, inputInfo) {
         const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null;
-        const preventScroll = inputInfo.input.preventTouchScrolling;
+        const {input} = inputInfo;
+        const preventScroll = input !== null && input.preventTouchScrolling;
 
         await this._searchAt(x, y, inputInfo);
 
         if (
             this._textSourceCurrent !== null &&
-            !this._textSourceCurrent.hasSameStart(textSourceCurrentPrevious)
+            !(textSourceCurrentPrevious !== null && this._textSourceCurrent.hasSameStart(textSourceCurrentPrevious))
         ) {
             this._preventScroll = preventScroll;
             this._preventNextContextMenu = true;
@@ -991,10 +1330,20 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {number} x
+     * @param {number} y
+     * @param {import('text-scanner').InputInfo} inputInfo
+     */
     async _searchAtFromTouchEnd(x, y, inputInfo) {
         await this._searchAt(x, y, inputInfo);
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @param {import('text-scanner').PointerEventType} eventType
+     * @param {boolean} prevent
+     */
     async _searchAtFromPen(e, eventType, prevent) {
         if (this._pendingLookup) { return; }
 
@@ -1002,9 +1351,9 @@ export class TextScanner extends EventDispatcher {
         if (inputInfo === null) { return; }
 
         const {input} = inputInfo;
-        if (!this._isPenEventSupported(eventType, input)) { return; }
+        if (input === null || !this._isPenEventSupported(eventType, input)) { return; }
 
-        const preventScroll = input.preventPenScrolling;
+        const preventScroll = input !== null && input.preventPenScrolling;
 
         await this._searchAt(e.clientX, e.clientY, inputInfo);
 
@@ -1019,6 +1368,11 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {import('text-scanner').PointerEventType} eventType
+     * @param {import('text-scanner').InputConfig} input
+     * @returns {boolean}
+     */
     _isPenEventSupported(eventType, input) {
         switch (eventType) {
             case 'pointerDown':
@@ -1038,12 +1392,25 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {import('text-scanner').PointerType} pointerType
+     * @param {import('text-scanner').PointerEventType} eventType
+     * @param {MouseEvent|TouchEvent} event
+     * @returns {?import('text-scanner').InputInfo}
+     */
     _getMatchingInputGroupFromEvent(pointerType, eventType, event) {
         const modifiers = DocumentUtil.getActiveModifiersAndButtons(event);
         const modifierKeys = DocumentUtil.getActiveModifiers(event);
         return this._getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys);
     }
 
+    /**
+     * @param {import('text-scanner').PointerType} pointerType
+     * @param {import('text-scanner').PointerEventType} eventType
+     * @param {import('input').Modifier[]} modifiers
+     * @param {import('input').ModifierKey[]} modifierKeys
+     * @returns {?import('text-scanner').InputInfo}
+     */
     _getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys) {
         let fallbackIndex = -1;
         const modifiersSet = new Set(modifiers);
@@ -1067,10 +1434,25 @@ export class TextScanner extends EventDispatcher {
         );
     }
 
+    /**
+     * @param {?import('text-scanner').InputConfig} input
+     * @param {import('text-scanner').PointerType} pointerType
+     * @param {import('text-scanner').PointerEventType} eventType
+     * @param {boolean} passive
+     * @param {import('input').Modifier[]} modifiers
+     * @param {import('input').ModifierKey[]} modifierKeys
+     * @param {import('text-scanner').InputInfoDetail} [detail]
+     * @returns {import('text-scanner').InputInfo}
+     */
     _createInputInfo(input, pointerType, eventType, passive, modifiers, modifierKeys, detail) {
         return {input, pointerType, eventType, passive, modifiers, modifierKeys, detail};
     }
 
+    /**
+     * @param {Set<string>} set
+     * @param {string[]} values
+     * @returns {boolean}
+     */
     _setHasAll(set, values) {
         for (const value of values) {
             if (!set.has(value)) {
@@ -1080,6 +1462,10 @@ export class TextScanner extends EventDispatcher {
         return true;
     }
 
+    /**
+     * @param {import('text-scanner').InputOptionsOuter} input
+     * @returns {import('text-scanner').InputConfig}
+     */
     _convertInput(input) {
         const {options} = input;
         return {
@@ -1101,6 +1487,10 @@ export class TextScanner extends EventDispatcher {
         };
     }
 
+    /**
+     * @param {string} value
+     * @returns {string[]}
+     */
     _getInputArray(value) {
         return (
             typeof value === 'string' ?
@@ -1109,6 +1499,10 @@ export class TextScanner extends EventDispatcher {
         );
     }
 
+    /**
+     * @param {{mouse: boolean, touch: boolean, pen: boolean}} details
+     * @returns {Set<'mouse'|'touch'|'pen'>}
+     */
     _getInputTypeSet({mouse, touch, pen}) {
         const set = new Set();
         if (mouse) { set.add('mouse'); }
@@ -1117,16 +1511,30 @@ export class TextScanner extends EventDispatcher {
         return set;
     }
 
+    /**
+     * @param {unknown} value
+     * @returns {boolean}
+     */
     _getInputBoolean(value) {
         return typeof value === 'boolean' && value;
     }
 
+    /**
+     * @param {PointerEvent} e
+     * @returns {string}
+     */
     _getPointerEventType(e) {
         // Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events.
         const cachedPointerType = this._pointerIdTypeMap.get(e.pointerId);
         return (typeof cachedPointerType !== 'undefined' ? cachedPointerType : e.pointerType);
     }
 
+    /**
+     * @param {import('text-source').TextSource} textSource
+     * @param {?string} includeSelector
+     * @param {?string} excludeSelector
+     * @param {boolean} layoutAwareScan
+     */
     _constrainTextSource(textSource, includeSelector, excludeSelector, layoutAwareScan) {
         let length = textSource.text().length;
         while (length > 0) {
@@ -1143,6 +1551,10 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {string} text
+     * @returns {Promise<boolean>}
+     */
     async _hasJapanese(text) {
         try {
             return await yomitan.api.textHasJapaneseCharacters(text);
@@ -1151,19 +1563,28 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @returns {import('text-scanner').SelectionRestoreInfo}
+     */
     _createSelectionRestoreInfo() {
         const ranges = [];
         const selection = window.getSelection();
-        for (let i = 0, ii = selection.rangeCount; i < ii; ++i) {
-            const range = selection.getRangeAt(i);
-            ranges.push(range.cloneRange());
+        if (selection !== null) {
+            for (let i = 0, ii = selection.rangeCount; i < ii; ++i) {
+                const range = selection.getRangeAt(i);
+                ranges.push(range.cloneRange());
+            }
         }
         return {ranges};
     }
 
+    /**
+     * @param {import('text-scanner').SelectionRestoreInfo} selectionRestoreInfo
+     */
     _restoreSelection(selectionRestoreInfo) {
         const {ranges} = selectionRestoreInfo;
         const selection = window.getSelection();
+        if (selection === null) { return; }
         selection.removeAllRanges();
         for (const range of ranges) {
             try {
@@ -1174,7 +1595,18 @@ export class TextScanner extends EventDispatcher {
         }
     }
 
+    /**
+     * @param {string} reason
+     */
     _triggerClear(reason) {
         this.trigger('clear', {reason});
     }
+
+    /**
+     * @returns {boolean}
+     */
+    _computeUserHasNotSelectedAnythingManually() {
+        const selection = window.getSelection();
+        return selection === null || selection.isCollapsed;
+    }
 }
diff --git a/ext/js/language/translator.js b/ext/js/language/translator.js
index 4044f379..9b01c1ff 100644
--- a/ext/js/language/translator.js
+++ b/ext/js/language/translator.js
@@ -25,35 +25,29 @@ import {DictionaryDatabase} from './dictionary-database.js';
  * Class which finds term and kanji dictionary entries for text.
  */
 export class Translator {
-    /**
-     * Information about how popup content should be shown, specifically related to the outer popup frame.
-     * @typedef {object} TermFrequency
-     * @property {string} term The term.
-     * @property {string} reading The reading of the term.
-     * @property {string} dictionary The name of the dictionary that the term frequency originates from.
-     * @property {boolean} hasReading Whether or not a reading was specified.
-     * @property {number|string} frequency The frequency value for the term.
-     */
-
     /**
      * Creates a new Translator instance.
-     * @param {object} details The details for the class.
-     * @param {JapaneseUtil} details.japaneseUtil An instance of JapaneseUtil.
-     * @param {DictionaryDatabase} details.database An instance of DictionaryDatabase.
+     * @param {import('translator').ConstructorDetails} details The details for the class.
      */
     constructor({japaneseUtil, database}) {
+        /** @type {JapaneseUtil} */
         this._japaneseUtil = japaneseUtil;
+        /** @type {DictionaryDatabase} */
         this._database = database;
+        /** @type {?Deinflector} */
         this._deinflector = null;
+        /** @type {import('translator').DictionaryTagCache} */
         this._tagCache = new Map();
+        /** @type {Intl.Collator} */
         this._stringComparer = new Intl.Collator('en-US'); // Invariant locale
+        /** @type {RegExp} */
         this._numberRegex = /[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/;
     }
 
     /**
      * Initializes the instance for use. The public API should not be used until
      * this function has been called.
-     * @param {object} deinflectionReasons The raw deinflections reasons data that the Deinflector uses.
+     * @param {import('deinflector').ReasonsRaw} deinflectionReasons The raw deinflections reasons data that the Deinflector uses.
      */
     prepare(deinflectionReasons) {
         this._deinflector = new Deinflector(deinflectionReasons);
@@ -68,22 +62,23 @@ export class Translator {
 
     /**
      * Finds term definitions for the given text.
-     * @param {string} mode The mode to use for finding terms, which determines the format of the resulting array.
+     * @param {import('translator').FindTermsMode} mode The mode to use for finding terms, which determines the format of the resulting array.
      *   One of: 'group', 'merge', 'split', 'simple'
      * @param {string} text The text to find terms for.
-     * @param {Translation.FindTermsOptions} options A object describing settings about the lookup.
-     * @returns {{dictionaryEntries: Translation.TermDictionaryEntry[], originalTextLength: number}} An object containing dictionary entries and the length of the original source text.
+     * @param {import('translation').FindTermsOptions} options A object describing settings about the lookup.
+     * @returns {Promise<{dictionaryEntries: import('dictionary').TermDictionaryEntry[], originalTextLength: number}>} An object containing dictionary entries and the length of the original source text.
      */
     async findTerms(mode, text, options) {
         const {enabledDictionaryMap, excludeDictionaryDefinitions, sortFrequencyDictionary, sortFrequencyDictionaryOrder} = options;
-        let {dictionaryEntries, originalTextLength} = await this._findTermsInternal(text, enabledDictionaryMap, options);
+        const tagAggregator = new TranslatorTagAggregator();
+        let {dictionaryEntries, originalTextLength} = await this._findTermsInternal(text, enabledDictionaryMap, options, tagAggregator);
 
         switch (mode) {
             case 'group':
-                dictionaryEntries = this._groupDictionaryEntriesByHeadword(dictionaryEntries);
+                dictionaryEntries = this._groupDictionaryEntriesByHeadword(dictionaryEntries, tagAggregator);
                 break;
             case 'merge':
-                dictionaryEntries = await this._getRelatedDictionaryEntries(dictionaryEntries, options.mainDictionary, enabledDictionaryMap);
+                dictionaryEntries = await this._getRelatedDictionaryEntries(dictionaryEntries, options.mainDictionary, enabledDictionaryMap, tagAggregator);
                 break;
         }
 
@@ -91,17 +86,19 @@ export class Translator {
             this._removeExcludedDefinitions(dictionaryEntries, excludeDictionaryDefinitions);
         }
 
-        if (mode === 'simple') {
+        if (mode !== 'simple') {
+            await this._addTermMeta(dictionaryEntries, enabledDictionaryMap, tagAggregator);
+            await this._expandTagGroupsAndGroup(tagAggregator.getTagExpansionTargets());
+        } else {
             if (sortFrequencyDictionary !== null) {
-                const sortDictionaryMap = [sortFrequencyDictionary]
-                    .filter((key) => enabledDictionaryMap.has(key))
-                    .reduce((subMap, key) => subMap.set(key, enabledDictionaryMap.get(key)), new Map());
-                await this._addTermMeta(dictionaryEntries, sortDictionaryMap);
+                /** @type {import('translation').TermEnabledDictionaryMap} */
+                const sortDictionaryMap = new Map();
+                const value = enabledDictionaryMap.get(sortFrequencyDictionary);
+                if (typeof value !== 'undefined') {
+                    sortDictionaryMap.set(sortFrequencyDictionary, value);
+                }
+                await this._addTermMeta(dictionaryEntries, sortDictionaryMap, tagAggregator);
             }
-            this._clearTermTags(dictionaryEntries);
-        } else {
-            await this._addTermMeta(dictionaryEntries, enabledDictionaryMap);
-            await this._expandTermTags(dictionaryEntries);
         }
 
         if (sortFrequencyDictionary !== null) {
@@ -125,8 +122,8 @@ export class Translator {
      * @param {string} text The text to find kanji definitions for. This string can be of any length,
      *   but is typically just one character, which is a single kanji. If the string is multiple
      *   characters long, each character will be searched in the database.
-     * @param {Translation.FindKanjiOptions} options A object describing settings about the lookup.
-     * @returns {Translation.KanjiDictionaryEntry[]} An array of definitions. See the _createKanjiDefinition() function for structure details.
+     * @param {import('translation').FindKanjiOptions} options A object describing settings about the lookup.
+     * @returns {Promise<import('dictionary').KanjiDictionaryEntry[]>} An array of definitions. See the _createKanjiDefinition() function for structure details.
      */
     async findKanji(text, options) {
         const {enabledDictionaryMap} = options;
@@ -140,19 +137,18 @@ export class Translator {
 
         this._sortDatabaseEntriesByIndex(databaseEntries);
 
+        /** @type {import('dictionary').KanjiDictionaryEntry[]} */
         const dictionaryEntries = [];
+        const tagAggregator = new TranslatorTagAggregator();
         for (const {character, onyomi, kunyomi, tags, definitions, stats, dictionary} of databaseEntries) {
             const expandedStats = await this._expandKanjiStats(stats, dictionary);
-
-            const tagGroups = [];
-            if (tags.length > 0) { tagGroups.push(this._createTagGroup(dictionary, tags)); }
-
-            const dictionaryEntry = this._createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, tagGroups, expandedStats, definitions);
+            const dictionaryEntry = this._createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, expandedStats, definitions);
             dictionaryEntries.push(dictionaryEntry);
+            tagAggregator.addTags(dictionaryEntry.tags, dictionary, tags);
         }
 
         await this._addKanjiMeta(dictionaryEntries, enabledDictionaryMap);
-        await this._expandKanjiTags(dictionaryEntries);
+        await this._expandTagGroupsAndGroup(tagAggregator.getTagExpansionTargets());
 
         this._sortKanjiDictionaryEntryData(dictionaryEntries);
 
@@ -164,8 +160,8 @@ export class Translator {
      * and a list of dictionaries.
      * @param {{term: string, reading: string|null}[]} termReadingList An array of `{term, reading}` pairs. If reading is null,
      *   the reading won't be compared.
-     * @param {Iterable<string>} dictionaries An array of dictionary names.
-     * @returns {TermFrequency[]} An array of term frequencies.
+     * @param {string[]} dictionaries An array of dictionary names.
+     * @returns {Promise<import('translator').TermFrequencySimple[]>} An array of term frequencies.
      */
     async getTermFrequencies(termReadingList, dictionaries) {
         const dictionarySet = new Set();
@@ -176,25 +172,26 @@ export class Translator {
         const termList = termReadingList.map(({term}) => term);
         const metas = await this._database.findTermMetaBulk(termList, dictionarySet);
 
+        /** @type {import('translator').TermFrequencySimple[]} */
         const results = [];
         for (const {mode, data, dictionary, index} of metas) {
             if (mode !== 'freq') { continue; }
             let {term, reading} = termReadingList[index];
-            let frequency = data;
-            const hasReading = (data !== null && typeof data === 'object');
-            if (hasReading) {
-                if (data.reading !== reading) {
-                    if (reading !== null) { continue; }
-                    reading = data.reading;
-                }
-                frequency = data.frequency;
+            const hasReading = (data !== null && typeof data === 'object' && typeof data.reading === 'string');
+            if (hasReading && data.reading !== reading) {
+                if (reading !== null) { continue; }
+                reading = data.reading;
             }
+            const frequency = hasReading ? data.frequency : /** @type {import('dictionary-data').GenericFrequencyData} */ (data);
+            const {frequency: frequencyValue, displayValue, displayValueParsed} = this._getFrequencyInfo(frequency);
             results.push({
                 term,
                 reading,
                 dictionary,
                 hasReading,
-                frequency
+                frequency: frequencyValue,
+                displayValue,
+                displayValueParsed
             });
         }
         return results;
@@ -202,7 +199,14 @@ export class Translator {
 
     // Find terms internal implementation
 
-    async _findTermsInternal(text, enabledDictionaryMap, options) {
+    /**
+     * @param {string} text
+     * @param {Map<string, import('translation').FindTermDictionary>} enabledDictionaryMap
+     * @param {import('translation').FindTermsOptions} options
+     * @param {TranslatorTagAggregator} tagAggregator
+     * @returns {Promise<{dictionaryEntries: import('dictionary').TermDictionaryEntry[], originalTextLength: number}>}
+     */
+    async _findTermsInternal(text, enabledDictionaryMap, options, tagAggregator) {
         if (options.removeNonJapaneseCharacters) {
             text = this._getJapaneseOnlyText(text);
         }
@@ -221,7 +225,7 @@ export class Translator {
             for (const databaseEntry of databaseEntries) {
                 const {id} = databaseEntry;
                 if (ids.has(id)) { continue; }
-                const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, reasons, true, enabledDictionaryMap);
+                const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, reasons, true, enabledDictionaryMap, tagAggregator);
                 dictionaryEntries.push(dictionaryEntry);
                 ids.add(id);
             }
@@ -230,11 +234,17 @@ export class Translator {
         return {dictionaryEntries, originalTextLength};
     }
 
+    /**
+     * @param {string} text
+     * @param {Map<string, import('translation').FindTermDictionary>} enabledDictionaryMap
+     * @param {import('translation').FindTermsOptions} options
+     * @returns {Promise<import('translation-internal').DatabaseDeinflection[]>}
+     */
     async _findTermsInternal2(text, enabledDictionaryMap, options) {
         const deinflections = (
             options.deinflect ?
             this._getAllDeinflections(text, options) :
-            [this._createDeinflection(text, text, text, 0, [], [])]
+            [this._createDeinflection(text, text, text, 0, [])]
         );
         if (deinflections.length === 0) { return []; }
 
@@ -271,7 +281,13 @@ export class Translator {
 
     // Deinflections and text transformations
 
+    /**
+     * @param {string} text
+     * @param {import('translation').FindTermsOptions} options
+     * @returns {import('translation-internal').DatabaseDeinflection[]}
+     */
     _getAllDeinflections(text, options) {
+        /** @type {import('translation-internal').TextDeinflectionOptionsArrays} */
         const textOptionVariantArray = [
             this._getTextReplacementsVariants(options),
             this._getTextOptionEntryVariants(options.convertHalfWidthCharacters),
@@ -283,9 +299,10 @@ export class Translator {
         ];
 
         const jp = this._japaneseUtil;
+        /** @type {import('translation-internal').DatabaseDeinflection[]} */
         const deinflections = [];
         const used = new Set();
-        for (const [textReplacements, halfWidth, numeric, alphabetic, katakana, hiragana, [collapseEmphatic, collapseEmphaticFull]] of this._getArrayVariants(textOptionVariantArray)) {
+        for (const [textReplacements, halfWidth, numeric, alphabetic, katakana, hiragana, [collapseEmphatic, collapseEmphaticFull]] of /** @type {Generator<import('translation-internal').TextDeinflectionOptions, void, unknown>} */ (this._getArrayVariants(textOptionVariantArray))) {
             let text2 = text;
             const sourceMap = new TextSourceMap(text2);
             if (textReplacements !== null) {
@@ -315,14 +332,20 @@ export class Translator {
                 if (used.has(source)) { break; }
                 used.add(source);
                 const rawSource = sourceMap.source.substring(0, sourceMap.getSourceLength(i));
-                for (const {term, rules, reasons} of this._deinflector.deinflect(source)) {
-                    deinflections.push(this._createDeinflection(rawSource, source, term, rules, reasons, []));
+                for (const {term, rules, reasons} of /** @type {Deinflector} */ (this._deinflector).deinflect(source)) {
+                    deinflections.push(this._createDeinflection(rawSource, source, term, rules, reasons));
                 }
             }
         }
         return deinflections;
     }
 
+    /**
+     * @param {string} text
+     * @param {TextSourceMap} sourceMap
+     * @param {import('translation').FindTermsTextReplacement[]} replacements
+     * @returns {string}
+     */
     _applyTextReplacements(text, sourceMap, replacements) {
         for (const {pattern, replacement} of replacements) {
             text = RegexUtil.applyTextReplacement(text, sourceMap, pattern, replacement);
@@ -330,11 +353,15 @@ export class Translator {
         return text;
     }
 
+    /**
+     * @param {string} text
+     * @returns {string}
+     */
     _getJapaneseOnlyText(text) {
         const jp = this._japaneseUtil;
         let length = 0;
         for (const c of text) {
-            if (!jp.isCodePointJapanese(c.codePointAt(0))) {
+            if (!jp.isCodePointJapanese(/** @type {number} */ (c.codePointAt(0)))) {
                 return text.substring(0, length);
             }
             length += c.length;
@@ -342,6 +369,10 @@ export class Translator {
         return text;
     }
 
+    /**
+     * @param {import('translation').FindTermsVariantMode} value
+     * @returns {boolean[]}
+     */
     _getTextOptionEntryVariants(value) {
         switch (value) {
             case 'true': return [true];
@@ -350,7 +381,12 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('translation').FindTermsOptions} options
+     * @returns {[collapseEmphatic: boolean, collapseEmphaticFull: boolean][]}
+     */
     _getCollapseEmphaticOptions(options) {
+        /** @type {[collapseEmphatic: boolean, collapseEmphaticFull: boolean][]} */
         const collapseEmphaticOptions = [[false, false]];
         switch (options.collapseEmphaticSequences) {
             case 'true':
@@ -363,20 +399,43 @@ export class Translator {
         return collapseEmphaticOptions;
     }
 
+    /**
+     * @param {import('translation').FindTermsOptions} options
+     * @returns {(import('translation').FindTermsTextReplacement[] | null)[]}
+     */
     _getTextReplacementsVariants(options) {
         return options.textReplacements;
     }
 
-    _createDeinflection(originalText, transformedText, deinflectedText, rules, reasons, databaseEntries) {
-        return {originalText, transformedText, deinflectedText, rules, reasons, databaseEntries};
+    /**
+     * @param {string} originalText
+     * @param {string} transformedText
+     * @param {string} deinflectedText
+     * @param {import('translation-internal').DeinflectionRuleFlags} rules
+     * @param {string[]} reasons
+     * @returns {import('translation-internal').DatabaseDeinflection}
+     */
+    _createDeinflection(originalText, transformedText, deinflectedText, rules, reasons) {
+        return {originalText, transformedText, deinflectedText, rules, reasons, databaseEntries: []};
     }
 
     // Term dictionary entry grouping
 
-    async _getRelatedDictionaryEntries(dictionaryEntries, mainDictionary, enabledDictionaryMap) {
+    /**
+     * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
+     * @param {string} mainDictionary
+     * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
+     * @param {TranslatorTagAggregator} tagAggregator
+     * @returns {Promise<import('dictionary').TermDictionaryEntry[]>}
+     */
+    async _getRelatedDictionaryEntries(dictionaryEntries, mainDictionary, enabledDictionaryMap, tagAggregator) {
+        /** @type {import('translator').SequenceQuery[]} */
         const sequenceList = [];
+        /** @type {import('translator').DictionaryEntryGroup[]} */
         const groupedDictionaryEntries = [];
+        /** @type {Map<number, import('translator').DictionaryEntryGroup>} */
         const groupedDictionaryEntriesMap = new Map();
+        /** @type {Map<number, import('dictionary').TermDictionaryEntry>} */
         const ungroupedDictionaryEntriesMap = new Map();
         for (const dictionaryEntry of dictionaryEntries) {
             const {definitions: [{id, dictionary, sequences: [sequence]}]} = dictionaryEntry;
@@ -400,24 +459,31 @@ export class Translator {
 
         if (sequenceList.length > 0) {
             const secondarySearchDictionaryMap = this._getSecondarySearchDictionaryMap(enabledDictionaryMap);
-            await this._addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap);
+            await this._addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap, tagAggregator);
             for (const group of groupedDictionaryEntries) {
                 this._sortTermDictionaryEntriesById(group.dictionaryEntries);
             }
             if (ungroupedDictionaryEntriesMap.size !== 0 || secondarySearchDictionaryMap.size !== 0) {
-                await this._addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap);
+                await this._addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap, tagAggregator);
             }
         }
 
         const newDictionaryEntries = [];
         for (const group of groupedDictionaryEntries) {
-            newDictionaryEntries.push(this._createGroupedDictionaryEntry(group.dictionaryEntries, true));
+            newDictionaryEntries.push(this._createGroupedDictionaryEntry(group.dictionaryEntries, true, tagAggregator));
         }
-        newDictionaryEntries.push(...this._groupDictionaryEntriesByHeadword(ungroupedDictionaryEntriesMap.values()));
+        newDictionaryEntries.push(...this._groupDictionaryEntriesByHeadword(ungroupedDictionaryEntriesMap.values(), tagAggregator));
         return newDictionaryEntries;
     }
 
-    async _addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap) {
+    /**
+     * @param {import('translator').DictionaryEntryGroup[]} groupedDictionaryEntries
+     * @param {Map<number, import('dictionary').TermDictionaryEntry>} ungroupedDictionaryEntriesMap
+     * @param {import('translator').SequenceQuery[]} sequenceList
+     * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
+     * @param {TranslatorTagAggregator} tagAggregator
+     */
+    async _addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap, tagAggregator) {
         const databaseEntries = await this._database.findTermsBySequenceBulk(sequenceList);
         for (const databaseEntry of databaseEntries) {
             const {dictionaryEntries, ids} = groupedDictionaryEntries[databaseEntry.index];
@@ -425,15 +491,23 @@ export class Translator {
             if (ids.has(id)) { continue; }
 
             const {term} = databaseEntry;
-            const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, term, term, term, [], false, enabledDictionaryMap);
+            const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, term, term, term, [], false, enabledDictionaryMap, tagAggregator);
             dictionaryEntries.push(dictionaryEntry);
             ids.add(id);
             ungroupedDictionaryEntriesMap.delete(id);
         }
     }
 
-    async _addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap) {
+    /**
+     * @param {import('translator').DictionaryEntryGroup[]} groupedDictionaryEntries
+     * @param {Map<number, import('dictionary').TermDictionaryEntry>} ungroupedDictionaryEntriesMap
+     * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
+     * @param {import('translation').TermEnabledDictionaryMap} secondarySearchDictionaryMap
+     * @param {TranslatorTagAggregator} tagAggregator
+     */
+    async _addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap, tagAggregator) {
         // Prepare grouping info
+        /** @type {import('dictionary-database').TermExactRequest[]} */
         const termList = [];
         const targetList = [];
         const targetMap = new Map();
@@ -484,7 +558,7 @@ export class Translator {
             for (const {ids, dictionaryEntries} of target.groups) {
                 if (ids.has(id)) { continue; }
 
-                const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, sourceText, sourceText, sourceText, [], false, enabledDictionaryMap);
+                const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, sourceText, sourceText, sourceText, [], false, enabledDictionaryMap, tagAggregator);
                 dictionaryEntries.push(dictionaryEntry);
                 ids.add(id);
                 ungroupedDictionaryEntriesMap.delete(id);
@@ -492,7 +566,12 @@ export class Translator {
         }
     }
 
-    _groupDictionaryEntriesByHeadword(dictionaryEntries) {
+    /**
+     * @param {Iterable<import('dictionary').TermDictionaryEntry>} dictionaryEntries
+     * @param {TranslatorTagAggregator} tagAggregator
+     * @returns {import('dictionary').TermDictionaryEntry[]}
+     */
+    _groupDictionaryEntriesByHeadword(dictionaryEntries, tagAggregator) {
         const groups = new Map();
         for (const dictionaryEntry of dictionaryEntries) {
             const {inflections, headwords: [{term, reading}]} = dictionaryEntry;
@@ -507,13 +586,17 @@ export class Translator {
 
         const newDictionaryEntries = [];
         for (const groupDictionaryEntries of groups.values()) {
-            newDictionaryEntries.push(this._createGroupedDictionaryEntry(groupDictionaryEntries, false));
+            newDictionaryEntries.push(this._createGroupedDictionaryEntry(groupDictionaryEntries, false, tagAggregator));
         }
         return newDictionaryEntries;
     }
 
     // Removing data
 
+    /**
+     * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
+     * @param {Set<string>} excludeDictionaryDefinitions
+     */
     _removeExcludedDefinitions(dictionaryEntries, excludeDictionaryDefinitions) {
         for (let i = dictionaryEntries.length - 1; i >= 0; --i) {
             const dictionaryEntry = dictionaryEntries[i];
@@ -534,6 +617,9 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
+     */
     _removeUnusedHeadwords(dictionaryEntry) {
         const {definitions, pronunciations, frequencies, headwords} = dictionaryEntry;
         const removeHeadwordIndices = new Set();
@@ -548,6 +634,7 @@ export class Translator {
 
         if (removeHeadwordIndices.size === 0) { return; }
 
+        /** @type {Map<number, number>} */
         const indexRemap = new Map();
         let oldIndex = 0;
         for (let i = 0, ii = headwords.length; i < ii; ++i) {
@@ -566,6 +653,10 @@ export class Translator {
         this._updateArrayItemsHeadwordIndex(frequencies, indexRemap);
     }
 
+    /**
+     * @param {import('dictionary').TermDefinition[]} definitions
+     * @param {Map<number, number>} indexRemap
+     */
     _updateDefinitionHeadwordIndices(definitions, indexRemap) {
         for (const {headwordIndices} of definitions) {
             for (let i = headwordIndices.length - 1; i >= 0; --i) {
@@ -579,6 +670,10 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('dictionary').TermPronunciation[]|import('dictionary').TermFrequency[]} array
+     * @param {Map<number, number>} indexRemap
+     */
     _updateArrayItemsHeadwordIndex(array, indexRemap) {
         for (let i = array.length - 1; i >= 0; --i) {
             const item = array[i];
@@ -592,6 +687,11 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('dictionary').TermPronunciation[]|import('dictionary').TermFrequency[]|import('dictionary').TermDefinition[]} array
+     * @param {Set<string>} excludeDictionaryDefinitions
+     * @returns {boolean}
+     */
     _removeArrayItemsWithDictionary(array, excludeDictionaryDefinitions) {
         let changed = false;
         for (let j = array.length - 1; j >= 0; --j) {
@@ -603,45 +703,48 @@ export class Translator {
         return changed;
     }
 
-    _removeTagGroupsWithDictionary(array, excludeDictionaryDefinitions) {
-        for (const {tags} of array) {
-            this._removeArrayItemsWithDictionary(tags, excludeDictionaryDefinitions);
+    /**
+     * @param {import('dictionary').Tag[]} array
+     * @param {Set<string>} excludeDictionaryDefinitions
+     * @returns {boolean}
+     */
+    _removeArrayItemsWithDictionary2(array, excludeDictionaryDefinitions) {
+        let changed = false;
+        for (let j = array.length - 1; j >= 0; --j) {
+            const {dictionaries} = array[j];
+            if (this._hasAny(excludeDictionaryDefinitions, dictionaries)) { continue; }
+            array.splice(j, 1);
+            changed = true;
         }
+        return changed;
     }
 
-    // Tags
-
-    _getTermTagTargets(dictionaryEntries) {
-        const tagTargets = [];
-        for (const {headwords, definitions, pronunciations} of dictionaryEntries) {
-            this._addTagExpansionTargets(tagTargets, headwords);
-            this._addTagExpansionTargets(tagTargets, definitions);
-            for (const {pitches} of pronunciations) {
-                this._addTagExpansionTargets(tagTargets, pitches);
-            }
+    /**
+     * @param {import('dictionary').TermDefinition[]|import('dictionary').TermHeadword[]} array
+     * @param {Set<string>} excludeDictionaryDefinitions
+     */
+    _removeTagGroupsWithDictionary(array, excludeDictionaryDefinitions) {
+        for (const {tags} of array) {
+            this._removeArrayItemsWithDictionary2(tags, excludeDictionaryDefinitions);
         }
-        return tagTargets;
-    }
-
-    _clearTermTags(dictionaryEntries) {
-        this._getTermTagTargets(dictionaryEntries);
     }
 
-    async _expandTermTags(dictionaryEntries) {
-        const tagTargets = this._getTermTagTargets(dictionaryEntries);
-        await this._expandTagGroups(tagTargets);
-        this._groupTags(tagTargets);
-    }
+    // Tags
 
-    async _expandKanjiTags(dictionaryEntries) {
-        const tagTargets = [];
-        this._addTagExpansionTargets(tagTargets, dictionaryEntries);
-        await this._expandTagGroups(tagTargets);
-        this._groupTags(tagTargets);
+    /**
+     * @param {import('translator').TagExpansionTarget[]} tagExpansionTargets
+     */
+    async _expandTagGroupsAndGroup(tagExpansionTargets) {
+        await this._expandTagGroups(tagExpansionTargets);
+        this._groupTags(tagExpansionTargets);
     }
 
+    /**
+     * @param {import('translator').TagExpansionTarget[]} tagTargets
+     */
     async _expandTagGroups(tagTargets) {
         const allItems = [];
+        /** @type {import('translator').TagTargetMap} */
         const targetMap = new Map();
         for (const {tagGroups, tags} of tagTargets) {
             for (const {dictionary, tagNames} of tagGroups) {
@@ -687,10 +790,12 @@ export class Translator {
             const databaseTags = await this._database.findTagMetaBulk(nonCachedItems);
             for (let i = 0; i < nonCachedItemCount; ++i) {
                 const item = nonCachedItems[i];
-                let databaseTag = databaseTags[i];
-                if (typeof databaseTag === 'undefined') { databaseTag = null; }
-                item.databaseTag = databaseTag;
-                item.cache.set(item.query, databaseTag);
+                const databaseTag = databaseTags[i];
+                const databaseTag2 = typeof databaseTag !== 'undefined' ? databaseTag : null;
+                item.databaseTag = databaseTag2;
+                if (item.cache !== null) {
+                    item.cache.set(item.query, databaseTag2);
+                }
             }
         }
 
@@ -701,8 +806,16 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('translator').TagExpansionTarget[]} tagTargets
+     */
     _groupTags(tagTargets) {
         const stringComparer = this._stringComparer;
+        /**
+         * @param {import('dictionary').Tag} v1
+         * @param {import('dictionary').Tag} v2
+         * @returns {number}
+         */
         const compare = (v1, v2) => {
             const i = v1.order - v2.order;
             return i !== 0 ? i : stringComparer.compare(v1.name, v2.name);
@@ -715,16 +828,9 @@ export class Translator {
         }
     }
 
-    _addTagExpansionTargets(tagTargets, objects) {
-        for (const value of objects) {
-            const tagGroups = value.tags;
-            if (tagGroups.length === 0) { continue; }
-            const tags = [];
-            value.tags = tags;
-            tagTargets.push({tagGroups, tags});
-        }
-    }
-
+    /**
+     * @param {import('dictionary').Tag[]} tags
+     */
     _mergeSimilarTags(tags) {
         let tagCount = tags.length;
         for (let i = 0; i < tagCount; ++i) {
@@ -745,6 +851,11 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('dictionary').Tag[]} tags
+     * @param {string} category
+     * @returns {string[]}
+     */
     _getTagNamesWithCategory(tags, category) {
         const results = [];
         for (const tag of tags) {
@@ -755,6 +866,9 @@ export class Translator {
         return results;
     }
 
+    /**
+     * @param {import('dictionary').TermDefinition[]} definitions
+     */
     _flagRedundantDefinitionTags(definitions) {
         if (definitions.length === 0) { return; }
 
@@ -789,7 +903,12 @@ export class Translator {
 
     // Metadata
 
-    async _addTermMeta(dictionaryEntries, enabledDictionaryMap) {
+    /**
+     * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
+     * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
+     * @param {TranslatorTagAggregator} tagAggregator
+     */
+    async _addTermMeta(dictionaryEntries, enabledDictionaryMap, tagAggregator) {
         const headwordMap = new Map();
         const headwordMapKeys = [];
         const headwordReadingMaps = [];
@@ -821,16 +940,11 @@ export class Translator {
                 switch (mode) {
                     case 'freq':
                         {
-                            let frequency = data;
                             const hasReading = (data !== null && typeof data === 'object' && typeof data.reading === 'string');
-                            if (hasReading) {
-                                if (data.reading !== reading) { continue; }
-                                frequency = data.frequency;
-                            }
+                            if (hasReading && data.reading !== reading) { continue; }
+                            const frequency = hasReading ? data.frequency : /** @type {import('dictionary-data').GenericFrequencyData} */ (data);
                             for (const {frequencies, headwordIndex} of targets) {
-                                let displayValue;
-                                let displayValueParsed;
-                                ({frequency, displayValue, displayValueParsed} = this._getFrequencyInfo(frequency));
+                                const {frequency: frequencyValue, displayValue, displayValueParsed} = this._getFrequencyInfo(frequency);
                                 frequencies.push(this._createTermFrequency(
                                     frequencies.length,
                                     headwordIndex,
@@ -838,7 +952,7 @@ export class Translator {
                                     dictionaryIndex,
                                     dictionaryPriority,
                                     hasReading,
-                                    frequency,
+                                    frequencyValue,
                                     displayValue,
                                     displayValueParsed
                                 ));
@@ -848,11 +962,13 @@ export class Translator {
                     case 'pitch':
                         {
                             if (data.reading !== reading) { continue; }
+                            /** @type {import('dictionary').TermPitch[]} */
                             const pitches = [];
                             for (const {position, tags, nasal, devoice} of data.pitches) {
+                                /** @type {import('dictionary').Tag[]} */
                                 const tags2 = [];
-                                if (Array.isArray(tags) && tags.length > 0) {
-                                    tags2.push(this._createTagGroup(dictionary, tags));
+                                if (Array.isArray(tags)) {
+                                    tagAggregator.addTags(tags2, dictionary, tags);
                                 }
                                 const nasalPositions = this._toNumberArray(nasal);
                                 const devoicePositions = this._toNumberArray(devoice);
@@ -875,6 +991,10 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('dictionary').KanjiDictionaryEntry[]} dictionaryEntries
+     * @param {import('translation').KanjiEnabledDictionaryMap} enabledDictionaryMap
+     */
     async _addKanjiMeta(dictionaryEntries, enabledDictionaryMap) {
         const kanjiList = [];
         for (const {character} of dictionaryEntries) {
@@ -905,6 +1025,11 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {{[key: string]: (string|number)}} stats
+     * @param {string} dictionary
+     * @returns {Promise<import('dictionary').KanjiStatGroups>}
+     */
     async _expandKanjiStats(stats, dictionary) {
         const statsEntries = Object.entries(stats);
         const items = [];
@@ -915,10 +1040,11 @@ export class Translator {
 
         const databaseInfos = await this._database.findTagMetaBulk(items);
 
+        /** @type {Map<string, import('dictionary').KanjiStat[]>} */
         const statsGroups = new Map();
         for (let i = 0, ii = statsEntries.length; i < ii; ++i) {
             const databaseInfo = databaseInfos[i];
-            if (databaseInfo === null) { continue; }
+            if (typeof databaseInfo === 'undefined') { continue; }
 
             const [name, value] = statsEntries[i];
             const {category} = databaseInfo;
@@ -931,6 +1057,7 @@ export class Translator {
             group.push(this._createKanjiStat(name, value, databaseInfo, dictionary));
         }
 
+        /** @type {import('dictionary').KanjiStatGroups} */
         const groupedStats = {};
         for (const [category, group] of statsGroups.entries()) {
             this._sortKanjiStats(group);
@@ -939,6 +1066,9 @@ export class Translator {
         return groupedStats;
     }
 
+    /**
+     * @param {import('dictionary').KanjiStat[]} stats
+     */
     _sortKanjiStats(stats) {
         if (stats.length <= 1) { return; }
         const stringComparer = this._stringComparer;
@@ -948,45 +1078,59 @@ export class Translator {
         });
     }
 
+    /**
+     * @param {string} value
+     * @returns {number}
+     */
     _convertStringToNumber(value) {
         const match = this._numberRegex.exec(value);
         if (match === null) { return 0; }
-        value = Number.parseFloat(match[0]);
-        return Number.isFinite(value) ? value : 0;
+        const result = Number.parseFloat(match[0]);
+        return Number.isFinite(result) ? result : 0;
     }
 
+    /**
+     * @param {import('dictionary-data').GenericFrequencyData} frequency
+     * @returns {{frequency: number, displayValue: ?string, displayValueParsed: boolean}}
+     */
     _getFrequencyInfo(frequency) {
+        let frequencyValue = 0;
         let displayValue = null;
         let displayValueParsed = false;
         if (typeof frequency === 'object' && frequency !== null) {
-            ({value: frequency, displayValue} = frequency);
-            if (typeof frequency !== 'number') { frequency = 0; }
-            if (typeof displayValue !== 'string') { displayValue = null; }
+            const {value: frequencyValue2, displayValue: displayValue2} = frequency;
+            if (typeof frequencyValue2 === 'number') { frequencyValue = frequencyValue2; }
+            if (typeof displayValue2 === 'string') { displayValue = displayValue2; }
         } else {
             switch (typeof frequency) {
                 case 'number':
-                    // No change
+                    frequencyValue = frequency;
                     break;
                 case 'string':
                     displayValue = frequency;
                     displayValueParsed = true;
-                    frequency = this._convertStringToNumber(frequency);
-                    break;
-                default:
-                    frequency = 0;
+                    frequencyValue = this._convertStringToNumber(frequency);
                     break;
             }
         }
-        return {frequency, displayValue, displayValueParsed};
+        return {frequency: frequencyValue, displayValue, displayValueParsed};
     }
 
     // Helpers
 
+    /**
+     * @param {string} name
+     * @returns {string}
+     */
     _getNameBase(name) {
         const pos = name.indexOf(':');
         return (pos >= 0 ? name.substring(0, pos) : name);
     }
 
+    /**
+     * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap
+     * @returns {import('translation').TermEnabledDictionaryMap}
+     */
     _getSecondarySearchDictionaryMap(enabledDictionaryMap) {
         const secondarySearchDictionaryMap = new Map();
         for (const [dictionary, details] of enabledDictionaryMap.entries()) {
@@ -996,12 +1140,22 @@ export class Translator {
         return secondarySearchDictionaryMap;
     }
 
+    /**
+     * @param {string} dictionary
+     * @param {import('translation').TermEnabledDictionaryMap|import('translation').KanjiEnabledDictionaryMap} enabledDictionaryMap
+     * @returns {{index: number, priority: number}}
+     */
     _getDictionaryOrder(dictionary, enabledDictionaryMap) {
         const info = enabledDictionaryMap.get(dictionary);
         const {index, priority} = typeof info !== 'undefined' ? info : {index: enabledDictionaryMap.size, priority: 0};
         return {index, priority};
     }
 
+    /**
+     * @param {[...args: unknown[][]]} arrayVariants
+     * @yields {[...args: unknown[]]}
+     * @returns {Generator<unknown[], void, unknown>}
+     */
     *_getArrayVariants(arrayVariants) {
         const ii = arrayVariants.length;
 
@@ -1022,16 +1176,31 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {unknown[]} array
+     * @returns {string}
+     */
     _createMapKey(array) {
         return JSON.stringify(array);
     }
 
+    /**
+     * @param {number|number[]|undefined} value
+     * @returns {number[]}
+     */
     _toNumberArray(value) {
         return Array.isArray(value) ? value : (typeof value === 'number' ? [value] : []);
     }
 
     // Kanji data
 
+    /**
+     * @param {string} name
+     * @param {string|number} value
+     * @param {import('dictionary-database').Tag} databaseInfo
+     * @param {string} dictionary
+     * @returns {import('dictionary').KanjiStat}
+     */
     _createKanjiStat(name, value, databaseInfo, dictionary) {
         const {category, notes, order, score} = databaseInfo;
         return {
@@ -1040,23 +1209,43 @@ export class Translator {
             content: (typeof notes === 'string' ? notes : ''),
             order: (typeof order === 'number' ? order : 0),
             score: (typeof score === 'number' ? score : 0),
-            dictionary: (typeof dictionary === 'string' ? dictionary : null),
+            dictionary,
             value
         };
     }
 
+    /**
+     * @param {number} index
+     * @param {string} dictionary
+     * @param {number} dictionaryIndex
+     * @param {number} dictionaryPriority
+     * @param {string} character
+     * @param {number} frequency
+     * @param {?string} displayValue
+     * @param {boolean} displayValueParsed
+     * @returns {import('dictionary').KanjiFrequency}
+     */
     _createKanjiFrequency(index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue, displayValueParsed) {
         return {index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue, displayValueParsed};
     }
 
-    _createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, tags, stats, definitions) {
+    /**
+     * @param {string} character
+     * @param {string} dictionary
+     * @param {string[]} onyomi
+     * @param {string[]} kunyomi
+     * @param {import('dictionary').KanjiStatGroups} stats
+     * @param {string[]} definitions
+     * @returns {import('dictionary').KanjiDictionaryEntry}
+     */
+    _createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, stats, definitions) {
         return {
             type: 'kanji',
             character,
             dictionary,
             onyomi,
             kunyomi,
-            tags,
+            tags: [],
             stats,
             definitions,
             frequencies: []
@@ -1065,8 +1254,17 @@ export class Translator {
 
     // Term data
 
+    /**
+     * @param {?import('dictionary-database').Tag} databaseTag
+     * @param {string} name
+     * @param {string} dictionary
+     * @returns {import('dictionary').Tag}
+     */
     _createTag(databaseTag, name, dictionary) {
-        const {category, notes, order, score} = (databaseTag !== null ? databaseTag : {});
+        let category, notes, order, score;
+        if (typeof databaseTag === 'object' && databaseTag !== null) {
+            ({category, notes, order, score} = databaseTag);
+        }
         return {
             name,
             category: (typeof category === 'string' && category.length > 0 ? category : 'default'),
@@ -1078,18 +1276,46 @@ export class Translator {
         };
     }
 
-    _createTagGroup(dictionary, tagNames) {
-        return {dictionary, tagNames};
-    }
-
+    /**
+     * @param {string} originalText
+     * @param {string} transformedText
+     * @param {string} deinflectedText
+     * @param {import('dictionary').TermSourceMatchType} matchType
+     * @param {import('dictionary').TermSourceMatchSource} matchSource
+     * @param {boolean} isPrimary
+     * @returns {import('dictionary').TermSource}
+     */
     _createSource(originalText, transformedText, deinflectedText, matchType, matchSource, isPrimary) {
         return {originalText, transformedText, deinflectedText, matchType, matchSource, isPrimary};
     }
 
+    /**
+     * @param {number} index
+     * @param {string} term
+     * @param {string} reading
+     * @param {import('dictionary').TermSource[]} sources
+     * @param {import('dictionary').Tag[]} tags
+     * @param {string[]} wordClasses
+     * @returns {import('dictionary').TermHeadword}
+     */
     _createTermHeadword(index, term, reading, sources, tags, wordClasses) {
         return {index, term, reading, sources, tags, wordClasses};
     }
 
+    /**
+     * @param {number} index
+     * @param {number[]} headwordIndices
+     * @param {string} dictionary
+     * @param {number} dictionaryIndex
+     * @param {number} dictionaryPriority
+     * @param {number} id
+     * @param {number} score
+     * @param {number[]} sequences
+     * @param {boolean} isPrimary
+     * @param {import('dictionary').Tag[]} tags
+     * @param {import('dictionary-data').TermGlossary[]} entries
+     * @returns {import('dictionary').TermDefinition}
+     */
     _createTermDefinition(index, headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, id, score, sequences, isPrimary, tags, entries) {
         return {
             index,
@@ -1107,14 +1333,47 @@ export class Translator {
         };
     }
 
+    /**
+     * @param {number} index
+     * @param {number} headwordIndex
+     * @param {string} dictionary
+     * @param {number} dictionaryIndex
+     * @param {number} dictionaryPriority
+     * @param {import('dictionary').TermPitch[]} pitches
+     * @returns {import('dictionary').TermPronunciation}
+     */
     _createTermPronunciation(index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pitches) {
         return {index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pitches};
     }
 
+    /**
+     * @param {number} index
+     * @param {number} headwordIndex
+     * @param {string} dictionary
+     * @param {number} dictionaryIndex
+     * @param {number} dictionaryPriority
+     * @param {boolean} hasReading
+     * @param {number} frequency
+     * @param {?string} displayValue
+     * @param {boolean} displayValueParsed
+     * @returns {import('dictionary').TermFrequency}
+     */
     _createTermFrequency(index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue, displayValueParsed) {
         return {index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue, displayValueParsed};
     }
 
+    /**
+     * @param {boolean} isPrimary
+     * @param {string[]} inflections
+     * @param {number} score
+     * @param {number} dictionaryIndex
+     * @param {number} dictionaryPriority
+     * @param {number} sourceTermExactMatchCount
+     * @param {number} maxTransformedTextLength
+     * @param {import('dictionary').TermHeadword[]} headwords
+     * @param {import('dictionary').TermDefinition[]} definitions
+     * @returns {import('dictionary').TermDictionaryEntry}
+     */
     _createTermDictionaryEntry(isPrimary, inflections, score, dictionaryIndex, dictionaryPriority, sourceTermExactMatchCount, maxTransformedTextLength, headwords, definitions) {
         return {
             type: 'term',
@@ -1133,7 +1392,18 @@ export class Translator {
         };
     }
 
-    _createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, reasons, isPrimary, enabledDictionaryMap) {
+    /**
+     * @param {import('dictionary-database').TermEntry} databaseEntry
+     * @param {string} originalText
+     * @param {string} transformedText
+     * @param {string} deinflectedText
+     * @param {string[]} reasons
+     * @param {boolean} isPrimary
+     * @param {Map<string, import('translation').FindTermDictionary>} enabledDictionaryMap
+     * @param {TranslatorTagAggregator} tagAggregator
+     * @returns {import('dictionary').TermDictionaryEntry}
+     */
+    _createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, reasons, isPrimary, enabledDictionaryMap, tagAggregator) {
         const {matchType, matchSource, term, reading: rawReading, definitionTags, termTags, definitions, score, dictionary, id, sequence: rawSequence, rules} = databaseEntry;
         const reading = (rawReading.length > 0 ? rawReading : term);
         const {index: dictionaryIndex, priority: dictionaryPriority} = this._getDictionaryOrder(dictionary, enabledDictionaryMap);
@@ -1143,10 +1413,12 @@ export class Translator {
         const hasSequence = (rawSequence >= 0);
         const sequence = hasSequence ? rawSequence : -1;
 
+        /** @type {import('dictionary').Tag[]} */
         const headwordTagGroups = [];
+        /** @type {import('dictionary').Tag[]} */
         const definitionTagGroups = [];
-        if (termTags.length > 0) { headwordTagGroups.push(this._createTagGroup(dictionary, termTags)); }
-        if (definitionTags.length > 0) { definitionTagGroups.push(this._createTagGroup(dictionary, definitionTags)); }
+        tagAggregator.addTags(headwordTagGroups, dictionary, termTags);
+        tagAggregator.addTags(definitionTagGroups, dictionary, definitionTags);
 
         return this._createTermDictionaryEntry(
             isPrimary,
@@ -1161,12 +1433,19 @@ export class Translator {
         );
     }
 
-    _createGroupedDictionaryEntry(dictionaryEntries, checkDuplicateDefinitions) {
+    /**
+     * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
+     * @param {boolean} checkDuplicateDefinitions
+     * @param {TranslatorTagAggregator} tagAggregator
+     * @returns {import('dictionary').TermDictionaryEntry}
+     */
+    _createGroupedDictionaryEntry(dictionaryEntries, checkDuplicateDefinitions, tagAggregator) {
         // Headwords are generated before sorting, so that the order of dictionaryEntries can be maintained
         const definitionEntries = [];
+        /** @type {Map<string, import('dictionary').TermHeadword>} */
         const headwords = new Map();
         for (const dictionaryEntry of dictionaryEntries) {
-            const headwordIndexMap = this._addTermHeadwords(headwords, dictionaryEntry.headwords);
+            const headwordIndexMap = this._addTermHeadwords(headwords, dictionaryEntry.headwords, tagAggregator);
             definitionEntries.push({index: definitionEntries.length, dictionaryEntry, headwordIndexMap});
         }
 
@@ -1181,7 +1460,9 @@ export class Translator {
         let dictionaryPriority = Number.MIN_SAFE_INTEGER;
         let maxTransformedTextLength = 0;
         let isPrimary = false;
+        /** @type {import('dictionary').TermDefinition[]} */
         const definitions = [];
+        /** @type {?Map<string, import('dictionary').TermDefinition>} */
         const definitionsMap = checkDuplicateDefinitions ? new Map() : null;
         let inflections = null;
 
@@ -1197,8 +1478,8 @@ export class Translator {
                     inflections = dictionaryEntryInflections;
                 }
             }
-            if (checkDuplicateDefinitions) {
-                this._addTermDefinitions(definitions, definitionsMap, dictionaryEntry.definitions, headwordIndexMap);
+            if (definitionsMap !== null) {
+                this._addTermDefinitions(definitions, definitionsMap, dictionaryEntry.definitions, headwordIndexMap, tagAggregator);
             } else {
                 this._addTermDefinitionsFast(definitions, dictionaryEntry.definitions, headwordIndexMap);
             }
@@ -1231,6 +1512,11 @@ export class Translator {
 
     // Data collection addition functions
 
+    /**
+     * @template [T=unknown]
+     * @param {T[]} list
+     * @param {T[]} newItems
+     */
     _addUniqueSimple(list, newItems) {
         for (const item of newItems) {
             if (!list.includes(item)) {
@@ -1239,6 +1525,10 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('dictionary').TermSource[]} sources
+     * @param {import('dictionary').TermSource[]} newSources
+     */
     _addUniqueSources(sources, newSources) {
         if (newSources.length === 0) { return; }
         if (sources.length === 0) {
@@ -1267,27 +1557,14 @@ export class Translator {
         }
     }
 
-    _addUniqueTagGroups(tagGroups, newTagGroups) {
-        if (newTagGroups.length === 0) { return; }
-        for (const newTagGroup of newTagGroups) {
-            const {dictionary} = newTagGroup;
-            const ii = tagGroups.length;
-            if (ii > 0) {
-                let i = 0;
-                for (; i < ii; ++i) {
-                    const tagGroup = tagGroups[i];
-                    if (tagGroup.dictionary === dictionary) {
-                        this._addUniqueSimple(tagGroup.tagNames, newTagGroup.tagNames);
-                        break;
-                    }
-                }
-                if (i < ii) { continue; }
-            }
-            tagGroups.push(newTagGroup);
-        }
-    }
-
-    _addTermHeadwords(headwordsMap, headwords) {
+    /**
+     * @param {Map<string, import('dictionary').TermHeadword>} headwordsMap
+     * @param {import('dictionary').TermHeadword[]} headwords
+     * @param {TranslatorTagAggregator} tagAggregator
+     * @returns {number[]}
+     */
+    _addTermHeadwords(headwordsMap, headwords, tagAggregator) {
+        /** @type {number[]} */
         const headwordIndexMap = [];
         for (const {term, reading, sources, tags, wordClasses} of headwords) {
             const key = this._createMapKey([term, reading]);
@@ -1297,13 +1574,17 @@ export class Translator {
                 headwordsMap.set(key, headword);
             }
             this._addUniqueSources(headword.sources, sources);
-            this._addUniqueTagGroups(headword.tags, tags);
             this._addUniqueSimple(headword.wordClasses, wordClasses);
+            tagAggregator.mergeTags(headword.tags, tags);
             headwordIndexMap.push(headword.index);
         }
         return headwordIndexMap;
     }
 
+    /**
+     * @param {number[]} headwordIndices
+     * @param {number} headwordIndex
+     */
     _addUniqueTermHeadwordIndex(headwordIndices, headwordIndex) {
         let end = headwordIndices.length;
         if (end === 0) {
@@ -1327,6 +1608,11 @@ export class Translator {
         headwordIndices.splice(start, 0, headwordIndex);
     }
 
+    /**
+     * @param {import('dictionary').TermDefinition[]} definitions
+     * @param {import('dictionary').TermDefinition[]} newDefinitions
+     * @param {number[]} headwordIndexMap
+     */
     _addTermDefinitionsFast(definitions, newDefinitions, headwordIndexMap) {
         for (const {headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, sequences, id, score, isPrimary, tags, entries} of newDefinitions) {
             const headwordIndicesNew = [];
@@ -1337,7 +1623,14 @@ export class Translator {
         }
     }
 
-    _addTermDefinitions(definitions, definitionsMap, newDefinitions, headwordIndexMap) {
+    /**
+     * @param {import('dictionary').TermDefinition[]} definitions
+     * @param {Map<string, import('dictionary').TermDefinition>} definitionsMap
+     * @param {import('dictionary').TermDefinition[]} newDefinitions
+     * @param {number[]} headwordIndexMap
+     * @param {TranslatorTagAggregator} tagAggregator
+     */
+    _addTermDefinitions(definitions, definitionsMap, newDefinitions, headwordIndexMap, tagAggregator) {
         for (const {headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, sequences, id, score, isPrimary, tags, entries} of newDefinitions) {
             const key = this._createMapKey([dictionary, ...entries]);
             let definition = definitionsMap.get(key);
@@ -1356,19 +1649,36 @@ export class Translator {
             for (const headwordIndex of headwordIndices) {
                 this._addUniqueTermHeadwordIndex(newHeadwordIndices, headwordIndexMap[headwordIndex]);
             }
-            this._addUniqueTagGroups(definition.tags, tags);
+            tagAggregator.mergeTags(definition.tags, tags);
         }
     }
 
     // Sorting functions
 
+    /**
+     * @param {import('dictionary-database').TermEntry[]|import('dictionary-database').KanjiEntry[]} databaseEntries
+     */
     _sortDatabaseEntriesByIndex(databaseEntries) {
         if (databaseEntries.length <= 1) { return; }
-        databaseEntries.sort((a, b) => a.index - b.index);
+        /**
+         * @param {import('dictionary-database').TermEntry|import('dictionary-database').KanjiEntry} v1
+         * @param {import('dictionary-database').TermEntry|import('dictionary-database').KanjiEntry} v2
+         * @returns {number}
+         */
+        const compareFunction = (v1, v2) => v1.index - v2.index;
+        databaseEntries.sort(compareFunction);
     }
 
+    /**
+     * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
+     */
     _sortTermDictionaryEntries(dictionaryEntries) {
         const stringComparer = this._stringComparer;
+        /**
+         * @param {import('dictionary').TermDictionaryEntry} v1
+         * @param {import('dictionary').TermDictionaryEntry} v2
+         * @returns {number}
+         */
         const compareFunction = (v1, v2) => {
             // Sort by length of source term
             let i = v2.maxTransformedTextLength - v1.maxTransformedTextLength;
@@ -1419,7 +1729,15 @@ export class Translator {
         dictionaryEntries.sort(compareFunction);
     }
 
+    /**
+     * @param {import('dictionary').TermDefinition[]} definitions
+     */
     _sortTermDictionaryEntryDefinitions(definitions) {
+        /**
+         * @param {import('dictionary').TermDefinition} v1
+         * @param {import('dictionary').TermDefinition} v2
+         * @returns {number}
+         */
         const compareFunction = (v1, v2) => {
             // Sort by frequency order
             let i = v1.frequencyOrder - v2.frequencyOrder;
@@ -1455,12 +1773,23 @@ export class Translator {
         definitions.sort(compareFunction);
     }
 
+    /**
+     * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
+     */
     _sortTermDictionaryEntriesById(dictionaryEntries) {
         if (dictionaryEntries.length <= 1) { return; }
         dictionaryEntries.sort((a, b) => a.definitions[0].id - b.definitions[0].id);
     }
 
+    /**
+     * @param {import('dictionary').TermFrequency[]|import('dictionary').TermPronunciation[]} dataList
+     */
     _sortTermDictionaryEntrySimpleData(dataList) {
+        /**
+         * @param {import('dictionary').TermFrequency|import('dictionary').TermPronunciation} v1
+         * @param {import('dictionary').TermFrequency|import('dictionary').TermPronunciation} v2
+         * @returns {number}
+         */
         const compare = (v1, v2) => {
             // Sort by dictionary priority
             let i = v2.dictionaryPriority - v1.dictionaryPriority;
@@ -1481,7 +1810,15 @@ export class Translator {
         dataList.sort(compare);
     }
 
+    /**
+     * @param {import('dictionary').KanjiDictionaryEntry[]} dictionaryEntries
+     */
     _sortKanjiDictionaryEntryData(dictionaryEntries) {
+        /**
+         * @param {import('dictionary').KanjiFrequency} v1
+         * @param {import('dictionary').KanjiFrequency} v2
+         * @returns {number}
+         */
         const compare = (v1, v2) => {
             // Sort by dictionary priority
             let i = v2.dictionaryPriority - v1.dictionaryPriority;
@@ -1501,6 +1838,11 @@ export class Translator {
         }
     }
 
+    /**
+     * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries
+     * @param {string} dictionary
+     * @param {boolean} ascending
+     */
     _updateSortFrequencies(dictionaryEntries, dictionary, ascending) {
         const frequencyMap = new Map();
         for (const dictionaryEntry of dictionaryEntries) {
@@ -1539,4 +1881,102 @@ export class Translator {
             frequencyMap.clear();
         }
     }
+
+    // Miscellaneous
+
+    /**
+     * @template T
+     * @param {Set<T>} set
+     * @param {T[]} values
+     * @returns {boolean}
+     */
+    _hasAny(set, values) {
+        for (const value of values) {
+            if (set.has(value)) { return true; }
+        }
+        return false;
+    }
+}
+
+class TranslatorTagAggregator {
+    constructor() {
+        /** @type {Map<import('dictionary').Tag[], import('translator').TagGroup[]>} */
+        this._tagExpansionTargetMap = new Map();
+    }
+
+    /**
+     * @param {import('dictionary').Tag[]} tags
+     * @param {string} dictionary
+     * @param {string[]} tagNames
+     */
+    addTags(tags, dictionary, tagNames) {
+        if (tagNames.length === 0) { return; }
+        const tagGroups = this._getOrCreateTagGroups(tags);
+        const tagGroup = this._getOrCreateTagGroup(tagGroups, dictionary);
+        this._addUniqueTags(tagGroup, tagNames);
+    }
+
+    /**
+     * @returns {import('translator').TagExpansionTarget[]}
+     */
+    getTagExpansionTargets() {
+        const results = [];
+        for (const [tags, tagGroups] of this._tagExpansionTargetMap) {
+            results.push({tags, tagGroups});
+        }
+        return results;
+    }
+
+    /**
+     * @param {import('dictionary').Tag[]} tags
+     * @param {import('dictionary').Tag[]} newTags
+     */
+    mergeTags(tags, newTags) {
+        const newTagGroups = this._tagExpansionTargetMap.get(newTags);
+        if (typeof newTagGroups === 'undefined') { return; }
+        const tagGroups = this._getOrCreateTagGroups(tags);
+        for (const {dictionary, tagNames} of newTagGroups) {
+            const tagGroup = this._getOrCreateTagGroup(tagGroups, dictionary);
+            this._addUniqueTags(tagGroup, tagNames);
+        }
+    }
+
+    /**
+     * @param {import('dictionary').Tag[]} tags
+     * @returns {import('translator').TagGroup[]}
+     */
+    _getOrCreateTagGroups(tags) {
+        let tagGroups = this._tagExpansionTargetMap.get(tags);
+        if (typeof tagGroups === 'undefined') {
+            tagGroups = [];
+            this._tagExpansionTargetMap.set(tags, tagGroups);
+        }
+        return tagGroups;
+    }
+
+    /**
+     * @param {import('translator').TagGroup[]} tagGroups
+     * @param {string} dictionary
+     * @returns {import('translator').TagGroup}
+     */
+    _getOrCreateTagGroup(tagGroups, dictionary) {
+        for (const tagGroup of tagGroups) {
+            if (tagGroup.dictionary === dictionary) { return tagGroup; }
+        }
+        const newTagGroup = {dictionary, tagNames: []};
+        tagGroups.push(newTagGroup);
+        return newTagGroup;
+    }
+
+    /**
+     * @param {import('translator').TagGroup} tagGroup
+     * @param {string[]} newTagNames
+     */
+    _addUniqueTags(tagGroup, newTagNames) {
+        const {tagNames} = tagGroup;
+        for (const tagName of newTagNames) {
+            if (tagNames.includes(tagName)) { continue; }
+            tagNames.push(tagName);
+        }
+    }
 }
-- 
cgit v1.2.3


From aabd761ee9064f6a46703f234e016f31f6441fa0 Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 13:36:04 -0500
Subject: Remove unneeded references

---
 ext/js/accessibility/accessibility-controller.js  |  5 ++---
 ext/js/accessibility/google-docs-util.js          |  1 -
 ext/js/app/frontend.js                            |  6 ++----
 ext/js/app/popup-proxy.js                         | 14 ++++++--------
 ext/js/app/popup-window.js                        |  9 ++++-----
 ext/js/background/backend.js                      |  1 -
 ext/js/comm/anki-connect.js                       |  1 -
 ext/js/dom/document-util.js                       |  1 -
 ext/js/dom/text-source-element.js                 |  1 -
 ext/js/dom/text-source-range.js                   |  1 -
 ext/js/language/dictionary-worker-handler.js      |  1 -
 ext/js/language/dictionary-worker-media-loader.js |  2 +-
 ext/js/language/translator.js                     |  1 -
 ext/js/templates/template-renderer-proxy.js       |  2 +-
 14 files changed, 16 insertions(+), 30 deletions(-)

(limited to 'ext/js/language')

diff --git a/ext/js/accessibility/accessibility-controller.js b/ext/js/accessibility/accessibility-controller.js
index a4239947..8250b369 100644
--- a/ext/js/accessibility/accessibility-controller.js
+++ b/ext/js/accessibility/accessibility-controller.js
@@ -16,7 +16,6 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {ScriptManager} from '../background/script-manager.js';
 import {log} from '../core.js';
 
 /**
@@ -25,10 +24,10 @@ import {log} from '../core.js';
 export class AccessibilityController {
     /**
      * Creates a new instance.
-     * @param {ScriptManager} scriptManager An instance of the `ScriptManager` class.
+     * @param {import('../background/script-manager.js').ScriptManager} scriptManager An instance of the `ScriptManager` class.
      */
     constructor(scriptManager) {
-        /** @type {ScriptManager} */
+        /** @type {import('../background/script-manager.js').ScriptManager} */
         this._scriptManager = scriptManager;
         /** @type {?import('core').TokenObject} */
         this._updateGoogleDocsAccessibilityToken = null;
diff --git a/ext/js/accessibility/google-docs-util.js b/ext/js/accessibility/google-docs-util.js
index 9db45cc1..4321f082 100644
--- a/ext/js/accessibility/google-docs-util.js
+++ b/ext/js/accessibility/google-docs-util.js
@@ -17,7 +17,6 @@
  */
 
 import {DocumentUtil} from '../dom/document-util.js';
-import {TextSourceElement} from '../dom/text-source-element.js';
 import {TextSourceRange} from '../dom/text-source-range.js';
 
 /**
diff --git a/ext/js/app/frontend.js b/ext/js/app/frontend.js
index fec933f8..628c504e 100644
--- a/ext/js/app/frontend.js
+++ b/ext/js/app/frontend.js
@@ -21,10 +21,8 @@ import {EventListenerCollection, invokeMessageHandler, log, promiseAnimationFram
 import {DocumentUtil} from '../dom/document-util.js';
 import {TextSourceElement} from '../dom/text-source-element.js';
 import {TextSourceRange} from '../dom/text-source-range.js';
-import {HotkeyHandler} from '../input/hotkey-handler.js';
 import {TextScanner} from '../language/text-scanner.js';
 import {yomitan} from '../yomitan.js';
-import {PopupFactory} from './popup-factory.js';
 
 /**
  * This is the main class responsible for scanning and handling webpage content.
@@ -50,7 +48,7 @@ export class Frontend {
     }) {
         /** @type {import('frontend').PageType} */
         this._pageType = pageType;
-        /** @type {PopupFactory} */
+        /** @type {import('./popup-factory.js').PopupFactory} */
         this._popupFactory = popupFactory;
         /** @type {number} */
         this._depth = depth;
@@ -70,7 +68,7 @@ export class Frontend {
         this._allowRootFramePopupProxy = allowRootFramePopupProxy;
         /** @type {boolean} */
         this._childrenSupported = childrenSupported;
-        /** @type {HotkeyHandler} */
+        /** @type {import('../input/hotkey-handler.js').HotkeyHandler} */
         this._hotkeyHandler = hotkeyHandler;
         /** @type {?import('popup').PopupAny} */
         this._popup = null;
diff --git a/ext/js/app/popup-proxy.js b/ext/js/app/popup-proxy.js
index 9b5b0214..924175e2 100644
--- a/ext/js/app/popup-proxy.js
+++ b/ext/js/app/popup-proxy.js
@@ -16,10 +16,8 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {FrameOffsetForwarder} from '../comm/frame-offset-forwarder.js';
 import {EventDispatcher, log} from '../core.js';
 import {yomitan} from '../yomitan.js';
-import {Popup} from './popup.js';
 
 /**
  * This class is a proxy for a Popup that is hosted in a different frame.
@@ -44,7 +42,7 @@ export class PopupProxy extends EventDispatcher {
         this._depth = depth;
         /** @type {number} */
         this._frameId = frameId;
-        /** @type {?FrameOffsetForwarder} */
+        /** @type {?import('../comm/frame-offset-forwarder.js').FrameOffsetForwarder} */
         this._frameOffsetForwarder = frameOffsetForwarder;
 
         /** @type {number} */
@@ -70,7 +68,7 @@ export class PopupProxy extends EventDispatcher {
     /**
      * The parent of the popup, which is always `null` for `PopupProxy` instances,
      * since any potential parent popups are in a different frame.
-     * @type {?Popup}
+     * @type {?import('./popup.js').Popup}
      */
     get parent() {
         return null;
@@ -78,7 +76,7 @@ export class PopupProxy extends EventDispatcher {
 
     /**
      * Attempts to set the parent popup.
-     * @param {Popup} _value The parent to assign.
+     * @param {import('./popup.js').Popup} _value The parent to assign.
      * @throws {Error} Throws an error, since this class doesn't support a direct parent.
      */
     set parent(_value) {
@@ -88,7 +86,7 @@ export class PopupProxy extends EventDispatcher {
     /**
      * The popup child popup, which is always null for `PopupProxy` instances,
      * since any potential child popups are in a different frame.
-     * @type {?Popup}
+     * @type {?import('./popup.js').Popup}
      */
     get child() {
         return null;
@@ -96,7 +94,7 @@ export class PopupProxy extends EventDispatcher {
 
     /**
      * Attempts to set the child popup.
-     * @param {Popup} _child The child to assign.
+     * @param {import('./popup.js').Popup} _child The child to assign.
      * @throws {Error} Throws an error, since this class doesn't support children.
      */
     set child(_child) {
@@ -354,7 +352,7 @@ export class PopupProxy extends EventDispatcher {
      * @param {number} now
      */
     async _updateFrameOffsetInner(now) {
-        this._frameOffsetPromise = /** @type {FrameOffsetForwarder} */ (this._frameOffsetForwarder).getOffset();
+        this._frameOffsetPromise = /** @type {import('../comm/frame-offset-forwarder.js').FrameOffsetForwarder} */ (this._frameOffsetForwarder).getOffset();
         try {
             const offset = await this._frameOffsetPromise;
             if (offset !== null) {
diff --git a/ext/js/app/popup-window.js b/ext/js/app/popup-window.js
index af1ac1e4..9a0f8011 100644
--- a/ext/js/app/popup-window.js
+++ b/ext/js/app/popup-window.js
@@ -18,7 +18,6 @@
 
 import {EventDispatcher} from '../core.js';
 import {yomitan} from '../yomitan.js';
-import {Popup} from './popup.js';
 
 /**
  * This class represents a popup that is hosted in a new native window.
@@ -54,7 +53,7 @@ export class PopupWindow extends EventDispatcher {
     }
 
     /**
-     * @type {?Popup}
+     * @type {?import('./popup.js').Popup}
      */
     get parent() {
         return null;
@@ -63,7 +62,7 @@ export class PopupWindow extends EventDispatcher {
     /**
      * The parent of the popup, which is always `null` for `PopupWindow` instances,
      * since any potential parent popups are in a different frame.
-     * @param {Popup} _value The parent to assign.
+     * @param {import('./popup.js').Popup} _value The parent to assign.
      * @throws {Error} Throws an error, since this class doesn't support children.
      */
     set parent(_value) {
@@ -73,7 +72,7 @@ export class PopupWindow extends EventDispatcher {
     /**
      * The popup child popup, which is always null for `PopupWindow` instances,
      * since any potential child popups are in a different frame.
-     * @type {?Popup}
+     * @type {?import('./popup.js').Popup}
      */
     get child() {
         return null;
@@ -81,7 +80,7 @@ export class PopupWindow extends EventDispatcher {
 
     /**
      * Attempts to set the child popup.
-     * @param {Popup} _value The child to assign.
+     * @param {import('./popup.js').Popup} _value The child to assign.
      * @throws Throws an error, since this class doesn't support children.
      */
     set child(_value) {
diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js
index a8683b3f..14877cf1 100644
--- a/ext/js/background/backend.js
+++ b/ext/js/background/backend.js
@@ -25,7 +25,6 @@ import {Mecab} from '../comm/mecab.js';
 import {clone, deferPromise, generateId, invokeMessageHandler, isObject, log, promiseTimeout} from '../core.js';
 import {ExtensionError} from '../core/extension-error.js';
 import {AnkiUtil} from '../data/anki-util.js';
-import {JsonSchema} from '../data/json-schema.js';
 import {OptionsUtil} from '../data/options-util.js';
 import {PermissionsUtil} from '../data/permissions-util.js';
 import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js
index 3262af41..b876703f 100644
--- a/ext/js/comm/anki-connect.js
+++ b/ext/js/comm/anki-connect.js
@@ -16,7 +16,6 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {isObject} from '../core.js';
 import {AnkiUtil} from '../data/anki-util.js';
 
 /**
diff --git a/ext/js/dom/document-util.js b/ext/js/dom/document-util.js
index f53d55fd..cf58d39f 100644
--- a/ext/js/dom/document-util.js
+++ b/ext/js/dom/document-util.js
@@ -16,7 +16,6 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {EventListenerCollection} from '../core.js';
 import {DOMTextScanner} from './dom-text-scanner.js';
 import {TextSourceElement} from './text-source-element.js';
 import {TextSourceRange} from './text-source-range.js';
diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js
index fbe89a61..47c18e30 100644
--- a/ext/js/dom/text-source-element.js
+++ b/ext/js/dom/text-source-element.js
@@ -18,7 +18,6 @@
 
 import {StringUtil} from '../data/sandbox/string-util.js';
 import {DocumentUtil} from './document-util.js';
-import {TextSourceRange} from './text-source-range.js';
 
 /**
  * This class represents a text source that is attached to a HTML element, such as an <img>
diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js
index 5c3d4184..5dbbd636 100644
--- a/ext/js/dom/text-source-range.js
+++ b/ext/js/dom/text-source-range.js
@@ -18,7 +18,6 @@
 
 import {DocumentUtil} from './document-util.js';
 import {DOMTextScanner} from './dom-text-scanner.js';
-import {TextSourceElement} from './text-source-element.js';
 
 /**
  * This class represents a text source that comes from text nodes in the document.
diff --git a/ext/js/language/dictionary-worker-handler.js b/ext/js/language/dictionary-worker-handler.js
index 8ac342b2..3a85cb71 100644
--- a/ext/js/language/dictionary-worker-handler.js
+++ b/ext/js/language/dictionary-worker-handler.js
@@ -16,7 +16,6 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {serializeError} from '../core.js';
 import {DictionaryDatabase} from './dictionary-database.js';
 import {DictionaryImporter} from './dictionary-importer.js';
 import {DictionaryWorkerMediaLoader} from './dictionary-worker-media-loader.js';
diff --git a/ext/js/language/dictionary-worker-media-loader.js b/ext/js/language/dictionary-worker-media-loader.js
index 9e3fd67e..2701389e 100644
--- a/ext/js/language/dictionary-worker-media-loader.js
+++ b/ext/js/language/dictionary-worker-media-loader.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {deserializeError, generateId} from '../core.js';
+import {generateId} from '../core.js';
 
 /**
  * Class used for loading and validating media from a worker thread
diff --git a/ext/js/language/translator.js b/ext/js/language/translator.js
index 9b01c1ff..67cc53c6 100644
--- a/ext/js/language/translator.js
+++ b/ext/js/language/translator.js
@@ -19,7 +19,6 @@
 import {RegexUtil} from '../general/regex-util.js';
 import {TextSourceMap} from '../general/text-source-map.js';
 import {Deinflector} from './deinflector.js';
-import {DictionaryDatabase} from './dictionary-database.js';
 
 /**
  * Class which finds term and kanji dictionary entries for text.
diff --git a/ext/js/templates/template-renderer-proxy.js b/ext/js/templates/template-renderer-proxy.js
index 6d019d14..e67b715a 100644
--- a/ext/js/templates/template-renderer-proxy.js
+++ b/ext/js/templates/template-renderer-proxy.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {deserializeError, generateId} from '../core.js';
+import {generateId} from '../core.js';
 
 export class TemplateRendererProxy {
     constructor() {
-- 
cgit v1.2.3


From 6a3dae04de68ab633da15bbc8ec6b350e38e6d2f Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 13:54:43 -0500
Subject: Add extension error imports

---
 ext/js/app/popup.js                                     | 1 +
 ext/js/comm/anki-connect.js                             | 1 +
 ext/js/data/anki-note-builder.js                        | 1 +
 ext/js/display/display-generator.js                     | 1 +
 ext/js/display/option-toggle-hotkey-handler.js          | 1 +
 ext/js/language/dictionary-worker-handler.js            | 1 +
 ext/js/language/dictionary-worker-media-loader.js       | 1 +
 ext/js/language/dictionary-worker.js                    | 1 +
 ext/js/media/audio-downloader.js                        | 1 +
 ext/js/pages/settings/anki-controller.js                | 1 +
 ext/js/pages/settings/anki-templates-controller.js      | 1 +
 ext/js/pages/settings/dictionary-import-controller.js   | 1 +
 ext/js/pages/settings/generic-setting-controller.js     | 1 +
 ext/js/templates/sandbox/template-renderer-frame-api.js | 2 ++
 ext/js/templates/sandbox/template-renderer.js           | 1 +
 ext/js/templates/template-renderer-proxy.js             | 1 +
 16 files changed, 17 insertions(+)

(limited to 'ext/js/language')

diff --git a/ext/js/app/popup.js b/ext/js/app/popup.js
index 31b18f01..4f201fc3 100644
--- a/ext/js/app/popup.js
+++ b/ext/js/app/popup.js
@@ -18,6 +18,7 @@
 
 import {FrameClient} from '../comm/frame-client.js';
 import {DynamicProperty, EventDispatcher, EventListenerCollection, deepEqual} from '../core.js';
+import {ExtensionError} from '../core/extension-error.js';
 import {DocumentUtil} from '../dom/document-util.js';
 import {dynamicLoader} from '../script/dynamic-loader.js';
 import {yomitan} from '../yomitan.js';
diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js
index b876703f..7ff8d0e1 100644
--- a/ext/js/comm/anki-connect.js
+++ b/ext/js/comm/anki-connect.js
@@ -16,6 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import {ExtensionError} from '../core/extension-error.js';
 import {AnkiUtil} from '../data/anki-util.js';
 
 /**
diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js
index f8296ee0..28809e1f 100644
--- a/ext/js/data/anki-note-builder.js
+++ b/ext/js/data/anki-note-builder.js
@@ -17,6 +17,7 @@
  */
 
 import {deferPromise} from '../core.js';
+import {ExtensionError} from '../core/extension-error.js';
 import {TemplateRendererProxy} from '../templates/template-renderer-proxy.js';
 import {yomitan} from '../yomitan.js';
 import {AnkiUtil} from './anki-util.js';
diff --git a/ext/js/display/display-generator.js b/ext/js/display/display-generator.js
index 9fc700f3..e8be79d9 100644
--- a/ext/js/display/display-generator.js
+++ b/ext/js/display/display-generator.js
@@ -17,6 +17,7 @@
  */
 
 import {isObject} from '../core.js';
+import {ExtensionError} from '../core/extension-error.js';
 import {HtmlTemplateCollection} from '../dom/html-template-collection.js';
 import {DictionaryDataUtil} from '../language/sandbox/dictionary-data-util.js';
 import {yomitan} from '../yomitan.js';
diff --git a/ext/js/display/option-toggle-hotkey-handler.js b/ext/js/display/option-toggle-hotkey-handler.js
index e73fcf04..9677e86b 100644
--- a/ext/js/display/option-toggle-hotkey-handler.js
+++ b/ext/js/display/option-toggle-hotkey-handler.js
@@ -16,6 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import {ExtensionError} from '../core/extension-error.js';
 import {yomitan} from '../yomitan.js';
 
 export class OptionToggleHotkeyHandler {
diff --git a/ext/js/language/dictionary-worker-handler.js b/ext/js/language/dictionary-worker-handler.js
index 3a85cb71..9a724386 100644
--- a/ext/js/language/dictionary-worker-handler.js
+++ b/ext/js/language/dictionary-worker-handler.js
@@ -16,6 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import {ExtensionError} from '../core/extension-error.js';
 import {DictionaryDatabase} from './dictionary-database.js';
 import {DictionaryImporter} from './dictionary-importer.js';
 import {DictionaryWorkerMediaLoader} from './dictionary-worker-media-loader.js';
diff --git a/ext/js/language/dictionary-worker-media-loader.js b/ext/js/language/dictionary-worker-media-loader.js
index 2701389e..e19a13d3 100644
--- a/ext/js/language/dictionary-worker-media-loader.js
+++ b/ext/js/language/dictionary-worker-media-loader.js
@@ -17,6 +17,7 @@
  */
 
 import {generateId} from '../core.js';
+import {ExtensionError} from '../core/extension-error.js';
 
 /**
  * Class used for loading and validating media from a worker thread
diff --git a/ext/js/language/dictionary-worker.js b/ext/js/language/dictionary-worker.js
index b9d0236c..3e78a6ff 100644
--- a/ext/js/language/dictionary-worker.js
+++ b/ext/js/language/dictionary-worker.js
@@ -16,6 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import {ExtensionError} from '../core/extension-error.js';
 import {DictionaryImporterMediaLoader} from './dictionary-importer-media-loader.js';
 
 export class DictionaryWorker {
diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js
index 8bd04b2b..7b236790 100644
--- a/ext/js/media/audio-downloader.js
+++ b/ext/js/media/audio-downloader.js
@@ -17,6 +17,7 @@
  */
 
 import {RequestBuilder} from '../background/request-builder.js';
+import {ExtensionError} from '../core/extension-error.js';
 import {JsonSchema} from '../data/json-schema.js';
 import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
 import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js';
diff --git a/ext/js/pages/settings/anki-controller.js b/ext/js/pages/settings/anki-controller.js
index 0ccd018d..722459df 100644
--- a/ext/js/pages/settings/anki-controller.js
+++ b/ext/js/pages/settings/anki-controller.js
@@ -18,6 +18,7 @@
 
 import {AnkiConnect} from '../../comm/anki-connect.js';
 import {EventListenerCollection, log} from '../../core.js';
+import {ExtensionError} from '../../core/extension-error.js';
 import {AnkiUtil} from '../../data/anki-util.js';
 import {SelectorObserver} from '../../dom/selector-observer.js';
 import {ObjectPropertyAccessor} from '../../general/object-property-accessor.js';
diff --git a/ext/js/pages/settings/anki-templates-controller.js b/ext/js/pages/settings/anki-templates-controller.js
index d2814880..a0ff96b2 100644
--- a/ext/js/pages/settings/anki-templates-controller.js
+++ b/ext/js/pages/settings/anki-templates-controller.js
@@ -16,6 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import {ExtensionError} from '../../core/extension-error.js';
 import {AnkiNoteBuilder} from '../../data/anki-note-builder.js';
 import {JapaneseUtil} from '../../language/sandbox/japanese-util.js';
 import {yomitan} from '../../yomitan.js';
diff --git a/ext/js/pages/settings/dictionary-import-controller.js b/ext/js/pages/settings/dictionary-import-controller.js
index 106ecbca..af8c2fcd 100644
--- a/ext/js/pages/settings/dictionary-import-controller.js
+++ b/ext/js/pages/settings/dictionary-import-controller.js
@@ -17,6 +17,7 @@
  */
 
 import {log} from '../../core.js';
+import {ExtensionError} from '../../core/extension-error.js';
 import {DictionaryWorker} from '../../language/dictionary-worker.js';
 import {yomitan} from '../../yomitan.js';
 import {DictionaryController} from './dictionary-controller.js';
diff --git a/ext/js/pages/settings/generic-setting-controller.js b/ext/js/pages/settings/generic-setting-controller.js
index 3c6104a9..47c0d6fe 100644
--- a/ext/js/pages/settings/generic-setting-controller.js
+++ b/ext/js/pages/settings/generic-setting-controller.js
@@ -16,6 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import {ExtensionError} from '../../core/extension-error.js';
 import {DocumentUtil} from '../../dom/document-util.js';
 import {DOMDataBinder} from '../../dom/dom-data-binder.js';
 
diff --git a/ext/js/templates/sandbox/template-renderer-frame-api.js b/ext/js/templates/sandbox/template-renderer-frame-api.js
index 31ba4500..91400ab8 100644
--- a/ext/js/templates/sandbox/template-renderer-frame-api.js
+++ b/ext/js/templates/sandbox/template-renderer-frame-api.js
@@ -16,6 +16,8 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import {ExtensionError} from '../../core/extension-error.js';
+
 export class TemplateRendererFrameApi {
     /**
      * @param {TemplateRenderer} templateRenderer
diff --git a/ext/js/templates/sandbox/template-renderer.js b/ext/js/templates/sandbox/template-renderer.js
index c7613533..716e3ccc 100644
--- a/ext/js/templates/sandbox/template-renderer.js
+++ b/ext/js/templates/sandbox/template-renderer.js
@@ -17,6 +17,7 @@
  */
 
 import {Handlebars} from '../../../lib/handlebars.js';
+import {ExtensionError} from '../../core/extension-error.js';
 
 export class TemplateRenderer {
     constructor() {
diff --git a/ext/js/templates/template-renderer-proxy.js b/ext/js/templates/template-renderer-proxy.js
index e67b715a..642eea8b 100644
--- a/ext/js/templates/template-renderer-proxy.js
+++ b/ext/js/templates/template-renderer-proxy.js
@@ -17,6 +17,7 @@
  */
 
 import {generateId} from '../core.js';
+import {ExtensionError} from '../core/extension-error.js';
 
 export class TemplateRendererProxy {
     constructor() {
-- 
cgit v1.2.3


From 7aed9a371b0d74c0d75179a08068e8935b76d780 Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 14:55:27 -0500
Subject: Update types

---
 ext/js/background/backend.js                       | 22 ++++-----
 ext/js/background/offscreen-proxy.js               | 27 +++++++++--
 ext/js/background/offscreen.js                     |  9 +++-
 ext/js/background/request-builder.js               | 13 ++----
 ext/js/comm/api.js                                 |  6 +--
 ext/js/comm/clipboard-reader.js                    |  4 +-
 ext/js/display/search-action-popup-controller.js   |  4 +-
 ext/js/dom/sandbox/css-style-applier.js            |  2 +-
 ext/js/dom/text-source-element.js                  |  2 +-
 ext/js/dom/text-source-range.js                    |  2 +-
 ext/js/general/regex-util.js                       |  2 +-
 .../__mocks__/dictionary-importer-media-loader.js  |  1 +
 ext/js/language/dictionary-importer.js             |  2 +-
 ext/js/language/dictionary-worker.js               |  2 +
 ext/js/language/sandbox/japanese-util.js           |  8 ++--
 ext/js/language/text-scanner.js                    |  1 +
 ext/js/language/translator.js                      |  4 +-
 ext/js/media/audio-downloader.js                   |  6 +--
 ext/js/pages/settings/backup-controller.js         | 54 +++++++++-------------
 .../settings/recommended-permissions-controller.js | 36 +++++++++++++--
 types/ext/api.d.ts                                 | 12 +++++
 types/ext/request-builder.d.ts                     |  2 +
 22 files changed, 139 insertions(+), 82 deletions(-)

(limited to 'ext/js/language')

diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js
index 14877cf1..be68ecf4 100644
--- a/ext/js/background/backend.js
+++ b/ext/js/background/backend.js
@@ -96,7 +96,7 @@ export class Backend {
         });
         /** @type {?import('settings').Options} */
         this._options = null;
-        /** @type {JsonSchema[]} */
+        /** @type {import('../data/json-schema.js').JsonSchema[]} */
         this._profileConditionsSchemaCache = [];
         /** @type {ProfileConditionsUtil} */
         this._profileConditionsUtil = new ProfileConditionsUtil();
@@ -665,7 +665,7 @@ export class Backend {
     async _onApiInjectStylesheet({type, value}, sender) {
         const {frameId, tab} = sender;
         if (typeof tab !== 'object' || tab === null || typeof tab.id !== 'number') { throw new Error('Invalid tab'); }
-        return await this._scriptManager.injectStylesheet(type, value, tab.id, frameId, false, true, 'document_start');
+        return await this._scriptManager.injectStylesheet(type, value, tab.id, frameId, false);
     }
 
     /** @type {import('api').Handler<import('api').GetStylesheetContentDetails, import('api').GetStylesheetContentResult>} */
@@ -895,13 +895,7 @@ export class Backend {
         }
     }
 
-    /**
-     *
-     * @param root0
-     * @param root0.targetTabId
-     * @param root0.targetFrameId
-     * @param sender
-     */
+    /** @type {import('api').Handler<import('api').OpenCrossFramePortDetails, import('api').OpenCrossFramePortResult, true>} */
     _onApiOpenCrossFramePort({targetTabId, targetFrameId}, sender) {
         const sourceTabId = (sender && sender.tab ? sender.tab.id : null);
         if (typeof sourceTabId !== 'number') {
@@ -922,7 +916,9 @@ export class Backend {
             otherTabId: sourceTabId,
             otherFrameId: sourceFrameId
         };
+        /** @type {?chrome.runtime.Port} */
         let sourcePort = chrome.tabs.connect(sourceTabId, {frameId: sourceFrameId, name: JSON.stringify(sourceDetails)});
+        /** @type {?chrome.runtime.Port} */
         let targetPort = chrome.tabs.connect(targetTabId, {frameId: targetFrameId, name: JSON.stringify(targetDetails)});
 
         const cleanup = () => {
@@ -937,8 +933,12 @@ export class Backend {
             }
         };
 
-        sourcePort.onMessage.addListener((message) => { targetPort.postMessage(message); });
-        targetPort.onMessage.addListener((message) => { sourcePort.postMessage(message); });
+        sourcePort.onMessage.addListener((message) => {
+            if (targetPort !== null) { targetPort.postMessage(message); }
+        });
+        targetPort.onMessage.addListener((message) => {
+            if (sourcePort !== null) { sourcePort.postMessage(message); }
+        });
         sourcePort.onDisconnect.addListener(cleanup);
         targetPort.onDisconnect.addListener(cleanup);
 
diff --git a/ext/js/background/offscreen-proxy.js b/ext/js/background/offscreen-proxy.js
index c01f523d..0fb2f269 100644
--- a/ext/js/background/offscreen-proxy.js
+++ b/ext/js/background/offscreen-proxy.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {deserializeError, isObject} from '../core.js';
+import {isObject} from '../core.js';
 import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
 
 export class OffscreenProxy {
@@ -158,15 +158,36 @@ export class TranslatorProxy {
 }
 
 export class ClipboardReaderProxy {
+    /**
+     * @param {OffscreenProxy} offscreen
+     */
     constructor(offscreen) {
+        /** @type {?import('environment').Browser} */
+        this._browser = null;
+        /** @type {OffscreenProxy} */
         this._offscreen = offscreen;
     }
 
+    /** @type {?import('environment').Browser} */
+    get browser() { return this._browser; }
+    set browser(value) {
+        if (this._browser === value) { return; }
+        this._browser = value;
+        this._offscreen.sendMessagePromise({action: 'clipboardSetBrowserOffsecreen', params: {value}});
+    }
+
+    /**
+     * @param {boolean} useRichText
+     * @returns {Promise<string>}
+     */
     async getText(useRichText) {
-        return this._offscreen.sendMessagePromise({action: 'clipboardGetTextOffscreen', params: {useRichText}});
+        return await this._offscreen.sendMessagePromise({action: 'clipboardGetTextOffscreen', params: {useRichText}});
     }
 
+    /**
+     * @returns {Promise<?string>}
+     */
     async getImage() {
-        return this._offscreen.sendMessagePromise({action: 'clipboardGetImageOffscreen'});
+        return await this._offscreen.sendMessagePromise({action: 'clipboardGetImageOffscreen'});
     }
 }
diff --git a/ext/js/background/offscreen.js b/ext/js/background/offscreen.js
index 27cee8c4..6302aa84 100644
--- a/ext/js/background/offscreen.js
+++ b/ext/js/background/offscreen.js
@@ -50,6 +50,7 @@ export class Offscreen {
         this._messageHandlers = new Map([
             ['clipboardGetTextOffscreen',    {async: true,  contentScript: true,  handler: this._getTextHandler.bind(this)}],
             ['clipboardGetImageOffscreen',   {async: true,  contentScript: true,  handler: this._getImageHandler.bind(this)}],
+            ['clipboardSetBrowserOffsecreen', {async: false, contentScript: true, handler: this._setClipboardBrowser.bind(this)}],
             ['databasePrepareOffscreen',     {async: true,  contentScript: true,  handler: this._prepareDatabaseHandler.bind(this)}],
             ['getDictionaryInfoOffscreen',   {async: true,  contentScript: true,  handler: this._getDictionaryInfoHandler.bind(this)}],
             ['databasePurgeOffscreen',       {async: true,  contentScript: true,  handler: this._purgeDatabaseHandler.bind(this)}],
@@ -59,7 +60,6 @@ export class Offscreen {
             ['findTermsOffscreen',           {async: true,  contentScript: true,  handler: this._findTermsHandler.bind(this)}],
             ['getTermFrequenciesOffscreen',  {async: true,  contentScript: true,  handler: this._getTermFrequenciesHandler.bind(this)}],
             ['clearDatabaseCachesOffscreen', {async: false,  contentScript: true,  handler: this._clearDatabaseCachesHandler.bind(this)}]
-
         ]);
 
         const onMessage = this._onMessage.bind(this);
@@ -76,6 +76,13 @@ export class Offscreen {
         return this._clipboardReader.getImage();
     }
 
+    /**
+     * @param {{value: import('environment').Browser}} details
+     */
+    _setClipboardBrowser({value}) {
+        this._clipboardReader.browser = value;
+    }
+
     _prepareDatabaseHandler() {
         if (this._prepareDatabasePromise !== null) {
             return this._prepareDatabasePromise;
diff --git a/ext/js/background/request-builder.js b/ext/js/background/request-builder.js
index 48fe2dd9..5ae7fbf5 100644
--- a/ext/js/background/request-builder.js
+++ b/ext/js/background/request-builder.js
@@ -21,12 +21,6 @@
  * with additional controls over anonymity and error handling.
  */
 export class RequestBuilder {
-    /**
-     * A progress callback for a fetch read.
-     * @callback ProgressCallback
-     * @param {boolean} complete Whether or not the data has been completely read.
-     */
-
     /**
      * Creates a new instance.
      */
@@ -109,14 +103,17 @@ export class RequestBuilder {
     /**
      * Reads the array buffer body of a fetch response, with an optional `onProgress` callback.
      * @param {Response} response The response of a `fetch` call.
-     * @param {ProgressCallback} onProgress The progress callback
+     * @param {?import('request-builder.js').ProgressCallback} onProgress The progress callback
      * @returns {Promise<Uint8Array>} The resulting binary data.
      */
     static async readFetchResponseArrayBuffer(response, onProgress) {
         let reader;
         try {
             if (typeof onProgress === 'function') {
-                reader = response.body.getReader();
+                const {body} = response;
+                if (body !== null) {
+                    reader = body.getReader();
+                }
             }
         } catch (e) {
             // Not supported
diff --git a/ext/js/comm/api.js b/ext/js/comm/api.js
index 62dc98b1..0cfdba59 100644
--- a/ext/js/comm/api.js
+++ b/ext/js/comm/api.js
@@ -415,9 +415,9 @@ export class API {
     }
 
     /**
-     *
-     * @param targetTabId
-     * @param targetFrameId
+     * @param {import('api').OpenCrossFramePortDetails['targetTabId']} targetTabId
+     * @param {import('api').OpenCrossFramePortDetails['targetFrameId']} targetFrameId
+     * @returns {Promise<import('api').OpenCrossFramePortResult>}
      */
     openCrossFramePort(targetTabId, targetFrameId) {
         return this._invoke('openCrossFramePort', {targetTabId, targetFrameId});
diff --git a/ext/js/comm/clipboard-reader.js b/ext/js/comm/clipboard-reader.js
index c7b45a7c..364e31a3 100644
--- a/ext/js/comm/clipboard-reader.js
+++ b/ext/js/comm/clipboard-reader.js
@@ -29,7 +29,7 @@ export class ClipboardReader {
     constructor({document=null, pasteTargetSelector=null, richContentPasteTargetSelector=null}) {
         /** @type {?Document} */
         this._document = document;
-        /** @type {?string} */
+        /** @type {?import('environment').Browser} */
         this._browser = null;
         /** @type {?HTMLTextAreaElement} */
         this._pasteTarget = null;
@@ -43,7 +43,7 @@ export class ClipboardReader {
 
     /**
      * Gets the browser being used.
-     * @type {?string}
+     * @type {?import('environment').Browser}
      */
     get browser() {
         return this._browser;
diff --git a/ext/js/display/search-action-popup-controller.js b/ext/js/display/search-action-popup-controller.js
index 733fd70a..3a2057a1 100644
--- a/ext/js/display/search-action-popup-controller.js
+++ b/ext/js/display/search-action-popup-controller.js
@@ -18,10 +18,10 @@
 
 export class SearchActionPopupController {
     /**
-     * @param {SearchPersistentStateController} searchPersistentStateController
+     * @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController
      */
     constructor(searchPersistentStateController) {
-        /** @type {SearchPersistentStateController} */
+        /** @type {import('./search-persistent-state-controller.js').SearchPersistentStateController} */
         this._searchPersistentStateController = searchPersistentStateController;
     }
 
diff --git a/ext/js/dom/sandbox/css-style-applier.js b/ext/js/dom/sandbox/css-style-applier.js
index 332ca4f2..ea36a02d 100644
--- a/ext/js/dom/sandbox/css-style-applier.js
+++ b/ext/js/dom/sandbox/css-style-applier.js
@@ -24,7 +24,7 @@ export class CssStyleApplier {
     /**
      * Creates a new instance of the class.
      * @param {string} styleDataUrl The local URL to the JSON file continaing the style rules.
-     *   The style rules should follow the format of {@link CssStyleApplierRawStyleData}.
+     *   The style rules should follow the format of `CssStyleApplierRawStyleData`.
      */
     constructor(styleDataUrl) {
         /** @type {string} */
diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js
index 47c18e30..40ff5cc9 100644
--- a/ext/js/dom/text-source-element.js
+++ b/ext/js/dom/text-source-element.js
@@ -173,7 +173,7 @@ export class TextSourceElement {
 
     /**
      * Checks whether another text source has the same starting point.
-     * @param {TextSourceElement|TextSourceRange} other The other source to test.
+     * @param {import('text-source').TextSource} other The other source to test.
      * @returns {boolean} `true` if the starting points are equivalent, `false` otherwise.
      */
     hasSameStart(other) {
diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js
index 5dbbd636..fd09fdda 100644
--- a/ext/js/dom/text-source-range.js
+++ b/ext/js/dom/text-source-range.js
@@ -206,7 +206,7 @@ export class TextSourceRange {
 
     /**
      * Checks whether another text source has the same starting point.
-     * @param {TextSourceElement|TextSourceRange} other The other source to test.
+     * @param {import('text-source').TextSource} other The other source to test.
      * @returns {boolean} `true` if the starting points are equivalent, `false` otherwise.
      * @throws {Error} An exception can be thrown if `Range.compareBoundaryPoints` fails,
      *   which shouldn't happen, but the handler is kept in case of unexpected errors.
diff --git a/ext/js/general/regex-util.js b/ext/js/general/regex-util.js
index 726ce9f2..62248968 100644
--- a/ext/js/general/regex-util.js
+++ b/ext/js/general/regex-util.js
@@ -25,7 +25,7 @@ export class RegexUtil {
      * Applies string.replace using a regular expression and replacement string as arguments.
      * A source map of the changes is also maintained.
      * @param {string} text A string of the text to replace.
-     * @param {TextSourceMap} sourceMap An instance of `TextSourceMap` which corresponds to `text`.
+     * @param {import('./text-source-map.js').TextSourceMap} sourceMap An instance of `TextSourceMap` which corresponds to `text`.
      * @param {RegExp} pattern A regular expression to use as the replacement.
      * @param {string} replacement A replacement string that follows the format of the standard
      *   JavaScript regular expression replacement string.
diff --git a/ext/js/language/__mocks__/dictionary-importer-media-loader.js b/ext/js/language/__mocks__/dictionary-importer-media-loader.js
index 96f0f6dd..ffda29b3 100644
--- a/ext/js/language/__mocks__/dictionary-importer-media-loader.js
+++ b/ext/js/language/__mocks__/dictionary-importer-media-loader.js
@@ -17,6 +17,7 @@
  */
 
 export class DictionaryImporterMediaLoader {
+    /** @type {import('dictionary-importer-media-loader').GetImageDetailsFunction} */
     async getImageDetails(content) {
         // Placeholder values
         return {content, width: 100, height: 100};
diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js
index aa6d7ae6..2a2f4063 100644
--- a/ext/js/language/dictionary-importer.js
+++ b/ext/js/language/dictionary-importer.js
@@ -36,7 +36,7 @@ export class DictionaryImporter {
     }
 
     /**
-     * @param {DictionaryDatabase} dictionaryDatabase
+     * @param {import('./dictionary-database.js').DictionaryDatabase} dictionaryDatabase
      * @param {ArrayBuffer} archiveContent
      * @param {import('dictionary-importer').ImportDetails} details
      * @returns {Promise<import('dictionary-importer').ImportResult>}
diff --git a/ext/js/language/dictionary-worker.js b/ext/js/language/dictionary-worker.js
index 3e78a6ff..3119dd7b 100644
--- a/ext/js/language/dictionary-worker.js
+++ b/ext/js/language/dictionary-worker.js
@@ -157,6 +157,8 @@ export class DictionaryWorker {
                 resolve(result2);
             } else {
                 // If formatResult is not provided, the response is assumed to be the same type
+                // For some reason, eslint thinks the TResponse type is undefined
+                // eslint-disable-next-line jsdoc/no-undefined-types
                 resolve(/** @type {TResponse} */ (/** @type {unknown} */ (result)));
             }
         }
diff --git a/ext/js/language/sandbox/japanese-util.js b/ext/js/language/sandbox/japanese-util.js
index f7f20b3b..4c9c46bd 100644
--- a/ext/js/language/sandbox/japanese-util.js
+++ b/ext/js/language/sandbox/japanese-util.js
@@ -466,7 +466,7 @@ export class JapaneseUtil {
 
     /**
      * @param {string} text
-     * @param {?TextSourceMap} [sourceMap]
+     * @param {?import('../../general/text-source-map.js').TextSourceMap} [sourceMap]
      * @returns {string}
      */
     convertHalfWidthKanaToFullWidth(text, sourceMap=null) {
@@ -513,7 +513,7 @@ export class JapaneseUtil {
 
     /**
      * @param {string} text
-     * @param {?TextSourceMap} sourceMap
+     * @param {?import('../../general/text-source-map.js').TextSourceMap} sourceMap
      * @returns {string}
      */
     convertAlphabeticToKana(text, sourceMap=null) {
@@ -676,7 +676,7 @@ export class JapaneseUtil {
     /**
      * @param {string} text
      * @param {boolean} fullCollapse
-     * @param {?TextSourceMap} [sourceMap]
+     * @param {?import('../../general/text-source-map.js').TextSourceMap} [sourceMap]
      * @returns {string}
      */
     collapseEmphaticSequences(text, fullCollapse, sourceMap=null) {
@@ -816,7 +816,7 @@ export class JapaneseUtil {
 
     /**
      * @param {string} text
-     * @param {?TextSourceMap} sourceMap
+     * @param {?import('../../general/text-source-map.js').TextSourceMap} sourceMap
      * @param {number} sourceMapStart
      * @returns {string}
      */
diff --git a/ext/js/language/text-scanner.js b/ext/js/language/text-scanner.js
index b4d9a642..f6bcde8d 100644
--- a/ext/js/language/text-scanner.js
+++ b/ext/js/language/text-scanner.js
@@ -18,6 +18,7 @@
 
 import {EventDispatcher, EventListenerCollection, clone, log} from '../core.js';
 import {DocumentUtil} from '../dom/document-util.js';
+import {TextSourceElement} from '../dom/text-source-element.js';
 import {yomitan} from '../yomitan.js';
 
 /**
diff --git a/ext/js/language/translator.js b/ext/js/language/translator.js
index 67cc53c6..c21b16b1 100644
--- a/ext/js/language/translator.js
+++ b/ext/js/language/translator.js
@@ -29,9 +29,9 @@ export class Translator {
      * @param {import('translator').ConstructorDetails} details The details for the class.
      */
     constructor({japaneseUtil, database}) {
-        /** @type {JapaneseUtil} */
+        /** @type {import('./sandbox/japanese-util.js').JapaneseUtil} */
         this._japaneseUtil = japaneseUtil;
-        /** @type {DictionaryDatabase} */
+        /** @type {import('./dictionary-database.js').DictionaryDatabase} */
         this._database = database;
         /** @type {?Deinflector} */
         this._deinflector = null;
diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js
index 7b236790..0847d479 100644
--- a/ext/js/media/audio-downloader.js
+++ b/ext/js/media/audio-downloader.js
@@ -25,10 +25,10 @@ import {SimpleDOMParser} from '../dom/simple-dom-parser.js';
 
 export class AudioDownloader {
     /**
-     * @param {{japaneseUtil: JapaneseUtil, requestBuilder: RequestBuilder}} details
+     * @param {{japaneseUtil: import('../language/sandbox/japanese-util.js').JapaneseUtil, requestBuilder: RequestBuilder}} details
      */
     constructor({japaneseUtil, requestBuilder}) {
-        /** @type {JapaneseUtil} */
+        /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */
         this._japaneseUtil = japaneseUtil;
         /** @type {RequestBuilder} */
         this._requestBuilder = requestBuilder;
@@ -314,7 +314,7 @@ export class AudioDownloader {
      */
     async _downloadAudioFromUrl(url, sourceType, idleTimeout) {
         let signal;
-        /** @type {?(done: boolean) => void} */
+        /** @type {?import('request-builder.js').ProgressCallback} */
         let onProgress = null;
         /** @type {?number} */
         let idleTimer = null;
diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js
index 50a50b1a..52c5f418 100644
--- a/ext/js/pages/settings/backup-controller.js
+++ b/ext/js/pages/settings/backup-controller.js
@@ -534,12 +534,11 @@ export class BackupController {
     // Exporting Dictionaries Database
 
     /**
-     *
-     * @param message
-     * @param isWarning
+     * @param {string} message
+     * @param {boolean} [isWarning]
      */
     _databaseExportImportErrorMessage(message, isWarning=false) {
-        const errorMessageContainer = document.querySelector('#db-ops-error-report');
+        const errorMessageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-error-report'));
         errorMessageContainer.style.display = 'block';
         errorMessageContainer.textContent = message;
 
@@ -553,15 +552,11 @@ export class BackupController {
     }
 
     /**
-     *
-     * @param root0
-     * @param root0.totalRows
-     * @param root0.completedRows
-     * @param root0.done
+     * @param {{totalRows: number, completedRows: number, done: boolean}} details
      */
     _databaseExportProgressCallback({totalRows, completedRows, done}) {
         console.log(`Progress: ${completedRows} of ${totalRows} rows completed`);
-        const messageContainer = document.querySelector('#db-ops-progress-report');
+        const messageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-progress-report'));
         messageContainer.style.display = 'block';
         messageContainer.textContent = `Export Progress: ${completedRows} of ${totalRows} rows completed`;
 
@@ -572,8 +567,8 @@ export class BackupController {
     }
 
     /**
-     *
-     * @param databaseName
+     * @param {string} databaseName
+     * @returns {Promise<Blob>}
      */
     async _exportDatabase(databaseName) {
         const db = await new Dexie(databaseName).open();
@@ -592,7 +587,7 @@ export class BackupController {
             return;
         }
 
-        const errorMessageContainer = document.querySelector('#db-ops-error-report');
+        const errorMessageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-error-report'));
         errorMessageContainer.style.display = 'none';
 
         const date = new Date(Date.now());
@@ -616,15 +611,11 @@ export class BackupController {
     // Importing Dictionaries Database
 
     /**
-     *
-     * @param root0
-     * @param root0.totalRows
-     * @param root0.completedRows
-     * @param root0.done
+     * @param {{totalRows: number, completedRows: number, done: boolean}} details
      */
     _databaseImportProgressCallback({totalRows, completedRows, done}) {
         console.log(`Progress: ${completedRows} of ${totalRows} rows completed`);
-        const messageContainer = document.querySelector('#db-ops-progress-report');
+        const messageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-progress-report'));
         messageContainer.style.display = 'block';
         messageContainer.style.color = '#4169e1';
         messageContainer.textContent = `Import Progress: ${completedRows} of ${totalRows} rows completed`;
@@ -637,9 +628,8 @@ export class BackupController {
     }
 
     /**
-     *
-     * @param databaseName
-     * @param file
+     * @param {string} databaseName
+     * @param {File} file
      */
     async _importDatabase(databaseName, file) {
         await yomitan.api.purgeDatabase();
@@ -648,16 +638,13 @@ export class BackupController {
         yomitan.trigger('storageChanged');
     }
 
-    /**
-     *
-     */
+    /** */
     _onSettingsImportDatabaseClick() {
-        document.querySelector('#settings-import-db').click();
+        /** @type {HTMLElement} */ (document.querySelector('#settings-import-db')).click();
     }
 
     /**
-     *
-     * @param e
+     * @param {Event} e
      */
     async _onSettingsImportDatabaseChange(e) {
         if (this._settingsExportDatabaseToken !== null) {
@@ -666,22 +653,23 @@ export class BackupController {
             return;
         }
 
-        const errorMessageContainer = document.querySelector('#db-ops-error-report');
+        const errorMessageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-error-report'));
         errorMessageContainer.style.display = 'none';
 
-        const files = e.target.files;
-        if (files.length === 0) { return; }
+        const element = /** @type {HTMLInputElement} */ (e.currentTarget);
+        const files = element.files;
+        if (files === null || files.length === 0) { return; }
 
         const pageExitPrevention = this._settingsController.preventPageExit();
         const file = files[0];
-        e.target.value = null;
+        element.value = '';
         try {
             const token = {};
             this._settingsExportDatabaseToken = token;
             await this._importDatabase(this._dictionariesDatabaseName, file);
         } catch (error) {
             console.log(error);
-            const messageContainer = document.querySelector('#db-ops-progress-report');
+            const messageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-progress-report'));
             messageContainer.style.color = 'red';
             this._databaseExportImportErrorMessage('Encountered errors when importing. Please restart the browser and try again. If it continues to fail, reinstall Yomitan and import dictionaries one-by-one.');
         } finally {
diff --git a/ext/js/pages/settings/recommended-permissions-controller.js b/ext/js/pages/settings/recommended-permissions-controller.js
index e04dbdf7..b19311aa 100644
--- a/ext/js/pages/settings/recommended-permissions-controller.js
+++ b/ext/js/pages/settings/recommended-permissions-controller.js
@@ -19,13 +19,21 @@
 import {EventListenerCollection} from '../../core.js';
 
 export class RecommendedPermissionsController {
+    /**
+     * @param {import('./settings-controller.js').SettingsController} settingsController
+     */
     constructor(settingsController) {
+        /** @type {import('./settings-controller.js').SettingsController} */
         this._settingsController = settingsController;
+        /** @type {?NodeListOf<HTMLInputElement>} */
         this._originToggleNodes = null;
+        /** @type {EventListenerCollection} */
         this._eventListeners = new EventListenerCollection();
+        /** @type {?HTMLElement} */
         this._errorContainer = null;
     }
 
+    /** */
     async prepare() {
         this._originToggleNodes = document.querySelectorAll('.recommended-permissions-toggle');
         this._errorContainer = document.querySelector('#recommended-permissions-error');
@@ -39,35 +47,53 @@ export class RecommendedPermissionsController {
 
     // Private
 
+    /**
+     * @param {import('settings-controller').PermissionsChangedEvent} details
+     */
     _onPermissionsChanged({permissions}) {
         this._eventListeners.removeAllEventListeners();
         const originsSet = new Set(permissions.origins);
-        for (const node of this._originToggleNodes) {
-            node.checked = originsSet.has(node.dataset.origin);
+        if (this._originToggleNodes !== null) {
+            for (const node of this._originToggleNodes) {
+                const {origin} = node.dataset;
+                node.checked = typeof origin === 'string' && originsSet.has(origin);
+            }
         }
     }
 
+    /**
+     * @param {Event} e
+     */
     _onOriginToggleChange(e) {
-        const node = e.currentTarget;
+        const node = /** @type {HTMLInputElement} */ (e.currentTarget);
         const value = node.checked;
         node.checked = !value;
 
         const {origin} = node.dataset;
+        if (typeof origin !== 'string') { return; }
         this._setOriginPermissionEnabled(origin, value);
     }
 
+    /** */
     async _updatePermissions() {
         const permissions = await this._settingsController.permissionsUtil.getAllPermissions();
         this._onPermissionsChanged({permissions});
     }
 
+    /**
+     * @param {string} origin
+     * @param {boolean} enabled
+     * @returns {Promise<boolean>}
+     */
     async _setOriginPermissionEnabled(origin, enabled) {
         let added = false;
         try {
             added = await this._settingsController.permissionsUtil.setPermissionsGranted({origins: [origin]}, enabled);
         } catch (e) {
-            this._errorContainer.hidden = false;
-            this._errorContainer.textContent = e.message;
+            if (this._errorContainer !== null) {
+                this._errorContainer.hidden = false;
+                this._errorContainer.textContent = e instanceof Error ? e.message : `${e}`;
+            }
         }
         if (!added) { return false; }
         await this._updatePermissions();
diff --git a/types/ext/api.d.ts b/types/ext/api.d.ts
index 19a62c1c..6b7b4b19 100644
--- a/types/ext/api.d.ts
+++ b/types/ext/api.d.ts
@@ -444,6 +444,18 @@ export type LoadExtensionScriptsDetails = {
 
 export type LoadExtensionScriptsResult = void;
 
+// openCrossFramePort
+
+export type OpenCrossFramePortDetails = {
+    targetTabId: number;
+    targetFrameId: number;
+};
+
+export type OpenCrossFramePortResult = {
+    targetTabId: number;
+    targetFrameId: number;
+};
+
 // requestBackendReadySignal
 
 export type RequestBackendReadySignalDetails = Record<string, never>;
diff --git a/types/ext/request-builder.d.ts b/types/ext/request-builder.d.ts
index 0acf5ede..8f375754 100644
--- a/types/ext/request-builder.d.ts
+++ b/types/ext/request-builder.d.ts
@@ -19,3 +19,5 @@ export type FetchEventListeners = {
     onBeforeSendHeaders: ((details: chrome.webRequest.WebRequestHeadersDetails) => (chrome.webRequest.BlockingResponse | void)) | null;
     onErrorOccurred: ((details: chrome.webRequest.WebResponseErrorDetails) => void) | null;
 };
+
+export type ProgressCallback = (complete: boolean) => void;
-- 
cgit v1.2.3


From d897c3b069f80fd170b7f1d0c0362fec4037d714 Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 16:09:55 -0500
Subject: Update dictionary importer

---
 ext/js/language/dictionary-importer.js | 130 ++++++++++++++++++++-------------
 jsconfig.json                          |   5 +-
 package-lock.json                      |  13 ++++
 package.json                           |   1 +
 types/ext/dictionary-importer.d.ts     |  27 ++++++-
 5 files changed, 123 insertions(+), 53 deletions(-)

(limited to 'ext/js/language')

diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js
index 2a2f4063..115e0726 100644
--- a/ext/js/language/dictionary-importer.js
+++ b/ext/js/language/dictionary-importer.js
@@ -16,10 +16,23 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as ajvSchemas from '../../lib/validate-schemas.js';
-import {BlobWriter, TextWriter, Uint8ArrayReader, ZipReader, configure} from '../../lib/zip.js';
+import * as ajvSchemas0 from '../../lib/validate-schemas.js';
+import {
+    BlobWriter as BlobWriter0,
+    TextWriter as TextWriter0,
+    Uint8ArrayReader as Uint8ArrayReader0,
+    ZipReader as ZipReader0,
+    configure
+} from '../../lib/zip.js';
 import {stringReverse} from '../core.js';
 import {MediaUtil} from '../media/media-util.js';
+import {ExtensionError} from '../core/extension-error.js';
+
+const ajvSchemas = /** @type {import('dictionary-importer').CompiledSchemaValidators} */ (/** @type {unknown} */ (ajvSchemas0));
+const BlobWriter = /** @type {typeof import('@zip.js/zip.js').BlobWriter} */ (/** @type {unknown} */ (BlobWriter0));
+const TextWriter = /** @type {typeof import('@zip.js/zip.js').TextWriter} */ (/** @type {unknown} */ (TextWriter0));
+const Uint8ArrayReader = /** @type {typeof import('@zip.js/zip.js').Uint8ArrayReader} */ (/** @type {unknown} */ (Uint8ArrayReader0));
+const ZipReader = /** @type {typeof import('@zip.js/zip.js').ZipReader} */ (/** @type {unknown} */ (ZipReader0));
 
 export class DictionaryImporter {
     /**
@@ -62,21 +75,21 @@ export class DictionaryImporter {
         const zipFileReader = new Uint8ArrayReader(new Uint8Array(archiveContent));
         const zipReader = new ZipReader(zipFileReader);
         const zipEntries = await zipReader.getEntries();
-        const zipEntriesObject = {};
+        /** @type {import('dictionary-importer').ArchiveFileMap} */
+        const fileMap = new Map();
         for (const entry of zipEntries) {
-            zipEntriesObject[entry.filename] = entry;
+            fileMap.set(entry.filename, entry);
         }
         // Read and validate index
         const indexFileName = 'index.json';
-        const indexFile = zipEntriesObject[indexFileName];
-        if (!indexFile) {
+        const indexFile = fileMap.get(indexFileName);
+        if (typeof indexFile === 'undefined') {
             throw new Error('No dictionary index found in archive');
         }
+        const indexFile2 = /** @type {import('@zip.js/zip.js').Entry} */ (indexFile);
 
-        const indexContent = await indexFile.getData(
-            new TextWriter()
-        );
-        const index = JSON.parse(indexContent);
+        const indexContent = await this._getData(indexFile2, new TextWriter());
+        const index = /** @type {import('dictionary-data').Index} */ (JSON.parse(indexContent));
 
         if (!ajvSchemas.dictionaryIndex(index)) {
             throw this._formatAjvSchemaError(ajvSchemas.dictionaryIndex, indexFileName);
@@ -99,11 +112,11 @@ export class DictionaryImporter {
         const dataBankSchemas = this._getDataBankSchemas(version);
 
         // Files
-        const termFiles      = this._getArchiveFiles(zipEntriesObject, 'term_bank_?.json');
-        const termMetaFiles  = this._getArchiveFiles(zipEntriesObject, 'term_meta_bank_?.json');
-        const kanjiFiles     = this._getArchiveFiles(zipEntriesObject, 'kanji_bank_?.json');
-        const kanjiMetaFiles = this._getArchiveFiles(zipEntriesObject, 'kanji_meta_bank_?.json');
-        const tagFiles       = this._getArchiveFiles(zipEntriesObject, 'tag_bank_?.json');
+        const termFiles      = this._getArchiveFiles(fileMap, 'term_bank_?.json');
+        const termMetaFiles  = this._getArchiveFiles(fileMap, 'term_meta_bank_?.json');
+        const kanjiFiles     = this._getArchiveFiles(fileMap, 'kanji_bank_?.json');
+        const kanjiMetaFiles = this._getArchiveFiles(fileMap, 'kanji_meta_bank_?.json');
+        const tagFiles       = this._getArchiveFiles(fileMap, 'tag_bank_?.json');
 
         // Load data
         this._progressNextStep(termFiles.length + termMetaFiles.length + kanjiFiles.length + kanjiMetaFiles.length + tagFiles.length);
@@ -153,7 +166,7 @@ export class DictionaryImporter {
 
         // Async requirements
         this._progressNextStep(requirements.length);
-        const {media} = await this._resolveAsyncRequirements(requirements, zipEntriesObject);
+        const {media} = await this._resolveAsyncRequirements(requirements, fileMap);
 
         // Add dictionary descriptor
         this._progressNextStep(termList.length + termMetaList.length + kanjiList.length + kanjiMetaList.length + tagList.length + media.length);
@@ -274,20 +287,20 @@ export class DictionaryImporter {
     }
 
     /**
-     *
-     * @param schema
-     * @param fileName
+     * @param {import('ajv').ValidateFunction} schema
+     * @param {string} fileName
+     * @returns {ExtensionError}
      */
     _formatAjvSchemaError(schema, fileName) {
-        const e2 = new Error(`Dictionary has invalid data in '${fileName}'`);
+        const e2 = new ExtensionError(`Dictionary has invalid data in '${fileName}'`);
         e2.data = schema.errors;
 
         return e2;
     }
 
     /**
-     *
-     * @param version
+     * @param {number} version
+     * @returns {import('dictionary-importer').CompiledSchemaNameArray}
      */
     _getDataBankSchemas(version) {
         const termBank = (
@@ -402,13 +415,15 @@ export class DictionaryImporter {
     }
 
     /**
-     *
-     * @param requirements
-     * @param zipEntriesObject
+     * @param {import('dictionary-importer').ImportRequirement[]} requirements
+     * @param {import('dictionary-importer').ArchiveFileMap} fileMap
+     * @returns {Promise<{media: import('dictionary-database').MediaDataArrayBufferContent[]}>}
      */
-    async _resolveAsyncRequirements(requirements, zipEntriesObject) {
+    async _resolveAsyncRequirements(requirements, fileMap) {
+        /** @type {Map<string, import('dictionary-database').MediaDataArrayBufferContent>} */
         const media = new Map();
-        const context = {zipEntriesObject, media};
+        /** @type {import('dictionary-importer').ImportRequirementContext} */
+        const context = {fileMap, media};
 
         for (const requirement of requirements) {
             await this._resolveAsyncRequirement(context, requirement);
@@ -537,15 +552,13 @@ export class DictionaryImporter {
         }
 
         // Find file in archive
-        const file = context.zipEntriesObject[path];
-        if (file === null) {
+        const file = context.fileMap.get(path);
+        if (typeof file === 'undefined') {
             throw createError('Could not find image');
         }
 
         // Load file content
-        let content = await (await file.getData(
-            new BlobWriter()
-        )).arrayBuffer();
+        let content = await (await this._getData(file, new BlobWriter())).arrayBuffer();
 
         const mediaType = MediaUtil.getImageMediaTypeFromFileName(path);
         if (mediaType === null) {
@@ -683,46 +696,48 @@ export class DictionaryImporter {
     }
 
     /**
-     *
-     * @param zipEntriesObject
-     * @param fileNameFormat
+     * @param {import('dictionary-importer').ArchiveFileMap} fileMap
+     * @param {string} fileNameFormat
+     * @returns {import('@zip.js/zip.js').Entry[]}
      */
-    _getArchiveFiles(zipEntriesObject, fileNameFormat) {
+    _getArchiveFiles(fileMap, fileNameFormat) {
         const indexPosition = fileNameFormat.indexOf('?');
         const prefix = fileNameFormat.substring(0, indexPosition);
         const suffix = fileNameFormat.substring(indexPosition + 1);
+        /** @type {import('@zip.js/zip.js').Entry[]} */
         const results = [];
-        for (const f of Object.keys(zipEntriesObject)) {
-            if (f.startsWith(prefix) && f.endsWith(suffix)) {
-                results.push(zipEntriesObject[f]);
+        for (const [name, value] of fileMap.entries()) {
+            if (name.startsWith(prefix) && name.endsWith(suffix)) {
+                results.push(value);
             }
         }
         return results;
     }
 
     /**
-     *
-     * @param files
-     * @param convertEntry
-     * @param schemaName
-     * @param dictionaryTitle
+     * @template [TEntry=unknown]
+     * @template [TResult=unknown]
+     * @param {import('@zip.js/zip.js').Entry[]} files
+     * @param {(entry: TEntry, dictionaryTitle: string) => TResult} convertEntry
+     * @param {import('dictionary-importer').CompiledSchemaName} schemaName
+     * @param {string} dictionaryTitle
+     * @returns {Promise<TResult[]>}
      */
     async _readFileSequence(files, convertEntry, schemaName, dictionaryTitle) {
         const progressData = this._progressData;
         let startIndex = 0;
 
         const results = [];
-        for (const fileName of Object.keys(files)) {
-            const content = await files[fileName].getData(
-                new TextWriter()
-            );
-            const entries = JSON.parse(content);
+        for (const file of files) {
+            const content = await this._getData(file, new TextWriter());
+            const entries = /** @type {unknown} */ (JSON.parse(content));
 
             startIndex = progressData.index;
             this._progress();
 
-            if (!ajvSchemas[schemaName](entries)) {
-                throw this._formatAjvSchemaError(ajvSchemas[schemaName], fileName);
+            const schema = ajvSchemas[schemaName];
+            if (!schema(entries)) {
+                throw this._formatAjvSchemaError(schema, file.filename);
             }
 
             progressData.index = startIndex + 1;
@@ -771,4 +786,17 @@ export class DictionaryImporter {
         // - '\ufa67'.normalize('NFC') => '\u9038' (逸 => 逸)
         return text;
     }
+
+    /**
+     * @template [T=unknown]
+     * @param {import('@zip.js/zip.js').Entry} entry
+     * @param {import('@zip.js/zip.js').Writer<T>|import('@zip.js/zip.js').WritableWriter} writer
+     * @returns {Promise<T>}
+     */
+    async _getData(entry, writer) {
+        if (typeof entry.getData === 'undefined') {
+            throw new Error(`Cannot read ${entry.filename}`);
+        }
+        return await entry.getData(writer);
+    }
 }
diff --git a/jsconfig.json b/jsconfig.json
index 7d23472f..ace0c2aa 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -20,7 +20,10 @@
             "handlebars",
             "jszip",
             "parse5",
-            "wanakana"
+            "wanakana",
+            "zip.js",
+            "dexie",
+            "ajv"
         ]
     },
     "include": [
diff --git a/package-lock.json b/package-lock.json
index 3fb951f7..8980b096 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
                 "@types/jsdom": "^21.1.6",
                 "@types/node": "^20.10.0",
                 "@types/wanakana": "^4.0.6",
+                "@types/zip.js": "^2.0.32",
                 "@typescript-eslint/eslint-plugin": "^6.12.0",
                 "@typescript-eslint/parser": "^6.12.0",
                 "@vitest/coverage-v8": "^0.34.6",
@@ -949,6 +950,12 @@
             "integrity": "sha512-al8hJELQI+RDcexy6JLV/BqghQ/nP0B9d62m0F3jEvPyxAq9RXFH9xDoGa73oT9/keCUKRxWCA6l37wv4TCfQw==",
             "dev": true
         },
+        "node_modules/@types/zip.js": {
+            "version": "2.0.32",
+            "resolved": "https://registry.npmjs.org/@types/zip.js/-/zip.js-2.0.32.tgz",
+            "integrity": "sha512-+/r1iYLsLUCTNsDiGcrqK7LQ9ui11GVC98Dj3x0GtpuvzKM2PK8k/gXeu2RyZWTiVR3k6pxodHnAiBMBVsNebw==",
+            "dev": true
+        },
         "node_modules/@typescript-eslint/eslint-plugin": {
             "version": "6.12.0",
             "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.12.0.tgz",
@@ -6477,6 +6484,12 @@
             "integrity": "sha512-al8hJELQI+RDcexy6JLV/BqghQ/nP0B9d62m0F3jEvPyxAq9RXFH9xDoGa73oT9/keCUKRxWCA6l37wv4TCfQw==",
             "dev": true
         },
+        "@types/zip.js": {
+            "version": "2.0.32",
+            "resolved": "https://registry.npmjs.org/@types/zip.js/-/zip.js-2.0.32.tgz",
+            "integrity": "sha512-+/r1iYLsLUCTNsDiGcrqK7LQ9ui11GVC98Dj3x0GtpuvzKM2PK8k/gXeu2RyZWTiVR3k6pxodHnAiBMBVsNebw==",
+            "dev": true
+        },
         "@typescript-eslint/eslint-plugin": {
             "version": "6.12.0",
             "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.12.0.tgz",
diff --git a/package.json b/package.json
index bc2aebd7..0d9796bb 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
         "@types/jsdom": "^21.1.6",
         "@types/node": "^20.10.0",
         "@types/wanakana": "^4.0.6",
+        "@types/zip.js": "^2.0.32",
         "@typescript-eslint/eslint-plugin": "^6.12.0",
         "@typescript-eslint/parser": "^6.12.0",
         "@vitest/coverage-v8": "^0.34.6",
diff --git a/types/ext/dictionary-importer.d.ts b/types/ext/dictionary-importer.d.ts
index 16ce66ce..de85d04a 100644
--- a/types/ext/dictionary-importer.d.ts
+++ b/types/ext/dictionary-importer.d.ts
@@ -15,6 +15,8 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import type * as Ajv from 'ajv';
+import type * as ZipJS from '@zip.js/zip.js';
 import type * as DictionaryData from './dictionary-data';
 import type * as DictionaryDatabase from './dictionary-database';
 import type * as StructuredContent from './structured-content';
@@ -90,6 +92,29 @@ export type StructuredContentImageImportRequirement = {
 };
 
 export type ImportRequirementContext = {
-    archive: import('jszip');
+    fileMap: ArchiveFileMap;
     media: Map<string, DictionaryDatabase.MediaDataArrayBufferContent>;
 };
+
+export type ArchiveFileMap = Map<string, ZipJS.Entry>;
+
+export type CompiledSchemaNameArray = [
+    termBank: CompiledSchemaName,
+    termMetaBank: CompiledSchemaName,
+    kanjiBank: CompiledSchemaName,
+    kanjiMetaBank: CompiledSchemaName,
+    tagBank: CompiledSchemaName,
+];
+
+export type CompiledSchemaValidators = {
+    dictionaryIndex: Ajv.ValidateFunction<unknown>;
+    dictionaryTermBankV1: Ajv.ValidateFunction<unknown>;
+    dictionaryTermBankV3: Ajv.ValidateFunction<unknown>;
+    dictionaryTermMetaBankV3: Ajv.ValidateFunction<unknown>;
+    dictionaryKanjiBankV1: Ajv.ValidateFunction<unknown>;
+    dictionaryKanjiBankV3: Ajv.ValidateFunction<unknown>;
+    dictionaryKanjiMetaBankV3: Ajv.ValidateFunction<unknown>;
+    dictionaryTagBankV3: Ajv.ValidateFunction<unknown>;
+};
+
+export type CompiledSchemaName = keyof CompiledSchemaValidators;
-- 
cgit v1.2.3


From 29317da4ea237557e1805a834913dad73c51ed8a Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 17:43:53 -0500
Subject: Update offscreen

---
 ext/js/background/offscreen-proxy.js | 137 +++++++++++++++++++++++++----------
 ext/js/background/offscreen.js       | 121 +++++++++++++++++--------------
 ext/js/language/translator.js        |   4 +-
 types/ext/dictionary-database.d.ts   |   2 +-
 types/ext/offscreen.d.ts             | 115 +++++++++++++++++++++++++++++
 types/ext/translator.d.ts            |  12 +++
 6 files changed, 298 insertions(+), 93 deletions(-)
 create mode 100644 types/ext/offscreen.d.ts

(limited to 'ext/js/language')

diff --git a/ext/js/background/offscreen-proxy.js b/ext/js/background/offscreen-proxy.js
index 0fb2f269..757d78d5 100644
--- a/ext/js/background/offscreen-proxy.js
+++ b/ext/js/background/offscreen-proxy.js
@@ -18,13 +18,17 @@
 
 import {isObject} from '../core.js';
 import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
+import {ExtensionError} from '../core/extension-error.js';
 
 export class OffscreenProxy {
     constructor() {
+        /** @type {?Promise<void>} */
         this._creatingOffscreen = null;
     }
 
-    // https://developer.chrome.com/docs/extensions/reference/offscreen/
+    /**
+     * @see https://developer.chrome.com/docs/extensions/reference/offscreen/
+     */
     async prepare() {
         if (await this._hasOffscreenDocument()) {
             return;
@@ -36,20 +40,30 @@ export class OffscreenProxy {
 
         this._creatingOffscreen = chrome.offscreen.createDocument({
             url: 'offscreen.html',
-            reasons: ['CLIPBOARD'],
+            reasons: [
+                /** @type {chrome.offscreen.Reason} */ ('CLIPBOARD')
+            ],
             justification: 'Access to the clipboard'
         });
         await this._creatingOffscreen;
         this._creatingOffscreen = null;
     }
 
+    /**
+     * @returns {Promise<boolean>}
+     */
     async _hasOffscreenDocument() {
         const offscreenUrl = chrome.runtime.getURL('offscreen.html');
-        if (!chrome.runtime.getContexts) { // chrome version <116
+        // @ts-ignore - API not defined yet
+        if (!chrome.runtime.getContexts) { // chrome version below 116
+            // Clients: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/clients
+            // @ts-ignore - Types not set up for service workers yet
             const matchedClients = await clients.matchAll();
+            // @ts-ignore - Types not set up for service workers yet
             return await matchedClients.some((client) => client.url === offscreenUrl);
         }
 
+        // @ts-ignore - API not defined yet
         const contexts = await chrome.runtime.getContexts({
             contextTypes: ['OFFSCREEN_DOCUMENT'],
             documentUrls: [offscreenUrl]
@@ -57,103 +71,152 @@ export class OffscreenProxy {
         return !!contexts.length;
     }
 
-    sendMessagePromise(...args) {
+    /**
+     * @template {import('offscreen').MessageType} TMessageType
+     * @param {import('offscreen').Message<TMessageType>} message
+     * @returns {Promise<import('offscreen').MessageReturn<TMessageType>>}
+     */
+    sendMessagePromise(message) {
         return new Promise((resolve, reject) => {
-            const callback = (response) => {
+            chrome.runtime.sendMessage(message, (response) => {
                 try {
                     resolve(this._getMessageResponseResult(response));
                 } catch (error) {
                     reject(error);
                 }
-            };
-
-            chrome.runtime.sendMessage(...args, callback);
+            });
         });
     }
 
+    /**
+     * @template [TReturn=unknown]
+     * @param {import('core').Response<TReturn>} response
+     * @returns {TReturn}
+     * @throws {Error}
+     */
     _getMessageResponseResult(response) {
-        let error = chrome.runtime.lastError;
+        const error = chrome.runtime.lastError;
         if (error) {
             throw new Error(error.message);
         }
         if (!isObject(response)) {
             throw new Error('Offscreen document did not respond');
         }
-        error = response.error;
-        if (error) {
-            throw deserializeError(error);
+        const error2 = response.error;
+        if (error2) {
+            throw ExtensionError.deserialize(error2);
         }
         return response.result;
     }
 }
 
 export class DictionaryDatabaseProxy {
+    /**
+     * @param {OffscreenProxy} offscreen
+     */
     constructor(offscreen) {
+        /** @type {OffscreenProxy} */
         this._offscreen = offscreen;
     }
 
-    prepare() {
-        return this._offscreen.sendMessagePromise({action: 'databasePrepareOffscreen'});
+    /**
+     * @returns {Promise<void>}
+     */
+    async prepare() {
+        await this._offscreen.sendMessagePromise({action: 'databasePrepareOffscreen'});
     }
 
-    getDictionaryInfo() {
+    /**
+     * @returns {Promise<import('dictionary-importer').Summary[]>}
+     */
+    async getDictionaryInfo() {
         return this._offscreen.sendMessagePromise({action: 'getDictionaryInfoOffscreen'});
     }
 
-    purge() {
-        return this._offscreen.sendMessagePromise({action: 'databasePurgeOffscreen'});
+    /**
+     * @returns {Promise<boolean>}
+     */
+    async purge() {
+        return await this._offscreen.sendMessagePromise({action: 'databasePurgeOffscreen'});
     }
 
+    /**
+     * @param {import('dictionary-database').MediaRequest[]} targets
+     * @returns {Promise<import('dictionary-database').Media[]>}
+     */
     async getMedia(targets) {
-        const serializedMedia = await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}});
+        const serializedMedia = /** @type {import('dictionary-database').Media<string>[]} */ (await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}}));
         const media = serializedMedia.map((m) => ({...m, content: ArrayBufferUtil.base64ToArrayBuffer(m.content)}));
         return media;
     }
 }
 
 export class TranslatorProxy {
+    /**
+     * @param {OffscreenProxy} offscreen
+     */
     constructor(offscreen) {
+        /** @type {OffscreenProxy} */
         this._offscreen = offscreen;
     }
 
-    prepare(deinflectionReasons) {
-        return this._offscreen.sendMessagePromise({action: 'translatorPrepareOffscreen', params: {deinflectionReasons}});
+    /**
+     * @param {import('deinflector').ReasonsRaw} deinflectionReasons
+     */
+    async prepare(deinflectionReasons) {
+        await this._offscreen.sendMessagePromise({action: 'translatorPrepareOffscreen', params: {deinflectionReasons}});
     }
 
-    async findKanji(text, findKanjiOptions) {
-        const enabledDictionaryMapList = [...findKanjiOptions.enabledDictionaryMap];
-        const modifiedKanjiOptions = {
-            ...findKanjiOptions,
+    /**
+     * @param {string} text
+     * @param {import('translation').FindKanjiOptions} options
+     * @returns {Promise<import('dictionary').KanjiDictionaryEntry[]>}
+     */
+    async findKanji(text, options) {
+        const enabledDictionaryMapList = [...options.enabledDictionaryMap];
+        /** @type {import('offscreen').FindKanjiOptionsOffscreen} */
+        const modifiedOptions = {
+            ...options,
             enabledDictionaryMap: enabledDictionaryMapList
         };
-        return this._offscreen.sendMessagePromise({action: 'findKanjiOffscreen', params: {text, findKanjiOptions: modifiedKanjiOptions}});
+        return this._offscreen.sendMessagePromise({action: 'findKanjiOffscreen', params: {text, options: modifiedOptions}});
     }
 
-    async findTerms(mode, text, findTermsOptions) {
-        const {enabledDictionaryMap, excludeDictionaryDefinitions, textReplacements} = findTermsOptions;
+    /**
+     * @param {import('translator').FindTermsMode} mode
+     * @param {string} text
+     * @param {import('translation').FindTermsOptions} options
+     * @returns {Promise<import('translator').FindTermsResult>}
+     */
+    async findTerms(mode, text, options) {
+        const {enabledDictionaryMap, excludeDictionaryDefinitions, textReplacements} = options;
         const enabledDictionaryMapList = [...enabledDictionaryMap];
         const excludeDictionaryDefinitionsList = excludeDictionaryDefinitions ? [...excludeDictionaryDefinitions] : null;
         const textReplacementsSerialized = textReplacements.map((group) => {
-            if (!group) {
-                return group;
-            }
-            return group.map((opt) => ({...opt, pattern: opt.pattern.toString()}));
+            return group !== null ? group.map((opt) => ({...opt, pattern: opt.pattern.toString()})) : null;
         });
-        const modifiedFindTermsOptions = {
-            ...findTermsOptions,
+        /** @type {import('offscreen').FindTermsOptionsOffscreen} */
+        const modifiedOptions = {
+            ...options,
             enabledDictionaryMap: enabledDictionaryMapList,
             excludeDictionaryDefinitions: excludeDictionaryDefinitionsList,
             textReplacements: textReplacementsSerialized
         };
-        return this._offscreen.sendMessagePromise({action: 'findTermsOffscreen', params: {mode, text, findTermsOptions: modifiedFindTermsOptions}});
+        return this._offscreen.sendMessagePromise({action: 'findTermsOffscreen', params: {mode, text, options: modifiedOptions}});
     }
 
+    /**
+     * @param {import('translator').TermReadingList} termReadingList
+     * @param {string[]} dictionaries
+     * @returns {Promise<import('translator').TermFrequencySimple[]>}
+     */
     async getTermFrequencies(termReadingList, dictionaries) {
         return this._offscreen.sendMessagePromise({action: 'getTermFrequenciesOffscreen', params: {termReadingList, dictionaries}});
     }
 
-    clearDatabaseCaches() {
-        return this._offscreen.sendMessagePromise({action: 'clearDatabaseCachesOffscreen'});
+    /** */
+    async clearDatabaseCaches() {
+        await this._offscreen.sendMessagePromise({action: 'clearDatabaseCachesOffscreen'});
     }
 }
 
@@ -173,7 +236,7 @@ export class ClipboardReaderProxy {
     set browser(value) {
         if (this._browser === value) { return; }
         this._browser = value;
-        this._offscreen.sendMessagePromise({action: 'clipboardSetBrowserOffsecreen', params: {value}});
+        this._offscreen.sendMessagePromise({action: 'clipboardSetBrowserOffscreen', params: {value}});
     }
 
     /**
diff --git a/ext/js/background/offscreen.js b/ext/js/background/offscreen.js
index 6302aa84..1b68887b 100644
--- a/ext/js/background/offscreen.js
+++ b/ext/js/background/offscreen.js
@@ -23,7 +23,6 @@ import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
 import {DictionaryDatabase} from '../language/dictionary-database.js';
 import {JapaneseUtil} from '../language/sandbox/japanese-util.js';
 import {Translator} from '../language/translator.js';
-import {yomitan} from '../yomitan.js';
 
 /**
  * This class controls the core logic of the extension, including API calls
@@ -34,12 +33,16 @@ export class Offscreen {
      * Creates a new instance.
      */
     constructor() {
+        /** @type {JapaneseUtil} */
         this._japaneseUtil = new JapaneseUtil(wanakana);
+        /** @type {DictionaryDatabase} */
         this._dictionaryDatabase = new DictionaryDatabase();
+        /** @type {Translator} */
         this._translator = new Translator({
             japaneseUtil: this._japaneseUtil,
             database: this._dictionaryDatabase
         });
+        /** @type {ClipboardReader} */
         this._clipboardReader = new ClipboardReader({
             // eslint-disable-next-line no-undef
             document: (typeof document === 'object' && document !== null ? document : null),
@@ -47,42 +50,45 @@ export class Offscreen {
             richContentPasteTargetSelector: '#clipboard-rich-content-paste-target'
         });
 
+        /** @type {import('offscreen').MessageHandlerMap} */
         this._messageHandlers = new Map([
-            ['clipboardGetTextOffscreen',    {async: true,  contentScript: true,  handler: this._getTextHandler.bind(this)}],
-            ['clipboardGetImageOffscreen',   {async: true,  contentScript: true,  handler: this._getImageHandler.bind(this)}],
-            ['clipboardSetBrowserOffsecreen', {async: false, contentScript: true, handler: this._setClipboardBrowser.bind(this)}],
-            ['databasePrepareOffscreen',     {async: true,  contentScript: true,  handler: this._prepareDatabaseHandler.bind(this)}],
-            ['getDictionaryInfoOffscreen',   {async: true,  contentScript: true,  handler: this._getDictionaryInfoHandler.bind(this)}],
-            ['databasePurgeOffscreen',       {async: true,  contentScript: true,  handler: this._purgeDatabaseHandler.bind(this)}],
-            ['databaseGetMediaOffscreen',    {async: true,  contentScript: true,  handler: this._getMediaHandler.bind(this)}],
-            ['translatorPrepareOffscreen',   {async: false,  contentScript: true,  handler: this._prepareTranslatorHandler.bind(this)}],
-            ['findKanjiOffscreen',           {async: true,  contentScript: true,  handler: this._findKanjiHandler.bind(this)}],
-            ['findTermsOffscreen',           {async: true,  contentScript: true,  handler: this._findTermsHandler.bind(this)}],
-            ['getTermFrequenciesOffscreen',  {async: true,  contentScript: true,  handler: this._getTermFrequenciesHandler.bind(this)}],
-            ['clearDatabaseCachesOffscreen', {async: false,  contentScript: true,  handler: this._clearDatabaseCachesHandler.bind(this)}]
+            ['clipboardGetTextOffscreen',    {async: true,  handler: this._getTextHandler.bind(this)}],
+            ['clipboardGetImageOffscreen',   {async: true,  handler: this._getImageHandler.bind(this)}],
+            ['clipboardSetBrowserOffscreen', {async: false, handler: this._setClipboardBrowser.bind(this)}],
+            ['databasePrepareOffscreen',     {async: true,  handler: this._prepareDatabaseHandler.bind(this)}],
+            ['getDictionaryInfoOffscreen',   {async: true,  handler: this._getDictionaryInfoHandler.bind(this)}],
+            ['databasePurgeOffscreen',       {async: true,  handler: this._purgeDatabaseHandler.bind(this)}],
+            ['databaseGetMediaOffscreen',    {async: true,  handler: this._getMediaHandler.bind(this)}],
+            ['translatorPrepareOffscreen',   {async: false, handler: this._prepareTranslatorHandler.bind(this)}],
+            ['findKanjiOffscreen',           {async: true,  handler: this._findKanjiHandler.bind(this)}],
+            ['findTermsOffscreen',           {async: true,  handler: this._findTermsHandler.bind(this)}],
+            ['getTermFrequenciesOffscreen',  {async: true,  handler: this._getTermFrequenciesHandler.bind(this)}],
+            ['clearDatabaseCachesOffscreen', {async: false, handler: this._clearDatabaseCachesHandler.bind(this)}]
         ]);
 
         const onMessage = this._onMessage.bind(this);
         chrome.runtime.onMessage.addListener(onMessage);
 
+        /** @type {?Promise<void>} */
         this._prepareDatabasePromise = null;
     }
 
-    _getTextHandler({useRichText}) {
-        return this._clipboardReader.getText(useRichText);
+    /** @type {import('offscreen').MessageHandler<'clipboardGetTextOffscreen', true>} */
+    async _getTextHandler({useRichText}) {
+        return await this._clipboardReader.getText(useRichText);
     }
 
-    _getImageHandler() {
-        return this._clipboardReader.getImage();
+    /** @type {import('offscreen').MessageHandler<'clipboardGetImageOffscreen', true>} */
+    async _getImageHandler() {
+        return await this._clipboardReader.getImage();
     }
 
-    /**
-     * @param {{value: import('environment').Browser}} details
-     */
+    /** @type {import('offscreen').MessageHandler<'clipboardSetBrowserOffscreen', false>} */
     _setClipboardBrowser({value}) {
         this._clipboardReader.browser = value;
     }
 
+    /** @type {import('offscreen').MessageHandler<'databasePrepareOffscreen', true>} */
     _prepareDatabaseHandler() {
         if (this._prepareDatabasePromise !== null) {
             return this._prepareDatabasePromise;
@@ -91,70 +97,79 @@ export class Offscreen {
         return this._prepareDatabasePromise;
     }
 
-    _getDictionaryInfoHandler() {
-        return this._dictionaryDatabase.getDictionaryInfo();
+    /** @type {import('offscreen').MessageHandler<'getDictionaryInfoOffscreen', true>} */
+    async _getDictionaryInfoHandler() {
+        return await this._dictionaryDatabase.getDictionaryInfo();
     }
 
-    _purgeDatabaseHandler() {
-        return this._dictionaryDatabase.purge();
+    /** @type {import('offscreen').MessageHandler<'databasePurgeOffscreen', true>} */
+    async _purgeDatabaseHandler() {
+        return await this._dictionaryDatabase.purge();
     }
 
+    /** @type {import('offscreen').MessageHandler<'databaseGetMediaOffscreen', true>} */
     async _getMediaHandler({targets}) {
         const media = await this._dictionaryDatabase.getMedia(targets);
         const serializedMedia = media.map((m) => ({...m, content: ArrayBufferUtil.arrayBufferToBase64(m.content)}));
         return serializedMedia;
     }
 
+    /** @type {import('offscreen').MessageHandler<'translatorPrepareOffscreen', false>} */
     _prepareTranslatorHandler({deinflectionReasons}) {
-        return this._translator.prepare(deinflectionReasons);
+        this._translator.prepare(deinflectionReasons);
     }
 
-    _findKanjiHandler({text, findKanjiOptions}) {
-        findKanjiOptions.enabledDictionaryMap = new Map(findKanjiOptions.enabledDictionaryMap);
-        return this._translator.findKanji(text, findKanjiOptions);
+    /** @type {import('offscreen').MessageHandler<'findKanjiOffscreen', true>} */
+    async _findKanjiHandler({text, options}) {
+        /** @type {import('translation').FindKanjiOptions} */
+        const modifiedOptions = {
+            ...options,
+            enabledDictionaryMap: new Map(options.enabledDictionaryMap)
+        };
+        return await this._translator.findKanji(text, modifiedOptions);
     }
 
-    _findTermsHandler({mode, text, findTermsOptions}) {
-        findTermsOptions.enabledDictionaryMap = new Map(findTermsOptions.enabledDictionaryMap);
-        if (findTermsOptions.excludeDictionaryDefinitions) {
-            findTermsOptions.excludeDictionaryDefinitions = new Set(findTermsOptions.excludeDictionaryDefinitions);
-        }
-        findTermsOptions.textReplacements = findTermsOptions.textReplacements.map((group) => {
-            if (!group) {
-                return group;
-            }
+    /** @type {import('offscreen').MessageHandler<'findTermsOffscreen', true>} */
+    _findTermsHandler({mode, text, options}) {
+        const enabledDictionaryMap = new Map(options.enabledDictionaryMap);
+        const excludeDictionaryDefinitions = (
+            options.excludeDictionaryDefinitions !== null ?
+            new Set(options.excludeDictionaryDefinitions) :
+            null
+        );
+        const textReplacements = options.textReplacements.map((group) => {
+            if (group === null) { return null; }
             return group.map((opt) => {
-                const [, pattern, flags] = opt.pattern.match(/\/(.*?)\/([a-z]*)?$/i); // https://stackoverflow.com/a/33642463
+                // https://stackoverflow.com/a/33642463
+                const match = opt.pattern.match(/\/(.*?)\/([a-z]*)?$/i);
+                const [, pattern, flags] = match !== null ? match : ['', '', ''];
                 return {...opt, pattern: new RegExp(pattern, flags ?? '')};
             });
         });
-        return this._translator.findTerms(mode, text, findTermsOptions);
+        /** @type {import('translation').FindTermsOptions} */
+        const modifiedOptions = {
+            ...options,
+            enabledDictionaryMap,
+            excludeDictionaryDefinitions,
+            textReplacements
+        };
+        return this._translator.findTerms(mode, text, modifiedOptions);
     }
 
+    /** @type {import('offscreen').MessageHandler<'getTermFrequenciesOffscreen', true>} */
     _getTermFrequenciesHandler({termReadingList, dictionaries}) {
         return this._translator.getTermFrequencies(termReadingList, dictionaries);
     }
 
+    /** @type {import('offscreen').MessageHandler<'clearDatabaseCachesOffscreen', false>} */
     _clearDatabaseCachesHandler() {
-        return this._translator.clearDatabaseCaches();
+        this._translator.clearDatabaseCaches();
     }
 
+    /** @type {import('extension').ChromeRuntimeOnMessageCallback} */
     _onMessage({action, params}, sender, callback) {
         const messageHandler = this._messageHandlers.get(action);
         if (typeof messageHandler === 'undefined') { return false; }
-        this._validatePrivilegedMessageSender(sender);
-
         return invokeMessageHandler(messageHandler, params, callback, sender);
     }
-
-    _validatePrivilegedMessageSender(sender) {
-        let {url} = sender;
-        if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; }
-        const {tab} = url;
-        if (typeof tab === 'object' && tab !== null) {
-            ({url} = tab);
-            if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; }
-        }
-        throw new Error('Invalid message sender');
-    }
 }
diff --git a/ext/js/language/translator.js b/ext/js/language/translator.js
index c21b16b1..aa1b71dd 100644
--- a/ext/js/language/translator.js
+++ b/ext/js/language/translator.js
@@ -157,7 +157,7 @@ export class Translator {
     /**
      * Gets a list of frequency information for a given list of term-reading pairs
      * and a list of dictionaries.
-     * @param {{term: string, reading: string|null}[]} termReadingList An array of `{term, reading}` pairs. If reading is null,
+     * @param {import('translator').TermReadingList} termReadingList An array of `{term, reading}` pairs. If reading is null,
      *   the reading won't be compared.
      * @param {string[]} dictionaries An array of dictionary names.
      * @returns {Promise<import('translator').TermFrequencySimple[]>} An array of term frequencies.
@@ -203,7 +203,7 @@ export class Translator {
      * @param {Map<string, import('translation').FindTermDictionary>} enabledDictionaryMap
      * @param {import('translation').FindTermsOptions} options
      * @param {TranslatorTagAggregator} tagAggregator
-     * @returns {Promise<{dictionaryEntries: import('dictionary').TermDictionaryEntry[], originalTextLength: number}>}
+     * @returns {Promise<import('translator').FindTermsResult>}
      */
     async _findTermsInternal(text, enabledDictionaryMap, options, tagAggregator) {
         if (options.removeNonJapaneseCharacters) {
diff --git a/types/ext/dictionary-database.d.ts b/types/ext/dictionary-database.d.ts
index 2e0f854b..06a246e8 100644
--- a/types/ext/dictionary-database.d.ts
+++ b/types/ext/dictionary-database.d.ts
@@ -35,7 +35,7 @@ export interface MediaDataBase<TContentType = unknown> {
 export interface MediaDataArrayBufferContent extends MediaDataBase<ArrayBuffer> {}
 export interface MediaDataStringContent extends MediaDataBase<string> {}
 
-export type Media = {index: number} & MediaDataBase<ArrayBuffer>;
+export type Media<T extends (ArrayBuffer | string) = ArrayBuffer> = {index: number} & MediaDataBase<T>;
 
 export type DatabaseTermEntry = {
     expression: string;
diff --git a/types/ext/offscreen.d.ts b/types/ext/offscreen.d.ts
new file mode 100644
index 00000000..7dd64d1e
--- /dev/null
+++ b/types/ext/offscreen.d.ts
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2023  Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import type * as Core from './core';
+import type * as Deinflector from './deinflector';
+import type * as Dictionary from './dictionary';
+import type * as DictionaryDatabase from './dictionary-database';
+import type * as DictionaryImporter from './dictionary-importer';
+import type * as Environment from './environment';
+import type * as Translation from './translation';
+import type * as Translator from './translator';
+
+export type MessageAny2 = Message<keyof MessageDetailsMap>;
+
+export type Message<T extends MessageType> = (
+    MessageDetailsMap[T] extends undefined ?
+        {action: T} :
+        {action: T, params: MessageDetailsMap[T]}
+);
+
+export type MessageReturn<T extends MessageType> = MessageReturnMap[T];
+
+type MessageDetailsMap = {
+    databasePrepareOffscreen: undefined;
+    getDictionaryInfoOffscreen: undefined;
+    databasePurgeOffscreen: undefined;
+    databaseGetMediaOffscreen: {
+        targets: DictionaryDatabase.MediaRequest[];
+    };
+    translatorPrepareOffscreen: {
+        deinflectionReasons: Deinflector.ReasonsRaw;
+    };
+    findKanjiOffscreen: {
+        text: string;
+        options: FindKanjiOptionsOffscreen;
+    };
+    findTermsOffscreen: {
+        mode: Translator.FindTermsMode;
+        text: string;
+        options: FindTermsOptionsOffscreen;
+    };
+    getTermFrequenciesOffscreen: {
+        termReadingList: Translator.TermReadingList;
+        dictionaries: string[];
+    };
+    clearDatabaseCachesOffscreen: undefined;
+    clipboardSetBrowserOffscreen: {
+        value: Environment.Browser | null;
+    };
+    clipboardGetTextOffscreen: {
+        useRichText: boolean;
+    };
+    clipboardGetImageOffscreen: undefined;
+};
+
+type MessageReturnMap = {
+    databasePrepareOffscreen: void;
+    getDictionaryInfoOffscreen: DictionaryImporter.Summary[];
+    databasePurgeOffscreen: boolean;
+    databaseGetMediaOffscreen: DictionaryDatabase.Media<string>[];
+    translatorPrepareOffscreen: void;
+    findKanjiOffscreen: Dictionary.KanjiDictionaryEntry[];
+    findTermsOffscreen: Translator.FindTermsResult;
+    getTermFrequenciesOffscreen: Translator.TermFrequencySimple[];
+    clearDatabaseCachesOffscreen: void;
+    clipboardSetBrowserOffscreen: void;
+    clipboardGetTextOffscreen: string;
+    clipboardGetImageOffscreen: string | null;
+};
+
+export type MessageType = keyof MessageDetailsMap;
+
+export type FindKanjiOptionsOffscreen = Omit<Translation.FindKanjiOptions, 'enabledDictionaryMap'> & {
+    enabledDictionaryMap: [
+        key: string,
+        options: Translation.FindKanjiDictionary,
+    ][];
+};
+
+
+export type FindTermsOptionsOffscreen = Omit<Translation.FindTermsOptions, 'enabledDictionaryMap' | 'excludeDictionaryDefinitions' | 'textReplacements'> & {
+    enabledDictionaryMap: [
+        key: string,
+        options: Translation.FindTermDictionary,
+    ][];
+    excludeDictionaryDefinitions: string[] | null;
+    textReplacements: (FindTermsTextReplacementOffscreen[] | null)[];
+};
+
+export type FindTermsTextReplacementOffscreen = Omit<Translation.FindTermsTextReplacement, 'pattern'> & {
+    pattern: string;
+};
+
+export type MessageHandler<
+    TMessage extends MessageType,
+    TIsAsync extends boolean,
+> = (
+    details: MessageDetailsMap[TMessage],
+) => (TIsAsync extends true ? Promise<MessageReturn<TMessage>> : MessageReturn<TMessage>);
+
+export type MessageHandlerMap = Map<MessageType, Core.MessageHandlerDetails>;
diff --git a/types/ext/translator.d.ts b/types/ext/translator.d.ts
index f17b3bf6..e7d45295 100644
--- a/types/ext/translator.d.ts
+++ b/types/ext/translator.d.ts
@@ -81,3 +81,15 @@ export type SequenceQuery = {
 };
 
 export type FindTermsMode = 'simple' | 'group' | 'merge' | 'split';
+
+export type TermReadingItem = {
+    term: string;
+    reading: string | null;
+};
+
+export type TermReadingList = TermReadingItem[];
+
+export type FindTermsResult = {
+    dictionaryEntries: Dictionary.TermDictionaryEntry[];
+    originalTextLength: number;
+};
-- 
cgit v1.2.3


From d5b1217df3fe7480fc5f58fe194f5bbf73281094 Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 19:23:17 -0500
Subject: Use import map

---
 dev/jsconfig.json                                            | 1 +
 ext/action-popup.html                                        | 8 ++++++++
 ext/background.html                                          | 8 ++++++++
 ext/info.html                                                | 8 ++++++++
 ext/issues.html                                              | 8 ++++++++
 ext/js/background/backend.js                                 | 2 +-
 ext/js/background/offscreen.js                               | 2 +-
 ext/js/display/search-display-controller.js                  | 2 +-
 ext/js/display/search-main.js                                | 2 +-
 ext/js/dom/simple-dom-parser.js                              | 2 +-
 ext/js/language/dictionary-importer.js                       | 4 ++--
 ext/js/pages/settings/backup-controller.js                   | 2 +-
 ext/js/pages/settings/popup-preview-frame.js                 | 2 +-
 ext/js/templates/sandbox/anki-template-renderer.js           | 2 +-
 ext/js/templates/sandbox/template-renderer-media-provider.js | 2 +-
 ext/js/templates/sandbox/template-renderer.js                | 2 +-
 ext/legal.html                                               | 8 ++++++++
 ext/offscreen.html                                           | 8 ++++++++
 ext/permissions.html                                         | 8 ++++++++
 ext/popup-preview.html                                       | 8 ++++++++
 ext/popup.html                                               | 8 ++++++++
 ext/search.html                                              | 8 ++++++++
 ext/settings.html                                            | 8 ++++++++
 ext/template-renderer.html                                   | 8 ++++++++
 ext/welcome.html                                             | 8 ++++++++
 jsconfig.json                                                | 3 ++-
 test/japanese-util.test.js                                   | 2 +-
 test/jsconfig.json                                           | 1 +
 28 files changed, 121 insertions(+), 14 deletions(-)

(limited to 'ext/js/language')

diff --git a/dev/jsconfig.json b/dev/jsconfig.json
index a012f32f..518f97ad 100644
--- a/dev/jsconfig.json
+++ b/dev/jsconfig.json
@@ -12,6 +12,7 @@
         "skipLibCheck": false,
         "baseUrl": ".",
         "paths": {
+            "zip.js": ["@zip.js/zip.js"],
             "anki-templates": ["../types/ext/anki-templates"],
             "anki-templates-internal": ["../types/ext/anki-templates-internal"],
             "cache-map": ["../types/ext/cache-map"],
diff --git a/ext/action-popup.html b/ext/action-popup.html
index b60e7055..5c6bfce4 100644
--- a/ext/action-popup.html
+++ b/ext/action-popup.html
@@ -12,6 +12,14 @@
     <link rel="icon" type="image/png" href="/images/icon64.png" sizes="64x64">
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/action-popup.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/background.html b/ext/background.html
index dc88f397..6f9ee5f6 100644
--- a/ext/background.html
+++ b/ext/background.html
@@ -12,6 +12,14 @@
     <link rel="icon" type="image/png" href="/images/icon64.png" sizes="64x64">
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/background.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/info.html b/ext/info.html
index cb80053d..9e71ffd4 100644
--- a/ext/info.html
+++ b/ext/info.html
@@ -13,6 +13,14 @@
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/issues.html b/ext/issues.html
index 904fbf16..c75683dd 100644
--- a/ext/issues.html
+++ b/ext/issues.html
@@ -13,6 +13,14 @@
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js
index be68ecf4..44f5a42d 100644
--- a/ext/js/background/backend.js
+++ b/ext/js/background/backend.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from '../../lib/wanakana.js';
+import * as wanakana from 'wanakana';
 import {AccessibilityController} from '../accessibility/accessibility-controller.js';
 import {AnkiConnect} from '../comm/anki-connect.js';
 import {ClipboardMonitor} from '../comm/clipboard-monitor.js';
diff --git a/ext/js/background/offscreen.js b/ext/js/background/offscreen.js
index 1b68887b..45345c01 100644
--- a/ext/js/background/offscreen.js
+++ b/ext/js/background/offscreen.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from '../../lib/wanakana.js';
+import * as wanakana from 'wanakana';
 import {ClipboardReader} from '../comm/clipboard-reader.js';
 import {invokeMessageHandler} from '../core.js';
 import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
diff --git a/ext/js/display/search-display-controller.js b/ext/js/display/search-display-controller.js
index a9bf2217..b93757c2 100644
--- a/ext/js/display/search-display-controller.js
+++ b/ext/js/display/search-display-controller.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from '../../lib/wanakana.js';
+import * as wanakana from 'wanakana';
 import {ClipboardMonitor} from '../comm/clipboard-monitor.js';
 import {EventListenerCollection, invokeMessageHandler} from '../core.js';
 import {yomitan} from '../yomitan.js';
diff --git a/ext/js/display/search-main.js b/ext/js/display/search-main.js
index c20cc135..c1445e37 100644
--- a/ext/js/display/search-main.js
+++ b/ext/js/display/search-main.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from '../../lib/wanakana.js';
+import * as wanakana from 'wanakana';
 import {log} from '../core.js';
 import {DocumentFocusController} from '../dom/document-focus-controller.js';
 import {HotkeyHandler} from '../input/hotkey-handler.js';
diff --git a/ext/js/dom/simple-dom-parser.js b/ext/js/dom/simple-dom-parser.js
index a1f63890..7ee28d51 100644
--- a/ext/js/dom/simple-dom-parser.js
+++ b/ext/js/dom/simple-dom-parser.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as parse5 from '../../lib/parse5.js';
+import * as parse5 from 'parse5';
 
 /**
  * @augments import('simple-dom-parser').ISimpleDomParser
diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js
index 115e0726..5a4d7257 100644
--- a/ext/js/language/dictionary-importer.js
+++ b/ext/js/language/dictionary-importer.js
@@ -16,14 +16,14 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as ajvSchemas0 from '../../lib/validate-schemas.js';
+import * as ajvSchemas0 from 'validate-schemas';
 import {
     BlobWriter as BlobWriter0,
     TextWriter as TextWriter0,
     Uint8ArrayReader as Uint8ArrayReader0,
     ZipReader as ZipReader0,
     configure
-} from '../../lib/zip.js';
+} from 'zip.js';
 import {stringReverse} from '../core.js';
 import {MediaUtil} from '../media/media-util.js';
 import {ExtensionError} from '../core/extension-error.js';
diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js
index 52c5f418..aeff2a97 100644
--- a/ext/js/pages/settings/backup-controller.js
+++ b/ext/js/pages/settings/backup-controller.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {Dexie} from '../../../lib/dexie.js';
+import {Dexie} from 'dexie';
 import {isObject, log} from '../../core.js';
 import {OptionsUtil} from '../../data/options-util.js';
 import {ArrayBufferUtil} from '../../data/sandbox/array-buffer-util.js';
diff --git a/ext/js/pages/settings/popup-preview-frame.js b/ext/js/pages/settings/popup-preview-frame.js
index c1a0d706..bb00829f 100644
--- a/ext/js/pages/settings/popup-preview-frame.js
+++ b/ext/js/pages/settings/popup-preview-frame.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from '../../../lib/wanakana.js';
+import * as wanakana from 'wanakana';
 import {Frontend} from '../../app/frontend.js';
 import {TextSourceRange} from '../../dom/text-source-range.js';
 import {yomitan} from '../../yomitan.js';
diff --git a/ext/js/templates/sandbox/anki-template-renderer.js b/ext/js/templates/sandbox/anki-template-renderer.js
index 9f4bf6ff..b0dc8223 100644
--- a/ext/js/templates/sandbox/anki-template-renderer.js
+++ b/ext/js/templates/sandbox/anki-template-renderer.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {Handlebars} from '../../../lib/handlebars.js';
+import {Handlebars} from 'handlebars';
 import {AnkiNoteDataCreator} from '../../data/sandbox/anki-note-data-creator.js';
 import {PronunciationGenerator} from '../../display/sandbox/pronunciation-generator.js';
 import {StructuredContentGenerator} from '../../display/sandbox/structured-content-generator.js';
diff --git a/ext/js/templates/sandbox/template-renderer-media-provider.js b/ext/js/templates/sandbox/template-renderer-media-provider.js
index d8a0a16d..23f334e1 100644
--- a/ext/js/templates/sandbox/template-renderer-media-provider.js
+++ b/ext/js/templates/sandbox/template-renderer-media-provider.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {Handlebars} from '../../../lib/handlebars.js';
+import {Handlebars} from 'handlebars';
 
 export class TemplateRendererMediaProvider {
     constructor() {
diff --git a/ext/js/templates/sandbox/template-renderer.js b/ext/js/templates/sandbox/template-renderer.js
index fe240b5f..d4aebd64 100644
--- a/ext/js/templates/sandbox/template-renderer.js
+++ b/ext/js/templates/sandbox/template-renderer.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {Handlebars} from '../../../lib/handlebars.js';
+import {Handlebars} from 'handlebars';
 import {ExtensionError} from '../../core/extension-error.js';
 
 export class TemplateRenderer {
diff --git a/ext/legal.html b/ext/legal.html
index 94912c7e..b853f3e8 100644
--- a/ext/legal.html
+++ b/ext/legal.html
@@ -14,6 +14,14 @@
    <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
    <link rel="stylesheet" type="text/css" href="/css/material.css">
    <link rel="stylesheet" type="text/css" href="/css/settings.css">
+   <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 
 <body>
diff --git a/ext/offscreen.html b/ext/offscreen.html
index afb7eb44..cfab53ee 100644
--- a/ext/offscreen.html
+++ b/ext/offscreen.html
@@ -12,6 +12,14 @@
     <link rel="icon" type="image/png" href="/images/icon64.png" sizes="64x64">
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/background.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/permissions.html b/ext/permissions.html
index 61b0d363..7baff14e 100644
--- a/ext/permissions.html
+++ b/ext/permissions.html
@@ -14,6 +14,14 @@
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
     <link rel="stylesheet" type="text/css" href="/css/permissions.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/popup-preview.html b/ext/popup-preview.html
index 15810242..7bd54470 100644
--- a/ext/popup-preview.html
+++ b/ext/popup-preview.html
@@ -13,6 +13,14 @@
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/popup-preview.css">
     <link rel="stylesheet" type="text/css" href="/css/popup-outer.css" id="popup-outer-css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/popup.html b/ext/popup.html
index 30e8a8c0..6bc8d690 100644
--- a/ext/popup.html
+++ b/ext/popup.html
@@ -15,6 +15,14 @@
     <link rel="stylesheet" type="text/css" href="/css/display.css">
     <link rel="stylesheet" type="text/css" href="/css/display-pronunciation.css">
     <link rel="stylesheet" type="text/css" href="/css/structured-content.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/search.html b/ext/search.html
index 8c595cc4..377a966a 100644
--- a/ext/search.html
+++ b/ext/search.html
@@ -16,6 +16,14 @@
     <link rel="stylesheet" type="text/css" href="/css/display-pronunciation.css">
     <link rel="stylesheet" type="text/css" href="/css/structured-content.css">
     <link rel="stylesheet" type="text/css" href="/css/search.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/settings.html b/ext/settings.html
index 346cc1d7..276a7222 100644
--- a/ext/settings.html
+++ b/ext/settings.html
@@ -14,6 +14,14 @@
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
     <link rel="stylesheet" type="text/css" href="/css/display-pronunciation.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/template-renderer.html b/ext/template-renderer.html
index 116f1a0c..d1747e99 100644
--- a/ext/template-renderer.html
+++ b/ext/template-renderer.html
@@ -11,6 +11,14 @@
     <link rel="icon" type="image/png" href="/images/icon48.png" sizes="48x48">
     <link rel="icon" type="image/png" href="/images/icon64.png" sizes="64x64">
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/ext/welcome.html b/ext/welcome.html
index 40639881..355bbc5f 100644
--- a/ext/welcome.html
+++ b/ext/welcome.html
@@ -13,6 +13,14 @@
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
+    <script type="importmap">{"imports":{
+"dexie": "./lib/dexie.js",
+"handlebars": "./lib/handlebars.js",
+"parse5": "./lib/parse5.js",
+"wanakana": "./lib/wanakana.js",
+"validate-schemas": "./lib/validate-schemas.js",
+"zip.js": "./lib/zip.js"
+}}</script>
 </head>
 <body>
 
diff --git a/jsconfig.json b/jsconfig.json
index ace0c2aa..4f316174 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -12,6 +12,7 @@
         "skipLibCheck": false,
         "baseUrl": ".",
         "paths": {
+            "zip.js": ["@zip.js/zip.js"],
             "*": ["./types/ext/*"]
         },
         "types": [
@@ -29,7 +30,7 @@
     "include": [
         "ext/**/*.js",
         "types/ext/**/*.ts",
-        "types/other/web-set-timeout.d.ts"
+        "types/other/globals.d.ts"
     ],
     "exclude": [
         "node_modules",
diff --git a/test/japanese-util.test.js b/test/japanese-util.test.js
index 47da4ccb..ae5b1a16 100644
--- a/test/japanese-util.test.js
+++ b/test/japanese-util.test.js
@@ -16,10 +16,10 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+import * as wanakana from 'wanakana';
 import {expect, test} from 'vitest';
 import {TextSourceMap} from '../ext/js/general/text-source-map.js';
 import {JapaneseUtil} from '../ext/js/language/sandbox/japanese-util.js';
-import * as wanakana from '../ext/lib/wanakana.js';
 
 const jp = new JapaneseUtil(wanakana);
 
diff --git a/test/jsconfig.json b/test/jsconfig.json
index b025918c..261ec345 100644
--- a/test/jsconfig.json
+++ b/test/jsconfig.json
@@ -12,6 +12,7 @@
         "skipLibCheck": false,
         "baseUrl": ".",
         "paths": {
+            "zip.js": ["@zip.js/zip.js"],
             "*": ["../types/ext/*"],
             "dev/*": ["../types/dev/*"],
             "test/*": ["../types/test/*"]
-- 
cgit v1.2.3


From 14d12f6ba20b837a04c638b935773f3120e194ff Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 19:33:01 -0500
Subject: Update timer types and such

---
 dev/jsconfig.json                              |  2 +-
 ext/js/app/frontend.js                         | 11 +++++++++--
 ext/js/background/backend.js                   |  4 ++--
 ext/js/background/offscreen.js                 |  4 +++-
 ext/js/comm/api.js                             |  2 +-
 ext/js/comm/clipboard-monitor.js               |  2 +-
 ext/js/comm/cross-frame-api.js                 | 21 +++++++++++++--------
 ext/js/comm/frame-ancestry-handler.js          |  2 +-
 ext/js/comm/frame-client.js                    |  2 +-
 ext/js/comm/mecab.js                           |  2 +-
 ext/js/core.js                                 |  2 +-
 ext/js/display/display-audio.js                |  2 +-
 ext/js/display/display-notification.js         |  2 +-
 ext/js/display/display.js                      |  2 +-
 ext/js/display/element-overflow-controller.js  |  8 ++++----
 ext/js/display/option-toggle-hotkey-handler.js |  2 +-
 ext/js/display/search-display-controller.js    |  2 +-
 ext/js/dom/document-util.js                    |  2 +-
 ext/js/dom/panel-element.js                    |  2 +-
 ext/js/language/text-scanner.js                |  6 +++---
 ext/js/media/audio-downloader.js               |  2 +-
 ext/js/pages/settings/popup-preview-frame.js   |  2 +-
 test/jsconfig.json                             |  2 +-
 types/ext/core.d.ts                            |  2 ++
 types/ext/cross-frame-api.d.ts                 |  2 +-
 types/ext/offscreen.d.ts                       |  2 +-
 types/other/globals.d.ts                       | 22 ++++++++++++++++++++++
 types/other/web-set-timeout.d.ts               | 24 ------------------------
 28 files changed, 77 insertions(+), 63 deletions(-)
 create mode 100644 types/other/globals.d.ts
 delete mode 100644 types/other/web-set-timeout.d.ts

(limited to 'ext/js/language')

diff --git a/dev/jsconfig.json b/dev/jsconfig.json
index 518f97ad..d4efe694 100644
--- a/dev/jsconfig.json
+++ b/dev/jsconfig.json
@@ -71,7 +71,7 @@
         "../ext/js/language/translator.js",
         "../ext/js/media/media-util.js",
         "../types/dev/**/*.ts",
-        "../types/other/web-set-timeout.d.ts"
+        "../types/other/globals.d.ts"
     ],
     "exclude": [
         "../node_modules"
diff --git a/ext/js/app/frontend.js b/ext/js/app/frontend.js
index 628c504e..e1f8d8c9 100644
--- a/ext/js/app/frontend.js
+++ b/ext/js/app/frontend.js
@@ -99,7 +99,7 @@ export class Frontend {
         this._popupEventListeners = new EventListenerCollection();
         /** @type {?import('core').TokenObject} */
         this._updatePopupToken = null;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._clearSelectionTimer = null;
         /** @type {boolean} */
         this._isPointerOverPopup = false;
@@ -840,7 +840,7 @@ export class Frontend {
      */
     async _waitForFrontendReady(frameId, timeout) {
         return new Promise((resolve, reject) => {
-            /** @type {?number} */
+            /** @type {?import('core').Timeout} */
             let timeoutId = null;
 
             const cleanup = () => {
@@ -973,6 +973,13 @@ export class Frontend {
         await yomitan.api.loadExtensionScripts([
             '/js/accessibility/google-docs-util.js'
         ]);
+        this._prepareGoogleDocs2();
+    }
+
+    /**
+     * @returns {Promise<void>}
+     */
+    async _prepareGoogleDocs2() {
         if (typeof GoogleDocsUtil === 'undefined') { return; }
         DocumentUtil.registerGetRangeFromPointHandler(GoogleDocsUtil.getRangeFromPoint.bind(GoogleDocsUtil));
     }
diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js
index 44f5a42d..f1a47e7f 100644
--- a/ext/js/background/backend.js
+++ b/ext/js/background/backend.js
@@ -138,7 +138,7 @@ export class Backend {
 
         /** @type {?string} */
         this._defaultBrowserActionTitle = null;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._badgePrepareDelayTimer = null;
         /** @type {?import('log').LogLevel} */
         this._logErrorLevel = null;
@@ -1981,7 +1981,7 @@ export class Backend {
      */
     _waitUntilTabFrameIsReady(tabId, frameId, timeout=null) {
         return new Promise((resolve, reject) => {
-            /** @type {?number} */
+            /** @type {?import('core').Timeout} */
             let timer = null;
             /** @type {?import('extension').ChromeRuntimeOnMessageCallback} */
             let onMessage = (message, sender) => {
diff --git a/ext/js/background/offscreen.js b/ext/js/background/offscreen.js
index 45345c01..8da661ad 100644
--- a/ext/js/background/offscreen.js
+++ b/ext/js/background/offscreen.js
@@ -51,7 +51,7 @@ export class Offscreen {
         });
 
         /** @type {import('offscreen').MessageHandlerMap} */
-        this._messageHandlers = new Map([
+        const messageHandlers = new Map([
             ['clipboardGetTextOffscreen',    {async: true,  handler: this._getTextHandler.bind(this)}],
             ['clipboardGetImageOffscreen',   {async: true,  handler: this._getImageHandler.bind(this)}],
             ['clipboardSetBrowserOffscreen', {async: false, handler: this._setClipboardBrowser.bind(this)}],
@@ -65,6 +65,8 @@ export class Offscreen {
             ['getTermFrequenciesOffscreen',  {async: true,  handler: this._getTermFrequenciesHandler.bind(this)}],
             ['clearDatabaseCachesOffscreen', {async: false, handler: this._clearDatabaseCachesHandler.bind(this)}]
         ]);
+        /** @type {import('offscreen').MessageHandlerMap<string>} */
+        this._messageHandlers = messageHandlers;
 
         const onMessage = this._onMessage.bind(this);
         chrome.runtime.onMessage.addListener(onMessage);
diff --git a/ext/js/comm/api.js b/ext/js/comm/api.js
index 0cfdba59..26218595 100644
--- a/ext/js/comm/api.js
+++ b/ext/js/comm/api.js
@@ -431,7 +431,7 @@ export class API {
      */
     _createActionPort(timeout) {
         return new Promise((resolve, reject) => {
-            /** @type {?number} */
+            /** @type {?import('core').Timeout} */
             let timer = null;
             const portDetails = deferPromise();
 
diff --git a/ext/js/comm/clipboard-monitor.js b/ext/js/comm/clipboard-monitor.js
index 06e95438..3b3a56a9 100644
--- a/ext/js/comm/clipboard-monitor.js
+++ b/ext/js/comm/clipboard-monitor.js
@@ -31,7 +31,7 @@ export class ClipboardMonitor extends EventDispatcher {
         this._japaneseUtil = japaneseUtil;
         /** @type {import('clipboard-monitor').ClipboardReaderLike} */
         this._clipboardReader = clipboardReader;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._timerId = null;
         /** @type {?import('core').TokenObject} */
         this._timerToken = null;
diff --git a/ext/js/comm/cross-frame-api.js b/ext/js/comm/cross-frame-api.js
index 34f3f36a..3ac38cf2 100644
--- a/ext/js/comm/cross-frame-api.js
+++ b/ext/js/comm/cross-frame-api.js
@@ -25,14 +25,14 @@ import {yomitan} from '../yomitan.js';
  */
 class CrossFrameAPIPort extends EventDispatcher {
     /**
-     * @param {?number} otherTabId
+     * @param {number} otherTabId
      * @param {number} otherFrameId
      * @param {chrome.runtime.Port} port
      * @param {import('core').MessageHandlerMap} messageHandlers
      */
     constructor(otherTabId, otherFrameId, port, messageHandlers) {
         super();
-        /** @type {?number} */
+        /** @type {number} */
         this._otherTabId = otherTabId;
         /** @type {number} */
         this._otherFrameId = otherFrameId;
@@ -48,7 +48,7 @@ class CrossFrameAPIPort extends EventDispatcher {
         this._eventListeners = new EventListenerCollection();
     }
 
-    /** @type {?number} */
+    /** @type {number} */
     get otherTabId() {
         return this._otherTabId;
     }
@@ -299,7 +299,7 @@ export class CrossFrameAPI {
         this._ackTimeout = 3000; // 3 seconds
         /** @type {number} */
         this._responseTimeout = 10000; // 10 seconds
-        /** @type {Map<?number, Map<number, CrossFrameAPIPort>>} */
+        /** @type {Map<number, Map<number, CrossFrameAPIPort>>} */
         this._commPorts = new Map();
         /** @type {import('core').MessageHandlerMap} */
         this._messageHandlers = new Map();
@@ -339,7 +339,12 @@ export class CrossFrameAPI {
      * @returns {Promise<TReturn>}
      */
     async invokeTab(targetTabId, targetFrameId, action, params) {
-        if (typeof targetTabId !== 'number') { targetTabId = this._tabId; }
+        if (typeof targetTabId !== 'number') {
+            targetTabId = this._tabId;
+            if (typeof targetTabId !== 'number') {
+                throw new Error('Unknown target tab id for invocation');
+            }
+        }
         const commPort = await this._getOrCreateCommPort(targetTabId, targetFrameId);
         return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout);
     }
@@ -405,7 +410,7 @@ export class CrossFrameAPI {
     }
 
     /**
-     * @param {?number} otherTabId
+     * @param {number} otherTabId
      * @param {number} otherFrameId
      * @returns {Promise<CrossFrameAPIPort>}
      */
@@ -420,7 +425,7 @@ export class CrossFrameAPI {
         return await this._createCommPort(otherTabId, otherFrameId);
     }
     /**
-     * @param {?number} otherTabId
+     * @param {number} otherTabId
      * @param {number} otherFrameId
      * @returns {Promise<CrossFrameAPIPort>}
      */
@@ -438,7 +443,7 @@ export class CrossFrameAPI {
     }
 
     /**
-     * @param {?number} otherTabId
+     * @param {number} otherTabId
      * @param {number} otherFrameId
      * @param {chrome.runtime.Port} port
      * @returns {CrossFrameAPIPort}
diff --git a/ext/js/comm/frame-ancestry-handler.js b/ext/js/comm/frame-ancestry-handler.js
index 49c96c22..687ec368 100644
--- a/ext/js/comm/frame-ancestry-handler.js
+++ b/ext/js/comm/frame-ancestry-handler.js
@@ -122,7 +122,7 @@ export class FrameAncestryHandler {
             const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`;
             /** @type {number[]} */
             const results = [];
-            /** @type {?number} */
+            /** @type {?import('core').Timeout} */
             let timer = null;
 
             const cleanup = () => {
diff --git a/ext/js/comm/frame-client.js b/ext/js/comm/frame-client.js
index 1519cf7f..8aa8c6d6 100644
--- a/ext/js/comm/frame-client.js
+++ b/ext/js/comm/frame-client.js
@@ -83,7 +83,7 @@ export class FrameClient {
     _connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) {
         return new Promise((resolve, reject) => {
             const tokenMap = new Map();
-            /** @type {?number} */
+            /** @type {?import('core').Timeout} */
             let timer = null;
             const deferPromiseDetails = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise());
             const frameLoadedPromise = deferPromiseDetails.promise;
diff --git a/ext/js/comm/mecab.js b/ext/js/comm/mecab.js
index 072b42f3..0a87463b 100644
--- a/ext/js/comm/mecab.js
+++ b/ext/js/comm/mecab.js
@@ -31,7 +31,7 @@ export class Mecab {
         this._port = null;
         /** @type {number} */
         this._sequence = 0;
-        /** @type {Map<number, {resolve: (value: unknown) => void, reject: (reason?: unknown) => void, timer: number}>} */
+        /** @type {Map<number, {resolve: (value: unknown) => void, reject: (reason?: unknown) => void, timer: import('core').Timeout}>} */
         this._invocations = new Map();
         /** @type {EventListenerCollection} */
         this._eventListeners = new EventListenerCollection();
diff --git a/ext/js/core.js b/ext/js/core.js
index cdac2020..a53d5572 100644
--- a/ext/js/core.js
+++ b/ext/js/core.js
@@ -307,7 +307,7 @@ export function promiseAnimationFrame(timeout) {
             return;
         }
 
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         let timer = null;
         /** @type {?number} */
         let frameRequest = null;
diff --git a/ext/js/display/display-audio.js b/ext/js/display/display-audio.js
index 3fbfc3c8..3576decb 100644
--- a/ext/js/display/display-audio.js
+++ b/ext/js/display/display-audio.js
@@ -36,7 +36,7 @@ export class DisplayAudio {
         this._playbackVolume = 1.0;
         /** @type {boolean} */
         this._autoPlay = false;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._autoPlayAudioTimer = null;
         /** @type {number} */
         this._autoPlayAudioDelay = 400;
diff --git a/ext/js/display/display-notification.js b/ext/js/display/display-notification.js
index b3f20700..d1cfa537 100644
--- a/ext/js/display/display-notification.js
+++ b/ext/js/display/display-notification.js
@@ -34,7 +34,7 @@ export class DisplayNotification {
         this._closeButton = /** @type {HTMLElement} */ (node.querySelector('.footer-notification-close-button'));
         /** @type {EventListenerCollection} */
         this._eventListeners = new EventListenerCollection();
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._closeTimer = null;
     }
 
diff --git a/ext/js/display/display.js b/ext/js/display/display.js
index f14291d1..6e1450c3 100644
--- a/ext/js/display/display.js
+++ b/ext/js/display/display.js
@@ -117,7 +117,7 @@ export class Display extends EventDispatcher {
         this._queryOffset = 0;
         /** @type {HTMLElement} */
         this._progressIndicator = /** @type {HTMLElement} */ (document.querySelector('#progress-indicator'));
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._progressIndicatorTimer = null;
         /** @type {DynamicProperty<boolean>} */
         this._progressIndicatorVisible = new DynamicProperty(false);
diff --git a/ext/js/display/element-overflow-controller.js b/ext/js/display/element-overflow-controller.js
index 1d2c808f..35277fa5 100644
--- a/ext/js/display/element-overflow-controller.js
+++ b/ext/js/display/element-overflow-controller.js
@@ -22,7 +22,7 @@ export class ElementOverflowController {
     constructor() {
         /** @type {Element[]} */
         this._elements = [];
-        /** @type {?number} */
+        /** @type {?(number|import('core').Timeout)} */
         this._checkTimer = null;
         /** @type {EventListenerCollection} */
         this._eventListeners = new EventListenerCollection();
@@ -154,7 +154,7 @@ export class ElementOverflowController {
     /**
      * @param {() => void} callback
      * @param {number} timeout
-     * @returns {number}
+     * @returns {number|import('core').Timeout}
      */
     _requestIdleCallback(callback, timeout) {
         if (typeof requestIdleCallback === 'function') {
@@ -165,11 +165,11 @@ export class ElementOverflowController {
     }
 
     /**
-     * @param {number} handle
+     * @param {number|import('core').Timeout} handle
      */
     _cancelIdleCallback(handle) {
         if (typeof cancelIdleCallback === 'function') {
-            cancelIdleCallback(handle);
+            cancelIdleCallback(/** @type {number} */ (handle));
         } else {
             clearTimeout(handle);
         }
diff --git a/ext/js/display/option-toggle-hotkey-handler.js b/ext/js/display/option-toggle-hotkey-handler.js
index 531e208d..edd7de5b 100644
--- a/ext/js/display/option-toggle-hotkey-handler.js
+++ b/ext/js/display/option-toggle-hotkey-handler.js
@@ -29,7 +29,7 @@ export class OptionToggleHotkeyHandler {
         this._display = display;
         /** @type {?import('./display-notification.js').DisplayNotification} */
         this._notification = null;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._notificationHideTimer = null;
         /** @type {number} */
         this._notificationHideTimeout = 5000;
diff --git a/ext/js/display/search-display-controller.js b/ext/js/display/search-display-controller.js
index b93757c2..98a4666b 100644
--- a/ext/js/display/search-display-controller.js
+++ b/ext/js/display/search-display-controller.js
@@ -63,7 +63,7 @@ export class SearchDisplayController {
         this._wanakanaBound = false;
         /** @type {boolean} */
         this._introVisible = true;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._introAnimationTimer = null;
         /** @type {boolean} */
         this._clipboardMonitorEnabled = false;
diff --git a/ext/js/dom/document-util.js b/ext/js/dom/document-util.js
index cf58d39f..549a8195 100644
--- a/ext/js/dom/document-util.js
+++ b/ext/js/dom/document-util.js
@@ -360,7 +360,7 @@ export class DocumentUtil {
     /**
      * Adds a fullscreen change event listener. This function handles all of the browser-specific variants.
      * @param {EventListener} onFullscreenChanged The event callback.
-     * @param {?EventListenerCollection} eventListenerCollection An optional `EventListenerCollection` to add the registration to.
+     * @param {?import('../core.js').EventListenerCollection} eventListenerCollection An optional `EventListenerCollection` to add the registration to.
      */
     static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) {
         const target = document;
diff --git a/ext/js/dom/panel-element.js b/ext/js/dom/panel-element.js
index 314eb2fd..748c3a36 100644
--- a/ext/js/dom/panel-element.js
+++ b/ext/js/dom/panel-element.js
@@ -37,7 +37,7 @@ export class PanelElement extends EventDispatcher {
         this._mutationObserver = null;
         /** @type {boolean} */
         this._visible = false;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._closeTimer = null;
     }
 
diff --git a/ext/js/language/text-scanner.js b/ext/js/language/text-scanner.js
index f6bcde8d..d1b033e6 100644
--- a/ext/js/language/text-scanner.js
+++ b/ext/js/language/text-scanner.js
@@ -120,7 +120,7 @@ export class TextScanner extends EventDispatcher {
 
         /** @type {boolean} */
         this._preventNextClickScan = false;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._preventNextClickScanTimer = null;
         /** @type {number} */
         this._preventNextClickScanTimerDuration = 50;
@@ -145,7 +145,7 @@ export class TextScanner extends EventDispatcher {
         /** @type {boolean} */
         this._canClearSelection = true;
 
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._textSelectionTimer = null;
         /** @type {boolean} */
         this._yomitanIsChangingTextSelectionNow = false;
@@ -995,7 +995,7 @@ export class TextScanner extends EventDispatcher {
     async _scanTimerWait() {
         const delay = this._delay;
         const promise = /** @type {Promise<boolean>} */ (new Promise((resolve) => {
-            /** @type {?number} */
+            /** @type {?import('core').Timeout} */
             let timeout = setTimeout(() => {
                 timeout = null;
                 resolve(true);
diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js
index 0847d479..e041cc67 100644
--- a/ext/js/media/audio-downloader.js
+++ b/ext/js/media/audio-downloader.js
@@ -316,7 +316,7 @@ export class AudioDownloader {
         let signal;
         /** @type {?import('request-builder.js').ProgressCallback} */
         let onProgress = null;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         let idleTimer = null;
         if (typeof idleTimeout === 'number') {
             const abortController = new AbortController();
diff --git a/ext/js/pages/settings/popup-preview-frame.js b/ext/js/pages/settings/popup-preview-frame.js
index bb00829f..dab21f4d 100644
--- a/ext/js/pages/settings/popup-preview-frame.js
+++ b/ext/js/pages/settings/popup-preview-frame.js
@@ -43,7 +43,7 @@ export class PopupPreviewFrame {
         this._apiOptionsGetOld = null;
         /** @type {boolean} */
         this._popupShown = false;
-        /** @type {?number} */
+        /** @type {?import('core').Timeout} */
         this._themeChangeTimeout = null;
         /** @type {?import('text-source').TextSource} */
         this._textSource = null;
diff --git a/test/jsconfig.json b/test/jsconfig.json
index 261ec345..934aab81 100644
--- a/test/jsconfig.json
+++ b/test/jsconfig.json
@@ -31,7 +31,7 @@
         "../ext/**/*.js",
         "../types/ext/**/*.ts",
         "../types/dev/**/*.ts",
-        "../types/other/web-set-timeout.d.ts"
+        "../types/other/globals.d.ts"
     ],
     "exclude": [
         "../node_modules",
diff --git a/types/ext/core.d.ts b/types/ext/core.d.ts
index ce3a09f0..b83e6a74 100644
--- a/types/ext/core.d.ts
+++ b/types/ext/core.d.ts
@@ -100,3 +100,5 @@ export type MessageHandlerDetails = {
 export type MessageHandlerMap = Map<string, MessageHandlerDetails>;
 
 export type MessageHandlerArray = [key: string, handlerDetails: MessageHandlerDetails][];
+
+export type Timeout = number | NodeJS.Timeout;
diff --git a/types/ext/cross-frame-api.d.ts b/types/ext/cross-frame-api.d.ts
index 5cedbec9..88ce59a7 100644
--- a/types/ext/cross-frame-api.d.ts
+++ b/types/ext/cross-frame-api.d.ts
@@ -50,5 +50,5 @@ export type Invocation = {
     responseTimeout: number;
     action: string;
     ack: boolean;
-    timer: number | null;
+    timer: Core.Timeout | null;
 };
diff --git a/types/ext/offscreen.d.ts b/types/ext/offscreen.d.ts
index 7dd64d1e..ddf7eadc 100644
--- a/types/ext/offscreen.d.ts
+++ b/types/ext/offscreen.d.ts
@@ -112,4 +112,4 @@ export type MessageHandler<
     details: MessageDetailsMap[TMessage],
 ) => (TIsAsync extends true ? Promise<MessageReturn<TMessage>> : MessageReturn<TMessage>);
 
-export type MessageHandlerMap = Map<MessageType, Core.MessageHandlerDetails>;
+export type MessageHandlerMap<T = MessageType> = Map<T, Core.MessageHandlerDetails>;
diff --git a/types/other/globals.d.ts b/types/other/globals.d.ts
new file mode 100644
index 00000000..330f16c2
--- /dev/null
+++ b/types/other/globals.d.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2023  Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+declare global {
+    function clearTimeout(timeoutId: NodeJS.Timeout | string | number | undefined): void;
+}
+
+export {};
diff --git a/types/other/web-set-timeout.d.ts b/types/other/web-set-timeout.d.ts
deleted file mode 100644
index 98e40dab..00000000
--- a/types/other/web-set-timeout.d.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright (C) 2023  Yomitan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-declare module 'web-set-timeout' {
-    global {
-        // These types are used to ensure that setTimeout returns a number
-        function setTimeout<TArgs extends unknown[]>(callback: (...args: TArgs) => void, ms?: number, ...args: TArgs): number;
-        function setTimeout(callback: (args: void) => void, ms?: number): number;
-    }
-}
-- 
cgit v1.2.3


From 58ae2ab871591eea82895b1ab2a18753521eab1f Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 22:07:28 -0500
Subject: Revert "Use import map"

---
 dev/jsconfig.json                                            | 1 -
 ext/action-popup.html                                        | 8 --------
 ext/background.html                                          | 8 --------
 ext/info.html                                                | 8 --------
 ext/issues.html                                              | 8 --------
 ext/js/background/backend.js                                 | 2 +-
 ext/js/background/offscreen.js                               | 2 +-
 ext/js/display/search-display-controller.js                  | 2 +-
 ext/js/display/search-main.js                                | 2 +-
 ext/js/dom/simple-dom-parser.js                              | 2 +-
 ext/js/language/dictionary-importer.js                       | 4 ++--
 ext/js/pages/settings/backup-controller.js                   | 2 +-
 ext/js/pages/settings/popup-preview-frame.js                 | 2 +-
 ext/js/templates/sandbox/anki-template-renderer.js           | 2 +-
 ext/js/templates/sandbox/template-renderer-media-provider.js | 2 +-
 ext/js/templates/sandbox/template-renderer.js                | 2 +-
 ext/legal.html                                               | 8 --------
 ext/offscreen.html                                           | 8 --------
 ext/permissions.html                                         | 8 --------
 ext/popup-preview.html                                       | 8 --------
 ext/popup.html                                               | 8 --------
 ext/search.html                                              | 8 --------
 ext/settings.html                                            | 8 --------
 ext/template-renderer.html                                   | 8 --------
 ext/welcome.html                                             | 8 --------
 jsconfig.json                                                | 1 -
 test/japanese-util.test.js                                   | 2 +-
 test/jsconfig.json                                           | 1 -
 28 files changed, 13 insertions(+), 120 deletions(-)

(limited to 'ext/js/language')

diff --git a/dev/jsconfig.json b/dev/jsconfig.json
index d4efe694..5b1c450c 100644
--- a/dev/jsconfig.json
+++ b/dev/jsconfig.json
@@ -12,7 +12,6 @@
         "skipLibCheck": false,
         "baseUrl": ".",
         "paths": {
-            "zip.js": ["@zip.js/zip.js"],
             "anki-templates": ["../types/ext/anki-templates"],
             "anki-templates-internal": ["../types/ext/anki-templates-internal"],
             "cache-map": ["../types/ext/cache-map"],
diff --git a/ext/action-popup.html b/ext/action-popup.html
index 5c6bfce4..b60e7055 100644
--- a/ext/action-popup.html
+++ b/ext/action-popup.html
@@ -12,14 +12,6 @@
     <link rel="icon" type="image/png" href="/images/icon64.png" sizes="64x64">
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/action-popup.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/background.html b/ext/background.html
index 6f9ee5f6..dc88f397 100644
--- a/ext/background.html
+++ b/ext/background.html
@@ -12,14 +12,6 @@
     <link rel="icon" type="image/png" href="/images/icon64.png" sizes="64x64">
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/background.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/info.html b/ext/info.html
index 9e71ffd4..cb80053d 100644
--- a/ext/info.html
+++ b/ext/info.html
@@ -13,14 +13,6 @@
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/issues.html b/ext/issues.html
index c75683dd..904fbf16 100644
--- a/ext/issues.html
+++ b/ext/issues.html
@@ -13,14 +13,6 @@
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js
index f1a47e7f..3eefed53 100644
--- a/ext/js/background/backend.js
+++ b/ext/js/background/backend.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from 'wanakana';
+import * as wanakana from '../../lib/wanakana.js';
 import {AccessibilityController} from '../accessibility/accessibility-controller.js';
 import {AnkiConnect} from '../comm/anki-connect.js';
 import {ClipboardMonitor} from '../comm/clipboard-monitor.js';
diff --git a/ext/js/background/offscreen.js b/ext/js/background/offscreen.js
index 8da661ad..4b57514d 100644
--- a/ext/js/background/offscreen.js
+++ b/ext/js/background/offscreen.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from 'wanakana';
+import * as wanakana from '../../lib/wanakana.js';
 import {ClipboardReader} from '../comm/clipboard-reader.js';
 import {invokeMessageHandler} from '../core.js';
 import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
diff --git a/ext/js/display/search-display-controller.js b/ext/js/display/search-display-controller.js
index 98a4666b..2778c4cd 100644
--- a/ext/js/display/search-display-controller.js
+++ b/ext/js/display/search-display-controller.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from 'wanakana';
+import * as wanakana from '../../lib/wanakana.js';
 import {ClipboardMonitor} from '../comm/clipboard-monitor.js';
 import {EventListenerCollection, invokeMessageHandler} from '../core.js';
 import {yomitan} from '../yomitan.js';
diff --git a/ext/js/display/search-main.js b/ext/js/display/search-main.js
index c1445e37..c20cc135 100644
--- a/ext/js/display/search-main.js
+++ b/ext/js/display/search-main.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from 'wanakana';
+import * as wanakana from '../../lib/wanakana.js';
 import {log} from '../core.js';
 import {DocumentFocusController} from '../dom/document-focus-controller.js';
 import {HotkeyHandler} from '../input/hotkey-handler.js';
diff --git a/ext/js/dom/simple-dom-parser.js b/ext/js/dom/simple-dom-parser.js
index 7ee28d51..a1f63890 100644
--- a/ext/js/dom/simple-dom-parser.js
+++ b/ext/js/dom/simple-dom-parser.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as parse5 from 'parse5';
+import * as parse5 from '../../lib/parse5.js';
 
 /**
  * @augments import('simple-dom-parser').ISimpleDomParser
diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js
index 5a4d7257..115e0726 100644
--- a/ext/js/language/dictionary-importer.js
+++ b/ext/js/language/dictionary-importer.js
@@ -16,14 +16,14 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as ajvSchemas0 from 'validate-schemas';
+import * as ajvSchemas0 from '../../lib/validate-schemas.js';
 import {
     BlobWriter as BlobWriter0,
     TextWriter as TextWriter0,
     Uint8ArrayReader as Uint8ArrayReader0,
     ZipReader as ZipReader0,
     configure
-} from 'zip.js';
+} from '../../lib/zip.js';
 import {stringReverse} from '../core.js';
 import {MediaUtil} from '../media/media-util.js';
 import {ExtensionError} from '../core/extension-error.js';
diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js
index aeff2a97..52c5f418 100644
--- a/ext/js/pages/settings/backup-controller.js
+++ b/ext/js/pages/settings/backup-controller.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {Dexie} from 'dexie';
+import {Dexie} from '../../../lib/dexie.js';
 import {isObject, log} from '../../core.js';
 import {OptionsUtil} from '../../data/options-util.js';
 import {ArrayBufferUtil} from '../../data/sandbox/array-buffer-util.js';
diff --git a/ext/js/pages/settings/popup-preview-frame.js b/ext/js/pages/settings/popup-preview-frame.js
index dab21f4d..60d264fa 100644
--- a/ext/js/pages/settings/popup-preview-frame.js
+++ b/ext/js/pages/settings/popup-preview-frame.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from 'wanakana';
+import * as wanakana from '../../../lib/wanakana.js';
 import {Frontend} from '../../app/frontend.js';
 import {TextSourceRange} from '../../dom/text-source-range.js';
 import {yomitan} from '../../yomitan.js';
diff --git a/ext/js/templates/sandbox/anki-template-renderer.js b/ext/js/templates/sandbox/anki-template-renderer.js
index b0dc8223..9f4bf6ff 100644
--- a/ext/js/templates/sandbox/anki-template-renderer.js
+++ b/ext/js/templates/sandbox/anki-template-renderer.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {Handlebars} from 'handlebars';
+import {Handlebars} from '../../../lib/handlebars.js';
 import {AnkiNoteDataCreator} from '../../data/sandbox/anki-note-data-creator.js';
 import {PronunciationGenerator} from '../../display/sandbox/pronunciation-generator.js';
 import {StructuredContentGenerator} from '../../display/sandbox/structured-content-generator.js';
diff --git a/ext/js/templates/sandbox/template-renderer-media-provider.js b/ext/js/templates/sandbox/template-renderer-media-provider.js
index 23f334e1..d8a0a16d 100644
--- a/ext/js/templates/sandbox/template-renderer-media-provider.js
+++ b/ext/js/templates/sandbox/template-renderer-media-provider.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {Handlebars} from 'handlebars';
+import {Handlebars} from '../../../lib/handlebars.js';
 
 export class TemplateRendererMediaProvider {
     constructor() {
diff --git a/ext/js/templates/sandbox/template-renderer.js b/ext/js/templates/sandbox/template-renderer.js
index d4aebd64..fe240b5f 100644
--- a/ext/js/templates/sandbox/template-renderer.js
+++ b/ext/js/templates/sandbox/template-renderer.js
@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import {Handlebars} from 'handlebars';
+import {Handlebars} from '../../../lib/handlebars.js';
 import {ExtensionError} from '../../core/extension-error.js';
 
 export class TemplateRenderer {
diff --git a/ext/legal.html b/ext/legal.html
index b853f3e8..94912c7e 100644
--- a/ext/legal.html
+++ b/ext/legal.html
@@ -14,14 +14,6 @@
    <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
    <link rel="stylesheet" type="text/css" href="/css/material.css">
    <link rel="stylesheet" type="text/css" href="/css/settings.css">
-   <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 
 <body>
diff --git a/ext/offscreen.html b/ext/offscreen.html
index cfab53ee..afb7eb44 100644
--- a/ext/offscreen.html
+++ b/ext/offscreen.html
@@ -12,14 +12,6 @@
     <link rel="icon" type="image/png" href="/images/icon64.png" sizes="64x64">
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/background.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/permissions.html b/ext/permissions.html
index 7baff14e..61b0d363 100644
--- a/ext/permissions.html
+++ b/ext/permissions.html
@@ -14,14 +14,6 @@
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
     <link rel="stylesheet" type="text/css" href="/css/permissions.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/popup-preview.html b/ext/popup-preview.html
index 7bd54470..15810242 100644
--- a/ext/popup-preview.html
+++ b/ext/popup-preview.html
@@ -13,14 +13,6 @@
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/popup-preview.css">
     <link rel="stylesheet" type="text/css" href="/css/popup-outer.css" id="popup-outer-css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/popup.html b/ext/popup.html
index 6bc8d690..30e8a8c0 100644
--- a/ext/popup.html
+++ b/ext/popup.html
@@ -15,14 +15,6 @@
     <link rel="stylesheet" type="text/css" href="/css/display.css">
     <link rel="stylesheet" type="text/css" href="/css/display-pronunciation.css">
     <link rel="stylesheet" type="text/css" href="/css/structured-content.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/search.html b/ext/search.html
index 377a966a..8c595cc4 100644
--- a/ext/search.html
+++ b/ext/search.html
@@ -16,14 +16,6 @@
     <link rel="stylesheet" type="text/css" href="/css/display-pronunciation.css">
     <link rel="stylesheet" type="text/css" href="/css/structured-content.css">
     <link rel="stylesheet" type="text/css" href="/css/search.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/settings.html b/ext/settings.html
index 276a7222..346cc1d7 100644
--- a/ext/settings.html
+++ b/ext/settings.html
@@ -14,14 +14,6 @@
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
     <link rel="stylesheet" type="text/css" href="/css/display-pronunciation.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/template-renderer.html b/ext/template-renderer.html
index d1747e99..116f1a0c 100644
--- a/ext/template-renderer.html
+++ b/ext/template-renderer.html
@@ -11,14 +11,6 @@
     <link rel="icon" type="image/png" href="/images/icon48.png" sizes="48x48">
     <link rel="icon" type="image/png" href="/images/icon64.png" sizes="64x64">
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/ext/welcome.html b/ext/welcome.html
index 355bbc5f..40639881 100644
--- a/ext/welcome.html
+++ b/ext/welcome.html
@@ -13,14 +13,6 @@
     <link rel="icon" type="image/png" href="/images/icon128.png" sizes="128x128">
     <link rel="stylesheet" type="text/css" href="/css/material.css">
     <link rel="stylesheet" type="text/css" href="/css/settings.css">
-    <script type="importmap">{"imports":{
-"dexie": "./lib/dexie.js",
-"handlebars": "./lib/handlebars.js",
-"parse5": "./lib/parse5.js",
-"wanakana": "./lib/wanakana.js",
-"validate-schemas": "./lib/validate-schemas.js",
-"zip.js": "./lib/zip.js"
-}}</script>
 </head>
 <body>
 
diff --git a/jsconfig.json b/jsconfig.json
index 4f316174..0f780ead 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -12,7 +12,6 @@
         "skipLibCheck": false,
         "baseUrl": ".",
         "paths": {
-            "zip.js": ["@zip.js/zip.js"],
             "*": ["./types/ext/*"]
         },
         "types": [
diff --git a/test/japanese-util.test.js b/test/japanese-util.test.js
index ae5b1a16..47da4ccb 100644
--- a/test/japanese-util.test.js
+++ b/test/japanese-util.test.js
@@ -16,10 +16,10 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-import * as wanakana from 'wanakana';
 import {expect, test} from 'vitest';
 import {TextSourceMap} from '../ext/js/general/text-source-map.js';
 import {JapaneseUtil} from '../ext/js/language/sandbox/japanese-util.js';
+import * as wanakana from '../ext/lib/wanakana.js';
 
 const jp = new JapaneseUtil(wanakana);
 
diff --git a/test/jsconfig.json b/test/jsconfig.json
index 934aab81..2461fda9 100644
--- a/test/jsconfig.json
+++ b/test/jsconfig.json
@@ -12,7 +12,6 @@
         "skipLibCheck": false,
         "baseUrl": ".",
         "paths": {
-            "zip.js": ["@zip.js/zip.js"],
             "*": ["../types/ext/*"],
             "dev/*": ["../types/dev/*"],
             "test/*": ["../types/test/*"]
-- 
cgit v1.2.3


From ec67de5c0c4abc11232d3f3a8a8e9bb2fe045daa Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Mon, 27 Nov 2023 23:05:37 -0500
Subject: Update library types

---
 ext/js/display/search-main.js                 | 4 +---
 ext/js/language/sandbox/japanese-util.js      | 4 ++--
 ext/js/templates/sandbox/template-renderer.js | 2 +-
 3 files changed, 4 insertions(+), 6 deletions(-)

(limited to 'ext/js/language')

diff --git a/ext/js/display/search-main.js b/ext/js/display/search-main.js
index c20cc135..5eee08d1 100644
--- a/ext/js/display/search-main.js
+++ b/ext/js/display/search-main.js
@@ -44,9 +44,7 @@ import {SearchPersistentStateController} from './search-persistent-state-control
 
         const {tabId, frameId} = await yomitan.api.frameInformationGet();
 
-        /** @type {import('wanakana')} */
-        const wanakanaLib = wanakana;
-        const japaneseUtil = new JapaneseUtil(wanakanaLib);
+        const japaneseUtil = new JapaneseUtil(wanakana);
 
         const hotkeyHandler = new HotkeyHandler();
         hotkeyHandler.prepare();
diff --git a/ext/js/language/sandbox/japanese-util.js b/ext/js/language/sandbox/japanese-util.js
index 4c9c46bd..6f4fc8e0 100644
--- a/ext/js/language/sandbox/japanese-util.js
+++ b/ext/js/language/sandbox/japanese-util.js
@@ -233,11 +233,11 @@ function getProlongedHiragana(previousCharacter) {
 
 export class JapaneseUtil {
     /**
-     * @param {?import('wanakana')} wanakana
+     * @param {?import('wanakana')|import('../../../lib/wanakana.js')} wanakana
      */
     constructor(wanakana=null) {
         /** @type {?import('wanakana')} */
-        this._wanakana = wanakana;
+        this._wanakana = /** @type {import('wanakana')} */ (wanakana);
     }
 
     // Character code testing functions
diff --git a/ext/js/templates/sandbox/template-renderer.js b/ext/js/templates/sandbox/template-renderer.js
index fe240b5f..239240b6 100644
--- a/ext/js/templates/sandbox/template-renderer.js
+++ b/ext/js/templates/sandbox/template-renderer.js
@@ -197,7 +197,7 @@ export class TemplateRenderer {
          */
         function wrapper(...args) {
             const argCountM1 = Math.max(0, args.length - 1);
-            const options = /** @type {Handlebars.HelperOptions} */ (args[argCountM1]);
+            const options = /** @type {import('handlebars').HelperOptions} */ (args[argCountM1]);
             args.length = argCountM1;
             return helper(args, this, options);
         }
-- 
cgit v1.2.3


From 083b4749139213c6fefe80166d73f54604a85267 Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Sun, 3 Dec 2023 10:45:08 -0500
Subject: Fix some import orderings

---
 dev/bin/build.js                       | 4 ++--
 ext/js/background/offscreen-proxy.js   | 2 +-
 ext/js/language/dictionary-importer.js | 2 +-
 test/database.test.js                  | 2 +-
 test/deinflector.test.js               | 2 +-
 test/dom-text-scanner.test.js          | 2 +-
 6 files changed, 7 insertions(+), 7 deletions(-)

(limited to 'ext/js/language')

diff --git a/dev/bin/build.js b/dev/bin/build.js
index 47c08f3c..deb82618 100644
--- a/dev/bin/build.js
+++ b/dev/bin/build.js
@@ -19,10 +19,10 @@
 import assert from 'assert';
 import childProcess from 'child_process';
 import fs from 'fs';
-import path from 'path';
-import readline from 'readline';
 import JSZip from 'jszip';
 import {fileURLToPath} from 'node:url';
+import path from 'path';
+import readline from 'readline';
 import {buildLibs} from '../build-libs.js';
 import {ManifestUtil} from '../manifest-util.js';
 import {getAllFiles, getArgs, testMain} from '../util.js';
diff --git a/ext/js/background/offscreen-proxy.js b/ext/js/background/offscreen-proxy.js
index 757d78d5..7b504855 100644
--- a/ext/js/background/offscreen-proxy.js
+++ b/ext/js/background/offscreen-proxy.js
@@ -17,8 +17,8 @@
  */
 
 import {isObject} from '../core.js';
-import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
 import {ExtensionError} from '../core/extension-error.js';
+import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
 
 export class OffscreenProxy {
     constructor() {
diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js
index 115e0726..08fcf86b 100644
--- a/ext/js/language/dictionary-importer.js
+++ b/ext/js/language/dictionary-importer.js
@@ -25,8 +25,8 @@ import {
     configure
 } from '../../lib/zip.js';
 import {stringReverse} from '../core.js';
-import {MediaUtil} from '../media/media-util.js';
 import {ExtensionError} from '../core/extension-error.js';
+import {MediaUtil} from '../media/media-util.js';
 
 const ajvSchemas = /** @type {import('dictionary-importer').CompiledSchemaValidators} */ (/** @type {unknown} */ (ajvSchemas0));
 const BlobWriter = /** @type {typeof import('@zip.js/zip.js').BlobWriter} */ (/** @type {unknown} */ (BlobWriter0));
diff --git a/test/database.test.js b/test/database.test.js
index 30854d55..80871f95 100644
--- a/test/database.test.js
+++ b/test/database.test.js
@@ -17,8 +17,8 @@
  */
 
 import {IDBFactory, IDBKeyRange} from 'fake-indexeddb';
-import path from 'path';
 import {fileURLToPath} from 'node:url';
+import path from 'path';
 import {beforeEach, describe, expect, test, vi} from 'vitest';
 import {createDictionaryArchive} from '../dev/util.js';
 import {DictionaryDatabase} from '../ext/js/language/dictionary-database.js';
diff --git a/test/deinflector.test.js b/test/deinflector.test.js
index a69f8e56..bd538428 100644
--- a/test/deinflector.test.js
+++ b/test/deinflector.test.js
@@ -17,10 +17,10 @@
  */
 
 import fs from 'fs';
+import {fileURLToPath} from 'node:url';
 import path from 'path';
 import {describe, expect, test} from 'vitest';
 import {Deinflector} from '../ext/js/language/deinflector.js';
-import {fileURLToPath} from 'node:url';
 
 const dirname = path.dirname(fileURLToPath(import.meta.url));
 
diff --git a/test/dom-text-scanner.test.js b/test/dom-text-scanner.test.js
index 30aec33e..f6a7410a 100644
--- a/test/dom-text-scanner.test.js
+++ b/test/dom-text-scanner.test.js
@@ -18,8 +18,8 @@
 
 import fs from 'fs';
 import {JSDOM} from 'jsdom';
-import path from 'path';
 import {fileURLToPath} from 'node:url';
+import path from 'path';
 import {expect, test} from 'vitest';
 import {DOMTextScanner} from '../ext/js/dom/dom-text-scanner.js';
 
-- 
cgit v1.2.3