src/token-cache.ts (9170 bytes)
1 // What the reader keeps in IndexedDB. Thin browser layer. 2 // 3 // - "pages": the tokens of each page. OCR is slow, so a page is read once. 4 // The key names the file, the page, the OCR language and the source (text 5 // layer or OCR). 6 // - "books": a copy of the last book, under the key "last". Android can stop 7 // the reader while the user is in the dictionary or in the player, and a 8 // reload forgets the file that the user opened. 9 // - "meta": for each book, the page that was open, the subtitles and the 10 // "Force OCR" setting. 11 // 12 // The reader does not need IndexedDB. When IndexedDB fails, a read finds 13 // nothing, a write does nothing, and one warning goes to the console. No 14 // function throws. 15 16 import { 17 openDB, 18 unwrap, 19 type DBSchema, 20 type IDBPDatabase, 21 type IDBPTransaction, 22 type StoreNames, 23 } from 'idb'; 24 import type { OcrToken } from './align'; 25 import { srtName } from './book-subtitles'; 26 27 export type TextSource = 'text' | 'ocr'; 28 29 export interface PageEntry { 30 tokens: OcrToken[]; 31 /** Size of the page in pixels, the space of the boxes. */ 32 width: number; 33 height: number; 34 source: TextSource; 35 } 36 37 /** The subtitles of a book. */ 38 export interface BookSubtitles { 39 /** The file name, for example "book.srt". */ 40 name: string; 41 /** The text of the subtitle file. */ 42 text: string; 43 /** A file that the user loaded, or the result of SubRead. */ 44 source: 'file' | 'subread'; 45 } 46 47 /** 48 * A SubRead result that came while another book was open. It waits in the 49 * meta data of its book, and the reader offers it, with the confirm and the 50 * checks, when the book opens (see subreadConcerns). 51 */ 52 export interface KeptResult { 53 /** The text of the .srt. */ 54 srt: string; 55 /** The number of cues, when SubRead told it. */ 56 cues?: number; 57 /** 0 to 1: the share of the lines that SubRead found in the book, when it told it. */ 58 matchRate?: number; 59 /** The language that SubRead found, when it told it. */ 60 language?: string | null; 61 } 62 63 /** What the reader keeps for a book, other than its pages. */ 64 export interface BookMeta { 65 /** The page that was open. 0 is the first page. */ 66 page?: number; 67 subtitles?: BookSubtitles; 68 /** The "Force OCR" setting for the book. */ 69 forceOcr?: boolean; 70 /** A SubRead result that waits for the next open of the book. */ 71 subread?: KeptResult; 72 } 73 74 /** The record of the last book. */ 75 interface LastBook { 76 /** See bookKey. */ 77 key: string; 78 name: string; 79 /** The MIME type of the file. */ 80 type: string; 81 blob: Blob; 82 } 83 84 interface Schema extends DBSchema { 85 pages: { key: string; value: PageEntry }; 86 books: { key: string; value: LastBook }; 87 meta: { key: string; value: BookMeta }; 88 /** Only in version 2. The upgrade to version 3 moves the subtitles to "meta". */ 89 srt: { key: string; value: string }; 90 } 91 92 const DB_NAME = 'scan-subread'; 93 94 /** 95 * Version 1: earlier builds kept the stores of their dictionary. 96 * Version 2: "pages" and "srt". The upgrade deletes the stores of the 97 * dictionary. 98 * Version 3: "pages", "books" and "meta". The upgrade moves each text of 99 * "srt" to "meta", and deletes "srt". 100 */ 101 const DB_VERSION = 3; 102 const STORES = ['pages', 'books', 'meta'] as const; 103 const OLD_STORES = ['dictionaries', 'terms']; 104 105 /** The key of the one record in "books". */ 106 const LAST = 'last'; 107 108 let dbPromise: Promise<IDBPDatabase<Schema>> | undefined; 109 let warned = false; 110 111 function db(): Promise<IDBPDatabase<Schema>> { 112 dbPromise ??= openDB<Schema>(DB_NAME, DB_VERSION, { 113 async upgrade(database, _oldVersion, _newVersion, transaction) { 114 // The typed database knows only the stores of the schema. 115 const raw = unwrap(database); 116 for (const name of OLD_STORES) { 117 if (raw.objectStoreNames.contains(name)) raw.deleteObjectStore(name); 118 } 119 for (const name of STORES) { 120 if (!raw.objectStoreNames.contains(name)) raw.createObjectStore(name); 121 } 122 if (raw.objectStoreNames.contains('srt')) { 123 await moveSrtToMeta(transaction); 124 raw.deleteObjectStore('srt'); 125 } 126 }, 127 }); 128 return dbPromise; 129 } 130 131 /** Copies each text of "srt" to "meta", as subtitles from SubRead. */ 132 async function moveSrtToMeta( 133 transaction: IDBPTransaction<Schema, StoreNames<Schema>[], 'versionchange'>, 134 ): Promise<void> { 135 const meta = transaction.objectStore('meta'); 136 let cursor = await transaction.objectStore('srt').openCursor(); 137 while (cursor) { 138 await meta.put({ subtitles: subreadSubtitles(cursor.key, cursor.value) }, cursor.key); 139 cursor = await cursor.continue(); 140 } 141 } 142 143 /** Subtitles from SubRead for the book `key`. "book.pdf|123" gives the name "book.srt". */ 144 function subreadSubtitles(key: string, text: string): BookSubtitles { 145 return { name: srtName(bookName(key)), text, source: 'subread' }; 146 } 147 148 /** Runs `op` on the database. Gives undefined when IndexedDB fails, and warns once. */ 149 async function tryDb<T>(op: (d: IDBPDatabase<Schema>) => Promise<T>): Promise<T | undefined> { 150 try { 151 return await op(await db()); 152 } catch (err) { 153 if (!warned) { 154 warned = true; 155 console.warn('IndexedDB failed. The reader continues without it.', err); 156 } 157 return undefined; 158 } 159 } 160 161 type FileId = Pick<File, 'name' | 'size'>; 162 163 /** The key of a book: the file name and size. */ 164 export function bookKey(file: FileId): string { 165 return `${file.name}|${file.size}`; 166 } 167 168 /** The file name in a book key: "book.pdf|123" gives "book.pdf". */ 169 export function bookName(key: string): string { 170 const bar = key.lastIndexOf('|'); 171 return bar < 0 ? key : key.slice(0, bar); 172 } 173 174 export function pageKey(file: FileId, page: number, lang: string, source: TextSource): string { 175 return `${bookKey(file)}|${page}|${lang}|${source}`; 176 } 177 178 export function getPage(key: string): Promise<PageEntry | undefined> { 179 return tryDb((d) => d.get('pages', key)); 180 } 181 182 export async function putPage(key: string, entry: PageEntry): Promise<void> { 183 await tryDb((d) => d.put('pages', entry, key)); 184 } 185 186 /** Removes the pages of the book `key` (see bookKey). Without a key, removes all pages. */ 187 export async function clearPages(key?: string): Promise<void> { 188 await tryDb((d) => 189 key === undefined 190 ? d.clear('pages') 191 : // "}" follows "|" in the order of the code units. So this range holds 192 // each key that starts with `${key}|`, and no other key. 193 d.delete('pages', IDBKeyRange.bound(`${key}|`, `${key}}`, false, true)), 194 ); 195 } 196 197 /** 198 * Keeps a copy of `file` as the last book, in place of the book before it. 199 * Gives false when IndexedDB fails, for example when the file is larger than 200 * the free quota. 201 */ 202 export async function putLastBook(file: File): Promise<boolean> { 203 const record: LastBook = { key: bookKey(file), name: file.name, type: file.type, blob: file }; 204 const done = await tryDb(async (d) => { 205 await d.put('books', record, LAST); 206 return true; 207 }); 208 return done === true; 209 } 210 211 /** The last book, or undefined when there is no last book or IndexedDB fails. */ 212 export function getLastBook(): Promise<File | undefined> { 213 return tryDb(async (d) => { 214 const book = await d.get('books', LAST); 215 return book && new File([book.blob], book.name, { type: book.type }); 216 }); 217 } 218 219 /** 220 * Removes the copy of the last book, for example when its data cannot be 221 * read, but only while the copy is of the book `key` (see bookKey). The copy 222 * of another book stays: the user can open a book while the restore of the 223 * last book runs, and the copy of that book replaces the last book. 224 */ 225 export async function clearLastBook(key: string): Promise<void> { 226 await tryDb((d) => { 227 // One transaction reads and deletes, so a new copy cannot come between. 228 const tx = d.transaction('books', 'readwrite'); 229 const remove = async (): Promise<void> => { 230 if ((await tx.store.get(LAST))?.key === key) await tx.store.delete(LAST); 231 }; 232 return Promise.all([remove(), tx.done]); 233 }); 234 } 235 236 /** 237 * Resolves when the database is open, or when IndexedDB failed. The first 238 * open after an upgrade can take long: an earlier build kept about 86 MB of 239 * dictionary data, and the upgrade deletes it. 240 */ 241 export async function dbReady(): Promise<void> { 242 await tryDb(() => Promise.resolve()); 243 } 244 245 /** What the reader keeps for the book `key`. An empty object when there is nothing, or IndexedDB fails. */ 246 export async function getMeta(key: string): Promise<BookMeta> { 247 return (await tryDb((d) => d.get('meta', key))) ?? {}; 248 } 249 250 /** 251 * Merges `patch` into what the reader keeps for the book `key`. A field that 252 * is undefined in `patch` is removed. 253 */ 254 export async function putMeta(key: string, patch: Partial<BookMeta>): Promise<void> { 255 await tryDb((d) => { 256 // One transaction reads and writes, so two merges at the same time keep 257 // the fields of both. 258 const tx = d.transaction('meta', 'readwrite'); 259 const merge = async (): Promise<void> => { 260 const meta: BookMeta = { ...(await tx.store.get(key)), ...patch }; 261 for (const field of Object.keys(patch) as (keyof BookMeta)[]) { 262 if (patch[field] === undefined) delete meta[field]; 263 } 264 await tx.store.put(meta, key); 265 }; 266 return Promise.all([merge(), tx.done]); 267 }); 268 }