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 02e190246e2510024bec6c929acfd2631ad64271
truex <truex@equwal.com>
2026-09-26 23:23:50 -0700

Do not let late meta data undo a change of the user

The meta data of a book can come many seconds after the open: getMeta
waits behind the database upgrade. Two changes of the user were lost:

- The user opens B and ticks Force OCR before the meta data of B comes.
  applyMeta then set Force OCR back and started the reading again
  without it, while the store said on.
- The user opens B. Page 1 shows, and a save of page 1 waits. The user
  opens C before the meta data of B comes: the save of page 1 went over
  the saved page of B, and the meta data of B never applied.

state.changed now notes a page turn (by the user or by the follow) and
a change of Force OCR after the open. lateMeta (book-open.ts) gives the
fields of the meta data that apply: each field that did not change.
The first page of an open is the saved page or a placeholder, and the
page of the meta data is the saved page, so showPage does not save
them. Only a page turn saves the page.

 README.md              |  3 +++
 src/book-open.ts       | 27 ++++++++++++++++++++++++++-
 src/main.ts            | 41 +++++++++++++++++++++++++++++------------
 test/book-open.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++
 4 files changed, 98 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 67905fc..ae5297d 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,9 @@ second after it changes.
 The first start after an upgrade from a build with the old dictionary
 can take long: the upgrade deletes about 86 MB of dictionary data. The
 strip then says "Updating the page cache...", and the start card shows.
+A book that you open in that time shows at once, and its meta data
+applies when it comes. A page turn or a change of "Force OCR" that you
+make before then stays.
 
 ### Android: Back, the screen and the suite
 
diff --git a/src/book-open.ts b/src/book-open.ts
index a16297e..d8b8c14 100644
--- a/src/book-open.ts
+++ b/src/book-open.ts
@@ -1,5 +1,8 @@
 // The open of a book: the open attempts, the owner of subtitles that load
-// during an open, and the restore of the last book. Pure: no DOM.
+// during an open, the restore of the last book, the error of a failed
+// attempt, and the meta data that comes after the open. Pure: no DOM.
+
+import type { BookMeta } from './token-cache';
 
 /**
  * The open attempts of books. Each attempt gets the next number: 1, 2, 3 ...
@@ -82,3 +85,25 @@ export function userOpened(o: Opens, since: number): boolean {
 export function failureShows(o: Opens, attempt: number, since?: number): boolean {
   return attempt > o.shown && (since === undefined || !userOpened(o, since));
 }
+
+/** What changed after the open of a book, before its meta data came. */
+export interface Changes {
+  /** A page turn: by the user, or by the follow of the audio. */
+  page: boolean;
+  /** A change of "Force OCR" by the user. */
+  forceOcr: boolean;
+}
+
+/**
+ * The fields of the meta data of a book that apply when the meta data comes
+ * after the open: each field that did not change after the open. A field
+ * that is not in the result does not apply. Without a saved setting, "Force
+ * OCR" is off. The upgrade of the database can hold the meta data for many
+ * seconds, and before, it set "Force OCR" back after the user ticked it.
+ */
+export function lateMeta(meta: BookMeta, changed: Changes): { page?: number; forceOcr?: boolean } {
+  const late: { page?: number; forceOcr?: boolean } = {};
+  if (!changed.page && meta.page !== undefined) late.page = meta.page;
+  if (!changed.forceOcr) late.forceOcr = meta.forceOcr ?? false;
+  return late;
+}
diff --git a/src/main.ts b/src/main.ts
index 8817e2d..3fb0ff4 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -14,8 +14,10 @@ import {
   createOpens,
   endOpen,
   failureShows,
+  lateMeta,
   subtitlesOwner,
   userOpened,
+  type Changes,
 } from './book-open';
 import {
   keepMakeStatus,
@@ -186,6 +188,8 @@ const state = {
   dbSlow: false,
   /** "Force OCR" of the open book: the reading skips the text layer. */
   forceOcr: false,
+  /** What changed after the open of the book. Its meta data does not undo it (lateMeta). */
+  changed: { page: false, forceOcr: false } as Changes,
   /** A SubRead job that waits until all pages of its book are read. */
   makeWait: null as { audio: { uri: string; name: string }; book: string } | null,
   /** The book of the SubRead job that runs, or null. */
@@ -283,6 +287,8 @@ ui.lang.addEventListener('change', () => {
 ui.forceOcr.addEventListener('change', () => {
   const book = state.book;
   if (book === null) return;
+  // Meta data of the book that comes later does not undo this (lateMeta).
+  state.changed.forceOcr = true;
   setForceOcr(ui.forceOcr.checked);
   void putMeta(book, { forceOcr: state.forceOcr });
   void readBook();
@@ -384,6 +390,7 @@ async function openBook(file: File, restore?: Restore): Promise<boolean> {
   state.activeCue = -1;
   state.markedCue = -1;
   state.held = false;
+  state.changed = { page: false, forceOcr: false };
   // The subtitles of another book go at once. Subtitles of no book, loaded
   // while no book was open or while this book loaded, are for this book.
   if (state.subtitles && state.subtitlesBook !== null && state.subtitlesBook !== key) {
@@ -407,7 +414,9 @@ async function openBook(file: File, restore?: Restore): Promise<boolean> {
   if (state.clock) showPlayerStatus(state.clock);
   say(pdfLoadedText(pdf.numPages));
   // showPage sets the current page at once, so the reading starts there.
-  void showPage(validPage(meta?.page, pdf.numPages) ?? 0);
+  // This page is the saved page, or page 1 until the meta data comes: no
+  // save, so page 1 does not go over the saved page of the book.
+  void showPage(validPage(meta?.page, pdf.numPages) ?? 0, false);
   // readBook stops the reading of the old book before its document closes.
   void readBook();
   void old?.destroy();
@@ -431,20 +440,19 @@ function validPage(page: number | undefined, pages: number): number | undefined
 /**
  * Applies the meta data of the book that open attempt `attempt` opened.
  * `subtitlesSeq` is the count of subtitle loads at the open: subtitles
- * that the user loaded after the open stay. The page applies only while
- * the first page shows, so a page turn after the open stays.
+ * that the user loaded after the open stay. A page turn and a change of
+ * "Force OCR" after the open stay too (lateMeta).
  */
 function applyMeta(meta: BookMeta, attempt: number, subtitlesSeq: number): void {
   const key = state.book;
   const pdf = state.pdf;
   if (attempt !== opens.shown || key === null || !pdf) return;
-  const page = validPage(meta.page, pdf.numPages);
-  if (page !== undefined && page !== state.currentPage && state.currentPage === 0) {
-    void showPage(page);
-  }
-  const forceOcr = meta.forceOcr ?? false;
-  if (forceOcr !== state.forceOcr) {
-    setForceOcr(forceOcr);
+  const late = lateMeta(meta, state.changed);
+  const page = validPage(late.page, pdf.numPages);
+  // The saved page needs no new save.
+  if (page !== undefined && page !== state.currentPage) void showPage(page, false);
+  if (late.forceOcr !== undefined && late.forceOcr !== state.forceOcr) {
+    setForceOcr(late.forceOcr);
     void readBook();
   }
   if (state.subtitlesSeq === subtitlesSeq) {
@@ -1287,12 +1295,21 @@ function marks(index: number): Marks | null {
   };
 }
 
-async function showPage(index: number): Promise<void> {
+/**
+ * Shows page `index`. `save` is true for a page turn by the user or by the
+ * follow: the page goes to the meta data of the book soon, and the meta
+ * data that comes later does not turn the page back (lateMeta). The page
+ * of the open and the saved page need no save.
+ */
+async function showPage(index: number, save = true): Promise<void> {
   const pdf = state.pdf;
   if (!pdf || index < 0 || index >= pdf.numPages) return;
   state.currentPage = index;
   nav.update();
-  savePageSoon();
+  if (save) {
+    state.changed.page = true;
+    savePageSoon();
+  }
   try {
     await view.show(pdf, index);
   } catch (err) {
diff --git a/test/book-open.test.ts b/test/book-open.test.ts
index 83e161b..7b41e38 100644
--- a/test/book-open.test.ts
+++ b/test/book-open.test.ts
@@ -5,12 +5,52 @@ import {
   createOpens,
   endOpen,
   failureShows,
+  lateMeta,
   opening,
   subtitlesOwner,
   userOpened,
   type Opens,
 } from '../src/book-open';
 
+describe('lateMeta', () => {
+  const none = { page: false, forceOcr: false };
+
+  it('does not undo "Force OCR" that the user set before the meta data came', () => {
+    // The finding: the user opens B and ticks Force OCR before the meta data
+    // of B comes. The meta data set Force OCR back, and the reading started
+    // again without it, while the store said on.
+    expect(lateMeta({}, { page: false, forceOcr: true })).toEqual({});
+    expect(lateMeta({ forceOcr: false }, { page: false, forceOcr: true })).toEqual({});
+  });
+
+  it('does not go to the saved page after a page turn', () => {
+    expect(lateMeta({ page: 57 }, { page: true, forceOcr: false })).toEqual({ forceOcr: false });
+  });
+
+  it('applies each field that did not change', () => {
+    expect(lateMeta({ page: 57, forceOcr: true }, none)).toEqual({ page: 57, forceOcr: true });
+    // Without a saved setting, Force OCR is off. Without a saved page, the page stays.
+    expect(lateMeta({}, none)).toEqual({ forceOcr: false });
+  });
+
+  it('gives exactly the fields that did not change, with their saved values', () => {
+    const meta = fc.record(
+      { page: fc.nat({ max: 500 }), forceOcr: fc.boolean() },
+      { requiredKeys: [] },
+    );
+    const changes = fc.record({ page: fc.boolean(), forceOcr: fc.boolean() });
+    fc.assert(
+      fc.property(meta, changes, (m, changed) => {
+        const late = lateMeta(m, changed);
+        expect('page' in late).toBe(!changed.page && m.page !== undefined);
+        if ('page' in late) expect(late.page).toBe(m.page);
+        expect('forceOcr' in late).toBe(!changed.forceOcr);
+        if ('forceOcr' in late) expect(late.forceOcr).toBe(m.forceOcr ?? false);
+      }),
+    );
+  });
+});
+
 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