Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
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}