Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion apps/desktop/src/pages/Reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ReaderMode } from '@bookdock/ebook-reader';
import { ArrowLeft, Settings, BookOpen, Bookmark, ChevronLeft, ChevronRight, Volume2, Timer, X, Sun, Moon, ScrollText, Plus, Highlighter, MessageSquare, MessageSquarePlus } from 'lucide-react';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import { getCachedChapters, setCachedChapters, getCachedChapterContent, setCachedChapterContent, getCachedFile, setCachedFile } from '../utils/bookCache';

// 设置 PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
Expand Down Expand Up @@ -805,15 +806,28 @@ export default function Reader() {
const apiClient = getApiClient();
const token = localStorage.getItem('bookdock_auth_token') || '';
const baseUrl = `${apiClient.baseURL}/books/${id}/download`;

// Try cache first
const cachedBlob = await getCachedFile(id);
if (cachedBlob) {
const pdfBlob = new Blob([cachedBlob], { type: 'application/pdf' });
const blobUrl = URL.createObjectURL(pdfBlob);
setPdfUrl(blobUrl);
return;
}

// Fetch from server and cache
fetch(`${baseUrl}?token=${encodeURIComponent(token)}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
.then(async (blob) => {
const pdfBlob = new Blob([blob], { type: 'application/pdf' });
const blobUrl = URL.createObjectURL(pdfBlob);
setPdfUrl(blobUrl);
// Cache the blob for next time
await setCachedFile(id, blob, 'application/pdf');
})
.catch((err) => {
console.error('Failed to load PDF:', err);
Expand All @@ -824,6 +838,30 @@ export default function Reader() {

try {
const apiClient = getApiClient();

// Try cache first
const cachedChapters = await getCachedChapters(id);
if (cachedChapters && cachedChapters.length > 0) {
const progressRes = await apiClient.getReadingProgress(id);
const progressData = progressRes?.success ? progressRes.data : null;
const savedChapter = progressData?.currentChapter ?? 0;
const savedScroll = progressData?.scrollOffset ?? 0;
if (savedChapter >= 0 && savedChapter < cachedChapters.length) {
setChapters(cachedChapters);
setCurrentChapter(savedChapter);
setPendingScrollTop(savedScroll);
} else {
setChapters(cachedChapters);
}
// Still fetch fresh chapters in background to update cache
apiClient.getChapters(id).then((res) => {
if (res.success && res.data && res.data.length > 0) {
setCachedChapters(id, res.data);
}
}).catch(() => { /* ignore background refresh error */ });
return;
}

const [chaptersRes, progressRes] = await Promise.allSettled([
apiClient.getChapters(id),
apiClient.getReadingProgress(id),
Expand All @@ -839,6 +877,9 @@ export default function Reader() {
: null;

if (chaptersData && chaptersData.length > 0) {
// Cache chapters
await setCachedChapters(id, chaptersData);

const savedChapter = progressData?.currentChapter ?? 0;
const savedScroll = progressData?.scrollOffset ?? 0;
if (savedChapter >= 0 && savedChapter < chaptersData.length) {
Expand All @@ -865,11 +906,32 @@ export default function Reader() {
setIsChapterLoading(true);
setReaderError(null);
try {
// Try cache first
const cachedContent = await getCachedChapterContent(id, currentChapter);
if (cachedContent) {
if (!cancelled) {
setChapterContent(cachedContent);
setIsChapterLoading(false);
}
// Still fetch fresh content in background to update cache
const apiClient = getApiClient();
apiClient.getChapterContent(id, currentChapter)
.then((response) => {
if (response.success && response.data && response.data.content) {
setCachedChapterContent(id, currentChapter, response.data.content);
}
})
.catch(() => { /* ignore background refresh error */ });
return;
}

const apiClient = getApiClient();
const response = await apiClient.getChapterContent(id, currentChapter);
if (cancelled) return;
if (response.success && response.data) {
setChapterContent(response.data.content);
// Cache the content
await setCachedChapterContent(id, currentChapter, response.data.content);
} else {
setReaderError(response.error || '加载章节失败');
}
Expand Down
177 changes: 177 additions & 0 deletions apps/desktop/src/utils/bookCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Desktop book cache utility
* Caches book files, chapters, and chapter content to IndexedDB for offline reading.
*/

const DB_NAME = 'BookDockCache';
const DB_VERSION = 1;
const STORE_FILES = 'files'; // { bookId -> Blob }
const STORE_CHAPTERS = 'chapters'; // { bookId -> Array<{title, index}> }
const STORE_CONTENT = 'content'; // { `${bookId}:${chapterIndex}` -> string }

function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_FILES)) {
db.createObjectStore(STORE_FILES, { keyPath: 'bookId' });
}
if (!db.objectStoreNames.contains(STORE_CHAPTERS)) {
db.createObjectStore(STORE_CHAPTERS, { keyPath: 'bookId' });
}
if (!db.objectStoreNames.contains(STORE_CONTENT)) {
db.createObjectStore(STORE_CONTENT, { keyPath: 'key' });
}
};
});
}

async function getStore(storeName: string, mode: IDBTransactionMode = 'readonly') {
const db = await openDB();
const tx = db.transaction(storeName, mode);
return tx.objectStore(storeName);
}

// ── File cache (PDF blob etc.) ───────────────────────────────────────

export async function getCachedFile(bookId: string): Promise<Blob | null> {
try {
const store = await getStore(STORE_FILES);
const req = store.get(bookId);
return new Promise((resolve, reject) => {
req.onsuccess = () => {
const result = req.result;
resolve(result?.blob ? new Blob([result.blob], { type: result.type || 'application/octet-stream' }) : null);
};
req.onerror = () => reject(req.error);
});
} catch {
return null;
}
}

export async function setCachedFile(bookId: string, blob: Blob, type?: string): Promise<void> {
try {
const store = await getStore(STORE_FILES, 'readwrite');
const arrayBuffer = await blob.arrayBuffer();
const req = store.put({ bookId, blob: arrayBuffer, type: type || blob.type, cachedAt: Date.now() });
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (e) {
console.warn('Failed to cache file:', e);
}
}

export async function deleteCachedFile(bookId: string): Promise<void> {
try {
const store = await getStore(STORE_FILES, 'readwrite');
const req = store.delete(bookId);
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (e) {
console.warn('Failed to delete cached file:', e);
}
}

// ── Chapters cache ─────────────────────────────────────────────────────

export async function getCachedChapters(bookId: string): Promise<Array<{ title: string; index: number }> | null> {
try {
const store = await getStore(STORE_CHAPTERS);
const req = store.get(bookId);
return new Promise((resolve, reject) => {
req.onsuccess = () => {
const result = req.result;
resolve(result?.chapters || null);
};
req.onerror = () => reject(req.error);
});
} catch {
return null;
}
}

export async function setCachedChapters(bookId: string, chapters: Array<{ title: string; index: number }>): Promise<void> {
try {
const store = await getStore(STORE_CHAPTERS, 'readwrite');
const req = store.put({ bookId, chapters, cachedAt: Date.now() });
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (e) {
console.warn('Failed to cache chapters:', e);
}
}

// ── Chapter content cache ──────────────────────────────────────────────

export async function getCachedChapterContent(bookId: string, chapterIndex: number): Promise<string | null> {
try {
const store = await getStore(STORE_CONTENT);
const req = store.get(`${bookId}:${chapterIndex}`);
return new Promise((resolve, reject) => {
req.onsuccess = () => {
const result = req.result;
resolve(result?.content || null);
};
req.onerror = () => reject(req.error);
});
} catch {
return null;
}
}

export async function setCachedChapterContent(bookId: string, chapterIndex: number, content: string): Promise<void> {
try {
const store = await getStore(STORE_CONTENT, 'readwrite');
const req = store.put({ key: `${bookId}:${chapterIndex}`, content, cachedAt: Date.now() });
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (e) {
console.warn('Failed to cache chapter content:', e);
}
}

// ── Clear all cache for a book ─────────────────────────────────────────

export async function clearBookCache(bookId: string): Promise<void> {
await Promise.all([
deleteCachedFile(bookId),
(async () => {
try {
const store = await getStore(STORE_CHAPTERS, 'readwrite');
store.delete(bookId);
} catch { /* ignore */ }
})(),
(async () => {
try {
const store = await getStore(STORE_CONTENT, 'readwrite');
// Delete all content keys for this book
const range = IDBKeyRange.bound(`${bookId}:`, `${bookId}:\xFF`);
const req = store.openCursor(range);
return new Promise<void>((resolve, reject) => {
req.onsuccess = () => {
const cursor = req.result;
if (cursor) {
cursor.delete();
cursor.continue();
} else {
resolve();
}
};
req.onerror = () => reject(req.error);
});
} catch { /* ignore */ }
})(),
]);
}
Loading