diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e9f02..c5726af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### 2026-02-01: 1.0.1 + +* Persist book metadata to localStorage + ### 2026-02-01: 1.0.0 * Add dark/light mode support to favicon diff --git a/README.md b/README.md index 60d058f..bab1584 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ⚡ Speed reader -![Version](https://img.shields.io/badge/version-1.0.0-blue?style=for-the-badge) +![Version](https://img.shields.io/badge/version-1.0.1-blue?style=for-the-badge) ![React](https://img.shields.io/badge/React-61DAFB?style=for-the-badge&logo=react&logoColor=black) ![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white) ![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black) diff --git a/package.json b/package.json index 047c23e..ed85909 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "rsvp-speed-reader", "private": true, - "version": "1.0.0", + "version": "1.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.jsx b/src/App.jsx index b97d32d..3523b1f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -71,7 +71,7 @@ function savePositionForText(text, position, positions) { return { ...positions, [hash]: position }; } -// Parse EPUB file and extract text +// Parse EPUB file and extract text and metadata async function parseEpub(file) { const zip = await JSZip.loadAsync(file); @@ -90,6 +90,52 @@ async function parseEpub(file) { const opfContent = await zip.file(opfPath)?.async("text"); if (!opfContent) throw new Error("Invalid EPUB: cannot read OPF"); + // Extract metadata + const titleMatch = opfContent.match(/]*>([^<]+)<\/dc:title>/i); + const authorMatch = opfContent.match(/]*>([^<]+)<\/dc:creator>/i); + + const metadata = { + title: titleMatch ? titleMatch[1].trim() : null, + author: authorMatch ? authorMatch[1].trim() : null, + cover: null, + }; + + // Find cover image - try multiple methods + // Method 1: Look for meta cover element + const metaCoverMatch = opfContent.match(/]*name="cover"[^>]*content="([^"]+)"/i); + // Method 2: Look for item with properties="cover-image" + const coverImageMatch = opfContent.match(/]*properties="cover-image"[^>]*href="([^"]+)"/i); + // Method 3: Look for item with id containing "cover" and image media-type + const coverIdMatch = opfContent.match(/]*id="[^"]*cover[^"]*"[^>]*href="([^"]+)"[^>]*media-type="image\/[^"]+"/i); + // Method 4: Alternate format for cover-image property + const coverImageMatch2 = opfContent.match(/]*href="([^"]+)"[^>]*properties="cover-image"/i); + + let coverHref = null; + if (coverImageMatch) { + coverHref = coverImageMatch[1]; + } else if (coverImageMatch2) { + coverHref = coverImageMatch2[1]; + } else if (metaCoverMatch) { + // Need to find the href for this id + const coverId = metaCoverMatch[1]; + const itemMatch = opfContent.match(new RegExp(`]*id="${coverId}"[^>]*href="([^"]+)"`, "i")); + if (itemMatch) coverHref = itemMatch[1]; + } else if (coverIdMatch) { + coverHref = coverIdMatch[1]; + } + + // Load cover image if found (as base64 data URL for persistence) + if (coverHref) { + const coverPath = coverHref.startsWith("/") ? coverHref.slice(1) : opfDir + coverHref; + const coverFile = zip.file(coverPath); + if (coverFile) { + const coverBase64 = await coverFile.async("base64"); + const mimeMatch = coverHref.match(/\.(jpe?g|png|gif|webp)$/i); + const mimeType = mimeMatch ? `image/${mimeMatch[1].toLowerCase().replace("jpg", "jpeg")}` : "image/jpeg"; + metadata.cover = `data:${mimeType};base64,${coverBase64}`; + } + } + // Get spine items (reading order) const spineMatches = [ ...opfContent.matchAll(/]*idref="([^"]+)"/g), @@ -152,7 +198,7 @@ async function parseEpub(file) { } } - return fullText.trim(); + return { text: fullText.trim(), metadata }; } // Spritz ORP algorithm - position where the eye naturally fixates @@ -206,6 +252,9 @@ function App() { const [showTextInput, setShowTextInput] = useState(false); const [showSettings, setShowSettings] = useState(false); const [isLoadingFile, setIsLoadingFile] = useState(false); + const [bookMetadata, setBookMetadata] = useState( + () => savedSettings?.bookMetadata || null, + ); const [sideOpacity, setSideOpacity] = useState( () => savedSettings?.sideOpacity ?? 0.5, ); @@ -223,11 +272,13 @@ function App() { setIsLoadingFile(true); try { if (file.name.endsWith(".epub")) { - const extractedText = await parseEpub(file); - setText(extractedText); + const result = await parseEpub(file); + setText(result.text); + setBookMetadata(result.metadata); } else if (file.name.endsWith(".txt")) { const textContent = await file.text(); setText(textContent); + setBookMetadata(null); } else { alert("Please upload an EPUB or TXT file"); } @@ -273,8 +324,9 @@ function App() { positions: positionsRef.current, sideOpacity, wordAmount, + bookMetadata, }); - }, [wpm, text, currentIndex, sideOpacity, wordAmount]); + }, [wpm, text, currentIndex, sideOpacity, wordAmount, bookMetadata]); const getBaseDelay = useCallback(() => { return (60 / wpm) * 1000; @@ -593,6 +645,27 @@ function App() { + {/* Book metadata display */} + {bookMetadata && (bookMetadata.title || bookMetadata.cover) && ( +
+ {bookMetadata.cover && ( + Book cover + )} +
+ {bookMetadata.title && ( +
{bookMetadata.title}
+ )} + {bookMetadata.author && ( +
{bookMetadata.author}
+ )} +
+
+ )} + {/* Keyboard shortcuts modal */} {showShortcuts && (