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/messages.ts (12101 bytes)

1 // The texts of the status strip. Pure: no DOM.
2 
3 import { PlayerError, type ClockState } from './clock-source';
4 
5 /** The actions of the buttons in the strip. main.ts runs them. */
6 export type ActionId = 'retry-reading' | 'open-overlay';
7 
8 /** A text, and a link or a button after it. */
9 export interface Message {
10   text: string;
11   link?: { href: string; text: string };
12   action?: { id: ActionId; text: string };
13 }
14 
15 /** A part of the status strip, for the panel of its full texts. */
16 export interface StripPart {
17   /** False while the part is hidden. */
18   shown: boolean;
19   /** The full text of its message: its title attribute, or '' when it has none. */
20   title: string;
21   /** Its text as it shows, with the text of its link or its button. */
22   text: string;
23 }
24 
25 /**
26  * The full texts of the parts of the strip that show, for the panel that a
27  * tap on the strip opens: the title of each part, else its text. A part
28  * with no text gives nothing. On the phone the one-line strip cut a long
29  * message, and a phone cannot hover to read the title.
30  */
31 export function stripTexts(parts: readonly StripPart[]): string[] {
32   return parts
33     .filter((part) => part.shown)
34     .map((part) => part.title || part.text.trim())
35     .filter((text) => text !== '');
36 }
37 
38 export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest';
39 export const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest';
40 export const DICTIONARY_RELEASES = 'https://github.com/equwal/subread-dictionary/releases/latest';
41 export const ANKI_RELEASES = 'https://github.com/equwal/subread-anki/releases/latest';
42 
43 /** A long press on the web: there is no SubRead Anki. */
44 export const ANKI_WEB_TEXT = 'Anki cards need SubRead Anki on Android.';
45 
46 /** The result of SubRead Anki in the strip. Null when the user closed the card. */
47 export function ankiText(result: { added?: boolean; error?: string }): Message | null {
48   if (result.error === 'not_installed') {
49     return {
50       text: 'SubRead Anki is not installed.',
51       link: { href: ANKI_RELEASES, text: 'Get SubRead Anki' },
52     };
53   }
54   if (result.error !== undefined) return { text: 'SubRead Anki could not start.' };
55   return result.added ? { text: 'Card added to Anki.' } : null;
56 }
57 
58 /** "1 page", "2 pages". */
59 function count(n: number, noun: string): string {
60   return `${n} ${noun}${n === 1 ? '' : 's'}`;
61 }
62 
63 /** A position as mm:ss, or h:mm:ss from one hour. */
64 export function clockText(ms: number): string {
65   const s = Math.floor(Math.max(0, ms) / 1000);
66   const pad = (n: number): string => String(n).padStart(2, '0');
67   const h = Math.floor(s / 3600);
68   return `${h > 0 ? `${h}:` : ''}${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`;
69 }
70 
71 export const OPEN_OVERLAY = { id: 'open-overlay', text: 'Open SubRead Overlay' } as const;
72 
73 /**
74  * What is wrong with the player, and what to do, for a reason of
75  * `PlayerError` or `ClockState.error`. Null for another reason.
76  * `overlayInstalled` is true when SubRead Overlay is on the device: then a
77  * button opens it, so that the user can give it notification access.
78  */
79 export function playerProblem(
80   reason: string,
81   android: boolean,
82   overlayInstalled = false,
83 ): Message | null {
84   switch (reason) {
85     case 'no_player':
86       return {
87         text: android
88           ? 'No audiobook is playing. Start it in your player app.'
89           : 'Load the audio file first.',
90       };
91     case 'no_overlay':
92       return overlayInstalled
93         ? { text: 'SubRead Overlay does not answer.', action: OPEN_OVERLAY }
94         : {
95             text: 'SubRead Overlay is not installed.',
96             link: { href: OVERLAY_RELEASES, text: 'Get it' },
97           };
98     case 'no_notification_access':
99       return { text: 'Allow notification access in SubRead Overlay.', action: OPEN_OVERLAY };
100     default:
101       return null;
102   }
103 }
104 
105 /**
106  * True when play and "audio to page" cannot work: no SubRead Overlay, no
107  * notification access, or on the web no audio file.
108  */
109 export function playerCommandsOff(error: string | null, android: boolean): boolean {
110   return (
111     error === 'no_overlay' ||
112     error === 'no_notification_access' ||
113     (!android && error === 'no_player')
114   );
115 }
116 
117 /** The text for a command that failed. A PlayerError gets the text of its reason. */
118 export function errorMessage(err: unknown, android: boolean, overlayInstalled = false): Message {
119   if (err instanceof PlayerError) {
120     return (
121       playerProblem(err.reason, android, overlayInstalled) ?? {
122         text: `The player failed: ${err.reason}.`,
123       }
124     );
125   }
126   return { text: String(err) };
127 }
128 
129 /**
130  * True when `err` is a PlayerError for the problem of the last player state
131  * (`playerError`). The player part of the strip shows that problem, so the
132  * event part does not say it again.
133  */
134 export function shownInPlayerPart(err: unknown, playerError: string | null | undefined): boolean {
135   return err instanceof PlayerError && err.reason === playerError;
136 }
137 
138 /** The player part of the strip: the time and the play state, or the problem. */
139 export function playerMessage(
140   s: ClockState | null,
141   android: boolean,
142   overlayInstalled = false,
143 ): Message {
144   const name = android ? 'Player' : 'Audio';
145   if (!s) return { text: `${name}: not read yet.` };
146   if (s.error !== null) {
147     return (
148       playerProblem(s.error, android, overlayInstalled) ?? {
149         text: `${name}: cannot read it (${s.error}).`,
150       }
151     );
152   }
153   if (s.positionMs === null) return { text: `${name}: no position.` };
154   return { text: `${name} ${clockText(s.positionMs)}, ${s.playing ? 'playing' : 'paused'}` };
155 }
156 
157 export function pdfLoadedText(pages: number): string {
158   return `PDF loaded: ${count(pages, 'page')}.`;
159 }
160 
161 /** The reading part of the strip while the pages are read. `note` tells about one page. */
162 export function readingText(read: number, total: number, note: string): string {
163   return `Reading ${read}/${total}${note ? ` · ${note}` : ''}`;
164 }
165 
166 /** The message after the last page is read. */
167 export function pagesReadText(counts: { text: number; ocr: number }): string {
168   return `${count(counts.text + counts.ocr, 'page')} read: ${counts.text} text layer, ${counts.ocr} OCR.`;
169 }
170 
171 export const OCR_START_TEXT =
172   'OCR could not start. The first OCR run needs a network connection to download the language data.';
173 
174 const RETRY = { id: 'retry-reading', text: 'Retry' } as const;
175 
176 /**
177  * The reading part of the strip after the reading of the pages ends: what
178  * went wrong, with Retry. `failed` is the count of pages that could not be
179  * read. `startFailed` is true when OCR could not start. Null when each page
180  * is read.
181  */
182 export function readingEndMessage(failed: number, startFailed: boolean): Message | null {
183   if (startFailed) return { text: OCR_START_TEXT, action: RETRY };
184   if (failed > 0) return { text: `${count(failed, 'page')} could not be read.`, action: RETRY };
185   return null;
186 }
187 
188 export const FORCE_OCR_NOTE = 'Force OCR is on';
189 
190 /** The reading part of the strip, with the note while Force OCR is on for the open book. */
191 export function withForceOcr(m: Message | null, forceOcr: boolean): Message | null {
192   if (!forceOcr) return m;
193   if (m === null) return { text: `${FORCE_OCR_NOTE}.` };
194   const text = m.text.endsWith('.')
195     ? `${m.text} ${FORCE_OCR_NOTE}.`
196     : `${m.text} · ${FORCE_OCR_NOTE}`;
197   return { ...m, text };
198 }
199 
200 export const SUBREAD_NOT_INSTALLED: Message = {
201   text: 'SubRead is not installed.',
202   link: { href: SUBREAD_RELEASES, text: 'Get SubRead' },
203 };
204 
205 /** SubRead waits for the reading: it makes the subtitles from the text of all pages. */
206 export function waitPagesText(read: number, total: number): string {
207   return `Reading pages ${read}/${total} first...`;
208 }
209 
210 /**
211  * The make status while a SubRead job waits for the pages of its book.
212  * `reading` is true while the reading of the pages runs. After the reading,
213  * the pages that are not read could not be read: the job waits until Retry
214  * reads them. Before, the status kept "Reading pages 38/40 first...", and
215  * SubRead never started.
216  */
217 export function waitMessage(read: number, total: number, reading: boolean): Message {
218   if (reading) return { text: waitPagesText(read, total) };
219   return {
220     text: `SubRead waits for ${count(total - read, 'page')} that could not be read.`,
221     action: RETRY,
222   };
223 }
224 
225 /** What SubRead made: the cues, the language and the share of the lines found in the book. */
226 export function subreadResultText(r: {
227   cues?: number;
228   language?: string | null;
229   matchRate?: number;
230 }): string {
231   const cues = r.cues !== undefined && r.cues >= 0 ? String(r.cues) : '?';
232   const rate = r.matchRate !== undefined && r.matchRate >= 0 ? r.matchRate : null;
233   return (
234     `${cues} cues, language ${r.language ?? '?'}` +
235     (rate === null ? '.' : `, ${Math.round(rate * 100)}% of the lines found in the book.`) +
236     (rate !== null && rate < 0.8
237       ? ' Under 80% usually means another edition or the wrong language.'
238       : '')
239   );
240 }
241 
242 /** SubRead made the subtitles of the book `name`, which is not open. They wait for that book. */
243 export function keptResultText(name: string): string {
244   return `SubRead made the subtitles of ${name}. The reader offers them when you open that book.`;
245 }
246 
247 /** Why SubRead made no subtitles. `cannot_start` means that the read grant of the audio is gone. */
248 export function subreadErrorText(error: string): string {
249   if (error.startsWith('cannot_start')) {
250     return 'SubRead cannot read the audio file. Choose the audio again.';
251   }
252   return `SubRead made no subtitles: ${error}.`;
253 }
254 
255 /** The question before the subtitles of SubRead load, with the reasons of subreadConcerns. */
256 export function subreadQuestion(concerns: readonly string[]): string {
257   return ['SubRead made subtitles for this book.', ...concerns, 'Load them?'].join('\n\n');
258 }
259 
260 /** The question before the saved pages of one book go. */
261 export function clearBookQuestion(name: string): string {
262   return `Remove the saved pages of ${name}? The reader reads them again the next time it opens the book.`;
263 }
264 
265 export const CLEAR_ALL_QUESTION =
266   'Remove the saved pages of all books? The reader reads each book again the next time it opens it.';
267 
268 /** The message for a tap on a page that is not read yet. */
269 export function notReadText(read: number, total: number): string {
270   return `This page is not read yet (${read}/${total} read).`;
271 }
272 
273 /** The strip says this while the database upgrade holds the reading. */
274 export const UPDATING_CACHE_TEXT = 'Updating the page cache...';
275 
276 /** The copy of the last book cannot be read, for example after the storage was cleared. */
277 export const LAST_BOOK_GONE_TEXT = 'The last book is no longer in the app storage. Open it again.';
278 
279 /** A picked file of 0 bytes, for example a file that another app still writes. */
280 export const EMPTY_FILE_TEXT = 'The file is empty or not ready. Try again in a moment.';
281 
282 /** The message when pdf.js cannot open a file. */
283 export function pdfOpenText(err: unknown): string {
284   if (err instanceof Error && err.name === 'InvalidPDFException') {
285     return 'This file is not a PDF, or it is damaged.';
286   }
287   return `Cannot open the PDF: ${String(err)}`;
288 }
289 
290 /** The message for a subtitle file with no cues, for example a PDF. */
291 export function noCuesText(name: string): string {
292   return `No subtitle lines in ${name}. Choose an .srt or .vtt file.`;
293 }
294 
295 /**
296  * When all pages are read and less than this share of the cues is
297  * matched, the subtitles are for another text or another language.
298  */
299 export const MISMATCH_SHARE = 0.3;
300 
301 export const MISMATCH_TEXT = 'These subtitles do not match this book or the OCR language.';
302 
303 /**
304  * The count of matched cues. When all pages are read and less than
305  * MISMATCH_SHARE of the cues is matched, the hint follows.
306  */
307 export function matchedText(matched: number, cues: number, allRead: boolean): string {
308   if (!allRead) return `${matched}/${cues} cues matched to the pages read so far.`;
309   const text = `${matched}/${cues} cues matched.`;
310   return cues > 0 && matched / cues < MISMATCH_SHARE ? `${text} ${MISMATCH_TEXT}` : text;
311 }