Add Open Library API integration for missing book metadata

This commit is contained in:
Roni Laukkarinen
2026-02-01 18:09:56 +02:00
parent 170bb786de
commit 3e23442f26
4 changed files with 109 additions and 4 deletions
+4
View File
@@ -1,3 +1,7 @@
### 2026-02-01: 1.0.3
* Add Open Library API integration for missing book metadata (opt-in)
### 2026-02-01: 1.0.2
* Convert book metadata to semantic HTML for accessibility
+3 -1
View File
@@ -1,6 +1,6 @@
# ⚡ Speed reader
![Version](https://img.shields.io/badge/version-1.0.2-blue?style=for-the-badge)
![Version](https://img.shields.io/badge/version-1.0.3-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)
@@ -22,6 +22,8 @@ A web-based speed reading tool using Rapid Serial Visual Presentation (RSVP) wit
Everything runs locally in your browser. Your text and files never leave your computer - no servers, no tracking, no analytics. EPUB and TXT files are processed entirely client-side. Settings and reading progress are stored in localStorage. Works offline once loaded.
Optional: If enabled in settings, missing book metadata (title, author, cover) can be fetched from [Open Library API](https://openlibrary.org/developers/api). This sends the book title to their servers.
## What is RSVP?
RSVP (Rapid Serial Visual Presentation) is a reading technique that displays text one word at a time at a fixed focal point. This eliminates saccades (eye movements) that normally slow down reading, allowing for significantly faster reading speeds.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rsvp-speed-reader",
"private": true,
"version": "1.0.2",
"version": "1.0.3",
"type": "module",
"scripts": {
"dev": "vite",
+101 -2
View File
@@ -233,6 +233,33 @@ function formatReadingTime(minutes) {
return `${hours}h ${mins}m`;
}
// Fetch book metadata from Open Library API
async function fetchMetadataFromOpenLibrary(title, author) {
if (!title && !author) return null;
try {
const query = [title, author].filter(Boolean).join(" ");
const url = `https://openlibrary.org/search.json?q=${encodeURIComponent(query)}&limit=1&fields=title,author_name,cover_i`;
const response = await fetch(url);
if (!response.ok) return null;
const data = await response.json();
if (!data.docs || data.docs.length === 0) return null;
const book = data.docs[0];
return {
title: book.title || null,
author: book.author_name?.[0] || null,
cover: book.cover_i
? `https://covers.openlibrary.org/b/id/${book.cover_i}-M.jpg`
: null,
};
} catch (e) {
console.error("Failed to fetch from Open Library:", e);
return null;
}
}
function App() {
// Load settings only once on mount
const [savedSettings] = useState(() => loadSettings());
@@ -271,6 +298,9 @@ function App() {
const [wordAmount, setWordAmount] = useState(
() => savedSettings?.wordAmount ?? 1,
);
const [fetchMetadataOnline, setFetchMetadataOnline] = useState(
() => savedSettings?.fetchMetadataOnline ?? false,
);
const timeoutRef = useRef(null);
const prevTextRef = useRef(text);
const fileInputRef = useRef(null);
@@ -284,7 +314,23 @@ function App() {
if (file.name.endsWith(".epub")) {
const result = await parseEpub(file);
setText(result.text);
setBookMetadata(result.metadata);
let metadata = result.metadata;
// Fetch missing metadata from Open Library if enabled
if (fetchMetadataOnline && (!metadata.title || !metadata.cover)) {
const onlineMetadata = await fetchMetadataFromOpenLibrary(
metadata.title,
metadata.author,
);
if (onlineMetadata) {
metadata = {
title: metadata.title || onlineMetadata.title,
author: metadata.author || onlineMetadata.author,
cover: metadata.cover || onlineMetadata.cover,
};
}
}
setBookMetadata(metadata);
} else if (file.name.endsWith(".txt")) {
const textContent = await file.text();
setText(textContent);
@@ -335,8 +381,9 @@ function App() {
sideOpacity,
wordAmount,
bookMetadata,
fetchMetadataOnline,
});
}, [wpm, text, currentIndex, sideOpacity, wordAmount, bookMetadata]);
}, [wpm, text, currentIndex, sideOpacity, wordAmount, bookMetadata, fetchMetadataOnline]);
const getBaseDelay = useCallback(() => {
return (60 / wpm) * 1000;
@@ -890,6 +937,31 @@ function App() {
</span>
</div>
</div>
<div style={styles.settingRow}>
<label style={styles.settingLabel}>
<span>Fetch missing metadata online</span>
<span style={styles.settingHint}>Uses Open Library API</span>
</label>
<div style={styles.settingControl}>
<button
onClick={() => setFetchMetadataOnline(!fetchMetadataOnline)}
style={{
...styles.toggleBtn,
backgroundColor: fetchMetadataOnline ? "#ff6b6b" : "#333",
}}
aria-pressed={fetchMetadataOnline}
>
<span
style={{
...styles.toggleKnob,
transform: fetchMetadataOnline
? "translateX(16px)"
: "translateX(0)",
}}
/>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -1243,6 +1315,8 @@ const styles = {
settingLabel: {
fontSize: "0.875rem",
color: "rgb(98, 98, 98)",
display: "flex",
flexDirection: "column",
},
settingControl: {
display: "flex",
@@ -1270,6 +1344,31 @@ const styles = {
width: "120px",
accentColor: "#ff6b6b",
},
settingHint: {
display: "block",
fontSize: "0.7rem",
color: "#555",
marginTop: "2px",
},
toggleBtn: {
width: "40px",
height: "24px",
borderRadius: "12px",
border: "none",
cursor: "pointer",
position: "relative",
transition: "background-color 0.2s",
},
toggleKnob: {
position: "absolute",
top: "3px",
left: "3px",
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: "#fff",
transition: "transform 0.2s",
},
shortcutList: {
display: "flex",
flexDirection: "column",