commit c9412f77aa0fd7a1091a31a5347ae33483593065
truex <truex@equwal.com>
2026-09-26 23:21:05 -0700
Do not show old status texts over the new book
- The user picks a large file A that is no PDF, then B. B shows first.
Then A failed, and the strip said "This file is not a PDF, or it is
damaged." over the good book B.
- A restore of the last book that failed after an open of the user
said its error over the book of the user.
- The SubRead result text of book A ("15 cues, language en, ...")
stayed in the menu when book B opened.
openBook now ends each failed attempt in one place, and says its error
only when failureShows (book-open.ts) allows it: no later attempt shows
a book, and for the restore, the user started no open after it. An
empty file is now an attempt too, so its error follows the same rule.
When a book opens, the make status goes unless a SubRead job runs or
waits for this book (keepMakeStatus, book-subtitles.ts). The status of
a job that runs stays, because it tells why the button is off.
src/book-open.ts | 12 ++++++++
src/book-subtitles.ts | 18 ++++++++++++
src/main.ts | 70 ++++++++++++++++++++++++++++-----------------
test/book-open.test.ts | 45 +++++++++++++++++++++++++++++
test/book-subtitles.test.ts | 29 ++++++++++++++++++-
5 files changed, 147 insertions(+), 27 deletions(-)
diff --git a/src/book-open.ts b/src/book-open.ts
index fab6fa7..a16297e 100644
--- a/src/book-open.ts
+++ b/src/book-open.ts
@@ -70,3 +70,15 @@ export function subtitlesOwner(o: Opens, book: string | null): string | null {
export function userOpened(o: Opens, since: number): boolean {
return o.byUser !== since;
}
+
+/**
+ * True when the strip may tell why the attempt `attempt` failed: no later
+ * attempt shows a book. For the restore of the last book, `since` is
+ * o.byUser when the restore started, and the error shows only when the user
+ * started no open after that: the user's book tells its own state. Before,
+ * "This file is not a PDF, or it is damaged." of an older pick showed over
+ * the good book that the user picked after it.
+ */
+export function failureShows(o: Opens, attempt: number, since?: number): boolean {
+ return attempt > o.shown && (since === undefined || !userOpened(o, since));
+}
diff --git a/src/book-subtitles.ts b/src/book-subtitles.ts
index 5db1729..c290670 100644
--- a/src/book-subtitles.ts
+++ b/src/book-subtitles.ts
@@ -100,3 +100,21 @@ export function subreadConcerns(c: SubreadCheck): string[] {
}
return out;
}
+
+/**
+ * True when the make status, the SubRead part of the menu, stays when the
+ * book `book` opens. `making` is the book of the SubRead job that runs, and
+ * `waitBook` the book of the job that waits for its pages, before the open.
+ *
+ * The status of a job that runs stays: one job runs at a time, so it tells
+ * why the button is off. The status of a wait for this book stays. Else the
+ * status of the book before goes: the SubRead result of book A stayed in
+ * the menu of book B.
+ */
+export function keepMakeStatus(
+ book: string,
+ making: string | null,
+ waitBook: string | null,
+): boolean {
+ return making !== null || waitBook === book;
+}
diff --git a/src/main.ts b/src/main.ts
index c74f97a..8817e2d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -9,8 +9,21 @@ import { App } from '@capacitor/app';
import { createAligner, shiftSpans, type Aligner, type OcrToken, type TokenSpan } from './align';
import { ankiCard, cardSource } from './anki-card';
import { backStep, keepScreenOn } from './app-state';
-import { beginOpen, createOpens, endOpen, subtitlesOwner, userOpened } from './book-open';
-import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from './book-subtitles';
+import {
+ beginOpen,
+ createOpens,
+ endOpen,
+ failureShows,
+ subtitlesOwner,
+ userOpened,
+} from './book-open';
+import {
+ keepMakeStatus,
+ resultName,
+ srtName,
+ subreadConcerns,
+ subtitlesOnOpen,
+} from './book-subtitles';
import { bookText } from './book-text';
import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source';
import { audioPage, cueParts, cueProgress, pageStartTime, type CueParts } from './cue-pages';
@@ -308,33 +321,40 @@ ui.pdfFile.addEventListener('change', () => {
/** The attempts to open a book, and the attempt of the book that is open. */
const opens = createOpens();
+/** The restore of the last book: its meta data, and opens.byUser when the restore started. */
+interface Restore {
+ meta: BookMeta;
+ since: number;
+}
+
/**
* Opens `file` as the book. The book that is open stays until the new
* file loads: a file that is not a PDF, or is damaged, changes nothing.
*
- * `meta` is what the reader keeps for the book, when the caller has it:
- * the book opens at its page. Only the restore of the last book gives it,
- * so an open without it is a file that the user picked. Else the meta data
- * comes from IndexedDB after the open. The page shows and the reading
- * starts at once, and the meta data applies when it comes: an upgrade of
- * the database can hold IndexedDB for many seconds. Gives true when the new
- * book is open.
+ * `restore` comes from the restore of the last book: the book opens at the
+ * page of its meta data. An open without it is a file that the user
+ * picked, and its meta data comes from IndexedDB after the open. The page
+ * shows and the reading starts at once, and the meta data applies when it
+ * comes: an upgrade of the database can hold IndexedDB for many seconds.
+ * Gives true when the new book is open.
*/
-async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
- if (file.size === 0) {
- say(EMPTY_FILE_TEXT);
+async function openBook(file: File, restore?: Restore): Promise<boolean> {
+ const meta = restore?.meta;
+ const attempt = beginOpen(opens, restore === undefined);
+ /** Ends the attempt that failed. The strip shows `text` only when failureShows allows it. */
+ const fail = (text: string): false => {
+ endOpen(opens, attempt, false);
+ claimSubtitles();
+ if (failureShows(opens, attempt, restore?.since)) say(text);
return false;
- }
- const attempt = beginOpen(opens, meta === undefined);
+ };
+ if (file.size === 0) return fail(EMPTY_FILE_TEXT);
let bytes: ArrayBuffer;
try {
bytes = await file.arrayBuffer();
} catch (err) {
- endOpen(opens, attempt, false);
- claimSubtitles();
// The copy of the last book is gone, for example after the storage was cleared.
- say(meta ? LAST_BOOK_GONE_TEXT : `Cannot read ${file.name}: ${String(err)}`);
- return false;
+ return fail(restore ? LAST_BOOK_GONE_TEXT : `Cannot read ${file.name}: ${String(err)}`);
}
let pdf: PdfDoc;
try {
@@ -342,10 +362,7 @@ async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
const { loadPdf } = await import('./pdf');
pdf = await loadPdf(bytes);
} catch (err) {
- endOpen(opens, attempt, false);
- claimSubtitles();
- say(pdfOpenText(err));
- return false;
+ return fail(pdfOpenText(err));
}
// A file that the user picked later is open already.
if (!endOpen(opens, attempt, true)) {
@@ -374,11 +391,12 @@ async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
}
claimSubtitles();
const subtitlesSeq = state.subtitlesSeq;
- // A SubRead job that waits for the pages of another book does not start.
- if (state.makeWait && state.makeWait.book !== key) {
- state.makeWait = null;
+ // The SubRead status of the book before goes, unless a job runs.
+ if (!keepMakeStatus(key, state.making, state.makeWait?.book ?? null)) {
ui.makeStatus.textContent = '';
}
+ // A SubRead job that waits for the pages of another book does not start.
+ if (state.makeWait && state.makeWait.book !== key) state.makeWait = null;
setForceOcr(meta?.forceOcr ?? false);
ui.forceOcr.disabled = false;
ui.clearBook.disabled = false;
@@ -503,7 +521,7 @@ async function restoreLastBook(): Promise<void> {
showStart();
return;
}
- if (await openBook(file, meta)) return;
+ if (await openBook(file, { meta, since })) return;
// The copy cannot be read, or it is no PDF now: forget it. After an open
// by the user, the copy can be of the user's book, so it stays.
if (!userOpened(opens, since)) await clearLastBook(bookKey(file));
diff --git a/test/book-open.test.ts b/test/book-open.test.ts
index 5d9e51b..83e161b 100644
--- a/test/book-open.test.ts
+++ b/test/book-open.test.ts
@@ -4,12 +4,57 @@ import {
beginOpen,
createOpens,
endOpen,
+ failureShows,
opening,
subtitlesOwner,
userOpened,
type Opens,
} from '../src/book-open';
+describe('failureShows', () => {
+ it('does not say the error of an older pick over the book that shows', () => {
+ // The finding: the user picks a large file A that is no PDF, then B. B
+ // shows first. Then A fails, and the strip said "This file is not a
+ // PDF, or it is damaged." over the good book B.
+ const o = createOpens();
+ const a = beginOpen(o, true);
+ const b = beginOpen(o, true);
+ expect(endOpen(o, b, true)).toBe(true);
+ expect(endOpen(o, a, false)).toBe(false);
+ expect(failureShows(o, a)).toBe(false);
+ });
+
+ it('says the error of a pick when no later pick shows', () => {
+ const o = createOpens();
+ const a = beginOpen(o, true);
+ endOpen(o, a, false);
+ expect(failureShows(o, a)).toBe(true);
+ // B still loads when A fails. When B shows, its message replaces the error.
+ const c = beginOpen(o, true);
+ beginOpen(o, true);
+ endOpen(o, c, false);
+ expect(failureShows(o, c)).toBe(true);
+ });
+
+ it('does not say the error of the restore after an open of the user', () => {
+ const o = createOpens();
+ const since = o.byUser;
+ const l = beginOpen(o, false);
+ beginOpen(o, true);
+ endOpen(o, l, false);
+ // The book of the user still loads, and it shows its own message.
+ expect(failureShows(o, l, since)).toBe(false);
+ });
+
+ it('says the error of the restore when the user opened nothing', () => {
+ const o = createOpens();
+ const since = o.byUser;
+ const l = beginOpen(o, false);
+ endOpen(o, l, false);
+ expect(failureShows(o, l, since)).toBe(true);
+ });
+});
+
/** One step of a script of opens: start the next attempt, or end one that loads. */
interface Step {
begin: boolean;
diff --git a/test/book-subtitles.test.ts b/test/book-subtitles.test.ts
index 394b446..7f89cb9 100644
--- a/test/book-subtitles.test.ts
+++ b/test/book-subtitles.test.ts
@@ -1,6 +1,33 @@
import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
-import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from '../src/book-subtitles';
+import {
+ keepMakeStatus,
+ resultName,
+ srtName,
+ subreadConcerns,
+ subtitlesOnOpen,
+} from '../src/book-subtitles';
+
+describe('keepMakeStatus', () => {
+ it('removes the SubRead result of the book before', () => {
+ // The finding: "15 cues, language en, 97% ..." of book A stayed in the
+ // menu when book B opened.
+ expect(keepMakeStatus('B', null, null)).toBe(false);
+ });
+
+ it('removes the wait of the book before: its job does not start', () => {
+ expect(keepMakeStatus('B', null, 'A')).toBe(false);
+ });
+
+ it('keeps the status of the job that runs: it tells why the button is off', () => {
+ expect(keepMakeStatus('B', 'A', null)).toBe(true);
+ expect(keepMakeStatus('A', 'A', null)).toBe(true);
+ });
+
+ it('keeps the wait of the book that opens again', () => {
+ expect(keepMakeStatus('A', null, 'A')).toBe(true);
+ });
+});
describe('srtName', () => {
it('puts .srt in place of .pdf', () => {