diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html
index 6967e9048..3e6d24bf5 100644
--- a/packages/app-expo/assets/reader/reader.html
+++ b/packages/app-expo/assets/reader/reader.html
@@ -262,6 +262,7 @@
let bookTextMetricsTimer = null;
let bookmarkPullGestureActive = false;
let pullBookmarkResetTimer = null;
+ let extractionSessions = null;
let refreshAnnotationsTimer = null;
let activeFootnoteTipKey = null;
let bookmarkPullStateMeta = {
@@ -1418,6 +1419,9 @@
case 'extractBookChapters':
await handleExtractBookChapters(msg);
break;
+ case 'cancelExtraction':
+ if (msg.requestId) getExtractionSessions().cancel(msg.requestId);
+ break;
case 'getChapterParagraphs':
handleGetChapterParagraphs();
break;
@@ -1435,9 +1439,61 @@
}
// ─── Book loading ───
+ const SUPPORTED_BOOK_FORMATS = new Set(['epub', 'pdf', 'txt', 'umd', 'mobi', 'azw', 'azw3']);
+ const BOOK_MIME_TYPES = {
+ epub: 'application/epub+zip',
+ pdf: 'application/pdf',
+ txt: 'text/plain',
+ umd: 'application/epub+zip',
+ mobi: 'application/x-mobipocket-ebook',
+ azw: 'application/vnd.amazon.ebook',
+ azw3: 'application/vnd.amazon.ebook',
+ };
+ const BOOK_FORMATS_BY_MIME = {
+ 'application/epub+zip': 'epub',
+ 'application/pdf': 'pdf',
+ 'text/plain': 'txt',
+ 'application/x-mobipocket-ebook': 'mobi',
+ 'application/vnd.amazon.ebook': 'azw3',
+ };
+
+ function resolveBookFormat(msg) {
+ const storedFormat = String(msg.bookFormat || '').trim().toLowerCase();
+ if (SUPPORTED_BOOK_FORMATS.has(storedFormat)) return storedFormat;
+
+ const cleanFileName = String(msg.fileName || '').split(/[?#]/, 1)[0];
+ const extension = cleanFileName.split('.').pop().toLowerCase();
+ if (SUPPORTED_BOOK_FORMATS.has(extension)) return extension;
+
+ const mimeType = String(msg.mimeType || '').split(';', 1)[0].trim().toLowerCase();
+ return BOOK_FORMATS_BY_MIME[mimeType] || null;
+ }
+
+ function getExtractionSessions() {
+ if (!extractionSessions) {
+ extractionSessions = new window.ReaderExtractionSessions();
+ }
+ return extractionSessions;
+ }
+
+ function getBookFileName(msg) {
+ const format = resolveBookFormat(msg);
+ const cleanFileName = String(msg.fileName || '').split(/[?#]/, 1)[0].split(/[\\/]/).pop();
+ if (!format) return cleanFileName || 'book.epub';
+ const baseName = cleanFileName?.replace(/\.[^.]*$/, '') || 'book';
+ return `${baseName}.${format}`;
+ }
+
+ function getBookMimeType(msg, fallback) {
+ const format = resolveBookFormat(msg);
+ return BOOK_MIME_TYPES[format] || msg.mimeType || fallback || 'application/octet-stream';
+ }
+
async function openBook(msg) {
const container = document.getElementById('reader-container');
const loading = document.getElementById('loading');
+ const fileName = getBookFileName(msg);
+ const mimeType = getBookMimeType(msg);
currentBookIsPdf = isPDFBookMessage(msg);
pdfPageLightCache = {};
pdfDocIndexMap = new WeakMap();
@@ -1448,12 +1504,13 @@
}
try {
+ getExtractionSessions().throwIfCancelled(msg.requestId);
let hasSignalledLoaded = false;
const markLoaded = () => {
if (loading) loading.classList.add('hidden');
if (!hasSignalledLoaded) {
hasSignalledLoaded = true;
- postToRN('loaded', {});
+ postToRN('loaded', { requestId: msg.requestId });
}
};
@@ -1462,22 +1519,22 @@
const binary = atob(msg.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- file = new File([bytes], msg.fileName || 'book.epub', {
- type: msg.mimeType || 'application/epub+zip'
+ file = new File([bytes], fileName, {
+ type: mimeType
});
} else if (msg.uri) {
postToRN('debug', {
message: `[ReaderFetch] open ${JSON.stringify({
uri: msg.uri,
- fileName: msg.fileName || '',
- mimeType: msg.mimeType || ''
+ fileName,
+ mimeType
})}`
});
// Try Range-based lazy loading for ZIP-based formats (EPUB, CBZ, FBZ)
// This avoids loading the entire file into memory — only reads
// the ZIP central directory (~few KB) then fetches entries on demand.
- const isZipFormat = /\.(epub|cbz|fb2\.zip|fbz)$/i.test(msg.fileName || '');
+ const isZipFormat = /\.(epub|cbz|fb2\.zip|fbz)$/i.test(fileName);
let lazyBook = null;
if (isZipFormat) {
@@ -1530,14 +1587,14 @@
// Try Range-based lazy loading for PDF
// pdf.js natively supports Range requests via url + disableAutoFetch
- if (!lazyBook && /\.pdf$/i.test(msg.fileName || '')) {
+ if (!lazyBook && /\.pdf$/i.test(fileName)) {
try {
const headRes = await fetch(msg.uri, { method: 'HEAD' });
const acceptRanges = headRes.headers.get('Accept-Ranges');
const contentLength = parseInt(headRes.headers.get('Content-Length'), 10);
if (acceptRanges === 'bytes' && contentLength > 0 && window._makePDFFromURL) {
- lazyBook = await window._makePDFFromURL(msg.uri, msg.fileName || 'book.pdf');
+ lazyBook = await window._makePDFFromURL(msg.uri, fileName);
}
} catch (pdfLazyErr) {
console.warn('[Reader] PDF lazy loading failed, falling back to full fetch:', pdfLazyErr);
@@ -1550,8 +1607,8 @@
const isLocalFileStatus = res.status === 0 && /^file:\/\//i.test(msg.uri);
if (!res.ok && !isLocalFileStatus) throw new Error(`Failed to fetch: ${res.status}`);
const blob = await res.blob();
- file = new File([blob], msg.fileName || 'book.epub', {
- type: msg.mimeType || blob.type || 'application/octet-stream'
+ file = new File([blob], fileName, {
+ type: getBookMimeType(msg, blob.type)
});
} else {
// Skip makeBook below — we already have the book object
@@ -1561,7 +1618,11 @@
throw new Error('No book data provided');
}
- const book = (file && file.sections) ? file : await makeBook(file);
+ const loadBook = async () => (file && file.sections) ? file : await makeBook(file);
+ const book = msg.requestId
+ ? await getExtractionSessions().open(msg.requestId, loadBook)
+ : await loadBook();
+ getExtractionSessions().throwIfCancelled(msg.requestId);
currentBook = book;
attachBookTransformHandler(book);
@@ -1802,7 +1863,8 @@
console.error('[WebView] Error in openBook:', err);
const message = `${String(err)}${msg?.uri ? ` (${msg.uri})` : ''}`;
loading.innerHTML = '
' + escapeHtml(message) + '
';
- postToRN('error', { message });
+ postToRN('error', { message, requestId: msg.requestId });
+ getExtractionSessions().release(msg.requestId);
}
}
@@ -4296,8 +4358,7 @@
// ─── Chapter Extraction for Vectorization ───
function isPDFBookMessage(msg) {
- const mimeType = (msg.mimeType || '').split(';')[0].trim().toLowerCase();
- return mimeType === 'application/pdf' || /\.pdf$/i.test(msg.fileName || '');
+ return resolveBookFormat(msg) === 'pdf';
}
async function createBookFileFromMessage(msg) {
@@ -4305,8 +4366,8 @@
const binary = atob(msg.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- return new File([bytes], msg.fileName || 'book.epub', {
- type: msg.mimeType || 'application/epub+zip'
+ return new File([bytes], getBookFileName(msg), {
+ type: getBookMimeType(msg)
});
}
@@ -4315,8 +4376,8 @@
const isLocalFileStatus = res.status === 0 && /^file:\/\//i.test(msg.uri);
if (!res.ok && !isLocalFileStatus) throw new Error(`Failed to fetch: ${res.status}`);
const blob = await res.blob();
- return new File([blob], msg.fileName || 'book.epub', {
- type: msg.mimeType || blob.type || 'application/octet-stream'
+ return new File([blob], getBookFileName(msg), {
+ type: getBookMimeType(msg, blob.type)
});
}
@@ -4324,7 +4385,9 @@
}
async function handleExtractBookChapters(msg) {
+ const requestId = msg.requestId;
try {
+ getExtractionSessions().throwIfCancelled(requestId);
if (isPDFBookMessage(msg)) {
if (typeof window._extractPDFChapters !== 'function') {
throw new Error('PDF extraction is not available in this reader build');
@@ -4344,6 +4407,7 @@
postToRN('debug', { message: `[PDFExtract] page ${JSON.stringify(detail)}` });
}
});
+ getExtractionSessions().throwIfCancelled(requestId);
postToRN('debug', {
message: `[PDFExtract] done ${JSON.stringify({
@@ -4353,27 +4417,42 @@
})}`
});
- postToRN('chaptersExtracted', { chapters });
+ postToRN('chaptersExtracted', { requestId, chapters });
return;
}
- currentBook = await makeBook(await createBookFileFromMessage(msg));
- await handleExtractChapters();
+ const requestBook = await getExtractionSessions().open(
+ requestId,
+ async () => makeBook(await createBookFileFromMessage(msg))
+ );
+ currentBook = requestBook;
+ getExtractionSessions().throwIfCancelled(requestId);
+ await handleExtractChapters(requestId);
} catch (err) {
console.error('[WebView] Error extracting book chapters:', err);
- postToRN('chaptersExtracted', { error: String(err) });
+ postToRN('chaptersExtracted', { requestId, error: String(err) });
+ } finally {
+ getExtractionSessions().release(requestId);
}
}
- async function handleExtractChapters() {
- if (!currentBook) {
- postToRN('chaptersExtracted', { error: 'No book loaded' });
+ async function handleExtractChapters(requestId) {
+ let extractionBook;
+ try {
+ extractionBook = requestId ? getExtractionSessions().getBook(requestId) : currentBook;
+ } catch (err) {
+ postToRN('chaptersExtracted', { requestId, error: String(err) });
+ return;
+ }
+ if (!extractionBook) {
+ postToRN('chaptersExtracted', { requestId, error: 'No book loaded' });
return;
}
try {
- const sections = currentBook.sections || [];
- const toc = currentBook.toc || [];
+ getExtractionSessions().throwIfCancelled(requestId);
+ const sections = extractionBook.sections || [];
+ const toc = extractionBook.toc || [];
// Build map of href to title mapping
const tocMap = new Map();
@@ -4398,6 +4477,7 @@
let skippedNoCreateDocument = 0;
for (let i = 0; i < sections.length; i++) {
+ getExtractionSessions().throwIfCancelled(requestId);
const section = sections[i];
if (!section.createDocument) {
skippedNoCreateDocument += 1;
@@ -4406,6 +4486,7 @@
try {
const doc = await section.createDocument();
+ getExtractionSessions().throwIfCancelled(requestId);
if (!doc.body) continue;
const title = tocMap.get(i) || tocMap.get(section.href || "") || `Section ${i + 1}`;
@@ -4432,6 +4513,8 @@
}
}
+ getExtractionSessions().throwIfCancelled(requestId);
+
console.log('[WebView] Chapter extraction summary:', {
sections: sections.length,
chapters: chapters.length,
@@ -4439,10 +4522,12 @@
skippedNoCreateDocument
});
- postToRN('chaptersExtracted', { chapters });
+ postToRN('chaptersExtracted', { requestId, chapters });
} catch (err) {
console.error('[WebView] Error extracting chapters:', err);
- postToRN('chaptersExtracted', { error: String(err) });
+ postToRN('chaptersExtracted', { requestId, error: String(err) });
+ } finally {
+ getExtractionSessions().release(requestId);
}
}
@@ -5025,8 +5110,8 @@
diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html
index d3658a0b3..78a8f3aab 100644
--- a/packages/app-expo/assets/reader/reader.template.html
+++ b/packages/app-expo/assets/reader/reader.template.html
@@ -215,6 +215,7 @@
let bookTextMetricsTimer = null;
let bookmarkPullGestureActive = false;
let pullBookmarkResetTimer = null;
+ let extractionSessions = null;
let refreshAnnotationsTimer = null;
let activeFootnoteTipKey = null;
let bookmarkPullStateMeta = {
@@ -1371,6 +1372,9 @@
case 'extractBookChapters':
await handleExtractBookChapters(msg);
break;
+ case 'cancelExtraction':
+ if (msg.requestId) getExtractionSessions().cancel(msg.requestId);
+ break;
case 'getChapterParagraphs':
handleGetChapterParagraphs();
break;
@@ -1388,9 +1392,61 @@
}
// ─── Book loading ───
+ const SUPPORTED_BOOK_FORMATS = new Set(['epub', 'pdf', 'txt', 'umd', 'mobi', 'azw', 'azw3']);
+ const BOOK_MIME_TYPES = {
+ epub: 'application/epub+zip',
+ pdf: 'application/pdf',
+ txt: 'text/plain',
+ umd: 'application/epub+zip',
+ mobi: 'application/x-mobipocket-ebook',
+ azw: 'application/vnd.amazon.ebook',
+ azw3: 'application/vnd.amazon.ebook',
+ };
+ const BOOK_FORMATS_BY_MIME = {
+ 'application/epub+zip': 'epub',
+ 'application/pdf': 'pdf',
+ 'text/plain': 'txt',
+ 'application/x-mobipocket-ebook': 'mobi',
+ 'application/vnd.amazon.ebook': 'azw3',
+ };
+
+ function resolveBookFormat(msg) {
+ const storedFormat = String(msg.bookFormat || '').trim().toLowerCase();
+ if (SUPPORTED_BOOK_FORMATS.has(storedFormat)) return storedFormat;
+
+ const cleanFileName = String(msg.fileName || '').split(/[?#]/, 1)[0];
+ const extension = cleanFileName.split('.').pop().toLowerCase();
+ if (SUPPORTED_BOOK_FORMATS.has(extension)) return extension;
+
+ const mimeType = String(msg.mimeType || '').split(';', 1)[0].trim().toLowerCase();
+ return BOOK_FORMATS_BY_MIME[mimeType] || null;
+ }
+
+ function getExtractionSessions() {
+ if (!extractionSessions) {
+ extractionSessions = new window.ReaderExtractionSessions();
+ }
+ return extractionSessions;
+ }
+
+ function getBookFileName(msg) {
+ const format = resolveBookFormat(msg);
+ const cleanFileName = String(msg.fileName || '').split(/[?#]/, 1)[0].split(/[\\/]/).pop();
+ if (!format) return cleanFileName || 'book.epub';
+ const baseName = cleanFileName?.replace(/\.[^.]*$/, '') || 'book';
+ return `${baseName}.${format}`;
+ }
+
+ function getBookMimeType(msg, fallback) {
+ const format = resolveBookFormat(msg);
+ return BOOK_MIME_TYPES[format] || msg.mimeType || fallback || 'application/octet-stream';
+ }
+
async function openBook(msg) {
const container = document.getElementById('reader-container');
const loading = document.getElementById('loading');
+ const fileName = getBookFileName(msg);
+ const mimeType = getBookMimeType(msg);
currentBookIsPdf = isPDFBookMessage(msg);
pdfPageLightCache = {};
pdfDocIndexMap = new WeakMap();
@@ -1401,12 +1457,13 @@
}
try {
+ getExtractionSessions().throwIfCancelled(msg.requestId);
let hasSignalledLoaded = false;
const markLoaded = () => {
if (loading) loading.classList.add('hidden');
if (!hasSignalledLoaded) {
hasSignalledLoaded = true;
- postToRN('loaded', {});
+ postToRN('loaded', { requestId: msg.requestId });
}
};
@@ -1415,22 +1472,22 @@
const binary = atob(msg.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- file = new File([bytes], msg.fileName || 'book.epub', {
- type: msg.mimeType || 'application/epub+zip'
+ file = new File([bytes], fileName, {
+ type: mimeType
});
} else if (msg.uri) {
postToRN('debug', {
message: `[ReaderFetch] open ${JSON.stringify({
uri: msg.uri,
- fileName: msg.fileName || '',
- mimeType: msg.mimeType || ''
+ fileName,
+ mimeType
})}`
});
// Try Range-based lazy loading for ZIP-based formats (EPUB, CBZ, FBZ)
// This avoids loading the entire file into memory — only reads
// the ZIP central directory (~few KB) then fetches entries on demand.
- const isZipFormat = /\.(epub|cbz|fb2\.zip|fbz)$/i.test(msg.fileName || '');
+ const isZipFormat = /\.(epub|cbz|fb2\.zip|fbz)$/i.test(fileName);
let lazyBook = null;
if (isZipFormat) {
@@ -1483,14 +1540,14 @@
// Try Range-based lazy loading for PDF
// pdf.js natively supports Range requests via url + disableAutoFetch
- if (!lazyBook && /\.pdf$/i.test(msg.fileName || '')) {
+ if (!lazyBook && /\.pdf$/i.test(fileName)) {
try {
const headRes = await fetch(msg.uri, { method: 'HEAD' });
const acceptRanges = headRes.headers.get('Accept-Ranges');
const contentLength = parseInt(headRes.headers.get('Content-Length'), 10);
if (acceptRanges === 'bytes' && contentLength > 0 && window._makePDFFromURL) {
- lazyBook = await window._makePDFFromURL(msg.uri, msg.fileName || 'book.pdf');
+ lazyBook = await window._makePDFFromURL(msg.uri, fileName);
}
} catch (pdfLazyErr) {
console.warn('[Reader] PDF lazy loading failed, falling back to full fetch:', pdfLazyErr);
@@ -1503,8 +1560,8 @@
const isLocalFileStatus = res.status === 0 && /^file:\/\//i.test(msg.uri);
if (!res.ok && !isLocalFileStatus) throw new Error(`Failed to fetch: ${res.status}`);
const blob = await res.blob();
- file = new File([blob], msg.fileName || 'book.epub', {
- type: msg.mimeType || blob.type || 'application/octet-stream'
+ file = new File([blob], fileName, {
+ type: getBookMimeType(msg, blob.type)
});
} else {
// Skip makeBook below — we already have the book object
@@ -1514,7 +1571,11 @@
throw new Error('No book data provided');
}
- const book = (file && file.sections) ? file : await makeBook(file);
+ const loadBook = async () => (file && file.sections) ? file : await makeBook(file);
+ const book = msg.requestId
+ ? await getExtractionSessions().open(msg.requestId, loadBook)
+ : await loadBook();
+ getExtractionSessions().throwIfCancelled(msg.requestId);
currentBook = book;
attachBookTransformHandler(book);
@@ -1755,7 +1816,8 @@
console.error('[WebView] Error in openBook:', err);
const message = `${String(err)}${msg?.uri ? ` (${msg.uri})` : ''}`;
loading.innerHTML = '' + escapeHtml(message) + '
';
- postToRN('error', { message });
+ postToRN('error', { message, requestId: msg.requestId });
+ getExtractionSessions().release(msg.requestId);
}
}
@@ -4249,8 +4311,7 @@
// ─── Chapter Extraction for Vectorization ───
function isPDFBookMessage(msg) {
- const mimeType = (msg.mimeType || '').split(';')[0].trim().toLowerCase();
- return mimeType === 'application/pdf' || /\.pdf$/i.test(msg.fileName || '');
+ return resolveBookFormat(msg) === 'pdf';
}
async function createBookFileFromMessage(msg) {
@@ -4258,8 +4319,8 @@
const binary = atob(msg.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- return new File([bytes], msg.fileName || 'book.epub', {
- type: msg.mimeType || 'application/epub+zip'
+ return new File([bytes], getBookFileName(msg), {
+ type: getBookMimeType(msg)
});
}
@@ -4268,8 +4329,8 @@
const isLocalFileStatus = res.status === 0 && /^file:\/\//i.test(msg.uri);
if (!res.ok && !isLocalFileStatus) throw new Error(`Failed to fetch: ${res.status}`);
const blob = await res.blob();
- return new File([blob], msg.fileName || 'book.epub', {
- type: msg.mimeType || blob.type || 'application/octet-stream'
+ return new File([blob], getBookFileName(msg), {
+ type: getBookMimeType(msg, blob.type)
});
}
@@ -4277,7 +4338,9 @@
}
async function handleExtractBookChapters(msg) {
+ const requestId = msg.requestId;
try {
+ getExtractionSessions().throwIfCancelled(requestId);
if (isPDFBookMessage(msg)) {
if (typeof window._extractPDFChapters !== 'function') {
throw new Error('PDF extraction is not available in this reader build');
@@ -4297,6 +4360,7 @@
postToRN('debug', { message: `[PDFExtract] page ${JSON.stringify(detail)}` });
}
});
+ getExtractionSessions().throwIfCancelled(requestId);
postToRN('debug', {
message: `[PDFExtract] done ${JSON.stringify({
@@ -4306,27 +4370,42 @@
})}`
});
- postToRN('chaptersExtracted', { chapters });
+ postToRN('chaptersExtracted', { requestId, chapters });
return;
}
- currentBook = await makeBook(await createBookFileFromMessage(msg));
- await handleExtractChapters();
+ const requestBook = await getExtractionSessions().open(
+ requestId,
+ async () => makeBook(await createBookFileFromMessage(msg))
+ );
+ currentBook = requestBook;
+ getExtractionSessions().throwIfCancelled(requestId);
+ await handleExtractChapters(requestId);
} catch (err) {
console.error('[WebView] Error extracting book chapters:', err);
- postToRN('chaptersExtracted', { error: String(err) });
+ postToRN('chaptersExtracted', { requestId, error: String(err) });
+ } finally {
+ getExtractionSessions().release(requestId);
}
}
- async function handleExtractChapters() {
- if (!currentBook) {
- postToRN('chaptersExtracted', { error: 'No book loaded' });
+ async function handleExtractChapters(requestId) {
+ let extractionBook;
+ try {
+ extractionBook = requestId ? getExtractionSessions().getBook(requestId) : currentBook;
+ } catch (err) {
+ postToRN('chaptersExtracted', { requestId, error: String(err) });
+ return;
+ }
+ if (!extractionBook) {
+ postToRN('chaptersExtracted', { requestId, error: 'No book loaded' });
return;
}
try {
- const sections = currentBook.sections || [];
- const toc = currentBook.toc || [];
+ getExtractionSessions().throwIfCancelled(requestId);
+ const sections = extractionBook.sections || [];
+ const toc = extractionBook.toc || [];
// Build map of href to title mapping
const tocMap = new Map();
@@ -4351,6 +4430,7 @@
let skippedNoCreateDocument = 0;
for (let i = 0; i < sections.length; i++) {
+ getExtractionSessions().throwIfCancelled(requestId);
const section = sections[i];
if (!section.createDocument) {
skippedNoCreateDocument += 1;
@@ -4359,6 +4439,7 @@
try {
const doc = await section.createDocument();
+ getExtractionSessions().throwIfCancelled(requestId);
if (!doc.body) continue;
const title = tocMap.get(i) || tocMap.get(section.href || "") || `Section ${i + 1}`;
@@ -4385,6 +4466,8 @@
}
}
+ getExtractionSessions().throwIfCancelled(requestId);
+
console.log('[WebView] Chapter extraction summary:', {
sections: sections.length,
chapters: chapters.length,
@@ -4392,10 +4475,12 @@
skippedNoCreateDocument
});
- postToRN('chaptersExtracted', { chapters });
+ postToRN('chaptersExtracted', { requestId, chapters });
} catch (err) {
console.error('[WebView] Error extracting chapters:', err);
- postToRN('chaptersExtracted', { error: String(err) });
+ postToRN('chaptersExtracted', { requestId, error: String(err) });
+ } finally {
+ getExtractionSessions().release(requestId);
}
}
diff --git a/packages/app-expo/scripts/build-reader.js b/packages/app-expo/scripts/build-reader.js
index ec5c53b56..d4aeb34c6 100644
--- a/packages/app-expo/scripts/build-reader.js
+++ b/packages/app-expo/scripts/build-reader.js
@@ -12,6 +12,7 @@ const FOLIATE_DIR = path.resolve(__dirname, "../../foliate-js");
const ASSETS_DIR = path.resolve(__dirname, "../assets/reader");
const TEMPLATE = path.resolve(ASSETS_DIR, "reader.template.html");
const OUTPUT = path.resolve(ASSETS_DIR, "reader.html");
+const EXTRACTION_SESSIONS = path.resolve(__dirname, "../src/lib/rag/reader-extraction-sessions.ts");
const JUSTIFIED_TEXT = path.resolve(ASSETS_DIR, "justified-text.js");
async function buildReader() {
@@ -23,6 +24,7 @@ async function buildReader() {
import { configure, ZipReader, BlobReader, TextWriter, BlobWriter } from "${FOLIATE_DIR.replace(/\\/g, "/")}/vendor/zip.js";
import { EPUB } from "${FOLIATE_DIR.replace(/\\/g, "/")}/epub.js";
import { extractPDFChapters, makePDFFromURL } from "${FOLIATE_DIR.replace(/\\/g, "/")}/pdf.js";
+ import { ReaderExtractionSessions } from "${EXTRACTION_SESSIONS.replace(/\\/g, "/")}";
window.makeBook = makeBook;
window.Overlayer = Overlayer;
@@ -33,6 +35,7 @@ async function buildReader() {
window._EPUB = EPUB;
window._makePDFFromURL = makePDFFromURL;
window._extractPDFChapters = extractPDFChapters;
+ window.ReaderExtractionSessions = ReaderExtractionSessions;
if (!customElements.get('foliate-view')) {
customElements.define('foliate-view', View);
diff --git a/packages/app-expo/src/components/library/BookCard.tsx b/packages/app-expo/src/components/library/BookCard.tsx
index 746f0a141..e0a6a6ed4 100644
--- a/packages/app-expo/src/components/library/BookCard.tsx
+++ b/packages/app-expo/src/components/library/BookCard.tsx
@@ -1,4 +1,5 @@
import { CheckIcon, ClockIcon, Loader2Icon, MoreVerticalIcon } from "@/components/ui/Icon";
+import { isVectorizationCancellable } from "@/lib/rag/vectorization-cancel-state";
import { useColors } from "@/styles/theme";
import { getPlatformService } from "@readany/core/services";
/**
@@ -54,6 +55,7 @@ interface BookCardProps {
onShowDetails?: (book: Book) => void;
onManageTags?: (book: Book) => void;
onVectorize?: (book: Book) => void;
+ onCancelVectorize?: (bookId: string) => void;
isVectorizing?: boolean;
isQueued?: boolean;
vectorProgress?: { status: string; processedChunks: number; totalChunks: number } | null;
@@ -72,6 +74,7 @@ export const BookCard = memo(function BookCard({
onShowDetails,
onManageTags,
onVectorize,
+ onCancelVectorize,
isVectorizing,
isQueued,
vectorProgress,
@@ -124,6 +127,10 @@ export const BookCard = memo(function BookCard({
? Math.round((vectorProgress.processedChunks / vectorProgress.totalChunks) * 100)
: 0
: 0;
+ const canCancelVectorization = isVectorizationCancellable(
+ Boolean(isVectorizing),
+ vectorProgress?.status,
+ );
const measureAnchor = useCallback(async () => {
const measureNode = (node: View | null, fallbackToBottomRight = false) =>
@@ -287,31 +294,74 @@ export const BookCard = memo(function BookCard({
)}
{/* Vectorization progress overlay */}
- {isVectorizing && (
+ {isVectorizing && canCancelVectorization && (
+ {
+ event.stopPropagation();
+ onCancelVectorize?.(book.id);
+ }}
+ >
+ {vectorProgress?.status !== "cancelled" && }
+
+ {vectorProgress?.status === "cancelling"
+ ? t("home.vec_cancelling", "Cancelling…")
+ : vectorProgress?.status === "cancelled"
+ ? t("home.vec_cancelled", "Cancelled")
+ : vectorProgress?.status === "chunking"
+ ? `${vecPct}%`
+ : vectorProgress?.status === "embedding"
+ ? `${vecPct}%`
+ : vectorProgress?.status === "indexing"
+ ? t("home.vec_indexing")
+ : vectorProgress?.status === "completed"
+ ? "✓"
+ : vectorProgress?.status === "error"
+ ? "✗"
+ : t("home.vec_processing")}
+
+ {vectorProgress?.status !== "cancelling" &&
+ vectorProgress?.status !== "cancelled" && (
+ {t("home.vec_cancel", "Cancel")}
+ )}
+
+ )}
+ {isVectorizing && !canCancelVectorization && (
-
+ {vectorProgress?.status !== "cancelled" && }
- {vectorProgress?.status === "chunking"
- ? `${vecPct}%`
- : vectorProgress?.status === "embedding"
- ? `${vecPct}%`
- : vectorProgress?.status === "indexing"
- ? t("home.vec_indexing")
- : vectorProgress?.status === "completed"
- ? "✓"
- : vectorProgress?.status === "error"
- ? "✗"
- : t("home.vec_processing")}
+ {vectorProgress?.status === "cancelling"
+ ? t("home.vec_cancelling", "Cancelling…")
+ : vectorProgress?.status === "cancelled"
+ ? t("home.vec_cancelled", "Cancelled")
+ : vectorProgress?.status === "completed"
+ ? "✓"
+ : vectorProgress?.status === "error"
+ ? "✕"
+ : t("home.vec_processing")}
)}
{/* Queued overlay */}
{isQueued && !isVectorizing && (
-
+ {
+ event.stopPropagation();
+ onCancelVectorize?.(book.id);
+ }}
+ >
{t("home.vec_queued", "排队中")}
-
+ {t("home.vec_cancel", "Cancel")}
+
)}
{/* Remote status overlay (on-demand download) */}
diff --git a/packages/app-expo/src/components/rag/ExtractorWebView.tsx b/packages/app-expo/src/components/rag/ExtractorWebView.tsx
index 2609a7b01..7d4280193 100644
--- a/packages/app-expo/src/components/rag/ExtractorWebView.tsx
+++ b/packages/app-expo/src/components/rag/ExtractorWebView.tsx
@@ -1,59 +1,64 @@
import type { ChapterData } from "@readany/core/rag";
+import type { Book } from "@readany/core/types";
import { Asset } from "expo-asset";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { StyleSheet, View } from "react-native";
-import { WebView } from "react-native-webview";
+import { WebView, type WebViewMessageEvent } from "react-native-webview";
+import { toBookExtractionError } from "../../lib/rag/extractor-error";
+import { createExtractorCommand } from "../../lib/rag/extractor-format";
+import { ExtractorRequestBoundary } from "../../lib/rag/extractor-request-boundary";
const READER_HTML_ASSET = Asset.fromModule(require("../../../assets/reader/reader.html"));
const EXTRACTION_TIMEOUT_MS = 45_000;
-const EXTRACTOR_EXTENSIONS_BY_MIME: Record = {
- "application/epub+zip": "epub",
- "application/pdf": "pdf",
- "application/x-mobipocket-ebook": "mobi",
- "application/vnd.amazon.ebook": "azw3",
- "application/vnd.comicbook+zip": "cbz",
- "application/x-fictionbook+xml": "fb2",
- "application/x-zip-compressed-fb2": "fbz",
- "text/plain": "txt",
-};
-
-function getExtractorFileName(mimeType: string) {
- const normalized = mimeType.split(";")[0]?.trim().toLowerCase() || "application/epub+zip";
- return `book.${EXTRACTOR_EXTENSIONS_BY_MIME[normalized] || "epub"}`;
-}
-
-function isPDFMimeType(mimeType: string) {
- return mimeType.split(";")[0]?.trim().toLowerCase() === "application/pdf";
-}
-
export interface ExtractorRef {
- extractChapters: (base64BookData: string, mimeType?: string) => Promise;
+ extractChapters: (
+ base64BookData: string,
+ mimeType?: string,
+ bookFormat?: Book["format"],
+ fileName?: string,
+ signal?: AbortSignal,
+ ) => Promise;
}
-interface PendingExtraction {
- resolve: (chapters: ChapterData[]) => void;
- reject: (err: Error) => void;
- timeoutId: ReturnType;
+function getAbortError(signal: AbortSignal): Error {
+ const reason = signal.reason;
+ if (reason instanceof Error) {
+ if (reason.name !== "AbortError") reason.name = "AbortError";
+ return reason;
+ }
+ const error = new Error("Vectorization cancelled");
+ error.name = "AbortError";
+ return error;
}
export const ExtractorWebView = forwardRef((_, ref) => {
const webViewRef = useRef(null);
const [htmlUri, setHtmlUri] = useState(null);
const [ready, setReady] = useState(false);
-
- // Pending extraction requests
- const pendingRequests = useRef([]);
+ const [requestBoundary] = useState(
+ () =>
+ new ExtractorRequestBoundary({
+ timeoutMs: EXTRACTION_TIMEOUT_MS,
+ sendCancel: (requestId) => {
+ webViewRef.current?.injectJavaScript(`
+ window.postMessage(${JSON.stringify(
+ JSON.stringify({ type: "cancelExtraction", requestId }),
+ )}, "*");
+ true;
+ `);
+ },
+ onCancelError: (requestId, error) => {
+ console.warn(`[ExtractorWebView] Failed to cancel request ${requestId}:`, error);
+ },
+ }),
+ );
useEffect(() => {
return () => {
- for (const pending of pendingRequests.current) {
- clearTimeout(pending.timeoutId);
- pending.reject(new Error("Extractor WebView unmounted"));
- }
- pendingRequests.current = [];
+ requestBoundary.rejectAll();
};
- }, []);
+ }, [requestBoundary]);
useEffect(() => {
const loadAsset = async () => {
@@ -69,75 +74,102 @@ export const ExtractorWebView = forwardRef((_, ref) => {
loadAsset();
}, []);
- // biome-ignore lint/suspicious/noExplicitAny: Required for React Native WebView events
- const handleMessage = useCallback((event: any) => {
- try {
- const msg = JSON.parse(event.nativeEvent.data);
- if (msg.type === "ready") {
- setReady(true);
- } else if (msg.type === "loaded") {
- // Trigger extraction once the book is fully loaded
- webViewRef.current?.injectJavaScript(`
+ const handleMessage = useCallback(
+ (event: WebViewMessageEvent) => {
+ try {
+ const msg = JSON.parse(event.nativeEvent.data);
+ if (msg.type === "ready") {
+ setReady(true);
+ } else if (msg.type === "loaded") {
+ if (!requestBoundary.has(msg.requestId)) return;
+ // Trigger extraction once the book is fully loaded
+ webViewRef.current?.injectJavaScript(`
if (window.handleExtractChapters) {
- window.handleExtractChapters();
+ window.handleExtractChapters(${JSON.stringify(msg.requestId)});
} else {
- window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'chaptersExtracted', error: 'Extraction not supported' }));
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'chaptersExtracted', requestId: ${JSON.stringify(msg.requestId)}, error: 'Extraction not supported' }));
}
true;
`);
- } else if (msg.type === "chaptersExtracted") {
- const pending = pendingRequests.current.shift();
- if (!pending) return;
-
- clearTimeout(pending.timeoutId);
- if (msg.error) {
- pending.reject(new Error(msg.error));
- } else if (msg.chapters) {
- pending.resolve(msg.chapters);
- }
- } else if (msg.type === "debug") {
- console.log("[ExtractorWebView]", msg.message);
- } else if (msg.type === "error") {
- console.error("[ExtractorWebView] WebView error:", msg.message);
- const pending = pendingRequests.current.shift();
- if (pending) {
- clearTimeout(pending.timeoutId);
- pending.reject(new Error(msg.message));
+ } else if (msg.type === "chaptersExtracted") {
+ const classificationFormat = requestBoundary.getContext(msg.requestId);
+ if (msg.error) {
+ requestBoundary.reject(
+ msg.requestId,
+ toBookExtractionError(new Error(String(msg.error)), classificationFormat),
+ );
+ } else if (msg.chapters) {
+ requestBoundary.resolve(msg.requestId, msg.chapters);
+ }
+ } else if (msg.type === "debug") {
+ console.log("[ExtractorWebView]", msg.message);
+ } else if (msg.type === "error") {
+ if (!requestBoundary.has(msg.requestId)) return;
+ console.error("[ExtractorWebView] WebView error:", msg.message);
+ const classificationFormat = requestBoundary.getContext(msg.requestId);
+ requestBoundary.reject(
+ msg.requestId,
+ toBookExtractionError(new Error(String(msg.message)), classificationFormat),
+ );
}
+ } catch (err) {
+ console.warn("[ExtractorWebView] Failed to parse message:", err);
}
- } catch (err) {
- console.warn("[ExtractorWebView] Failed to parse message:", err);
- }
- }, []);
+ },
+ [requestBoundary],
+ );
useImperativeHandle(ref, () => ({
- extractChapters: (base64BookData: string, mimeType = "application/epub+zip") => {
+ extractChapters: (
+ base64BookData: string,
+ mimeType = "application/epub+zip",
+ bookFormat?: Book["format"],
+ fileName?: string,
+ signal?: AbortSignal,
+ ) => {
+ const requestId = `extract-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ const baseCommand = createExtractorCommand({
+ base64BookData,
+ mimeType,
+ bookFormat,
+ fileName,
+ });
+ const command = { ...baseCommand, requestId };
+ const classificationFormat = command.bookFormat ?? undefined;
return new Promise((resolve, reject) => {
+ if (signal?.aborted) return reject(getAbortError(signal));
if (!ready || !webViewRef.current) {
- return reject(new Error("Extractor WebView not ready"));
+ return reject(
+ toBookExtractionError(new Error("Extractor WebView not ready"), classificationFormat),
+ );
}
- const timeoutId = setTimeout(() => {
- const index = pendingRequests.current.findIndex((pending) => pending.reject === reject);
- if (index >= 0) pendingRequests.current.splice(index, 1);
- reject(new Error("Timed out extracting book content"));
- }, EXTRACTION_TIMEOUT_MS);
-
- pendingRequests.current.push({ resolve, reject, timeoutId });
+ requestBoundary.add({
+ requestId,
+ resolve,
+ reject,
+ context: classificationFormat,
+ signal,
+ abortError: () => getAbortError(signal as AbortSignal),
+ timeoutError: () =>
+ toBookExtractionError(
+ new Error("Timed out extracting book content"),
+ classificationFormat,
+ ),
+ disposeError: () =>
+ toBookExtractionError(new Error("Extractor WebView unmounted"), classificationFormat),
+ });
// Command the webview to open the book first.
// It will reply with "loaded" when it finishes rendering.
- const cmd = {
- type: isPDFMimeType(mimeType) ? "extractBookChapters" : "openBook",
- base64: base64BookData,
- mimeType,
- fileName: getExtractorFileName(mimeType),
- };
-
- webViewRef.current.injectJavaScript(`
- window.postMessage(${JSON.stringify(JSON.stringify(cmd))}, "*");
- true;
- `);
+ try {
+ webViewRef.current.injectJavaScript(`
+ window.postMessage(${JSON.stringify(JSON.stringify(command))}, "*");
+ true;
+ `);
+ } catch (error) {
+ requestBoundary.reject(requestId, toBookExtractionError(error, classificationFormat));
+ }
});
},
}));
diff --git a/packages/app-expo/src/lib/rag/__fixtures__/README.md b/packages/app-expo/src/lib/rag/__fixtures__/README.md
new file mode 100644
index 000000000..92f46bd7f
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/__fixtures__/README.md
@@ -0,0 +1,18 @@
+# MOBI-family integration fixtures
+
+These are byte-for-byte Project Gutenberg downloads of ebook 11, *Alice's Adventures in Wonderland* by Lewis Carroll. They were retrieved on 2026-08-16 and are used only to prove ReadAny's real foliate MOBI-family extraction path.
+
+| Fixture | Official acquisition URL | Resolved Project Gutenberg file | Bytes | SHA-256 |
+| --- | --- | --- | ---: | --- |
+| `gutenberg-11.mobi` | | | 240,898 | `4cc3901c405178935a0d4b25ac03bdafc776e0ec3ce81b24482844f2a47ecd13` |
+| `gutenberg-11.azw3` | | | 256,060 | `fffee390f393ecf004f65c7fcd2cbefb3ee2652ff6f3fa8daa09c8a9a5644df0` |
+
+## Byte identity
+
+Both files have the Palm Database type/creator bytes `BOOKMOBI` and PalmDOC encryption value `0` (unencrypted). The older-Kindle download declares MOBI version 6. The KF8 download declares MOBI version 8 and is stored here with the `.azw3` extension so the integration test covers ReadAny's AZW3 input path without converting or modifying the source bytes.
+
+Project Gutenberg currently publishes the KF8 acquisition with a `.mobi` filename. The Library of Congress format description records that Amazon registered `application/vnd.amazon.mobi8-ebook` for the MOBI version that uses the `.azw3` extension: . That byte-level version evidence, rather than a fabricated conversion, is why the untouched KF8 download is the AZW3 fixture.
+
+## Rights and repository suitability
+
+Project Gutenberg's ebook page identifies this title as public domain in the USA: . Each fixture includes the Project Gutenberg License and its distribution terms in the ebook text. The two fixtures total 496,958 bytes (about 485 KiB), small enough for deterministic upstream integration coverage while retaining real MOBI v6 and KF8 containers, metadata, sections, compression, and images.
diff --git a/packages/app-expo/src/lib/rag/__fixtures__/gutenberg-11.azw3 b/packages/app-expo/src/lib/rag/__fixtures__/gutenberg-11.azw3
new file mode 100644
index 000000000..36f5e4e81
Binary files /dev/null and b/packages/app-expo/src/lib/rag/__fixtures__/gutenberg-11.azw3 differ
diff --git a/packages/app-expo/src/lib/rag/__fixtures__/gutenberg-11.mobi b/packages/app-expo/src/lib/rag/__fixtures__/gutenberg-11.mobi
new file mode 100644
index 000000000..562b9ab8d
Binary files /dev/null and b/packages/app-expo/src/lib/rag/__fixtures__/gutenberg-11.mobi differ
diff --git a/packages/app-expo/src/lib/rag/auto-vectorize-book.ts b/packages/app-expo/src/lib/rag/auto-vectorize-book.ts
index f798a8abc..f19265504 100644
--- a/packages/app-expo/src/lib/rag/auto-vectorize-book.ts
+++ b/packages/app-expo/src/lib/rag/auto-vectorize-book.ts
@@ -2,19 +2,7 @@ import { getPlatformService } from "@readany/core/services";
import type { Book } from "@readany/core/types";
import * as FileSystem from "expo-file-system/legacy";
import { queueBook as queueAutoVectorize } from "./auto-vectorize-service";
-
-const MIME_TYPES: Record = {
- epub: "application/epub+zip",
- pdf: "application/pdf",
- txt: "text/plain",
- // Mobile UMD imports are converted and stored as EPUB before vectorization.
- umd: "application/epub+zip",
-};
-
-export function getMobileVectorizeMimeType(format: string | undefined): string | null {
- const normalized = String(format || "").toLowerCase();
- return MIME_TYPES[normalized] ?? null;
-}
+import { getMobileVectorizeCapability } from "./mobile-vectorize-capability";
function bytesToBase64(bytes: Uint8Array): string {
const chunkSize = 0x8000;
@@ -57,8 +45,9 @@ export async function inspectMobileBookForVectorize(book: Book): Promise<{
reason?: "unsupported-format" | "missing-file";
}> {
const absPath = await resolveMobileBookPath(book.filePath);
- const mimeType = getMobileVectorizeMimeType(book.format);
- if (!mimeType) {
+ const capability = getMobileVectorizeCapability(book.format);
+ const { mimeType } = capability;
+ if (!capability.supported) {
return { absPath, mimeType, size: null, canVectorize: false, reason: "unsupported-format" };
}
diff --git a/packages/app-expo/src/lib/rag/auto-vectorize-service.test.ts b/packages/app-expo/src/lib/rag/auto-vectorize-service.test.ts
new file mode 100644
index 000000000..2ff709047
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/auto-vectorize-service.test.ts
@@ -0,0 +1,109 @@
+import { VectorizationCleanupError } from "@readany/core/rag";
+import type { Book } from "@readany/core/types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const vectorizeMocks = vi.hoisted(() => ({
+ resetBookVectorization: vi.fn(),
+ triggerVectorizeBook: vi.fn(),
+}));
+
+vi.mock("./vectorize-trigger", () => vectorizeMocks);
+
+import { isProcessing, queueBook, setCallback, setExtractorRef } from "./auto-vectorize-service";
+
+const book: Book = {
+ id: "book-1",
+ filePath: "books/protected.mobi",
+ format: "mobi",
+ meta: { title: "Protected book", author: "Author" },
+ addedAt: 1,
+ updatedAt: 1,
+ progress: 0,
+ isVectorized: true,
+ vectorizeProgress: 1,
+ tags: [],
+ syncStatus: "local",
+};
+
+afterEach(() => {
+ setExtractorRef(null);
+ setCallback(null);
+ vi.clearAllMocks();
+});
+
+describe("automatic vectorization failure lifecycle", () => {
+ it("cleans the book before publishing a classified extraction error", async () => {
+ const events: string[] = [];
+ vectorizeMocks.resetBookVectorization.mockImplementation(async () => {
+ events.push("cleanup");
+ });
+ setExtractorRef({
+ extractChapters: vi.fn().mockRejectedValue(new Error("Encrypted MOBI records")),
+ });
+
+ const errorPublished = new Promise((resolve) => {
+ setCallback((_bookId, progress) => {
+ if (progress.status === "error") {
+ events.push(`error:${progress.errorCategory}`);
+ resolve();
+ }
+ });
+ });
+
+ await queueBook(book, "base64", "application/x-mobipocket-ebook");
+ await errorPublished;
+
+ expect(events).toEqual(["cleanup", "error:drm-protected"]);
+ expect(vectorizeMocks.triggerVectorizeBook).not.toHaveBeenCalled();
+ });
+
+ it("publishes the failure and releases the queue if cleanup itself rejects", async () => {
+ const cleanupError = new Error("cleanup failed");
+ vectorizeMocks.resetBookVectorization.mockRejectedValueOnce(cleanupError);
+ setExtractorRef({
+ extractChapters: vi.fn().mockRejectedValue(new Error("loader failed")),
+ });
+ const callback = vi.fn();
+ setCallback(callback);
+
+ await queueBook(book, "base64", "application/x-mobipocket-ebook");
+
+ await vi.waitFor(() => {
+ expect(callback).toHaveBeenCalledWith("book-1", {
+ status: "error",
+ progress: 0,
+ error: expect.anything(),
+ errorCategory: "unknown",
+ cleanupError,
+ });
+ expect(isProcessing()).toBe(false);
+ });
+ });
+
+ it("surfaces core cleanup failure instead of discarding it", async () => {
+ const cleanupError = new Error("partial vectors remain");
+ setExtractorRef({
+ extractChapters: vi.fn().mockResolvedValue([{ index: 0, title: "Chapter", content: "text" }]),
+ });
+ vectorizeMocks.triggerVectorizeBook.mockRejectedValueOnce(
+ new VectorizationCleanupError(new Error("cancelled"), cleanupError),
+ );
+ const callback = vi.fn();
+ setCallback(callback);
+
+ await queueBook(book, "base64", "application/x-mobipocket-ebook");
+
+ await vi.waitFor(() => {
+ expect(callback).toHaveBeenCalledWith(
+ "book-1",
+ expect.objectContaining({
+ status: "error",
+ error: expect.any(VectorizationCleanupError),
+ cleanupError,
+ }),
+ );
+ expect(vectorizeMocks.resetBookVectorization).not.toHaveBeenCalled();
+ expect(isProcessing()).toBe(false);
+ });
+ });
+});
diff --git a/packages/app-expo/src/lib/rag/auto-vectorize-service.ts b/packages/app-expo/src/lib/rag/auto-vectorize-service.ts
index 4d27ec229..220963ad8 100644
--- a/packages/app-expo/src/lib/rag/auto-vectorize-service.ts
+++ b/packages/app-expo/src/lib/rag/auto-vectorize-service.ts
@@ -1,10 +1,27 @@
import type { ChapterData } from "@readany/core/rag";
import type { Book } from "@readany/core/types";
-
-export type AutoVectorizeCallback = (bookId: string, progress: { status: string; progress: number }) => void;
+import type { BookExtractionErrorCategory } from "./extractor-error";
+import { runVectorizeQueueJob, throwIfQueueJobAborted } from "./vectorize-queue-job";
+
+export type AutoVectorizeCallback = (
+ bookId: string,
+ progress: {
+ status: string;
+ progress: number;
+ error?: unknown;
+ errorCategory?: BookExtractionErrorCategory;
+ cleanupError?: unknown;
+ },
+) => void;
interface ExtractorRef {
- extractChapters: (base64BookData: string, mimeType?: string) => Promise;
+ extractChapters: (
+ base64BookData: string,
+ mimeType?: string,
+ bookFormat?: Book["format"],
+ fileName?: string,
+ signal?: AbortSignal,
+ ) => Promise;
}
interface QueueItem {
@@ -15,7 +32,7 @@ interface QueueItem {
let extractorRef: ExtractorRef | null = null;
let callback: AutoVectorizeCallback | null = null;
-let queue: QueueItem[] = [];
+const queue: QueueItem[] = [];
let processing = false;
export function setExtractorRef(ref: ExtractorRef | null) {
@@ -45,41 +62,71 @@ async function processQueue() {
if (processing) return;
processing = true;
- const { triggerVectorizeBook } = await import("./vectorize-trigger");
-
- while (queue.length > 0) {
- const item = queue.shift();
- if (!item) break;
-
- const { book, base64Data, mimeType } = item;
-
- try {
- callback?.(book.id, { status: "extracting", progress: 0 });
-
- if (!extractorRef) {
- console.warn("[AutoVectorize] Extractor not ready, skipping");
- continue;
- }
-
- const chapters = await extractorRef.extractChapters(base64Data, mimeType);
- if (!chapters || chapters.length === 0) {
- console.warn(`[AutoVectorize] No chapters for ${book.meta.title}`);
- continue;
- }
-
- callback?.(book.id, { status: "vectorizing", progress: 0 });
-
- await triggerVectorizeBook(book.id, book.filePath, chapters, (progress) => {
- const pct = progress.totalChunks > 0 ? progress.processedChunks / progress.totalChunks : 0;
- callback?.(book.id, { status: "vectorizing", progress: pct });
+ try {
+ const { resetBookVectorization, triggerVectorizeBook } = await import("./vectorize-trigger");
+
+ while (queue.length > 0) {
+ const item = queue.shift();
+ if (!item) break;
+
+ const { book, base64Data, mimeType } = item;
+
+ await runVectorizeQueueJob({
+ format: book.format,
+ extract: async (signal) => {
+ throwIfQueueJobAborted(signal);
+ if (!extractorRef) throw new Error("Extractor WebView not ready");
+ return extractorRef.extractChapters(
+ base64Data,
+ mimeType,
+ book.format,
+ book.filePath,
+ signal,
+ );
+ },
+ vectorize: async (chapters, onProgress, signal) => {
+ await triggerVectorizeBook(
+ book.id,
+ book.filePath,
+ chapters,
+ (progress) => {
+ const pct =
+ progress.totalChunks > 0 ? progress.processedChunks / progress.totalChunks : 0;
+ onProgress?.(pct);
+ },
+ signal,
+ );
+ },
+ cleanup: () => resetBookVectorization(book.id),
+ onEvent: (event) => {
+ if (event.status === "extracting") {
+ callback?.(book.id, { status: "extracting", progress: 0 });
+ } else if (event.status === "vectorizing") {
+ callback?.(book.id, { status: "vectorizing", progress: event.progress ?? 0 });
+ } else if (event.status === "completed") {
+ callback?.(book.id, { status: "completed", progress: 1 });
+ } else if (event.status === "cancelled") {
+ callback?.(book.id, { status: "cancelled", progress: 0 });
+ } else {
+ console.error(`[AutoVectorize] Failed for ${book.meta.title}:`, event.error);
+ if (event.cleanupError) {
+ console.error(
+ `[AutoVectorize] Failed to clean up ${book.meta.title}:`,
+ event.cleanupError,
+ );
+ }
+ callback?.(book.id, {
+ status: "error",
+ progress: 0,
+ error: event.error,
+ errorCategory: event.errorCategory,
+ cleanupError: event.cleanupError,
+ });
+ }
+ },
});
-
- callback?.(book.id, { status: "completed", progress: 1 });
- } catch (err) {
- console.error(`[AutoVectorize] Failed for ${book.meta.title}:`, err);
- callback?.(book.id, { status: "error", progress: 0 });
}
+ } finally {
+ processing = false;
}
-
- processing = false;
}
diff --git a/packages/app-expo/src/lib/rag/extractor-error.test.ts b/packages/app-expo/src/lib/rag/extractor-error.test.ts
new file mode 100644
index 000000000..155dca04c
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/extractor-error.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import {
+ BookExtractionError,
+ classifyBookExtractionError,
+ getBookExtractionErrorMessageKeys,
+ toBookExtractionError,
+} from "./extractor-error";
+
+describe("classifyBookExtractionError", () => {
+ it.each([
+ ["mobi", "Encrypted MOBI records are not supported"],
+ ["azw", "DRM protected content"],
+ ["azw3", "encryption is not supported"],
+ ])("classifies narrow protection evidence for %s", (format, message) => {
+ expect(classifyBookExtractionError(new Error(message), format)).toBe("drm-protected");
+ });
+
+ it.each(["epub", "pdf", "kfx", undefined])(
+ "does not classify protection wording for non-MOBI format %s",
+ (format) => {
+ expect(classifyBookExtractionError(new Error("encrypted content"), format)).toBe("unknown");
+ },
+ );
+
+ it.each([
+ "invalid PDB record offset",
+ "truncated MOBI header",
+ "invalid record structure",
+ "record offset is outside the file",
+ "Invalid HUFF record",
+ "Invalid CDIC record",
+ "Invalid INDX record",
+ "Invalid TAGX section",
+ "Invalid EXTH header",
+ "Missing MOBI header",
+ "Missing FDST record",
+ "Record index out of bounds",
+ "Offset is outside the bounds of the DataView",
+ ])("classifies malformed structure evidence: %s", (message) => {
+ expect(classifyBookExtractionError(new Error(message), "mobi")).toBe("malformed");
+ });
+
+ it("does not guess DRM from a generic MOBI parser failure", () => {
+ expect(classifyBookExtractionError(new Error("loader failed"), "mobi")).toBe("unknown");
+ });
+
+ it("classifies explicit unsupported-format failures", () => {
+ expect(classifyBookExtractionError(new Error("unsupported format: kfx"), "kfx")).toBe(
+ "unsupported-format",
+ );
+ });
+
+ it("handles non-Error rejections without broadening classification", () => {
+ expect(classifyBookExtractionError("loader failed", "azw3")).toBe("unknown");
+ });
+});
+
+describe("BookExtractionError", () => {
+ it("carries bounded parser classification across the extraction boundary", () => {
+ const cause = new Error("Encrypted MOBI records are not supported");
+
+ const error = toBookExtractionError(cause, "mobi");
+
+ expect(error).toBeInstanceOf(BookExtractionError);
+ expect(error.message).toBe(cause.message);
+ expect(error.category).toBe("drm-protected");
+ expect(error.cause).toBe(cause);
+ });
+
+ it("keeps extraction message keys distinct for every category", () => {
+ expect(getBookExtractionErrorMessageKeys("drm-protected")).toEqual({
+ title: "vectorize.protectedBookTitle",
+ description: "vectorize.protectedBookDesc",
+ });
+ expect(getBookExtractionErrorMessageKeys("malformed")).toEqual({
+ title: "vectorize.malformedBookTitle",
+ description: "vectorize.malformedBookDesc",
+ });
+ expect(getBookExtractionErrorMessageKeys("unsupported-format")).toEqual({
+ title: "vectorize.unsupportedFormatTitle",
+ description: "vectorize.unsupportedFormatDesc",
+ });
+ expect(getBookExtractionErrorMessageKeys("unknown")).toEqual({
+ title: "vectorize.extractionFailedTitle",
+ description: "vectorize.extractionFailedDesc",
+ });
+ });
+});
diff --git a/packages/app-expo/src/lib/rag/extractor-error.ts b/packages/app-expo/src/lib/rag/extractor-error.ts
new file mode 100644
index 000000000..0d9e22ae7
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/extractor-error.ts
@@ -0,0 +1,79 @@
+export type BookExtractionErrorCategory =
+ | "drm-protected"
+ | "malformed"
+ | "unsupported-format"
+ | "unknown";
+
+const MOBI_FAMILY = new Set(["mobi", "azw", "azw3"]);
+const PROTECTION_EVIDENCE = /\b(?:encrypt(?:ed|ion)?|drm|protected)\b/i;
+const GENERIC_MALFORMED_EVIDENCE =
+ /\b(?:truncat(?:ed|ion)|invalid\s+(?:(?:pdb|mobi)\s+)?record(?:\s+(?:offset|structure|header|index))?|record\s+(?:offset|structure|header|index))\b/i;
+const MOBI_PARSER_MALFORMED_EVIDENCE =
+ /^(?:Invalid (?:HUFF|CDIC|INDX) record|Invalid TAGX section|Invalid EXTH header|Missing MOBI header|Missing FDST record|Record index out of bounds|Offset is outside (?:the )?bounds of (?:the )?DataView)$/i;
+const UNSUPPORTED_FORMAT_EVIDENCE = /\bunsupported\s+(?:book\s+)?format\b/i;
+
+const MESSAGE_KEYS: Record = {
+ "drm-protected": {
+ title: "vectorize.protectedBookTitle",
+ description: "vectorize.protectedBookDesc",
+ },
+ malformed: {
+ title: "vectorize.malformedBookTitle",
+ description: "vectorize.malformedBookDesc",
+ },
+ "unsupported-format": {
+ title: "vectorize.unsupportedFormatTitle",
+ description: "vectorize.unsupportedFormatDesc",
+ },
+ unknown: {
+ title: "vectorize.extractionFailedTitle",
+ description: "vectorize.extractionFailedDesc",
+ },
+};
+
+export function classifyBookExtractionError(
+ error: unknown,
+ format: string | undefined,
+): BookExtractionErrorCategory {
+ const message = error instanceof Error ? error.message : String(error);
+ const parserMessage = message.replace(/^(?:Error|RangeError):\s*/i, "");
+ const normalizedFormat = format?.trim().toLowerCase();
+
+ if (
+ normalizedFormat &&
+ MOBI_FAMILY.has(normalizedFormat) &&
+ PROTECTION_EVIDENCE.test(parserMessage)
+ ) {
+ return "drm-protected";
+ }
+ if (
+ GENERIC_MALFORMED_EVIDENCE.test(parserMessage) ||
+ MOBI_PARSER_MALFORMED_EVIDENCE.test(parserMessage)
+ )
+ return "malformed";
+ if (UNSUPPORTED_FORMAT_EVIDENCE.test(parserMessage)) return "unsupported-format";
+ return "unknown";
+}
+
+export class BookExtractionError extends Error {
+ readonly category: BookExtractionErrorCategory;
+ override readonly cause: unknown;
+
+ constructor(error: unknown, format: string | undefined) {
+ super(error instanceof Error ? error.message : String(error));
+ this.name = "BookExtractionError";
+ this.category = classifyBookExtractionError(error, format);
+ this.cause = error;
+ }
+}
+
+export function toBookExtractionError(
+ error: unknown,
+ format: string | undefined,
+): BookExtractionError {
+ return error instanceof BookExtractionError ? error : new BookExtractionError(error, format);
+}
+
+export function getBookExtractionErrorMessageKeys(category: BookExtractionErrorCategory) {
+ return MESSAGE_KEYS[category];
+}
diff --git a/packages/app-expo/src/lib/rag/extractor-format.test.ts b/packages/app-expo/src/lib/rag/extractor-format.test.ts
new file mode 100644
index 000000000..7f67a1552
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/extractor-format.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import { createExtractorCommand, resolveExtractorFormat } from "./extractor-format";
+
+describe("resolveExtractorFormat", () => {
+ it.each(["epub", "pdf", "txt", "umd", "mobi", "azw", "azw3"])(
+ "prefers the supported stored %s format",
+ (bookFormat) => {
+ expect(
+ resolveExtractorFormat({
+ bookFormat,
+ mimeType: "application/octet-stream",
+ fileName: "misleading.epub",
+ }),
+ ).toBe(bookFormat);
+ },
+ );
+
+ it("uses a supported filename extension when the stored format is unavailable", () => {
+ expect(
+ resolveExtractorFormat({
+ bookFormat: undefined,
+ mimeType: "application/vnd.amazon.ebook",
+ fileName: "x.AZW3",
+ }),
+ ).toBe("azw3");
+ });
+
+ it.each([
+ ["application/epub+zip", "epub"],
+ ["application/pdf", "pdf"],
+ ["text/plain; charset=utf-8", "txt"],
+ ["application/x-mobipocket-ebook", "mobi"],
+ ["application/vnd.amazon.ebook", "azw3"],
+ ])("falls back from %s to %s", (mimeType, format) => {
+ expect(resolveExtractorFormat({ mimeType })).toBe(format);
+ });
+
+ it("normalizes stored format and ignores query text after the filename extension", () => {
+ expect(resolveExtractorFormat({ bookFormat: "MOBI" })).toBe("mobi");
+ expect(resolveExtractorFormat({ fileName: "download.AZW?token=1" })).toBe("azw");
+ });
+
+ it("rejects KFX and unknown signals", () => {
+ expect(
+ resolveExtractorFormat({
+ bookFormat: "kfx",
+ mimeType: "application/octet-stream",
+ fileName: "x.kfx",
+ }),
+ ).toBeNull();
+ expect(resolveExtractorFormat({ bookFormat: "unknown" })).toBeNull();
+ expect(resolveExtractorFormat({})).toBeNull();
+ });
+});
+
+describe("createExtractorCommand", () => {
+ it.each([
+ [
+ { bookFormat: "mobi", mimeType: "application/pdf", fileName: "stored.pdf" },
+ { type: "openBook", bookFormat: "mobi", fileName: "stored.mobi" },
+ ],
+ [
+ { mimeType: "application/pdf", fileName: "filename.mobi" },
+ { type: "openBook", bookFormat: "mobi", fileName: "filename.mobi" },
+ ],
+ [
+ {
+ bookFormat: "pdf",
+ mimeType: "application/x-mobipocket-ebook",
+ fileName: "stored.mobi",
+ },
+ { type: "extractBookChapters", bookFormat: "pdf", fileName: "stored.pdf" },
+ ],
+ [
+ { mimeType: "application/x-mobipocket-ebook", fileName: "filename.pdf" },
+ { type: "extractBookChapters", bookFormat: "pdf", fileName: "filename.pdf" },
+ ],
+ ])("dispatches from resolved format for %#", (input, expected) => {
+ expect(createExtractorCommand({ base64BookData: "data", ...input })).toMatchObject(expected);
+ });
+});
+
+describe("extractor pending-request classification", () => {
+ it.each([
+ [{ mimeType: "application/octet-stream", fileName: "inferred.mobi" }, "mobi"],
+ [{ mimeType: "application/vnd.amazon.ebook" }, "azw3"],
+ ])("preserves the resolved %s format for error classification", (input, expected) => {
+ const command = createExtractorCommand({ base64BookData: "data", ...input });
+
+ expect(command.bookFormat).toBe(expected);
+ expect(command.bookFormat ?? undefined).toBe(expected);
+ });
+});
diff --git a/packages/app-expo/src/lib/rag/extractor-format.ts b/packages/app-expo/src/lib/rag/extractor-format.ts
new file mode 100644
index 000000000..19a882a11
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/extractor-format.ts
@@ -0,0 +1,84 @@
+import type { Book } from "@readany/core/types";
+
+const EXTRACTOR_EXTENSIONS_BY_MIME: Record = {
+ "application/epub+zip": "epub",
+ "application/pdf": "pdf",
+ "application/x-mobipocket-ebook": "mobi",
+ "application/vnd.amazon.ebook": "azw3",
+ "application/vnd.comicbook+zip": "cbz",
+ "application/x-fictionbook+xml": "fb2",
+ "application/x-zip-compressed-fb2": "fbz",
+ "text/plain": "txt",
+};
+
+const SUPPORTED_FORMATS = new Set([
+ "epub",
+ "pdf",
+ "txt",
+ "umd",
+ "mobi",
+ "azw",
+ "azw3",
+]);
+
+const FORMAT_BY_MIME_TYPE: Partial> = {
+ "application/epub+zip": "epub",
+ "application/pdf": "pdf",
+ "application/vnd.amazon.ebook": "azw3",
+ "application/x-mobipocket-ebook": "mobi",
+ "text/plain": "txt",
+};
+
+function asSupportedFormat(value: string | undefined): Book["format"] | null {
+ const normalized = value?.trim().toLowerCase() as Book["format"] | undefined;
+ return normalized && SUPPORTED_FORMATS.has(normalized) ? normalized : null;
+}
+
+export function resolveExtractorFormat(input: {
+ bookFormat?: string;
+ mimeType?: string;
+ fileName?: string;
+}): Book["format"] | null {
+ const storedFormat = asSupportedFormat(input.bookFormat);
+ if (storedFormat) return storedFormat;
+
+ const cleanFileName = input.fileName?.split(/[?#]/, 1)[0];
+ const extension = cleanFileName?.split(".").pop();
+ const fileFormat = asSupportedFormat(extension);
+ if (fileFormat) return fileFormat;
+
+ const normalizedMimeType = input.mimeType?.split(";", 1)[0]?.trim().toLowerCase();
+ return normalizedMimeType ? FORMAT_BY_MIME_TYPE[normalizedMimeType] || null : null;
+}
+
+function getExtractorFileName(
+ mimeType: string,
+ bookFormat: Book["format"] | null,
+ fileName?: string,
+) {
+ const cleanFileName = fileName?.split(/[?#]/, 1)[0]?.split(/[\\/]/).pop();
+ if (bookFormat) {
+ const baseName = cleanFileName?.replace(/\.[^.]*$/, "") || "book";
+ return `${baseName}.${bookFormat}`;
+ }
+ if (cleanFileName) return cleanFileName;
+
+ const normalizedMimeType = mimeType.split(";")[0]?.trim().toLowerCase() || "application/epub+zip";
+ return `book.${EXTRACTOR_EXTENSIONS_BY_MIME[normalizedMimeType] || "epub"}`;
+}
+
+export function createExtractorCommand(input: {
+ base64BookData: string;
+ mimeType: string;
+ bookFormat?: string;
+ fileName?: string;
+}) {
+ const resolvedFormat = resolveExtractorFormat(input);
+ return {
+ type: resolvedFormat === "pdf" ? "extractBookChapters" : "openBook",
+ base64: input.base64BookData,
+ mimeType: input.mimeType,
+ bookFormat: resolvedFormat,
+ fileName: getExtractorFileName(input.mimeType, resolvedFormat, input.fileName),
+ };
+}
diff --git a/packages/app-expo/src/lib/rag/extractor-request-boundary.test.ts b/packages/app-expo/src/lib/rag/extractor-request-boundary.test.ts
new file mode 100644
index 000000000..9f075c85b
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/extractor-request-boundary.test.ts
@@ -0,0 +1,104 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ExtractorRequestBoundary } from "./extractor-request-boundary";
+import { ReaderExtractionSessions } from "./reader-extraction-sessions";
+
+function deferred() {
+ let resolve: (value: T) => void = () => {};
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+ return { promise, resolve };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("ExtractorRequestBoundary timeout", () => {
+ it("cancels and releases timed-out A before isolated B completes", async () => {
+ vi.useFakeTimers();
+ const order: string[] = [];
+ const sessions = new ReaderExtractionSessions<{ chapters: string[] }>();
+ const boundary = new ExtractorRequestBoundary({
+ timeoutMs: 45_000,
+ sendCancel: (requestId) => {
+ order.push(`cancel:${requestId}`);
+ sessions.cancel(requestId);
+ },
+ });
+ const parserA = deferred<{ chapters: string[] }>();
+ const openA = sessions.open("A", () => parserA.promise);
+ const resultA = new Promise((resolve, reject) => {
+ boundary.add({
+ requestId: "A",
+ resolve,
+ reject: (error) => {
+ order.push("reject:A");
+ reject(error);
+ },
+ timeoutError: () => new Error("Timed out extracting book content"),
+ });
+ });
+
+ const timedOut = expect(resultA).rejects.toThrow("Timed out extracting book content");
+ await vi.advanceTimersByTimeAsync(45_000);
+ await timedOut;
+ expect(order).toEqual(["cancel:A", "reject:A"]);
+
+ const cancelledOpen = expect(openA).rejects.toMatchObject({ name: "AbortError" });
+ parserA.resolve({ chapters: ["A chapter"] });
+ await cancelledOpen;
+ expect(boundary.resolve("A", ["late A chapter"])).toBe(false);
+
+ const openB = sessions.open("B", async () => ({ chapters: ["B chapter"] }));
+ const resultB = new Promise((resolve, reject) => {
+ boundary.add({
+ requestId: "B",
+ resolve,
+ reject,
+ timeoutError: () => new Error("B timed out"),
+ });
+ });
+ const bookB = await openB;
+ expect(boundary.resolve("B", bookB.chapters)).toBe(true);
+
+ await expect(resultB).resolves.toEqual(["B chapter"]);
+ expect(sessions.getBook("B").chapters).toEqual(["B chapter"]);
+ await vi.runAllTimersAsync();
+ expect(order).toEqual(["cancel:A", "reject:A"]);
+ });
+
+ it("keeps explicit abort cancellation one-shot and ignores its late reply", async () => {
+ vi.useFakeTimers();
+ const cancelled: string[] = [];
+ const controller = new AbortController();
+ const boundary = new ExtractorRequestBoundary({
+ timeoutMs: 45_000,
+ sendCancel: (requestId) => cancelled.push(requestId),
+ });
+ const result = new Promise((resolve, reject) => {
+ boundary.add({
+ requestId: "A",
+ resolve,
+ reject,
+ signal: controller.signal,
+ abortError: () => {
+ const error = new Error("cancelled");
+ error.name = "AbortError";
+ return error;
+ },
+ timeoutError: () => new Error("timed out"),
+ });
+ });
+ const rejected = expect(result).rejects.toMatchObject({ name: "AbortError" });
+
+ controller.abort();
+ controller.abort();
+
+ await rejected;
+ expect(cancelled).toEqual(["A"]);
+ expect(boundary.resolve("A", ["late chapter"])).toBe(false);
+ await vi.runAllTimersAsync();
+ expect(cancelled).toEqual(["A"]);
+ });
+});
diff --git a/packages/app-expo/src/lib/rag/extractor-request-boundary.ts b/packages/app-expo/src/lib/rag/extractor-request-boundary.ts
new file mode 100644
index 000000000..b54f3f3cf
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/extractor-request-boundary.ts
@@ -0,0 +1,108 @@
+interface ExtractorRequestBoundaryOptions {
+ timeoutMs: number;
+ sendCancel: (requestId: string) => void;
+ onCancelError?: (requestId: string, error: unknown) => void;
+}
+
+interface AddExtractorRequest {
+ requestId: string;
+ resolve: (result: Result) => void;
+ reject: (error: Error) => void;
+ timeoutError: () => Error;
+ disposeError?: () => Error;
+ signal?: AbortSignal;
+ abortError?: () => Error;
+ context?: Context;
+}
+
+interface PendingExtractorRequest extends AddExtractorRequest {
+ timeoutId: ReturnType;
+ abortHandler?: () => void;
+}
+
+function defaultAbortError(): Error {
+ const error = new Error("Vectorization cancelled");
+ error.name = "AbortError";
+ return error;
+}
+
+/** Owns RN pending requests and their cancellation notification to the reader. */
+export class ExtractorRequestBoundary {
+ private readonly requests = new Map>();
+
+ constructor(private readonly options: ExtractorRequestBoundaryOptions) {}
+
+ add(request: AddExtractorRequest): void {
+ if (this.requests.has(request.requestId)) {
+ throw new Error(`Duplicate extractor request: ${request.requestId}`);
+ }
+
+ const pending: PendingExtractorRequest = {
+ ...request,
+ timeoutId: setTimeout(() => {
+ this.cancel(request.requestId, request.timeoutError());
+ }, this.options.timeoutMs),
+ };
+ this.requests.set(request.requestId, pending);
+
+ if (request.signal) {
+ pending.abortHandler = () => {
+ this.cancel(request.requestId, request.abortError?.() ?? defaultAbortError());
+ };
+ if (request.signal.aborted) pending.abortHandler();
+ else request.signal.addEventListener("abort", pending.abortHandler, { once: true });
+ }
+ }
+
+ has(requestId: string): boolean {
+ return this.requests.has(requestId);
+ }
+
+ getContext(requestId: string): Context | undefined {
+ return this.requests.get(requestId)?.context;
+ }
+
+ resolve(requestId: string, result: Result): boolean {
+ const pending = this.take(requestId);
+ if (!pending) return false;
+ pending.resolve(result);
+ return true;
+ }
+
+ reject(requestId: string, error: Error): boolean {
+ const pending = this.take(requestId);
+ if (!pending) return false;
+ pending.reject(error);
+ return true;
+ }
+
+ cancel(requestId: string, error: Error): boolean {
+ const pending = this.take(requestId);
+ if (!pending) return false;
+ try {
+ this.options.sendCancel(requestId);
+ } catch (cancelError) {
+ this.options.onCancelError?.(requestId, cancelError);
+ }
+ pending.reject(error);
+ return true;
+ }
+
+ rejectAll(): void {
+ for (const requestId of [...this.requests.keys()]) {
+ const pending = this.take(requestId);
+ if (pending) pending.reject(pending.disposeError?.() ?? new Error("Extractor disposed"));
+ }
+ }
+
+ private take(requestId: string): PendingExtractorRequest | undefined {
+ const pending = this.requests.get(requestId);
+ if (!pending) return undefined;
+ this.requests.delete(requestId);
+ clearTimeout(pending.timeoutId);
+ if (pending.abortHandler) {
+ pending.signal?.removeEventListener("abort", pending.abortHandler);
+ }
+ return pending;
+ }
+}
diff --git a/packages/app-expo/src/lib/rag/mobi-extraction.integration.test.ts b/packages/app-expo/src/lib/rag/mobi-extraction.integration.test.ts
new file mode 100644
index 000000000..811cacf04
--- /dev/null
+++ b/packages/app-expo/src/lib/rag/mobi-extraction.integration.test.ts
@@ -0,0 +1,166 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { MOBI } from "../../../../foliate-js/mobi.js";
+import { unzlibSync } from "../../../../foliate-js/vendor/fflate.js";
+import { BookExtractionError } from "./extractor-error";
+import { runVectorizeQueueJob } from "./vectorize-queue-job";
+
+const FIXTURE_URL = new URL("./__fixtures__/", import.meta.url);
+
+function decodeHtmlEntities(value: string): string {
+ return value
+ .replace(/(x?[\da-f]+);/gi, (_match, digits: string) => {
+ const hexadecimal = digits[0]?.toLowerCase() === "x";
+ return String.fromCodePoint(
+ Number.parseInt(hexadecimal ? digits.slice(1) : digits, hexadecimal ? 16 : 10),
+ );
+ })
+ .replace(/&(amp|apos|gt|lt|nbsp|quot);/gi, (_match, entity: string) => {
+ const values: Record = {
+ amp: "&",
+ apos: "'",
+ gt: ">",
+ lt: "<",
+ nbsp: " ",
+ quot: '"',
+ };
+ return values[entity.toLowerCase()] ?? _match;
+ });
+}
+
+function htmlToText(value: string): string {
+ return decodeHtmlEntities(
+ value
+ .replace(/