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


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', () => {