Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


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 }