Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 618 lines 24 kB view raw
1/* 2 * Copyright (C) 2023-2025 Yomitan Authors 3 * Copyright (C) 2020-2022 Yomichan Authors 4 * 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <https://www.gnu.org/licenses/>. 17 */ 18 19import {toError} from '../core/to-error.js'; 20 21/** 22 * @template {string} TObjectStoreName 23 */ 24export class Database { 25 constructor() { 26 /** @type {?IDBDatabase} */ 27 this._db = null; 28 /** @type {boolean} */ 29 this._isOpening = false; 30 } 31 32 /** 33 * @param {string} databaseName 34 * @param {number} version 35 * @param {import('database').StructureDefinition<TObjectStoreName>[]?} structure 36 */ 37 async open(databaseName, version, structure) { 38 if (this._db !== null) { 39 throw new Error('Database already open'); 40 } 41 if (this._isOpening) { 42 throw new Error('Already opening'); 43 } 44 45 try { 46 this._isOpening = true; 47 this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => { 48 if (structure !== null) { 49 this._upgrade(db, transaction, oldVersion, structure); 50 } 51 }); 52 if (this._db.objectStoreNames.length === 0) { 53 this.close(); 54 await Database.deleteDatabase(databaseName); 55 this._isOpening = false; 56 await this.open(databaseName, version, structure); 57 } 58 } finally { 59 this._isOpening = false; 60 } 61 } 62 63 /** 64 * @throws {Error} 65 */ 66 close() { 67 if (this._db === null) { 68 throw new Error('Database is not open'); 69 } 70 71 this._db.close(); 72 this._db = null; 73 } 74 75 /** 76 * Returns true if the database opening is in process. 77 * @returns {boolean} 78 */ 79 isOpening() { 80 return this._isOpening; 81 } 82 83 /** 84 * Returns true if the database is fully opened. 85 * @returns {boolean} 86 */ 87 isOpen() { 88 return this._db !== null; 89 } 90 91 /** 92 * Returns a new transaction with the given mode ("readonly" or "readwrite") and scope which can be a single object store name or an array of names. 93 * @param {string[]} storeNames 94 * @param {IDBTransactionMode} mode 95 * @returns {IDBTransaction} 96 * @throws {Error} 97 */ 98 transaction(storeNames, mode) { 99 if (this._db === null) { 100 throw new Error(this._isOpening ? 'Database not ready' : 'Database not open'); 101 } 102 try { 103 return this._db.transaction(storeNames, mode); 104 } catch (e) { 105 throw new Error(toError(e).message + '\nDatabase transaction error, you may need to Delete All dictionaries to reset the database or manually delete the Indexed DB database.'); 106 } 107 } 108 109 /** 110 * Add items in bulk to the object store. 111 * _count_ items will be added, starting from _start_ index of _items_ list. 112 * @param {TObjectStoreName} objectStoreName 113 * @param {unknown[]} items List of items to add. 114 * @param {number} start Start index. Added items begin at _items_[_start_]. 115 * @param {number} count Count of items to add. 116 * @returns {Promise<void>} 117 */ 118 bulkAdd(objectStoreName, items, start, count) { 119 return new Promise((resolve, reject) => { 120 if (start + count > items.length) { 121 count = items.length - start; 122 } 123 124 if (count <= 0) { 125 resolve(); 126 return; 127 } 128 129 const transaction = this._readWriteTransaction([objectStoreName], resolve, reject); 130 const objectStore = transaction.objectStore(objectStoreName); 131 for (let i = start, ii = start + count; i < ii; ++i) { 132 objectStore.add(items[i]); 133 } 134 transaction.commit(); 135 }); 136 } 137 138 /** 139 * Add a single item and return a promise containing the resulting primaryKey. 140 * Holding onto the result value makes the GC not clean up until much later even if the value is not used. 141 * Only call this method if the primaryKey of the added value is required. 142 * @param {TObjectStoreName} objectStoreName 143 * @param {unknown} item Item to add. 144 * @returns {Promise<IDBRequest<IDBValidKey>>} 145 */ 146 addWithResult(objectStoreName, item) { 147 return new Promise((resolve, reject) => { 148 const transaction = this._readWriteTransaction([objectStoreName], () => {}, reject); 149 const objectStore = transaction.objectStore(objectStoreName); 150 const result = objectStore.add(item); 151 transaction.commit(); 152 resolve(result); 153 }); 154 } 155 156 /** 157 * Update items in bulk to the object store. 158 * Items that do not exist will be added. 159 * _count_ items will be updated, starting from _start_ index of _items_ list. 160 * @param {TObjectStoreName} objectStoreName 161 * @param {import('dictionary-database').DatabaseUpdateItem[]} items List of items to update. 162 * @param {number} start Start index. Updated items begin at _items_[_start_]. 163 * @param {number} count Count of items to update. 164 * @returns {Promise<void>} 165 */ 166 bulkUpdate(objectStoreName, items, start, count) { 167 return new Promise((resolve, reject) => { 168 if (start + count > items.length) { 169 count = items.length - start; 170 } 171 172 if (count <= 0) { 173 resolve(); 174 return; 175 } 176 177 const transaction = this._readWriteTransaction([objectStoreName], resolve, reject); 178 const objectStore = transaction.objectStore(objectStoreName); 179 180 for (let i = start, ii = start + count; i < ii; ++i) { 181 objectStore.put(items[i].data, items[i].primaryKey); 182 } 183 transaction.commit(); 184 }); 185 } 186 187 /** 188 * @template [TData=unknown] 189 * @template [TResult=unknown] 190 * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex 191 * @param {?IDBValidKey|IDBKeyRange} query 192 * @param {(results: TResult[], data: TData) => void} onSuccess 193 * @param {(reason: unknown, data: TData) => void} onError 194 * @param {TData} data 195 */ 196 getAll(objectStoreOrIndex, query, onSuccess, onError, data) { 197 if (typeof objectStoreOrIndex.getAll === 'function') { 198 this._getAllFast(objectStoreOrIndex, query, onSuccess, onError, data); 199 } else { 200 this._getAllUsingCursor(objectStoreOrIndex, query, onSuccess, onError, data); 201 } 202 } 203 204 /** 205 * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex 206 * @param {IDBValidKey|IDBKeyRange} query 207 * @param {(value: IDBValidKey[]) => void} onSuccess 208 * @param {(reason?: unknown) => void} onError 209 */ 210 getAllKeys(objectStoreOrIndex, query, onSuccess, onError) { 211 if (typeof objectStoreOrIndex.getAllKeys === 'function') { 212 this._getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError); 213 } else { 214 this._getAllKeysUsingCursor(objectStoreOrIndex, query, onSuccess, onError); 215 } 216 } 217 218 /** 219 * @template [TPredicateArg=unknown] 220 * @template [TResult=unknown] 221 * @template [TResultDefault=unknown] 222 * @param {TObjectStoreName} objectStoreName 223 * @param {?string} indexName 224 * @param {?IDBValidKey|IDBKeyRange} query 225 * @param {?((value: TResult|TResultDefault, predicateArg: TPredicateArg) => boolean)} predicate 226 * @param {TPredicateArg} predicateArg 227 * @param {TResultDefault} defaultValue 228 * @returns {Promise<TResult|TResultDefault>} 229 */ 230 find(objectStoreName, indexName, query, predicate, predicateArg, defaultValue) { 231 return new Promise((resolve, reject) => { 232 const transaction = this.transaction([objectStoreName], 'readonly'); 233 const objectStore = transaction.objectStore(objectStoreName); 234 const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; 235 this.findFirst(objectStoreOrIndex, query, resolve, reject, null, predicate, predicateArg, defaultValue); 236 }); 237 } 238 239 /** 240 * @template [TData=unknown] 241 * @template [TPredicateArg=unknown] 242 * @template [TResult=unknown] 243 * @template [TResultDefault=unknown] 244 * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex 245 * @param {?IDBValidKey|IDBKeyRange} query 246 * @param {(value: TResult|TResultDefault, data: TData) => void} resolve 247 * @param {(reason: unknown, data: TData) => void} reject 248 * @param {TData} data 249 * @param {?((value: TResult, predicateArg: TPredicateArg) => boolean)} predicate 250 * @param {TPredicateArg} predicateArg 251 * @param {TResultDefault} defaultValue 252 */ 253 findFirst(objectStoreOrIndex, query, resolve, reject, data, predicate, predicateArg, defaultValue) { 254 const noPredicate = (typeof predicate !== 'function'); 255 const request = objectStoreOrIndex.openCursor(query, 'next'); 256 request.onerror = (e) => reject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data); 257 request.onsuccess = (e) => { 258 const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result; 259 if (cursor) { 260 /** @type {unknown} */ 261 const value = cursor.value; 262 if (noPredicate || predicate(/** @type {TResult} */ (value), predicateArg)) { 263 resolve(/** @type {TResult} */ (value), data); 264 } else { 265 cursor.continue(); 266 } 267 } else { 268 resolve(defaultValue, data); 269 } 270 }; 271 } 272 273 /** 274 * @param {import('database').CountTarget[]} targets 275 * @param {(results: number[]) => void} resolve 276 * @param {(reason?: unknown) => void} reject 277 */ 278 bulkCount(targets, resolve, reject) { 279 const targetCount = targets.length; 280 if (targetCount <= 0) { 281 resolve([]); 282 return; 283 } 284 285 let completedCount = 0; 286 /** @type {number[]} */ 287 const results = new Array(targetCount).fill(null); 288 289 /** 290 * @param {Event} e 291 * @returns {void} 292 */ 293 const onError = (e) => reject(/** @type {IDBRequest<number>} */ (e.target).error); 294 /** 295 * @param {Event} e 296 * @param {number} index 297 */ 298 const onSuccess = (e, index) => { 299 const count = /** @type {IDBRequest<number>} */ (e.target).result; 300 results[index] = count; 301 if (++completedCount >= targetCount) { 302 resolve(results); 303 } 304 }; 305 306 for (let i = 0; i < targetCount; ++i) { 307 const index = i; 308 const [objectStoreOrIndex, query] = targets[i]; 309 const request = objectStoreOrIndex.count(query); 310 request.onerror = onError; 311 request.onsuccess = (e) => onSuccess(e, index); 312 } 313 } 314 315 /** 316 * Deletes records in store with the given key or in the given key range in query. 317 * @param {TObjectStoreName} objectStoreName 318 * @param {IDBValidKey|IDBKeyRange} key 319 * @returns {Promise<void>} 320 */ 321 delete(objectStoreName, key) { 322 return new Promise((resolve, reject) => { 323 const transaction = this._readWriteTransaction([objectStoreName], resolve, reject); 324 const objectStore = transaction.objectStore(objectStoreName); 325 objectStore.delete(key); 326 transaction.commit(); 327 }); 328 } 329 330 /** 331 * Delete items in bulk from the object store. 332 * @param {TObjectStoreName} objectStoreName 333 * @param {?string} indexName 334 * @param {IDBKeyRange} query 335 * @param {?(keys: IDBValidKey[]) => IDBValidKey[]} filterKeys 336 * @param {?(completedCount: number, totalCount: number) => void} onProgress 337 * @returns {Promise<void>} 338 */ 339 bulkDelete(objectStoreName, indexName, query, filterKeys = null, onProgress = null) { 340 return new Promise((resolve, reject) => { 341 const transaction = this._readWriteTransaction([objectStoreName], resolve, reject); 342 const objectStore = transaction.objectStore(objectStoreName); 343 const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; 344 345 /** 346 * @param {IDBValidKey[]} keys 347 */ 348 const onGetKeys = (keys) => { 349 try { 350 if (typeof filterKeys === 'function') { 351 keys = filterKeys(keys); 352 } 353 this._bulkDeleteInternal(objectStore, keys, 1000, 0, onProgress, (error) => { 354 if (error !== null) { 355 transaction.commit(); 356 } 357 }); 358 } catch (e) { 359 reject(e); 360 } 361 }; 362 363 this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject); 364 }); 365 } 366 367 /** 368 * Attempts to delete the named database. 369 * If the database already exists and there are open connections that don't close in response to a versionchange event, the request will be blocked until all they close. 370 * If the request is successful request's result will be null. 371 * @param {string} databaseName 372 * @returns {Promise<void>} 373 */ 374 static deleteDatabase(databaseName) { 375 return new Promise((resolve, reject) => { 376 const request = indexedDB.deleteDatabase(databaseName); 377 request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); 378 request.onsuccess = () => resolve(); 379 request.onblocked = () => reject(new Error('Database deletion blocked')); 380 }); 381 } 382 383 // Private 384 385 /** 386 * @param {string} name 387 * @param {number} version 388 * @param {import('database').UpdateFunction} onUpgradeNeeded 389 * @returns {Promise<IDBDatabase>} 390 */ 391 _open(name, version, onUpgradeNeeded) { 392 return new Promise((resolve, reject) => { 393 const request = indexedDB.open(name, version); 394 395 request.onupgradeneeded = (event) => { 396 try { 397 const transaction = /** @type {IDBTransaction} */ (request.transaction); 398 transaction.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); 399 onUpgradeNeeded(request.result, transaction, event.oldVersion, event.newVersion); 400 } catch (e) { 401 reject(e); 402 } 403 }; 404 405 request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); 406 request.onsuccess = () => resolve(request.result); 407 }); 408 } 409 410 /** 411 * @param {IDBDatabase} db 412 * @param {IDBTransaction} transaction 413 * @param {number} oldVersion 414 * @param {import('database').StructureDefinition<TObjectStoreName>[]} upgrades 415 */ 416 _upgrade(db, transaction, oldVersion, upgrades) { 417 for (const {version, stores} of upgrades) { 418 if (oldVersion >= version) { continue; } 419 420 /** @type {[objectStoreName: string, value: import('database').StoreDefinition][]} */ 421 const entries = Object.entries(stores); 422 for (const [objectStoreName, {primaryKey, indices}] of entries) { 423 const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames; 424 const objectStore = ( 425 this._listContains(existingObjectStoreNames, objectStoreName) ? 426 transaction.objectStore(objectStoreName) : 427 db.createObjectStore(objectStoreName, primaryKey) 428 ); 429 const existingIndexNames = objectStore.indexNames; 430 431 for (const indexName of indices) { 432 if (this._listContains(existingIndexNames, indexName)) { continue; } 433 434 objectStore.createIndex(indexName, indexName, {}); 435 } 436 } 437 } 438 } 439 440 /** 441 * @param {DOMStringList} list 442 * @param {string} value 443 * @returns {boolean} 444 */ 445 _listContains(list, value) { 446 for (let i = 0, ii = list.length; i < ii; ++i) { 447 if (list[i] === value) { return true; } 448 } 449 return false; 450 } 451 452 /** 453 * @template [TData=unknown] 454 * @template [TResult=unknown] 455 * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex 456 * @param {?IDBValidKey|IDBKeyRange} query 457 * @param {(results: TResult[], data: TData) => void} onSuccess 458 * @param {(reason: unknown, data: TData) => void} onReject 459 * @param {TData} data 460 */ 461 _getAllFast(objectStoreOrIndex, query, onSuccess, onReject, data) { 462 const request = objectStoreOrIndex.getAll(query); 463 request.onerror = (e) => { 464 const target = /** @type {IDBRequest<TResult[]>} */ (e.target); 465 onReject(target.error, data); 466 }; 467 request.onsuccess = (e) => { 468 const target = /** @type {IDBRequest<TResult[]>} */ (e.target); 469 onSuccess(target.result, data); 470 }; 471 } 472 473 /** 474 * @template [TData=unknown] 475 * @template [TResult=unknown] 476 * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex 477 * @param {?IDBValidKey|IDBKeyRange} query 478 * @param {(results: TResult[], data: TData) => void} onSuccess 479 * @param {(reason: unknown, data: TData) => void} onReject 480 * @param {TData} data 481 */ 482 _getAllUsingCursor(objectStoreOrIndex, query, onSuccess, onReject, data) { 483 /** @type {TResult[]} */ 484 const results = []; 485 const request = objectStoreOrIndex.openCursor(query, 'next'); 486 request.onerror = (e) => onReject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data); 487 request.onsuccess = (e) => { 488 const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result; 489 if (cursor) { 490 /** @type {unknown} */ 491 const value = cursor.value; 492 results.push(/** @type {TResult} */ (value)); 493 cursor.continue(); 494 } else { 495 onSuccess(results, data); 496 } 497 }; 498 } 499 500 /** 501 * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex 502 * @param {IDBValidKey|IDBKeyRange} query 503 * @param {(value: IDBValidKey[]) => void} onSuccess 504 * @param {(reason?: unknown) => void} onError 505 */ 506 _getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError) { 507 const request = objectStoreOrIndex.getAllKeys(query); 508 request.onerror = (e) => onError(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).error); 509 request.onsuccess = (e) => onSuccess(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).result); 510 } 511 512 /** 513 * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex 514 * @param {IDBValidKey|IDBKeyRange} query 515 * @param {(value: IDBValidKey[]) => void} onSuccess 516 * @param {(reason?: unknown) => void} onError 517 */ 518 _getAllKeysUsingCursor(objectStoreOrIndex, query, onSuccess, onError) { 519 /** @type {IDBValidKey[]} */ 520 const results = []; 521 const request = objectStoreOrIndex.openKeyCursor(query, 'next'); 522 request.onerror = (e) => onError(/** @type {IDBRequest<?IDBCursor>} */ (e.target).error); 523 request.onsuccess = (e) => { 524 const cursor = /** @type {IDBRequest<?IDBCursor>} */ (e.target).result; 525 if (cursor) { 526 results.push(cursor.primaryKey); 527 cursor.continue(); 528 } else { 529 onSuccess(results); 530 } 531 }; 532 } 533 534 /** 535 * @param {IDBObjectStore} objectStore The object store from which items are being deleted. 536 * @param {IDBValidKey[]} keys An array of keys to delete from the object store. 537 * @param {number} maxActiveRequests The maximum number of concurrent requests. 538 * @param {number} maxActiveRequestsForContinue The maximum number of requests that can be active before the next set of requests is started. 539 * For example: 540 * - If this value is `0`, all of the `maxActiveRequests` requests must complete before another group of `maxActiveRequests` is started off. 541 * - If the value is greater than or equal to `maxActiveRequests-1`, every time a single request completes, a new single request will be started. 542 * @param {?(completedCount: number, totalCount: number) => void} onProgress An optional progress callback function. 543 * @param {(error: ?Error) => void} onComplete A function which is called after all operations have finished. 544 * If an error occured, the `error` parameter will be non-`null`. Otherwise, it will be `null`. 545 * @throws {Error} An error is thrown if the input parameters are invalid. 546 */ 547 _bulkDeleteInternal(objectStore, keys, maxActiveRequests, maxActiveRequestsForContinue, onProgress, onComplete) { 548 if (maxActiveRequests <= 0) { throw new Error(`maxActiveRequests has an invalid value: ${maxActiveRequests}`); } 549 if (maxActiveRequestsForContinue < 0) { throw new Error(`maxActiveRequestsForContinue has an invalid value: ${maxActiveRequestsForContinue}`); } 550 551 const count = keys.length; 552 if (count === 0) { 553 onComplete(null); 554 return; 555 } 556 557 let completedCount = 0; 558 let completed = false; 559 let index = 0; 560 let active = 0; 561 562 const onSuccess = () => { 563 if (completed) { return; } 564 --active; 565 ++completedCount; 566 if (onProgress !== null) { 567 try { 568 onProgress(completedCount, count); 569 } catch (e) { 570 // NOP 571 } 572 } 573 if (completedCount >= count) { 574 completed = true; 575 onComplete(null); 576 } else if (active <= maxActiveRequestsForContinue) { 577 next(); 578 } 579 }; 580 581 /** 582 * @param {Event} event 583 */ 584 const onError = (event) => { 585 if (completed) { return; } 586 completed = true; 587 const request = /** @type {IDBRequest<undefined>} */ (event.target); 588 const {error} = request; 589 onComplete(error); 590 }; 591 592 const next = () => { 593 for (; index < count && active < maxActiveRequests; ++index) { 594 const key = keys[index]; 595 const request = objectStore.delete(key); 596 request.onsuccess = onSuccess; 597 request.onerror = onError; 598 ++active; 599 } 600 }; 601 602 next(); 603 } 604 605 /** 606 * @param {string[]} storeNames 607 * @param {() => void} resolve 608 * @param {(reason?: unknown) => void} reject 609 * @returns {IDBTransaction} 610 */ 611 _readWriteTransaction(storeNames, resolve, reject) { 612 const transaction = this.transaction(storeNames, 'readwrite'); 613 transaction.onerror = (e) => reject(/** @type {IDBTransaction} */ (e.target).error); 614 transaction.onabort = () => reject(new Error('Transaction aborted')); 615 transaction.oncomplete = () => resolve(); 616 return transaction; 617 } 618}