Add Open Library API integration for missing book metadata
This commit is contained in:
@@ -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
|
### 2026-02-01: 1.0.2
|
||||||
|
|
||||||
* Convert book metadata to semantic HTML for accessibility
|
* Convert book metadata to semantic HTML for accessibility
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ⚡ Speed reader
|
# ⚡ Speed reader
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -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.
|
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?
|
## 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.
|
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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rsvp-speed-reader",
|
"name": "rsvp-speed-reader",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+101
-2
@@ -233,6 +233,33 @@ function formatReadingTime(minutes) {
|
|||||||
return `${hours}h ${mins}m`;
|
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() {
|
function App() {
|
||||||
// Load settings only once on mount
|
// Load settings only once on mount
|
||||||
const [savedSettings] = useState(() => loadSettings());
|
const [savedSettings] = useState(() => loadSettings());
|
||||||
@@ -271,6 +298,9 @@ function App() {
|
|||||||
const [wordAmount, setWordAmount] = useState(
|
const [wordAmount, setWordAmount] = useState(
|
||||||
() => savedSettings?.wordAmount ?? 1,
|
() => savedSettings?.wordAmount ?? 1,
|
||||||
);
|
);
|
||||||
|
const [fetchMetadataOnline, setFetchMetadataOnline] = useState(
|
||||||
|
() => savedSettings?.fetchMetadataOnline ?? false,
|
||||||
|
);
|
||||||
const timeoutRef = useRef(null);
|
const timeoutRef = useRef(null);
|
||||||
const prevTextRef = useRef(text);
|
const prevTextRef = useRef(text);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
@@ -284,7 +314,23 @@ function App() {
|
|||||||
if (file.name.endsWith(".epub")) {
|
if (file.name.endsWith(".epub")) {
|
||||||
const result = await parseEpub(file);
|
const result = await parseEpub(file);
|
||||||
setText(result.text);
|
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")) {
|
} else if (file.name.endsWith(".txt")) {
|
||||||
const textContent = await file.text();
|
const textContent = await file.text();
|
||||||
setText(textContent);
|
setText(textContent);
|
||||||
@@ -335,8 +381,9 @@ function App() {
|
|||||||
sideOpacity,
|
sideOpacity,
|
||||||
wordAmount,
|
wordAmount,
|
||||||
bookMetadata,
|
bookMetadata,
|
||||||
|
fetchMetadataOnline,
|
||||||
});
|
});
|
||||||
}, [wpm, text, currentIndex, sideOpacity, wordAmount, bookMetadata]);
|
}, [wpm, text, currentIndex, sideOpacity, wordAmount, bookMetadata, fetchMetadataOnline]);
|
||||||
|
|
||||||
const getBaseDelay = useCallback(() => {
|
const getBaseDelay = useCallback(() => {
|
||||||
return (60 / wpm) * 1000;
|
return (60 / wpm) * 1000;
|
||||||
@@ -890,6 +937,31 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1243,6 +1315,8 @@ const styles = {
|
|||||||
settingLabel: {
|
settingLabel: {
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
color: "rgb(98, 98, 98)",
|
color: "rgb(98, 98, 98)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
},
|
},
|
||||||
settingControl: {
|
settingControl: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -1270,6 +1344,31 @@ const styles = {
|
|||||||
width: "120px",
|
width: "120px",
|
||||||
accentColor: "#ff6b6b",
|
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: {
|
shortcutList: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
|||||||
Reference in New Issue
Block a user