diff options
Diffstat (limited to 'ext/js/data')
-rw-r--r-- | ext/js/data/anki-note-builder.js | 144 | ||||
-rw-r--r-- | ext/js/data/anki-util.js | 2 | ||||
-rw-r--r-- | ext/js/data/database.js | 206 | ||||
-rw-r--r-- | ext/js/data/json-schema.js | 927 | ||||
-rw-r--r-- | ext/js/data/options-util.js | 192 | ||||
-rw-r--r-- | ext/js/data/permissions-util.js | 22 | ||||
-rw-r--r-- | ext/js/data/sandbox/anki-note-data-creator.js | 245 |
7 files changed, 1374 insertions, 364 deletions
diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js index 398036c0..4920db39 100644 --- a/ext/js/data/anki-note-builder.js +++ b/ext/js/data/anki-note-builder.js @@ -16,20 +16,33 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deferPromise, deserializeError} from '../core.js'; +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'; export class AnkiNoteBuilder { + /** + * @param {{japaneseUtil: import('../language/sandbox/japanese-util.js').JapaneseUtil}} details + */ constructor({japaneseUtil}) { + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {RegExp} */ this._markerPattern = AnkiUtil.cloneFieldMarkerPattern(true); + /** @type {TemplateRendererProxy} */ this._templateRenderer = new TemplateRendererProxy(); + /** @type {import('anki-note-builder').BatchedRequestGroup[]} */ this._batchedRequests = []; + /** @type {boolean} */ this._batchedRequestsQueued = false; } + /** + * @param {import('anki-note-builder').CreateNoteDetails} details + * @returns {Promise<import('anki-note-builder').CreateNoteResult>} + */ async createNote({ dictionaryEntry, mode, @@ -56,16 +69,15 @@ export class AnkiNoteBuilder { duplicateScopeCheckChildren = true; } + /** @type {Error[]} */ const allErrors = []; let media; if (requirements.length > 0 && mediaOptions !== null) { let errors; ({media, errors} = await this._injectMedia(dictionaryEntry, requirements, mediaOptions)); for (const error of errors) { - allErrors.push(deserializeError(error)); + allErrors.push(ExtensionError.deserialize(error)); } - } else { - media = {}; } const commonData = this._createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, media); @@ -77,6 +89,7 @@ export class AnkiNoteBuilder { const formattedFieldValues = await Promise.all(formattedFieldValuePromises); const uniqueRequirements = new Map(); + /** @type {import('anki').NoteFields} */ const noteFields = {}; for (let i = 0, ii = fields.length; i < ii; ++i) { const fieldName = fields[i][0]; @@ -90,6 +103,7 @@ export class AnkiNoteBuilder { } } + /** @type {import('anki').Note} */ const note = { fields: noteFields, tags, @@ -108,6 +122,10 @@ export class AnkiNoteBuilder { return {note, errors: allErrors, requirements: [...uniqueRequirements.values()]}; } + /** + * @param {import('anki-note-builder').GetRenderingDataDetails} details + * @returns {Promise<import('anki-templates').NoteData>} + */ async getRenderingData({ dictionaryEntry, mode, @@ -115,12 +133,16 @@ export class AnkiNoteBuilder { resultOutputMode='split', glossaryLayoutMode='default', compactTags=false, - marker=null + marker }) { - const commonData = this._createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, {}); + const commonData = this._createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, void 0); return await this._templateRenderer.getModifiedData({marker, commonData}, 'ankiNote'); } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {import('api').InjectAnkiNoteMediaDefinitionDetails} + */ getDictionaryEntryDetailsForNote(dictionaryEntry) { const {type} = dictionaryEntry; if (type === 'kanji') { @@ -150,6 +172,16 @@ export class AnkiNoteBuilder { // Private + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').CreateMode} mode + * @param {import('anki-templates-internal').Context} context + * @param {import('settings').ResultOutputMode} resultOutputMode + * @param {import('settings').GlossaryLayoutMode} glossaryLayoutMode + * @param {boolean} compactTags + * @param {import('anki-templates').Media|undefined} media + * @returns {import('anki-note-builder').CommonData} + */ _createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, media) { return { dictionaryEntry, @@ -162,16 +194,25 @@ export class AnkiNoteBuilder { }; } + /** + * @param {string} field + * @param {import('anki-note-builder').CommonData} commonData + * @param {string} template + * @returns {Promise<{value: string, errors: ExtensionError[], requirements: import('anki-note-builder').Requirement[]}>} + */ async _formatField(field, commonData, template) { + /** @type {ExtensionError[]} */ const errors = []; + /** @type {import('anki-note-builder').Requirement[]} */ const requirements = []; - const value = await this._stringReplaceAsync(field, this._markerPattern, async (g0, marker) => { + const value = await this._stringReplaceAsync(field, this._markerPattern, async (match) => { + const marker = match[1]; try { const {result, requirements: fieldRequirements} = await this._renderTemplateBatched(template, commonData, marker); requirements.push(...fieldRequirements); return result; } catch (e) { - const error = new Error(`Template render error for {${marker}}`); + const error = new ExtensionError(`Template render error for {${marker}}`); error.data = {error: e}; errors.push(error); return `{${marker}-render-error}`; @@ -180,12 +221,19 @@ export class AnkiNoteBuilder { return {value, errors, requirements}; } + /** + * @param {string} str + * @param {RegExp} regex + * @param {(match: RegExpExecArray, index: number, str: string) => (string|Promise<string>)} replacer + * @returns {Promise<string>} + */ async _stringReplaceAsync(str, regex, replacer) { let match; let index = 0; + /** @type {(Promise<string>|string)[]} */ const parts = []; while ((match = regex.exec(str)) !== null) { - parts.push(str.substring(index, match.index), replacer(...match, match.index, str)); + parts.push(str.substring(index, match.index), replacer(match, match.index, str)); index = regex.lastIndex; } if (parts.length === 0) { @@ -195,6 +243,10 @@ export class AnkiNoteBuilder { return (await Promise.all(parts)).join(''); } + /** + * @param {string} template + * @returns {import('anki-note-builder').BatchedRequestGroup} + */ _getBatchedTemplateGroup(template) { for (const item of this._batchedRequests) { if (item.template === template) { @@ -207,7 +259,14 @@ export class AnkiNoteBuilder { return result; } + /** + * @param {string} template + * @param {import('anki-note-builder').CommonData} commonData + * @param {string} marker + * @returns {Promise<import('template-renderer').RenderResult>} + */ _renderTemplateBatched(template, commonData, marker) { + /** @type {import('core').DeferredPromiseDetails<import('template-renderer').RenderResult>} */ const {promise, resolve, reject} = deferPromise(); const {commonDataRequestsMap} = this._getBatchedTemplateGroup(template); let requests = commonDataRequestsMap.get(commonData); @@ -220,6 +279,9 @@ export class AnkiNoteBuilder { return promise; } + /** + * @returns {void} + */ _runBatchedRequestsDelayed() { if (this._batchedRequestsQueued) { return; } this._batchedRequestsQueued = true; @@ -229,20 +291,30 @@ export class AnkiNoteBuilder { }); } + /** + * @returns {void} + */ _runBatchedRequests() { if (this._batchedRequests.length === 0) { return; } const allRequests = []; + /** @type {import('template-renderer').RenderMultiItem[]} */ const items = []; for (const {template, commonDataRequestsMap} of this._batchedRequests) { + /** @type {import('template-renderer').RenderMultiTemplateItem[]} */ const templateItems = []; for (const [commonData, requests] of commonDataRequestsMap.entries()) { + /** @type {import('template-renderer').PartialOrCompositeRenderData[]} */ const datas = []; for (const {marker} of requests) { - datas.push(marker); + datas.push({marker}); } allRequests.push(...requests); - templateItems.push({type: 'ankiNote', commonData, datas}); + templateItems.push({ + type: /** @type {import('anki-templates').RenderMode} */ ('ankiNote'), + commonData, + datas + }); } items.push({template, templateItems}); } @@ -252,6 +324,10 @@ export class AnkiNoteBuilder { this._resolveBatchedRequests(items, allRequests); } + /** + * @param {import('template-renderer').RenderMultiItem[]} items + * @param {import('anki-note-builder').BatchedRequestData[]} requests + */ async _resolveBatchedRequests(items, requests) { let responses; try { @@ -269,7 +345,7 @@ export class AnkiNoteBuilder { const response = responses[i]; const {error} = response; if (typeof error !== 'undefined') { - throw deserializeError(error); + throw ExtensionError.deserialize(error); } else { request.resolve(response.result); } @@ -279,6 +355,12 @@ export class AnkiNoteBuilder { } } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-note-builder').Requirement[]} requirements + * @param {import('anki-note-builder').MediaOptions} mediaOptions + * @returns {Promise<{media: import('anki-templates').Media, errors: import('core').SerializedError[]}>} + */ async _injectMedia(dictionaryEntry, requirements, mediaOptions) { const timestamp = Date.now(); @@ -288,7 +370,9 @@ export class AnkiNoteBuilder { let injectClipboardImage = false; let injectClipboardText = false; let injectSelectionText = false; + /** @type {import('anki-note-builder').TextFuriganaDetails[]} */ const textFuriganaDetails = []; + /** @type {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} */ const dictionaryMediaDetails = []; for (const requirement of requirements) { const {type} = requirement; @@ -315,8 +399,11 @@ export class AnkiNoteBuilder { // Generate request data const dictionaryEntryDetails = this.getDictionaryEntryDetailsForNote(dictionaryEntry); + /** @type {?import('api').InjectAnkiNoteMediaAudioDetails} */ let audioDetails = null; + /** @type {?import('api').InjectAnkiNoteMediaScreenshotDetails} */ let screenshotDetails = null; + /** @type {import('api').InjectAnkiNoteMediaClipboardDetails} */ const clipboardDetails = {image: injectClipboardImage, text: injectClipboardText}; if (injectAudio && dictionaryEntryDetails.type !== 'kanji') { const audioOptions = mediaOptions.audio; @@ -357,6 +444,7 @@ export class AnkiNoteBuilder { const textFurigana = textFuriganaPromise !== null ? await textFuriganaPromise : []; // Format results + /** @type {import('anki-templates').DictionaryMedia} */ const dictionaryMedia = {}; for (const {dictionary, path, fileName} of dictionaryMediaArray) { if (fileName === null) { continue; } @@ -368,21 +456,31 @@ export class AnkiNoteBuilder { dictionaryMedia2[path] = {value: fileName}; } const media = { - audio: (typeof audioFileName === 'string' ? {value: audioFileName} : null), - screenshot: (typeof screenshotFileName === 'string' ? {value: screenshotFileName} : null), - clipboardImage: (typeof clipboardImageFileName === 'string' ? {value: clipboardImageFileName} : null), - clipboardText: (typeof clipboardText === 'string' ? {value: clipboardText} : null), - selectionText: (typeof selectionText === 'string' ? {value: selectionText} : null), + audio: (typeof audioFileName === 'string' ? {value: audioFileName} : void 0), + screenshot: (typeof screenshotFileName === 'string' ? {value: screenshotFileName} : void 0), + clipboardImage: (typeof clipboardImageFileName === 'string' ? {value: clipboardImageFileName} : void 0), + clipboardText: (typeof clipboardText === 'string' ? {value: clipboardText} : void 0), + selectionText: (typeof selectionText === 'string' ? {value: selectionText} : void 0), textFurigana, dictionaryMedia }; return {media, errors}; } + /** + * @returns {string} + */ _getSelectionText() { - return document.getSelection().toString(); + const selection = document.getSelection(); + return selection !== null ? selection.toString() : ''; } + /** + * @param {import('anki-note-builder').TextFuriganaDetails[]} entries + * @param {import('settings').OptionsContext} optionsContext + * @param {number} scanLength + * @returns {Promise<import('anki-templates').TextFuriganaSegment[]>} + */ async _getTextFurigana(entries, optionsContext, scanLength) { const results = []; for (const {text, readingMode} of entries) { @@ -401,6 +499,11 @@ export class AnkiNoteBuilder { return results; } + /** + * @param {import('api').ParseTextLine[]} data + * @param {?import('anki-templates').TextFuriganaReadingMode} readingMode + * @returns {string} + */ _createFuriganaHtml(data, readingMode) { let result = ''; for (const term of data) { @@ -418,6 +521,11 @@ export class AnkiNoteBuilder { return result; } + /** + * @param {string} reading + * @param {?import('anki-templates').TextFuriganaReadingMode} readingMode + * @returns {string} + */ _convertReading(reading, readingMode) { switch (readingMode) { case 'hiragana': diff --git a/ext/js/data/anki-util.js b/ext/js/data/anki-util.js index c08b562e..1d5272a6 100644 --- a/ext/js/data/anki-util.js +++ b/ext/js/data/anki-util.js @@ -71,7 +71,7 @@ export class AnkiUtil { /** * Checks whether or not a note object is valid. - * @param {*} note A note object to check. + * @param {import('anki').Note} note A note object to check. * @returns {boolean} `true` if the note is valid, `false` otherwise. */ static isNoteDataValid(note) { diff --git a/ext/js/data/database.js b/ext/js/data/database.js index 8e818d8b..026945ca 100644 --- a/ext/js/data/database.js +++ b/ext/js/data/database.js @@ -16,12 +16,22 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/** + * @template {string} TObjectStoreName + */ export class Database { constructor() { + /** @type {?IDBDatabase} */ this._db = null; + /** @type {boolean} */ this._isOpening = false; } + /** + * @param {string} databaseName + * @param {number} version + * @param {import('database').StructureDefinition<TObjectStoreName>[]} structure + */ async open(databaseName, version, structure) { if (this._db !== null) { throw new Error('Database already open'); @@ -40,6 +50,9 @@ export class Database { } } + /** + * @throws {Error} + */ close() { if (this._db === null) { throw new Error('Database is not open'); @@ -49,14 +62,26 @@ export class Database { this._db = null; } + /** + * @returns {boolean} + */ isOpening() { return this._isOpening; } + /** + * @returns {boolean} + */ isOpen() { return this._db !== null; } + /** + * @param {string[]} storeNames + * @param {IDBTransactionMode} mode + * @returns {IDBTransaction} + * @throws {Error} + */ transaction(storeNames, mode) { if (this._db === null) { throw new Error(this._isOpening ? 'Database not ready' : 'Database not open'); @@ -64,6 +89,13 @@ export class Database { return this._db.transaction(storeNames, mode); } + /** + * @param {TObjectStoreName} objectStoreName + * @param {unknown[]} items + * @param {number} start + * @param {number} count + * @returns {Promise<void>} + */ bulkAdd(objectStoreName, items, start, count) { return new Promise((resolve, reject) => { if (start + count > items.length) { @@ -84,6 +116,15 @@ export class Database { }); } + /** + * @template [TData=unknown] + * @template [TResult=unknown] + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {?IDBValidKey|IDBKeyRange} query + * @param {(results: TResult[], data: TData) => void} onSuccess + * @param {(reason: unknown, data: TData) => void} onError + * @param {TData} data + */ getAll(objectStoreOrIndex, query, onSuccess, onError, data) { if (typeof objectStoreOrIndex.getAll === 'function') { this._getAllFast(objectStoreOrIndex, query, onSuccess, onError, data); @@ -92,6 +133,12 @@ export class Database { } } + /** + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {IDBValidKey|IDBKeyRange} query + * @param {(value: IDBValidKey[]) => void} onSuccess + * @param {(reason?: unknown) => void} onError + */ getAllKeys(objectStoreOrIndex, query, onSuccess, onError) { if (typeof objectStoreOrIndex.getAllKeys === 'function') { this._getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError); @@ -100,6 +147,18 @@ export class Database { } } + /** + * @template TPredicateArg + * @template [TResult=unknown] + * @template [TResultDefault=unknown] + * @param {TObjectStoreName} objectStoreName + * @param {?string} indexName + * @param {?IDBValidKey|IDBKeyRange} query + * @param {?((value: TResult|TResultDefault, predicateArg: TPredicateArg) => boolean)} predicate + * @param {TPredicateArg} predicateArg + * @param {TResultDefault} defaultValue + * @returns {Promise<TResult|TResultDefault>} + */ find(objectStoreName, indexName, query, predicate, predicateArg, defaultValue) { return new Promise((resolve, reject) => { const transaction = this.transaction([objectStoreName], 'readonly'); @@ -109,12 +168,26 @@ export class Database { }); } + /** + * @template TData + * @template TPredicateArg + * @template [TResult=unknown] + * @template [TResultDefault=unknown] + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {?IDBValidKey|IDBKeyRange} query + * @param {(value: TResult|TResultDefault, data: TData) => void} resolve + * @param {(reason: unknown, data: TData) => void} reject + * @param {TData} data + * @param {?((value: TResult, predicateArg: TPredicateArg) => boolean)} predicate + * @param {TPredicateArg} predicateArg + * @param {TResultDefault} defaultValue + */ findFirst(objectStoreOrIndex, query, resolve, reject, data, predicate, predicateArg, defaultValue) { const noPredicate = (typeof predicate !== 'function'); const request = objectStoreOrIndex.openCursor(query, 'next'); - request.onerror = (e) => reject(e.target.error, data); + request.onerror = (e) => reject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data); request.onsuccess = (e) => { - const cursor = e.target.result; + const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result; if (cursor) { const {value} = cursor; if (noPredicate || predicate(value, predicateArg)) { @@ -128,19 +201,33 @@ export class Database { }; } + /** + * @param {import('database').CountTarget[]} targets + * @param {(results: number[]) => void} resolve + * @param {(reason?: unknown) => void} reject + */ bulkCount(targets, resolve, reject) { const targetCount = targets.length; if (targetCount <= 0) { - resolve(); + resolve([]); return; } let completedCount = 0; + /** @type {number[]} */ const results = new Array(targetCount).fill(null); - const onError = (e) => reject(e.target.error); + /** + * @param {Event} e + * @returns {void} + */ + const onError = (e) => reject(/** @type {IDBRequest<number>} */ (e.target).error); + /** + * @param {Event} e + * @param {number} index + */ const onSuccess = (e, index) => { - const count = e.target.result; + const count = /** @type {IDBRequest<number>} */ (e.target).result; results[index] = count; if (++completedCount >= targetCount) { resolve(results); @@ -156,6 +243,11 @@ export class Database { } } + /** + * @param {TObjectStoreName} objectStoreName + * @param {IDBValidKey|IDBKeyRange} key + * @returns {Promise<void>} + */ delete(objectStoreName, key) { return new Promise((resolve, reject) => { const transaction = this._readWriteTransaction([objectStoreName], resolve, reject); @@ -165,12 +257,23 @@ export class Database { }); } + /** + * @param {TObjectStoreName} objectStoreName + * @param {?string} indexName + * @param {IDBKeyRange} query + * @param {?(keys: IDBValidKey[]) => IDBValidKey[]} filterKeys + * @param {?(completedCount: number, totalCount: number) => void} onProgress + * @returns {Promise<void>} + */ bulkDelete(objectStoreName, indexName, query, filterKeys=null, onProgress=null) { return new Promise((resolve, reject) => { const transaction = this._readWriteTransaction([objectStoreName], resolve, reject); const objectStore = transaction.objectStore(objectStoreName); const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + /** + * @param {IDBValidKey[]} keys + */ const onGetKeys = (keys) => { try { if (typeof filterKeys === 'function') { @@ -187,10 +290,14 @@ export class Database { }); } + /** + * @param {string} databaseName + * @returns {Promise<void>} + */ static deleteDatabase(databaseName) { return new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase(databaseName); - request.onerror = (e) => reject(e.target.error); + request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); request.onsuccess = () => resolve(); request.onblocked = () => reject(new Error('Database deletion blocked')); }); @@ -198,24 +305,37 @@ export class Database { // Private + /** + * @param {string} name + * @param {number} version + * @param {import('database').UpdateFunction} onUpgradeNeeded + * @returns {Promise<IDBDatabase>} + */ _open(name, version, onUpgradeNeeded) { return new Promise((resolve, reject) => { const request = indexedDB.open(name, version); request.onupgradeneeded = (event) => { try { - request.transaction.onerror = (e) => reject(e.target.error); - onUpgradeNeeded(request.result, request.transaction, event.oldVersion, event.newVersion); + const transaction = /** @type {IDBTransaction} */ (request.transaction); + transaction.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); + onUpgradeNeeded(request.result, transaction, event.oldVersion, event.newVersion); } catch (e) { reject(e); } }; - request.onerror = (e) => reject(e.target.error); + request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); request.onsuccess = () => resolve(request.result); }); } + /** + * @param {IDBDatabase} db + * @param {IDBTransaction} transaction + * @param {number} oldVersion + * @param {import('database').StructureDefinition<TObjectStoreName>[]} upgrades + */ _upgrade(db, transaction, oldVersion, upgrades) { for (const {version, stores} of upgrades) { if (oldVersion >= version) { continue; } @@ -238,6 +358,11 @@ export class Database { } } + /** + * @param {DOMStringList} list + * @param {string} value + * @returns {boolean} + */ _listContains(list, value) { for (let i = 0, ii = list.length; i < ii; ++i) { if (list[i] === value) { return true; } @@ -245,18 +370,37 @@ export class Database { return false; } + /** + * @template [TData=unknown] + * @template [TResult=unknown] + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {?IDBValidKey|IDBKeyRange} query + * @param {(results: TResult[], data: TData) => void} onSuccess + * @param {(reason: unknown, data: TData) => void} onReject + * @param {TData} data + */ _getAllFast(objectStoreOrIndex, query, onSuccess, onReject, data) { const request = objectStoreOrIndex.getAll(query); - request.onerror = (e) => onReject(e.target.error, data); - request.onsuccess = (e) => onSuccess(e.target.result, data); + request.onerror = (e) => onReject(/** @type {IDBRequest<import('core').SafeAny[]>} */ (e.target).error, data); + request.onsuccess = (e) => onSuccess(/** @type {IDBRequest<import('core').SafeAny[]>} */ (e.target).result, data); } + /** + * @template [TData=unknown] + * @template [TResult=unknown] + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {?IDBValidKey|IDBKeyRange} query + * @param {(results: TResult[], data: TData) => void} onSuccess + * @param {(reason: unknown, data: TData) => void} onReject + * @param {TData} data + */ _getAllUsingCursor(objectStoreOrIndex, query, onSuccess, onReject, data) { + /** @type {TResult[]} */ const results = []; const request = objectStoreOrIndex.openCursor(query, 'next'); - request.onerror = (e) => onReject(e.target.error, data); + request.onerror = (e) => onReject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data); request.onsuccess = (e) => { - const cursor = e.target.result; + const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result; if (cursor) { results.push(cursor.value); cursor.continue(); @@ -266,18 +410,31 @@ export class Database { }; } + /** + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {IDBValidKey|IDBKeyRange} query + * @param {(value: IDBValidKey[]) => void} onSuccess + * @param {(reason?: unknown) => void} onError + */ _getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError) { const request = objectStoreOrIndex.getAllKeys(query); - request.onerror = (e) => onError(e.target.error); - request.onsuccess = (e) => onSuccess(e.target.result); + request.onerror = (e) => onError(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).error); + request.onsuccess = (e) => onSuccess(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).result); } + /** + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {IDBValidKey|IDBKeyRange} query + * @param {(value: IDBValidKey[]) => void} onSuccess + * @param {(reason?: unknown) => void} onError + */ _getAllKeysUsingCursor(objectStoreOrIndex, query, onSuccess, onError) { + /** @type {IDBValidKey[]} */ const results = []; const request = objectStoreOrIndex.openKeyCursor(query, 'next'); - request.onerror = (e) => onError(e.target.error); + request.onerror = (e) => onError(/** @type {IDBRequest<?IDBCursor>} */ (e.target).error); request.onsuccess = (e) => { - const cursor = e.target.result; + const cursor = /** @type {IDBRequest<?IDBCursor>} */ (e.target).result; if (cursor) { results.push(cursor.primaryKey); cursor.continue(); @@ -287,6 +444,11 @@ export class Database { }; } + /** + * @param {IDBObjectStore} objectStore + * @param {IDBValidKey[]} keys + * @param {?(completedCount: number, totalCount: number) => void} onProgress + */ _bulkDeleteInternal(objectStore, keys, onProgress) { const count = keys.length; if (count === 0) { return; } @@ -295,7 +457,7 @@ export class Database { const onSuccess = () => { ++completedCount; try { - onProgress(completedCount, count); + /** @type {(completedCount: number, totalCount: number) => void}} */ (onProgress)(completedCount, count); } catch (e) { // NOP } @@ -310,9 +472,15 @@ export class Database { } } + /** + * @param {string[]} storeNames + * @param {() => void} resolve + * @param {(reason?: unknown) => void} reject + * @returns {IDBTransaction} + */ _readWriteTransaction(storeNames, resolve, reject) { const transaction = this.transaction(storeNames, 'readwrite'); - transaction.onerror = (e) => reject(e.target.error); + transaction.onerror = (e) => reject(/** @type {IDBTransaction} */ (e.target).error); transaction.onabort = () => reject(new Error('Transaction aborted')); transaction.oncomplete = () => resolve(); return transaction; diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js index 93c8cd59..d63cfd1a 100644 --- a/ext/js/data/json-schema.js +++ b/ext/js/data/json-schema.js @@ -19,31 +19,70 @@ import {clone} from '../core.js'; import {CacheMap} from '../general/cache-map.js'; +export class JsonSchemaError extends Error { + /** + * @param {string} message + * @param {import('json-schema').ValueStackItem[]} valueStack + * @param {import('json-schema').SchemaStackItem[]} schemaStack + */ + constructor(message, valueStack, schemaStack) { + super(message); + /** @type {import('json-schema').ValueStackItem[]} */ + this._valueStack = valueStack; + /** @type {import('json-schema').SchemaStackItem[]} */ + this._schemaStack = schemaStack; + } + + /** @type {unknown|undefined} */ + get value() { return this._valueStack.length > 0 ? this._valueStack[this._valueStack.length - 1].value : void 0; } + + /** @type {import('json-schema').Schema|import('json-schema').Schema[]|undefined} */ + get schema() { return this._schemaStack.length > 0 ? this._schemaStack[this._schemaStack.length - 1].schema : void 0; } + + /** @type {import('json-schema').ValueStackItem[]} */ + get valueStack() { return this._valueStack; } + + /** @type {import('json-schema').SchemaStackItem[]} */ + get schemaStack() { return this._schemaStack; } +} + export class JsonSchema { + /** + * @param {import('json-schema').Schema} schema + * @param {import('json-schema').Schema} [rootSchema] + */ constructor(schema, rootSchema) { - this._schema = null; + /** @type {import('json-schema').Schema} */ this._startSchema = schema; + /** @type {import('json-schema').Schema} */ this._rootSchema = typeof rootSchema !== 'undefined' ? rootSchema : schema; + /** @type {?CacheMap<string, RegExp>} */ this._regexCache = null; + /** @type {?Map<string, {schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}>} */ this._refCache = null; + /** @type {import('json-schema').ValueStackItem[]} */ this._valueStack = []; + /** @type {import('json-schema').SchemaStackItem[]} */ this._schemaStack = []; + /** @type {?(jsonSchema: JsonSchema) => void} */ this._progress = null; + /** @type {number} */ this._progressCounter = 0; + /** @type {number} */ this._progressInterval = 1; - - this._schemaPush(null, null); - this._valuePush(null, null); } + /** @type {import('json-schema').Schema} */ get schema() { return this._startSchema; } + /** @type {import('json-schema').Schema} */ get rootSchema() { return this._rootSchema; } + /** @type {?(jsonSchema: JsonSchema) => void} */ get progress() { return this._progress; } @@ -52,6 +91,7 @@ export class JsonSchema { this._progress = value; } + /** @type {number} */ get progressInterval() { return this._progressInterval; } @@ -60,6 +100,10 @@ export class JsonSchema { this._progressInterval = value; } + /** + * @param {import('json-schema').Value} value + * @returns {import('json-schema').Value} + */ createProxy(value) { return ( typeof value === 'object' && value !== null ? @@ -68,6 +112,10 @@ export class JsonSchema { ); } + /** + * @param {unknown} value + * @returns {boolean} + */ isValid(value) { try { this.validate(value); @@ -77,123 +125,203 @@ export class JsonSchema { } } + /** + * @param {unknown} value + */ validate(value) { - this._schemaPush(this._startSchema, null); + const schema = this._startSchema; + this._schemaPush(schema, null); this._valuePush(value, null); try { - this._validate(value); + this._validate(schema, value); } finally { this._valuePop(); this._schemaPop(); } } + /** + * @param {unknown} [value] + * @returns {import('json-schema').Value} + */ getValidValueOrDefault(value) { - return this._getValidValueOrDefault(null, value, {schema: this._startSchema, path: null}); + const schema = this._startSchema; + return this._getValidValueOrDefault(schema, null, value, [{schema, path: null}]); } + /** + * @param {string} property + * @returns {?JsonSchema} + */ getObjectPropertySchema(property) { - const startSchemaInfo = this._getResolveSchemaInfo({schema: this._startSchema, path: null}); - this._schemaPush(startSchemaInfo.schema, startSchemaInfo.path); + const schema = this._startSchema; + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, [{schema, path: null}]); + this._schemaPushMultiple(stack); try { - const schemaInfo = this._getObjectPropertySchemaInfo(property); - return schemaInfo !== null ? new JsonSchema(schemaInfo.schema, this._rootSchema) : null; + const {schema: propertySchema} = this._getObjectPropertySchemaInfo(schema2, property); + return propertySchema !== false ? new JsonSchema(propertySchema, this._rootSchema) : null; } finally { - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } + /** + * @param {number} index + * @returns {?JsonSchema} + */ getArrayItemSchema(index) { - const startSchemaInfo = this._getResolveSchemaInfo({schema: this._startSchema, path: null}); - this._schemaPush(startSchemaInfo.schema, startSchemaInfo.path); + const schema = this._startSchema; + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, [{schema, path: null}]); + this._schemaPushMultiple(stack); try { - const schemaInfo = this._getArrayItemSchemaInfo(index); - return schemaInfo !== null ? new JsonSchema(schemaInfo.schema, this._rootSchema) : null; + const {schema: itemSchema} = this._getArrayItemSchemaInfo(schema2, index); + return itemSchema !== false ? new JsonSchema(itemSchema, this._rootSchema) : null; } finally { - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } + /** + * @param {string} property + * @returns {boolean} + */ isObjectPropertyRequired(property) { - const {required} = this._startSchema; + const schema = this._startSchema; + if (typeof schema === 'boolean') { return false; } + const {required} = schema; return Array.isArray(required) && required.includes(property); } // Internal state functions for error construction and progress callback + /** + * @returns {import('json-schema').ValueStackItem[]} + */ getValueStack() { - const valueStack = []; - for (let i = 1, ii = this._valueStack.length; i < ii; ++i) { - const {value, path} = this._valueStack[i]; - valueStack.push({value, path}); + const result = []; + for (const {value, path} of this._valueStack) { + result.push({value, path}); } - return valueStack; + return result; } + /** + * @returns {import('json-schema').SchemaStackItem[]} + */ getSchemaStack() { - const schemaStack = []; - for (let i = 1, ii = this._schemaStack.length; i < ii; ++i) { - const {schema, path} = this._schemaStack[i]; - schemaStack.push({schema, path}); + const result = []; + for (const {schema, path} of this._schemaStack) { + result.push({schema, path}); } - return schemaStack; + return result; } + /** + * @returns {number} + */ getValueStackLength() { return this._valueStack.length - 1; } + /** + * @param {number} index + * @returns {import('json-schema').ValueStackItem} + */ getValueStackItem(index) { const {value, path} = this._valueStack[index + 1]; return {value, path}; } + /** + * @returns {number} + */ getSchemaStackLength() { return this._schemaStack.length - 1; } + /** + * @param {number} index + * @returns {import('json-schema').SchemaStackItem} + */ getSchemaStackItem(index) { const {schema, path} = this._schemaStack[index + 1]; return {schema, path}; } + /** + * @template T + * @param {T} value + * @returns {T} + */ + static clone(value) { + return clone(value); + } + // Stack + /** + * @param {unknown} value + * @param {string|number|null} path + */ _valuePush(value, path) { this._valueStack.push({value, path}); } + /** + * @returns {void} + */ _valuePop() { this._valueStack.pop(); } + /** + * @param {import('json-schema').Schema|import('json-schema').Schema[]} schema + * @param {string|number|null} path + */ _schemaPush(schema, path) { this._schemaStack.push({schema, path}); - this._schema = schema; } + /** + * @param {import('json-schema').SchemaStackItem[]} items + */ + _schemaPushMultiple(items) { + this._schemaStack.push(...items); + } + + /** + * @returns {void} + */ _schemaPop() { this._schemaStack.pop(); - this._schema = this._schemaStack[this._schemaStack.length - 1].schema; + } + + /** + * @param {number} count + */ + _schemaPopMultiple(count) { + for (let i = 0; i < count; ++i) { + this._schemaStack.pop(); + } } // Private + /** + * @param {string} message + * @returns {JsonSchemaError} + */ _createError(message) { const valueStack = this.getValueStack(); const schemaStack = this.getSchemaStack(); - const error = new Error(message); - error.value = valueStack[valueStack.length - 1].value; - error.schema = schemaStack[schemaStack.length - 1].schema; - error.valueStack = valueStack; - error.schemaStack = schemaStack; - return error; - } - - _isObject(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); + return new JsonSchemaError(message, valueStack, schemaStack); } + /** + * @param {string} pattern + * @param {string} flags + * @returns {RegExp} + */ _getRegex(pattern, flags) { if (this._regexCache === null) { this._regexCache = new CacheMap(100); @@ -208,81 +336,125 @@ export class JsonSchema { return regex; } - _getUnconstrainedSchema() { - return {}; - } - - _getObjectPropertySchemaInfo(property) { - const {properties} = this._schema; - if (this._isObject(properties)) { + /** + * @param {import('json-schema').Schema} schema + * @param {string} property + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getObjectPropertySchemaInfo(schema, property) { + if (typeof schema === 'boolean') { + return {schema, stack: [{schema, path: null}]}; + } + const {properties} = schema; + if (typeof properties !== 'undefined' && Object.prototype.hasOwnProperty.call(properties, property)) { const propertySchema = properties[property]; - if (this._isObject(propertySchema)) { - return {schema: propertySchema, path: ['properties', property]}; + if (typeof propertySchema !== 'undefined') { + return { + schema: propertySchema, + stack: [ + {schema: properties, path: 'properties'}, + {schema: propertySchema, path: property} + ] + }; } } - - const {additionalProperties} = this._schema; - if (additionalProperties === false) { - return null; - } else if (this._isObject(additionalProperties)) { - return {schema: additionalProperties, path: 'additionalProperties'}; - } else { - const result = this._getUnconstrainedSchema(); - return {schema: result, path: null}; - } - } - - _getArrayItemSchemaInfo(index) { - const {items} = this._schema; - if (this._isObject(items)) { - return {schema: items, path: 'items'}; - } - if (Array.isArray(items)) { - if (index >= 0 && index < items.length) { - const propertySchema = items[index]; - if (this._isObject(propertySchema)) { - return {schema: propertySchema, path: ['items', index]}; + return this._getOptionalSchemaInfo(schema.additionalProperties, 'additionalProperties'); + } + + /** + * @param {import('json-schema').Schema} schema + * @param {number} index + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getArrayItemSchemaInfo(schema, index) { + if (typeof schema === 'boolean') { + return {schema, stack: [{schema, path: null}]}; + } + const {prefixItems} = schema; + if (typeof prefixItems !== 'undefined') { + if (index >= 0 && index < prefixItems.length) { + const itemSchema = prefixItems[index]; + if (typeof itemSchema !== 'undefined') { + return { + schema: itemSchema, + stack: [ + {schema: prefixItems, path: 'prefixItems'}, + {schema: itemSchema, path: index} + ] + }; } } } - - const {additionalItems} = this._schema; - if (additionalItems === false) { - return null; - } else if (this._isObject(additionalItems)) { - return {schema: additionalItems, path: 'additionalItems'}; - } else { - const result = this._getUnconstrainedSchema(); - return {schema: result, path: null}; - } - } - - _getSchemaOrValueType(value) { - const {type} = this._schema; - - if (Array.isArray(type)) { - if (typeof value !== 'undefined') { - const valueType = this._getValueType(value); - if (type.indexOf(valueType) >= 0) { - return valueType; + const {items} = schema; + if (typeof items !== 'undefined') { + if (Array.isArray(items)) { // Legacy schema format + if (index >= 0 && index < items.length) { + const itemSchema = items[index]; + if (typeof itemSchema !== 'undefined') { + return { + schema: itemSchema, + stack: [ + {schema: items, path: 'items'}, + {schema: itemSchema, path: index} + ] + }; + } } + } else { + return { + schema: items, + stack: [{schema: items, path: 'items'}] + }; } - return null; } + return this._getOptionalSchemaInfo(schema.additionalItems, 'additionalItems'); + } - if (typeof type !== 'undefined') { return type; } - return (typeof value !== 'undefined') ? this._getValueType(value) : null; + /** + * @param {import('json-schema').Schema|undefined} schema + * @param {string|number|null} path + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getOptionalSchemaInfo(schema, path) { + switch (typeof schema) { + case 'boolean': + case 'object': + break; + default: + schema = true; + path = null; + break; + } + return {schema, stack: [{schema, path}]}; } + /** + * @param {unknown} value + * @returns {?import('json-schema').Type} + * @throws {Error} + */ _getValueType(value) { const type = typeof value; - if (type === 'object') { - if (value === null) { return 'null'; } - if (Array.isArray(value)) { return 'array'; } + switch (type) { + case 'object': + if (value === null) { return 'null'; } + if (Array.isArray(value)) { return 'array'; } + return 'object'; + case 'string': + case 'number': + case 'boolean': + return type; + default: + return null; } - return type; } + /** + * @param {unknown} value + * @param {?import('json-schema').Type} type + * @param {import('json-schema').Type|import('json-schema').Type[]|undefined} schemaTypes + * @returns {boolean} + */ _isValueTypeAny(value, type, schemaTypes) { if (typeof schemaTypes === 'string') { return this._isValueType(value, type, schemaTypes); @@ -297,13 +469,24 @@ export class JsonSchema { return true; } + /** + * @param {unknown} value + * @param {?import('json-schema').Type} type + * @param {import('json-schema').Type} schemaType + * @returns {boolean} + */ _isValueType(value, type, schemaType) { return ( type === schemaType || - (schemaType === 'integer' && Math.floor(value) === value) + (schemaType === 'integer' && typeof value === 'number' && Math.floor(value) === value) ); } + /** + * @param {unknown} value1 + * @param {import('json-schema').Value[]} valueList + * @returns {boolean} + */ _valuesAreEqualAny(value1, valueList) { for (const value2 of valueList) { if (this._valuesAreEqual(value1, value2)) { @@ -313,29 +496,45 @@ export class JsonSchema { return false; } + /** + * @param {unknown} value1 + * @param {import('json-schema').Value} value2 + * @returns {boolean} + */ _valuesAreEqual(value1, value2) { return value1 === value2; } - _getResolveSchemaInfo(schemaInfo) { - const ref = schemaInfo.schema.$ref; - if (typeof ref !== 'string') { return schemaInfo; } - - const {path: basePath} = schemaInfo; - const {schema, path} = this._getReference(ref); - if (Array.isArray(basePath)) { - path.unshift(...basePath); - } else { - path.unshift(basePath); + /** + * @param {import('json-schema').Schema} schema + * @param {import('json-schema').SchemaStackItem[]} stack + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getResolvedSchemaInfo(schema, stack) { + if (typeof schema !== 'boolean') { + const ref = schema.$ref; + if (typeof ref === 'string') { + const {schema: schema2, stack: stack2} = this._getReference(ref); + return { + schema: schema2, + stack: [...stack, ...stack2] + }; + } } - return {schema, path}; + return {schema, stack}; } + /** + * @param {string} ref + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + * @throws {Error} + */ _getReference(ref) { if (!ref.startsWith('#/')) { throw this._createError(`Unsupported reference path: ${ref}`); } + /** @type {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}|undefined} */ let info; if (this._refCache !== null) { info = this._refCache.get(ref); @@ -348,12 +547,20 @@ export class JsonSchema { this._refCache.set(ref, info); } - return {schema: info.schema, path: [...info.path]}; + info.stack = this._copySchemaStack(info.stack); + return info; } + /** + * @param {string} ref + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + * @throws {Error} + */ _getReferenceUncached(ref) { + /** @type {Set<string>} */ const visited = new Set(); - const path = []; + /** @type {import('json-schema').SchemaStackItem[]} */ + const stack = []; while (true) { if (visited.has(ref)) { throw this._createError(`Recursive reference: ${ref}`); @@ -362,106 +569,139 @@ export class JsonSchema { const pathParts = ref.substring(2).split('/'); let schema = this._rootSchema; - try { - for (const pathPart of pathParts) { - schema = schema[pathPart]; + stack.push({schema, path: null}); + for (const pathPart of pathParts) { + if (!(typeof schema === 'object' && schema !== null && Object.prototype.hasOwnProperty.call(schema, pathPart))) { + throw this._createError(`Invalid reference: ${ref}`); } - } catch (e) { - throw this._createError(`Invalid reference: ${ref}`); + const schemaNext = /** @type {import('core').UnknownObject} */ (schema)[pathPart]; + if (!(typeof schemaNext === 'boolean' || (typeof schemaNext === 'object' && schemaNext !== null))) { + throw this._createError(`Invalid reference: ${ref}`); + } + schema = schemaNext; + stack.push({schema, path: pathPart}); } - if (!this._isObject(schema)) { + if (Array.isArray(schema)) { throw this._createError(`Invalid reference: ${ref}`); } - path.push(null, ...pathParts); - - ref = schema.$ref; - if (typeof ref !== 'string') { - return {schema, path}; + const refNext = typeof schema === 'object' && schema !== null ? schema.$ref : void 0; + if (typeof refNext !== 'string') { + return {schema, stack}; } + ref = refNext; + } + } + + /** + * @param {import('json-schema').SchemaStackItem[]} schemaStack + * @returns {import('json-schema').SchemaStackItem[]} + */ + _copySchemaStack(schemaStack) { + /** @type {import('json-schema').SchemaStackItem[]} */ + const results = []; + for (const {schema, path} of schemaStack) { + results.push({schema, path}); } + return results; } // Validation - _isValidCurrent(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @returns {boolean} + */ + _isValidCurrent(schema, value) { try { - this._validate(value); + this._validate(schema, value); return true; } catch (e) { return false; } } - _validate(value) { + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + */ + _validate(schema, value) { if (this._progress !== null) { const counter = (this._progressCounter + 1) % this._progressInterval; this._progressCounter = counter; if (counter === 0) { this._progress(this); } } - const ref = this._schema.$ref; - const schemaInfo = (typeof ref === 'string') ? this._getReference(ref) : null; - - if (schemaInfo === null) { - this._validateInner(value); - } else { - this._schemaPush(schemaInfo.schema, schemaInfo.path); - try { - this._validateInner(value); - } finally { - this._schemaPop(); - } - } - } - - _validateInner(value) { - this._validateSingleSchema(value); - this._validateConditional(value); - this._validateAllOf(value); - this._validateAnyOf(value); - this._validateOneOf(value); - this._validateNoneOf(value); - } - - _validateConditional(value) { - const ifSchema = this._schema.if; - if (!this._isObject(ifSchema)) { return; } + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, []); + this._schemaPushMultiple(stack); + try { + this._validateInner(schema2, value); + } finally { + this._schemaPopMultiple(stack.length); + } + } + + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + * @throws {Error} + */ + _validateInner(schema, value) { + if (schema === true) { return; } + if (schema === false) { throw this._createError('False schema'); } + this._validateSingleSchema(schema, value); + this._validateConditional(schema, value); + this._validateAllOf(schema, value); + this._validateAnyOf(schema, value); + this._validateOneOf(schema, value); + this._validateNot(schema, value); + } + + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateConditional(schema, value) { + const ifSchema = schema.if; + if (typeof ifSchema === 'undefined') { return; } let okay = true; this._schemaPush(ifSchema, 'if'); try { - this._validate(value); + this._validate(ifSchema, value); } catch (e) { okay = false; } finally { this._schemaPop(); } - const nextSchema = okay ? this._schema.then : this._schema.else; - if (this._isObject(nextSchema)) { return; } + const nextSchema = okay ? schema.then : schema.else; + if (typeof nextSchema === 'undefined') { return; } this._schemaPush(nextSchema, okay ? 'then' : 'else'); try { - this._validate(value); + this._validate(nextSchema, value); } finally { this._schemaPop(); } } - _validateAllOf(value) { - const subSchemas = this._schema.allOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateAllOf(schema, value) { + const subSchemas = schema.allOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'allOf'); try { for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); } finally { this._schemaPop(); } @@ -471,19 +711,21 @@ export class JsonSchema { } } - _validateAnyOf(value) { - const subSchemas = this._schema.anyOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateAnyOf(schema, value) { + const subSchemas = schema.anyOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'anyOf'); try { for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); return; } catch (e) { // NOP @@ -498,8 +740,12 @@ export class JsonSchema { } } - _validateOneOf(value) { - const subSchemas = this._schema.oneOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateOneOf(schema, value) { + const subSchemas = schema.oneOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'oneOf'); @@ -507,11 +753,9 @@ export class JsonSchema { let count = 0; for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); ++count; } catch (e) { // NOP @@ -528,33 +772,37 @@ export class JsonSchema { } } - _validateNoneOf(value) { - const subSchemas = this._schema.not; - if (!Array.isArray(subSchemas)) { return; } + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @throws {Error} + */ + _validateNot(schema, value) { + const notSchema = schema.not; + if (typeof notSchema === 'undefined') { return; } - this._schemaPush(subSchemas, 'not'); - try { - for (let i = 0, ii = subSchemas.length; i < ii; ++i) { - const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } + if (Array.isArray(notSchema)) { + throw this._createError('not schema is an array'); + } - this._schemaPush(subSchema, i); - try { - this._validate(value); - } catch (e) { - continue; - } finally { - this._schemaPop(); - } - throw this._createError(`not[${i}] schema matched`); - } + this._schemaPush(notSchema, 'not'); + try { + this._validate(notSchema, value); + } catch (e) { + return; } finally { this._schemaPop(); } + throw this._createError('not schema matched'); } - _validateSingleSchema(value) { - const {type: schemaType, const: schemaConst, enum: schemaEnum} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @throws {Error} + */ + _validateSingleSchema(schema, value) { + const {type: schemaType, const: schemaConst, enum: schemaEnum} = schema; const type = this._getValueType(value); if (!this._isValueTypeAny(value, type, schemaType)) { throw this._createError(`Value type ${type} does not match schema type ${schemaType}`); @@ -570,22 +818,27 @@ export class JsonSchema { switch (type) { case 'number': - this._validateNumber(value); + this._validateNumber(schema, /** @type {number} */ (value)); break; case 'string': - this._validateString(value); + this._validateString(schema, /** @type {string} */ (value)); break; case 'array': - this._validateArray(value); + this._validateArray(schema, /** @type {import('json-schema').Value[]} */ (value)); break; case 'object': - this._validateObject(value); + this._validateObject(schema, /** @type {import('json-schema').ValueObject} */ (value)); break; } } - _validateNumber(value) { - const {multipleOf, minimum, exclusiveMinimum, maximum, exclusiveMaximum} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {number} value + * @throws {Error} + */ + _validateNumber(schema, value) { + const {multipleOf, minimum, exclusiveMinimum, maximum, exclusiveMaximum} = schema; if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { throw this._createError(`Number is not a multiple of ${multipleOf}`); } @@ -607,8 +860,13 @@ export class JsonSchema { } } - _validateString(value) { - const {minLength, maxLength, pattern} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {string} value + * @throws {Error} + */ + _validateString(schema, value) { + const {minLength, maxLength, pattern} = schema; if (typeof minLength === 'number' && value.length < minLength) { throw this._createError('String length too short'); } @@ -618,14 +876,14 @@ export class JsonSchema { } if (typeof pattern === 'string') { - let {patternFlags} = this._schema; + let {patternFlags} = schema; if (typeof patternFlags !== 'string') { patternFlags = ''; } let regex; try { regex = this._getRegex(pattern, patternFlags); } catch (e) { - throw this._createError(`Pattern is invalid (${e.message})`); + throw this._createError(`Pattern is invalid (${e instanceof Error ? e.message : `${e}`})`); } if (!regex.test(value)) { @@ -634,8 +892,13 @@ export class JsonSchema { } } - _validateArray(value) { - const {minItems, maxItems} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown[]} value + * @throws {Error} + */ + _validateArray(schema, value) { + const {minItems, maxItems} = schema; const {length} = value; if (typeof minItems === 'number' && length < minItems) { @@ -646,30 +909,35 @@ export class JsonSchema { throw this._createError('Array length too long'); } - this._validateArrayContains(value); + this._validateArrayContains(schema, value); for (let i = 0; i < length; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { throw this._createError(`No schema found for array[${i}]`); } const propertyValue = value[i]; - this._schemaPush(schemaInfo.schema, schemaInfo.path); + this._schemaPushMultiple(stack); this._valuePush(propertyValue, i); try { - this._validate(propertyValue); + this._validate(itemSchema, propertyValue); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } } - _validateArrayContains(value) { - const containsSchema = this._schema.contains; - if (!this._isObject(containsSchema)) { return; } + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown[]} value + * @throws {Error} + */ + _validateArrayContains(schema, value) { + const containsSchema = schema.contains; + if (typeof containsSchema === 'undefined') { return; } this._schemaPush(containsSchema, 'contains'); try { @@ -677,7 +945,7 @@ export class JsonSchema { const propertyValue = value[i]; this._valuePush(propertyValue, i); try { - this._validate(propertyValue); + this._validate(containsSchema, propertyValue); return; } catch (e) { // NOP @@ -691,8 +959,13 @@ export class JsonSchema { } } - _validateObject(value) { - const {required, minProperties, maxProperties} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').ValueObject} value + * @throws {Error} + */ + _validateObject(schema, value) { + const {required, minProperties, maxProperties} = schema; const properties = Object.getOwnPropertyNames(value); const {length} = properties; @@ -714,27 +987,32 @@ export class JsonSchema { for (let i = 0; i < length; ++i) { const property = properties[i]; - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { throw this._createError(`No schema found for ${property}`); } const propertyValue = value[property]; - this._schemaPush(schemaInfo.schema, schemaInfo.path); + this._schemaPushMultiple(stack); this._valuePush(propertyValue, property); try { - this._validate(propertyValue); + this._validate(propertySchema, propertyValue); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } } // Creation + /** + * @param {import('json-schema').Type|import('json-schema').Type[]|undefined} type + * @returns {import('json-schema').Value} + */ _getDefaultTypeValue(type) { + if (Array.isArray(type)) { type = type[0]; } if (typeof type === 'string') { switch (type) { case 'null': @@ -755,95 +1033,122 @@ export class JsonSchema { return null; } - _getDefaultSchemaValue() { - const {type: schemaType, default: schemaDefault} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @returns {import('json-schema').Value} + */ + _getDefaultSchemaValue(schema) { + const {type: schemaType, default: schemaDefault} = schema; return ( typeof schemaDefault !== 'undefined' && this._isValueTypeAny(schemaDefault, this._getValueType(schemaDefault), schemaType) ? - clone(schemaDefault) : + JsonSchema.clone(schemaDefault) : this._getDefaultTypeValue(schemaType) ); } - _getValidValueOrDefault(path, value, schemaInfo) { - schemaInfo = this._getResolveSchemaInfo(schemaInfo); - this._schemaPush(schemaInfo.schema, schemaInfo.path); + /** + * @param {import('json-schema').Schema} schema + * @param {string|number|null} path + * @param {unknown} value + * @param {import('json-schema').SchemaStackItem[]} stack + * @returns {import('json-schema').Value} + */ + _getValidValueOrDefault(schema, path, value, stack) { + ({schema, stack} = this._getResolvedSchemaInfo(schema, stack)); + this._schemaPushMultiple(stack); this._valuePush(value, path); try { - return this._getValidValueOrDefaultInner(value); + return this._getValidValueOrDefaultInner(schema, value); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } - _getValidValueOrDefaultInner(value) { + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + * @returns {import('json-schema').Value} + */ + _getValidValueOrDefaultInner(schema, value) { let type = this._getValueType(value); - if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, this._schema.type)) { - value = this._getDefaultSchemaValue(); + if (typeof schema === 'boolean') { + return type !== null ? /** @type {import('json-schema').ValueObject} */ (value) : null; + } + if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, schema.type)) { + value = this._getDefaultSchemaValue(schema); type = this._getValueType(value); } switch (type) { case 'object': - value = this._populateObjectDefaults(value); - break; + return this._populateObjectDefaults(schema, /** @type {import('json-schema').ValueObject} */ (value)); case 'array': - value = this._populateArrayDefaults(value); - break; + return this._populateArrayDefaults(schema, /** @type {import('json-schema').Value[]} */ (value)); default: - if (!this._isValidCurrent(value)) { - const schemaDefault = this._getDefaultSchemaValue(); - if (this._isValidCurrent(schemaDefault)) { - value = schemaDefault; + if (!this._isValidCurrent(schema, value)) { + const schemaDefault = this._getDefaultSchemaValue(schema); + if (this._isValidCurrent(schema, schemaDefault)) { + return schemaDefault; } } break; } - return value; + return /** @type {import('json-schema').ValueObject} */ (value); } - _populateObjectDefaults(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').ValueObject} value + * @returns {import('json-schema').ValueObject} + */ + _populateObjectDefaults(schema, value) { const properties = new Set(Object.getOwnPropertyNames(value)); - const {required} = this._schema; + const {required} = schema; if (Array.isArray(required)) { for (const property of required) { properties.delete(property); - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { continue; } + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { continue; } const propertyValue = Object.prototype.hasOwnProperty.call(value, property) ? value[property] : void 0; - value[property] = this._getValidValueOrDefault(property, propertyValue, schemaInfo); + value[property] = this._getValidValueOrDefault(propertySchema, property, propertyValue, stack); } } for (const property of properties) { - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { Reflect.deleteProperty(value, property); } else { - value[property] = this._getValidValueOrDefault(property, value[property], schemaInfo); + value[property] = this._getValidValueOrDefault(propertySchema, property, value[property], stack); } } return value; } - _populateArrayDefaults(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').Value[]} value + * @returns {import('json-schema').Value[]} + */ + _populateArrayDefaults(schema, value) { for (let i = 0, ii = value.length; i < ii; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { continue; } + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { continue; } const propertyValue = value[i]; - value[i] = this._getValidValueOrDefault(i, propertyValue, schemaInfo); + value[i] = this._getValidValueOrDefault(itemSchema, i, propertyValue, stack); } - const {minItems, maxItems} = this._schema; + const {minItems, maxItems} = schema; if (typeof minItems === 'number' && value.length < minItems) { for (let i = value.length; i < minItems; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { break; } - const item = this._getValidValueOrDefault(i, void 0, schemaInfo); + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { break; } + const item = this._getValidValueOrDefault(itemSchema, i, void 0, stack); value.push(item); } } @@ -856,115 +1161,187 @@ export class JsonSchema { } } +/** + * @implements {ProxyHandler<import('json-schema').ValueObjectOrArray>} + */ class JsonSchemaProxyHandler { - constructor(schema) { - this._schema = schema; + /** + * @param {JsonSchema} schemaValidator + */ + constructor(schemaValidator) { + /** @type {JsonSchema} */ + this._schemaValidator = schemaValidator; + /** @type {RegExp} */ this._numberPattern = /^(?:0|[1-9]\d*)$/; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {?import('core').UnknownObject} + */ getPrototypeOf(target) { return Object.getPrototypeOf(target); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, newPrototype: ?unknown) => boolean} + */ setPrototypeOf() { throw new Error('setPrototypeOf not supported'); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {boolean} + */ isExtensible(target) { return Object.isExtensible(target); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {boolean} + */ preventExtensions(target) { Object.preventExtensions(target); return true; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {PropertyDescriptor|undefined} + */ getOwnPropertyDescriptor(target, property) { return Object.getOwnPropertyDescriptor(target, property); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, property: string|symbol, attributes: PropertyDescriptor) => boolean} + */ defineProperty() { throw new Error('defineProperty not supported'); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {boolean} + */ has(target, property) { return property in target; } - get(target, property) { - if (typeof property === 'symbol') { return target[property]; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @param {import('core').SafeAny} _receiver + * @returns {import('core').SafeAny} + */ + get(target, property, _receiver) { + if (typeof property === 'symbol') { return /** @type {import('core').UnknownObject} */ (target)[property]; } let propertySchema; if (Array.isArray(target)) { const index = this._getArrayIndex(property); if (index === null) { // Note: this does not currently wrap mutating functions like push, pop, shift, unshift, splice - return target[property]; + return /** @type {import('core').SafeAny} */ (target)[property]; } - property = index; - propertySchema = this._schema.getArrayItemSchema(property); + property = `${index}`; + propertySchema = this._schemaValidator.getArrayItemSchema(index); } else { - propertySchema = this._schema.getObjectPropertySchema(property); + propertySchema = this._schemaValidator.getObjectPropertySchema(property); } if (propertySchema === null) { return void 0; } - const value = target[property]; - return value !== null && typeof value === 'object' ? propertySchema.createProxy(value) : value; + const value = /** @type {import('core').UnknownObject} */ (target)[property]; + return value !== null && typeof value === 'object' ? propertySchema.createProxy(/** @type {import('json-schema').Value} */ (value)) : value; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|number|symbol} property + * @param {import('core').SafeAny} value + * @returns {boolean} + * @throws {Error} + */ set(target, property, value) { - if (typeof property === 'symbol') { throw new Error(`Cannot assign symbol property ${property}`); } + if (typeof property === 'symbol') { throw new Error(`Cannot assign symbol property ${typeof property === 'symbol' ? '<symbol>' : property}`); } let propertySchema; if (Array.isArray(target)) { const index = this._getArrayIndex(property); if (index === null) { - target[property] = value; + /** @type {import('core').SafeAny} */ (target)[property] = value; return true; } if (index > target.length) { throw new Error('Array index out of range'); } property = index; - propertySchema = this._schema.getArrayItemSchema(property); + propertySchema = this._schemaValidator.getArrayItemSchema(property); } else { - propertySchema = this._schema.getObjectPropertySchema(property); + if (typeof property !== 'string') { + property = `${property}`; + } + propertySchema = this._schemaValidator.getObjectPropertySchema(property); } if (propertySchema === null) { throw new Error(`Property ${property} not supported`); } - value = clone(value); + value = JsonSchema.clone(value); propertySchema.validate(value); - target[property] = value; + /** @type {import('core').UnknownObject} */ (target)[property] = value; return true; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {boolean} + * @throws {Error} + */ deleteProperty(target, property) { const required = ( (typeof target === 'object' && target !== null) ? - (!Array.isArray(target) && this._schema.isObjectPropertyRequired(property)) : + (!Array.isArray(target) && typeof property === 'string' && this._schemaValidator.isObjectPropertyRequired(property)) : true ); if (required) { - throw new Error(`${property} cannot be deleted`); + throw new Error(`${typeof property === 'symbol' ? '<symbol>' : property} cannot be deleted`); } return Reflect.deleteProperty(target, property); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {ArrayLike<string|symbol>} + */ ownKeys(target) { return Reflect.ownKeys(target); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, thisArg: import('core').SafeAny, argArray: import('core').SafeAny[]) => import('core').SafeAny} + */ apply() { throw new Error('apply not supported'); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, argArray: import('core').SafeAny[], newTarget: import('core').SafeFunction) => import('json-schema').ValueObjectOrArray} + */ construct() { throw new Error('construct not supported'); } // Private + /** + * @param {string|symbol|number} property + * @returns {?number} + */ _getArrayIndex(property) { if (typeof property === 'string' && this._numberPattern.test(property)) { return Number.parseInt(property, 10); diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index 3858cb55..70c1622f 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -22,20 +22,29 @@ import {JsonSchema} from './json-schema.js'; export class OptionsUtil { constructor() { + /** @type {?TemplatePatcher} */ this._templatePatcher = null; + /** @type {?JsonSchema} */ this._optionsSchema = null; } + /** */ async prepare() { - const schema = await this._fetchAsset('/data/schemas/options-schema.json', true); + const schema = /** @type {import('json-schema').Schema} */ (await this._fetchJson('/data/schemas/options-schema.json')); this._optionsSchema = new JsonSchema(schema); } - async update(options, targetVersion=null) { + /** + * @param {unknown} optionsInput + * @param {?number} [targetVersion] + * @returns {Promise<import('settings').Options>} + */ + async update(optionsInput, targetVersion=null) { // Invalid options - if (!isObject(options)) { - options = {}; - } + let options = /** @type {{[key: string]: unknown}} */ ( + typeof optionsInput === 'object' && optionsInput !== null && !Array.isArray(optionsInput) ? + optionsInput : {} + ); // Check for legacy options let defaultProfileOptions = {}; @@ -50,7 +59,7 @@ export class OptionsUtil { } // Remove invalid profiles - const profiles = options.profiles; + const profiles = /** @type {unknown[]} */ (options.profiles); for (let i = profiles.length - 1; i >= 0; --i) { if (!isObject(profiles[i])) { profiles.splice(i, 1); @@ -87,12 +96,12 @@ export class OptionsUtil { options = await this._applyUpdates(options, this._getVersionUpdates(targetVersion)); // Validation - options = this._optionsSchema.getValidValueOrDefault(options); - - // Result - return options; + return /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).getValidValueOrDefault(options)); } + /** + * @returns {Promise<import('settings').Options>} + */ async load() { let options; try { @@ -121,6 +130,10 @@ export class OptionsUtil { return options; } + /** + * @param {import('settings').Options} options + * @returns {Promise<void>} + */ save(options) { return new Promise((resolve, reject) => { chrome.storage.local.set({options: JSON.stringify(options)}, () => { @@ -134,23 +147,36 @@ export class OptionsUtil { }); } + /** + * @returns {import('settings').Options} + */ getDefault() { - const optionsVersion = this._getVersionUpdates().length; - const options = this._optionsSchema.getValidValueOrDefault(); + const optionsVersion = this._getVersionUpdates(null).length; + const options = /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).getValidValueOrDefault()); options.version = optionsVersion; return options; } + /** + * @param {import('settings').Options} options + * @returns {import('settings').Options} + */ createValidatingProxy(options) { - return this._optionsSchema.createProxy(options); + return /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).createProxy(options)); } + /** + * @param {import('settings').Options} options + */ validate(options) { - return this._optionsSchema.validate(options); + /** @type {JsonSchema} */ (this._optionsSchema).validate(options); } // Legacy profile updating + /** + * @returns {(?import('options-util').LegacyUpdateFunction)[]} + */ _legacyProfileUpdateGetUpdates() { return [ null, @@ -242,6 +268,9 @@ export class OptionsUtil { ]; } + /** + * @returns {import('options-util').LegacyOptions} + */ _legacyProfileUpdateGetDefaults() { return { general: { @@ -341,9 +370,17 @@ export class OptionsUtil { }; } + /** + * @param {import('options-util').IntermediateOptions} options + * @returns {import('options-util').IntermediateOptions} + */ _legacyProfileUpdateAssignDefaults(options) { const defaults = this._legacyProfileUpdateGetDefaults(); + /** + * @param {import('options-util').IntermediateOptions} target + * @param {import('core').UnknownObject} source + */ const combine = (target, source) => { for (const key in source) { if (!Object.prototype.hasOwnProperty.call(target, key)) { @@ -362,6 +399,10 @@ export class OptionsUtil { return options; } + /** + * @param {import('options-util').IntermediateOptions} options + * @returns {import('options-util').IntermediateOptions} + */ _legacyProfileUpdateUpdateVersion(options) { const updates = this._legacyProfileUpdateGetUpdates(); this._legacyProfileUpdateAssignDefaults(options); @@ -384,6 +425,10 @@ export class OptionsUtil { // Private + /** + * @param {import('options-util').IntermediateOptions} options + * @param {string} modificationsUrl + */ async _applyAnkiFieldTemplatesPatch(options, modificationsUrl) { let patch = null; for (const {options: profileOptions} of options.profiles) { @@ -391,18 +436,22 @@ export class OptionsUtil { if (fieldTemplates === null) { continue; } if (patch === null) { - const content = await this._fetchAsset(modificationsUrl); + const content = await this._fetchText(modificationsUrl); if (this._templatePatcher === null) { this._templatePatcher = new TemplatePatcher(); } patch = this._templatePatcher.parsePatch(content); } - profileOptions.anki.fieldTemplates = this._templatePatcher.applyPatch(fieldTemplates, patch); + profileOptions.anki.fieldTemplates = /** @type {TemplatePatcher} */ (this._templatePatcher).applyPatch(fieldTemplates, patch); } } - async _fetchAsset(url, json=false) { + /** + * @param {string} url + * @returns {Promise<Response>} + */ + async _fetchGeneric(url) { url = chrome.runtime.getURL(url); const response = await fetch(url, { method: 'GET', @@ -415,9 +464,31 @@ export class OptionsUtil { if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.status}`); } - return await (json ? response.json() : response.text()); + return response; + } + + /** + * @param {string} url + * @returns {Promise<string>} + */ + async _fetchText(url) { + const response = await this._fetchGeneric(url); + return await response.text(); + } + + /** + * @param {string} url + * @returns {Promise<unknown>} + */ + async _fetchJson(url) { + const response = await this._fetchGeneric(url); + return await response.json(); } + /** + * @param {string} string + * @returns {number} + */ _getStringHashCode(string) { let hashCode = 0; @@ -431,6 +502,11 @@ export class OptionsUtil { return hashCode; } + /** + * @param {import('options-util').IntermediateOptions} options + * @param {import('options-util').ModernUpdate[]} updates + * @returns {Promise<import('settings').Options>} + */ async _applyUpdates(options, updates) { const targetVersion = updates.length; let currentVersion = options.version; @@ -449,6 +525,10 @@ export class OptionsUtil { return options; } + /** + * @param {?number} targetVersion + * @returns {import('options-util').ModernUpdate[]} + */ _getVersionUpdates(targetVersion) { const result = [ {async: false, update: this._updateVersion1.bind(this)}, @@ -479,6 +559,9 @@ export class OptionsUtil { return result; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion1(options) { // Version 1 changes: // Added options.global.database.prefixWildcardsSupported = false. @@ -490,6 +573,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion2(options) { // Version 2 changes: // Legacy profile update process moved into this upgrade function. @@ -502,6 +588,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion3(options) { // Version 3 changes: // Pitch accent Anki field templates added. @@ -509,6 +598,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion4(options) { // Version 4 changes: // Options conditions converted to string representations. @@ -594,6 +686,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion5(options) { // Version 5 changes: // Removed legacy version number from profile options. @@ -603,6 +698,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion6(options) { // Version 6 changes: // Updated handlebars templates to include "conjugation" definition. @@ -625,6 +723,10 @@ export class OptionsUtil { return options; } + /** + * @param {string} templates + * @returns {string} + */ _updateVersion6AnkiTemplatesCompactTags(templates) { const rawPattern1 = '{{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}}'; const pattern1 = new RegExp(`((\r?\n)?[ \t]*)${escapeRegExp(rawPattern1)}`, 'g'); @@ -649,6 +751,9 @@ export class OptionsUtil { return templates; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion7(options) { // Version 7 changes: // Added general.maximumClipboardSearchLength. @@ -666,6 +771,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion8(options) { // Version 8 changes: // Added translation.textReplacements. @@ -755,6 +863,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion9(options) { // Version 9 changes: // Added general.frequencyDisplayMode. @@ -766,6 +877,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion10(options) { // Version 10 changes: // Removed global option useSettingsV2. @@ -803,6 +917,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion11(options) { // Version 11 changes: // Changed dictionaries to an array. @@ -827,6 +944,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion12(options) { // Version 12 changes: // Changed sentenceParsing.enableTerminationCharacters to sentenceParsing.terminationCharacterMode. @@ -841,7 +961,7 @@ export class OptionsUtil { delete sentenceParsing.enableTerminationCharacters; const {sources, customSourceUrl, customSourceType, textToSpeechVoice} = audio; - audio.sources = sources.map((type) => { + audio.sources = /** @type {string[]} */ (sources).map((type) => { switch (type) { case 'text-to-speech': case 'text-to-speech-reading': @@ -859,6 +979,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion13(options) { // Version 13 changes: // Handlebars templates updated to use formatGlossary. @@ -874,6 +997,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion14(options) { // Version 14 changes: // Added accessibility options. @@ -885,6 +1011,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion15(options) { // Version 15 changes: // Added general.sortFrequencyDictionary. @@ -896,6 +1025,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion16(options) { // Version 16 changes: // Added scanning.matchTypePrefix. @@ -905,12 +1037,16 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion17(options) { // Version 17 changes: // Added vertical sentence punctuation to terminationCharacters. const additions = ['︒', '︕', '︖', '︙']; for (const profile of options.profiles) { - const {terminationCharacters} = profile.options.sentenceParsing; + /** @type {import('settings').SentenceParsingTerminationCharacterOption[]} */ + const terminationCharacters = profile.options.sentenceParsing.terminationCharacters; const newAdditions = []; for (const character of additions) { if (terminationCharacters.findIndex((value) => (value.character1 === character && value.character2 === null)) < 0) { @@ -930,6 +1066,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion18(options) { // Version 18 changes: // general.popupTheme's 'default' value changed to 'light' @@ -952,6 +1091,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion19(options) { // Version 19 changes: // Added anki.noteGuiMode. @@ -979,6 +1121,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion20(options) { // Version 20 changes: // Added anki.downloadTimeout. @@ -999,6 +1144,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion21(options) { await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v21.handlebars'); @@ -1018,6 +1166,10 @@ export class OptionsUtil { return options; } + /** + * @param {string} url + * @returns {Promise<chrome.tabs.Tab>} + */ _createTab(url) { return new Promise((resolve, reject) => { chrome.tabs.create({url}, (tab) => { diff --git a/ext/js/data/permissions-util.js b/ext/js/data/permissions-util.js index d645f21e..76c5031b 100644 --- a/ext/js/data/permissions-util.js +++ b/ext/js/data/permissions-util.js @@ -20,12 +20,17 @@ import {AnkiUtil} from './anki-util.js'; export class PermissionsUtil { constructor() { + /** @type {Set<string>} */ this._ankiFieldMarkersRequiringClipboardPermission = new Set([ 'clipboard-image', 'clipboard-text' ]); } + /** + * @param {chrome.permissions.Permissions} permissions + * @returns {Promise<boolean>} + */ hasPermissions(permissions) { return new Promise((resolve, reject) => chrome.permissions.contains(permissions, (result) => { const e = chrome.runtime.lastError; @@ -37,6 +42,11 @@ export class PermissionsUtil { })); } + /** + * @param {chrome.permissions.Permissions} permissions + * @param {boolean} shouldHave + * @returns {Promise<boolean>} + */ setPermissionsGranted(permissions, shouldHave) { return ( shouldHave ? @@ -59,6 +69,9 @@ export class PermissionsUtil { ); } + /** + * @returns {Promise<chrome.permissions.Permissions>} + */ getAllPermissions() { return new Promise((resolve, reject) => chrome.permissions.getAll((result) => { const e = chrome.runtime.lastError; @@ -70,6 +83,10 @@ export class PermissionsUtil { })); } + /** + * @param {string} fieldValue + * @returns {string[]} + */ getRequiredPermissionsForAnkiFieldValue(fieldValue) { const markers = AnkiUtil.getFieldMarkers(fieldValue); const markerPermissions = this._ankiFieldMarkersRequiringClipboardPermission; @@ -81,6 +98,11 @@ export class PermissionsUtil { return []; } + /** + * @param {chrome.permissions.Permissions} permissions + * @param {import('settings').ProfileOptions} options + * @returns {boolean} + */ hasRequiredPermissionsForOptions(permissions, options) { const permissionsSet = new Set(permissions.permissions); diff --git a/ext/js/data/sandbox/anki-note-data-creator.js b/ext/js/data/sandbox/anki-note-data-creator.js index 371a62a2..dce71938 100644 --- a/ext/js/data/sandbox/anki-note-data-creator.js +++ b/ext/js/data/sandbox/anki-note-data-creator.js @@ -25,24 +25,18 @@ import {DictionaryDataUtil} from '../../language/sandbox/dictionary-data-util.js export class AnkiNoteDataCreator { /** * Creates a new instance. - * @param {JapaneseUtil} japaneseUtil An instance of `JapaneseUtil`. + * @param {import('../../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil An instance of `JapaneseUtil`. */ constructor(japaneseUtil) { + /** @type {import('../../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; } /** * Creates a compatibility representation of the specified data. * @param {string} marker The marker that is being used for template rendering. - * @param {object} details Information which is used to generate the data. - * @param {Translation.DictionaryEntry} details.dictionaryEntry The dictionary entry. - * @param {string} details.resultOutputMode The result output mode. - * @param {string} details.mode The mode being used to generate the Anki data. - * @param {string} details.glossaryLayoutMode The glossary layout mode. - * @param {boolean} details.compactTags Whether or not compact tags mode is enabled. - * @param {{documentTitle: string, query: string, fullQuery: string}} details.context Contextual information about the source of the dictionary entry. - * @param {object} details.media Media data. - * @returns {object} An object used for rendering Anki templates. + * @param {import('anki-templates-internal').CreateDetails} details Information which is used to generate the data. + * @returns {import('anki-templates').NoteData} An object used for rendering Anki templates. */ create(marker, { dictionaryEntry, @@ -53,6 +47,7 @@ export class AnkiNoteDataCreator { context, media }) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const definition = this.createCachedValue(this._getDefinition.bind(this, dictionaryEntry, context, resultOutputMode)); const uniqueExpressions = this.createCachedValue(this._getUniqueExpressions.bind(this, dictionaryEntry)); @@ -60,7 +55,18 @@ export class AnkiNoteDataCreator { const context2 = this.createCachedValue(this._getPublicContext.bind(this, context)); const pitches = this.createCachedValue(this._getPitches.bind(this, dictionaryEntry)); const pitchCount = this.createCachedValue(this._getPitchCount.bind(this, pitches)); - if (typeof media !== 'object' || media === null || Array.isArray(media)) { media = {}; } + if (typeof media !== 'object' || media === null || Array.isArray(media)) { + media = { + audio: void 0, + screenshot: void 0, + clipboardImage: void 0, + clipboardText: void 0, + selectionText: void 0, + textFurigana: [], + dictionaryMedia: {} + }; + } + /** @type {import('anki-templates').NoteData} */ const result = { marker, get definition() { return self.getCachedValue(definition); }, @@ -77,7 +83,8 @@ export class AnkiNoteDataCreator { get pitches() { return self.getCachedValue(pitches); }, get pitchCount() { return self.getCachedValue(pitchCount); }, get context() { return self.getCachedValue(context2); }, - media + media, + dictionaryEntry }; Object.defineProperty(result, 'dictionaryEntry', { configurable: false, @@ -90,8 +97,9 @@ export class AnkiNoteDataCreator { /** * Creates a deferred-evaluation value. - * @param {Function} getter The function to invoke to get the return value. - * @returns {{getter: Function, hasValue: false, value: undefined}} An object which can be passed into `getCachedValue`. + * @template [T=unknown] + * @param {() => T} getter The function to invoke to get the return value. + * @returns {import('anki-templates-internal').CachedValue<T>} An object which can be passed into `getCachedValue`. */ createCachedValue(getter) { return {getter, hasValue: false, value: void 0}; @@ -99,11 +107,12 @@ export class AnkiNoteDataCreator { /** * Gets the value of a cached object. - * @param {{getter: Function, hasValue: boolean, value: *}} item An object that was returned from `createCachedValue`. - * @returns {*} The result of evaluating the getter, which is cached after the first invocation. + * @template [T=unknown] + * @param {import('anki-templates-internal').CachedValue<T>} item An object that was returned from `createCachedValue`. + * @returns {T} The result of evaluating the getter, which is cached after the first invocation. */ getCachedValue(item) { - if (item.hasValue) { return item.value; } + if (item.hasValue) { return /** @type {T} */ (item.value); } const value = item.getter(); item.value = value; item.hasValue = true; @@ -112,10 +121,10 @@ export class AnkiNoteDataCreator { // Private - _asObject(value) { - return (typeof value === 'object' && value !== null ? value : {}); - } - + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {?import('dictionary').TermSource} + */ _getPrimarySource(dictionaryEntry) { for (const headword of dictionaryEntry.headwords) { for (const source of headword.sources) { @@ -125,6 +134,10 @@ export class AnkiNoteDataCreator { return null; } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {string[]} + */ _getUniqueExpressions(dictionaryEntry) { if (dictionaryEntry.type === 'term') { const results = new Set(); @@ -137,6 +150,10 @@ export class AnkiNoteDataCreator { } } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {string[]} + */ _getUniqueReadings(dictionaryEntry) { if (dictionaryEntry.type === 'term') { const results = new Set(); @@ -149,8 +166,12 @@ export class AnkiNoteDataCreator { } } + /** + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').Context} + */ _getPublicContext(context) { - let {documentTitle, query, fullQuery} = this._asObject(context); + let {documentTitle, query, fullQuery} = context; if (typeof documentTitle !== 'string') { documentTitle = ''; } return { query, @@ -161,10 +182,16 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').PitchGroup[]} + */ _getPitches(dictionaryEntry) { + /** @type {import('anki-templates').PitchGroup[]} */ const results = []; if (dictionaryEntry.type === 'term') { for (const {dictionary, pronunciations} of DictionaryDataUtil.getGroupedPronunciations(dictionaryEntry)) { + /** @type {import('anki-templates').Pitch[]} */ const pitches = []; for (const {terms, reading, position, nasalPositions, devoicePositions, tags, exclusiveTerms, exclusiveReadings} of pronunciations) { pitches.push({ @@ -173,7 +200,7 @@ export class AnkiNoteDataCreator { position, nasalPositions, devoicePositions, - tags, + tags: this._convertPitchTags(tags), exclusiveExpressions: exclusiveTerms, exclusiveReadings }); @@ -184,11 +211,21 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('anki-templates-internal').CachedValue<import('anki-templates').PitchGroup[]>} cachedPitches + * @returns {number} + */ _getPitchCount(cachedPitches) { const pitches = this.getCachedValue(cachedPitches); return pitches.reduce((i, v) => i + v.pitches.length, 0); } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @param {import('settings').ResultOutputMode} resultOutputMode + * @returns {import('anki-templates').DictionaryEntry} + */ _getDefinition(dictionaryEntry, context, resultOutputMode) { switch (dictionaryEntry.type) { case 'term': @@ -196,16 +233,22 @@ export class AnkiNoteDataCreator { case 'kanji': return this._getKanjiDefinition(dictionaryEntry, context); default: - return {}; + return /** @type {import('anki-templates').UnknownDictionaryEntry} */ ({}); } } + /** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').KanjiDictionaryEntry} + */ _getKanjiDefinition(dictionaryEntry, context) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const {character, dictionary, onyomi, kunyomi, definitions} = dictionaryEntry; - let {url} = this._asObject(context); + let {url} = context; if (typeof url !== 'string') { url = ''; } const stats = this.createCachedValue(this._getKanjiStats.bind(this, dictionaryEntry)); @@ -228,14 +271,24 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').KanjiStatGroups} + */ _getKanjiStats(dictionaryEntry) { + /** @type {import('anki-templates').KanjiStatGroups} */ const results = {}; + const convertKanjiStatBind = this._convertKanjiStat.bind(this); for (const [key, value] of Object.entries(dictionaryEntry.stats)) { - results[key] = value.map(this._convertKanjiStat.bind(this)); + results[key] = value.map(convertKanjiStatBind); } return results; } + /** + * @param {import('dictionary').KanjiStat} kanjiStat + * @returns {import('anki-templates').KanjiStat} + */ _convertKanjiStat({name, category, content, order, score, dictionary, value}) { return { name, @@ -248,7 +301,12 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').KanjiFrequency[]} + */ _getKanjiFrequencies(dictionaryEntry) { + /** @type {import('anki-templates').KanjiFrequency[]} */ const results = []; for (const {index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue} of dictionaryEntry.frequencies) { results.push({ @@ -265,9 +323,17 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @param {import('settings').ResultOutputMode} resultOutputMode + * @returns {import('anki-templates').TermDictionaryEntry} + */ _getTermDefinition(dictionaryEntry, context, resultOutputMode) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; + /** @type {import('anki-templates').TermDictionaryEntryType} */ let type = 'term'; switch (resultOutputMode) { case 'group': type = 'termGrouped'; break; @@ -276,7 +342,7 @@ export class AnkiNoteDataCreator { const {inflections, score, dictionaryIndex, dictionaryPriority, sourceTermExactMatchCount, definitions} = dictionaryEntry; - let {url} = this._asObject(context); + let {url} = context; if (typeof url !== 'string') { url = ''; } const primarySource = this._getPrimarySource(dictionaryEntry); @@ -330,6 +396,10 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {string[]} + */ _getTermDictionaryNames(dictionaryEntry) { const dictionaryNames = new Set(); for (const {dictionary} of dictionaryEntry.definitions) { @@ -338,11 +408,18 @@ export class AnkiNoteDataCreator { return [...dictionaryNames]; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').TermDictionaryEntryCommonInfo} + */ _getTermDictionaryEntryCommonInfo(dictionaryEntry, type) { const merged = (type === 'termMerged'); const hasDefinitions = (type !== 'term'); + /** @type {Set<string>} */ const allTermsSet = new Set(); + /** @type {Set<string>} */ const allReadingsSet = new Set(); for (const {term, reading} of dictionaryEntry.headwords) { allTermsSet.add(term); @@ -351,7 +428,9 @@ export class AnkiNoteDataCreator { const uniqueTerms = [...allTermsSet]; const uniqueReadings = [...allReadingsSet]; + /** @type {import('anki-templates').TermDefinition[]} */ const definitions = []; + /** @type {import('anki-templates').Tag[]} */ const definitionTags = []; for (const {tags, headwordIndices, entries, dictionary, sequences} of dictionaryEntry.definitions) { const definitionTags2 = []; @@ -378,6 +457,10 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermFrequency[]} + */ _getTermFrequencies(dictionaryEntry) { const results = []; const {headwords} = dictionaryEntry; @@ -400,7 +483,12 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermPronunciation[]} + */ _getTermPitches(dictionaryEntry) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; const {headwords} = dictionaryEntry; @@ -423,7 +511,12 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermPitch[]} pitches + * @returns {import('anki-templates').TermPitch[]} + */ _getTermPitchesInner(pitches) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; for (const {position, tags} of pitches) { @@ -436,7 +529,12 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermHeadword[]} + */ _getTermExpressions(dictionaryEntry) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; const {headwords} = dictionaryEntry; @@ -463,6 +561,11 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {number} i + * @returns {import('anki-templates').TermFrequency[]} + */ _getTermExpressionFrequencies(dictionaryEntry, i) { const results = []; const {headwords, frequencies} = dictionaryEntry; @@ -486,7 +589,13 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {number} i + * @returns {import('anki-templates').TermPronunciation[]} + */ _getTermExpressionPitches(dictionaryEntry, i) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; const {headwords, pronunciations} = dictionaryEntry; @@ -510,11 +619,20 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('anki-templates-internal').CachedValue<import('anki-templates').Tag[]>} cachedTermTags + * @returns {import('anki-templates').TermFrequencyType} + */ _getTermExpressionTermFrequency(cachedTermTags) { const termTags = this.getCachedValue(cachedTermTags); return DictionaryDataUtil.getTermFrequency(termTags); } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('dictionary-data').TermGlossary[]|undefined} + */ _getTermGlossaryArray(dictionaryEntry, type) { if (type === 'term') { const results = []; @@ -526,6 +644,11 @@ export class AnkiNoteDataCreator { return void 0; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').Tag[]|undefined} + */ _getTermTags(dictionaryEntry, type) { if (type !== 'termMerged') { const results = []; @@ -537,6 +660,10 @@ export class AnkiNoteDataCreator { return void 0; } + /** + * @param {import('dictionary').Tag[]} tags + * @returns {import('anki-templates').Tag[]} + */ _convertTags(tags) { const results = []; for (const tag of tags) { @@ -545,6 +672,10 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').Tag} tag + * @returns {import('anki-templates').Tag} + */ _convertTag({name, category, content, order, score, dictionaries, redundant}) { return { name, @@ -557,6 +688,39 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').Tag[]} tags + * @returns {import('anki-templates').PitchTag[]} + */ + _convertPitchTags(tags) { + const results = []; + for (const tag of tags) { + results.push(this._convertPitchTag(tag)); + } + return results; + } + + /** + * @param {import('dictionary').Tag} tag + * @returns {import('anki-templates').PitchTag} + */ + _convertPitchTag({name, category, content, order, score, dictionaries, redundant}) { + return { + name, + category, + order, + score, + content: [...content], + dictionaries: [...dictionaries], + redundant + }; + } + + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').Cloze} + */ _getCloze(dictionaryEntry, context) { let originalText = ''; switch (dictionaryEntry.type) { @@ -571,8 +735,12 @@ export class AnkiNoteDataCreator { break; } - const {sentence} = this._asObject(context); - let {text, offset} = this._asObject(sentence); + const {sentence} = context; + let text; + let offset; + if (typeof sentence === 'object' && sentence !== null) { + ({text, offset} = sentence); + } if (typeof text !== 'string') { text = ''; } if (typeof offset !== 'number') { offset = 0; } @@ -584,6 +752,11 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').FuriganaSegment[]|undefined} + */ _getTermFuriganaSegments(dictionaryEntry, type) { if (type === 'term') { for (const {term, reading} of dictionaryEntry.headwords) { @@ -593,7 +766,13 @@ export class AnkiNoteDataCreator { return void 0; } + /** + * @param {string} term + * @param {string} reading + * @returns {import('anki-templates').FuriganaSegment[]} + */ _getTermHeadwordFuriganaSegments(term, reading) { + /** @type {import('anki-templates').FuriganaSegment[]} */ const result = []; for (const {text, reading: reading2} of this._japaneseUtil.distributeFurigana(term, reading)) { result.push({text, furigana: reading2}); @@ -601,11 +780,15 @@ export class AnkiNoteDataCreator { return result; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {number} + */ _getTermDictionaryEntrySequence(dictionaryEntry) { let hasSequence = false; let mainSequence = -1; - for (const {sequences, isPrimary} of dictionaryEntry.definitions) { - if (!isPrimary) { continue; } + if (!dictionaryEntry.isPrimary) { return mainSequence; } + for (const {sequences} of dictionaryEntry.definitions) { const sequence = sequences[0]; if (!hasSequence) { mainSequence = sequence; |