[add] progress & activity sync with web reader

This commit is contained in:
Evan Reichard 2023-10-12 19:14:29 -04:00
parent 8ecd6ad57d
commit a972b5a8aa
4 changed files with 1460 additions and 49 deletions

View File

@ -7,7 +7,7 @@ class EBookReader {
pages: 0, pages: 0,
percentage: 0, percentage: 0,
progress: "", progress: "",
readEvents: [], readActivity: [],
words: 0, words: 0,
}; };
@ -34,25 +34,29 @@ class EBookReader {
this.loadSettings(); this.loadSettings();
// Initialize // Initialize
this.initDevice();
this.initThemes(); this.initThemes();
this.initRenditionListeners(); this.initRenditionListeners();
this.initDocumentListeners(); this.initDocumentListeners();
} }
/** /**
* Load position and generate locations * Load progress and generate locations
**/ **/
async setupReader() { async setupReader() {
// Load Position // Load Progress
let currentCFI = await this.fromPosition(this.bookState.progress); let currentCFI = await this.fromProgress(this.bookState.progress);
if (!currentCFI) this.bookState.currentWord = 0; if (!currentCFI) this.bookState.currentWord = 0;
await this.rendition.display(currentCFI); await this.rendition.display(currentCFI);
// Start Timer // Restore Theme
this.bookState.pageStart = Date.now(); this.setTheme(this.readerSettings.theme || "tan");
// Get Stats
let getStats = function () { let getStats = function () {
// Start Timer
this.bookState.pageStart = Date.now();
// Get Stats
let stats = this.getBookStats(); let stats = this.getBookStats();
this.updateBookStats(stats); this.updateBookStats(stats);
}.bind(this); }.bind(this);
@ -62,6 +66,24 @@ class EBookReader {
getStats(); getStats();
} }
initDevice() {
function randomID() {
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
.toString(16)
.toUpperCase()
);
}
this.readerSettings.deviceName =
this.readerSettings.deviceName ||
platform.os.toString() + " - " + platform.name;
this.readerSettings.deviceID = this.readerSettings.deviceID || randomID();
this.saveSettings();
}
/** /**
* Register all themes with reader * Register all themes with reader
**/ **/
@ -70,8 +92,20 @@ class EBookReader {
THEMES.forEach((theme) => THEMES.forEach((theme) =>
this.rendition.themes.register(theme, THEME_FILE) this.rendition.themes.register(theme, THEME_FILE)
); );
}
this.rendition.themes.select(this.readerSettings.theme || "tan"); /**
* Set theme & meta theme color
**/
setTheme(themeName) {
this.rendition.themes.select(themeName);
let themeColorEl = document.querySelector("[name='theme-color']");
let backgroundColor = window.getComputedStyle(
this.rendition.getContents()[0].content
).backgroundColor;
themeColorEl.setAttribute("content", backgroundColor);
document.body.style.backgroundColor = backgroundColor;
this.saveSettings();
} }
/** /**
@ -288,8 +322,7 @@ class EBookReader {
if (THEMES.length == currentThemeIdx + 1) if (THEMES.length == currentThemeIdx + 1)
readerSettings.theme = THEMES[0]; readerSettings.theme = THEMES[0];
else readerSettings.theme = THEMES[currentThemeIdx + 1]; else readerSettings.theme = THEMES[currentThemeIdx + 1];
this.themes.select(readerSettings.theme); setTheme(readerSettings.theme);
this.setSettings();
} }
}, },
false false
@ -326,10 +359,9 @@ class EBookReader {
if (THEMES.length == currentThemeIdx + 1) if (THEMES.length == currentThemeIdx + 1)
this.readerSettings.theme = THEMES[0]; this.readerSettings.theme = THEMES[0];
else this.readerSettings.theme = THEMES[currentThemeIdx + 1]; else this.readerSettings.theme = THEMES[currentThemeIdx + 1];
this.rendition.themes.select(readerSettings.theme); this.setTheme(this.readerSettings.theme);
this.setSettings();
} }
}, }.bind(this),
false false
); );
@ -339,8 +371,8 @@ class EBookReader {
"click", "click",
function (event) { function (event) {
this.readerSettings.theme = event.target.innerText; this.readerSettings.theme = event.target.innerText;
this.rendition.themes.select(this.readerSettings.theme);
this.saveSettings(); this.setTheme(this.readerSettings.theme);
}.bind(this) }.bind(this)
); );
}.bind(this) }.bind(this)
@ -355,16 +387,26 @@ class EBookReader {
* Progresses to the next page & monitors reading activity * Progresses to the next page & monitors reading activity
**/ **/
async nextPage() { async nextPage() {
// Flush Activity
this.flushActivity();
// Get Elapsed Time // Get Elapsed Time
let elapsedTime = Date.now() - this.bookState.pageStart; let elapsedTime = Date.now() - this.bookState.pageStart;
// Update Current Word // Update Current Word
let pageWords = await this.getVisibleWordCount(); let pageWords = await this.getVisibleWordCount();
let startingWord = this.bookState.currentWord; let startingWord = this.bookState.currentWord;
let percentRead = pageWords / this.bookState.words;
this.bookState.currentWord += pageWords; this.bookState.currentWord += pageWords;
// Add Read Event // Add Read Event
this.bookState.readEvents.push({ startingWord, pageWords, elapsedTime }); this.bookState.readActivity.push({
percentRead,
startingWord,
pageWords,
elapsedTime,
startTime: this.bookState.pageStart,
});
// Render Next Page // Render Next Page
await this.rendition.next(); await this.rendition.next();
@ -376,14 +418,18 @@ class EBookReader {
let stats = this.getBookStats(); let stats = this.getBookStats();
this.updateBookStats(stats); this.updateBookStats(stats);
// Test Position // Update & Flush Progress
console.log(await this.toPosition()); this.bookState.progress = await this.toProgress();
this.flushProgress();
} }
/** /**
* Progresses to the previous page & monitors reading activity * Progresses to the previous page & monitors reading activity
**/ **/
async prevPage() { async prevPage() {
// Flush Activity
this.flushActivity();
// Render Previous Page // Render Previous Page
await this.rendition.prev(); await this.rendition.prev();
@ -398,8 +444,119 @@ class EBookReader {
let stats = this.getBookStats(); let stats = this.getBookStats();
this.updateBookStats(stats); this.updateBookStats(stats);
// Test Position // Update & Flush Progress
console.log(await this.toPosition()); this.bookState.progress = await this.toProgress();
this.flushProgress();
}
/**
* Normalize and flush activity
**/
async flushActivity() {
// Process & Reset Activity
let allActivity = this.bookState.readActivity;
this.bookState.readActivity = [];
const WPM_MAX = 2000;
const WPM_MIN = 100;
let normalizedActivity = allActivity
// Exclude Fast WPM
.filter((item) => item.pageWords / (item.elapsedTime / 60000) < WPM_MAX)
.map((item) => {
let pageWPM = item.pageWords / (item.elapsedTime / 60000);
// Min WPM
if (pageWPM < WPM_MIN) {
// TODO - Exclude Event?
item.elapsedTime = (item.pageWords / WPM_MIN) * 60000;
}
item.pages = Math.round(1 / item.percentRead);
item.page = Math.round(
(item.startingWord * item.pages) / this.bookState.words
);
// Estimate Accuracy Loss (Debugging)
// let wordLoss = Math.abs(
// item.pageWords - this.bookState.words / item.pages
// );
// console.log("Word Loss:", wordLoss);
return {
document: this.bookState.id,
duration: Math.round(item.elapsedTime / 1000),
start_time: Math.round(item.startTime / 1000),
page: item.page,
pages: item.pages,
};
});
if (normalizedActivity.length == 0) return;
console.log("Flushing Activity...");
// Create Activity Event
let activityEvent = {
device_id: this.readerSettings.deviceID,
device: this.readerSettings.deviceName,
activity: normalizedActivity,
};
// Flush Activity
fetch("/api/ko/activity", {
method: "POST",
body: JSON.stringify(activityEvent),
})
.then(async (r) =>
console.log("Flushed Activity:", {
response: r,
json: await r.json(),
data: activityEvent,
})
)
.catch((e) =>
console.error("Activity Flush Failed:", {
error: e,
data: activityEvent,
})
);
}
async flushProgress() {
console.log("Flushing Progress...");
// Create Progress Event
let progressEvent = {
document: this.bookState.id,
device_id: this.readerSettings.deviceID,
device: this.readerSettings.deviceName,
percentage:
Math.round(
(this.bookState.currentWord / this.bookState.words) * 100000
) / 100000,
progress: this.bookState.progress,
};
// Flush Progress
fetch("/api/ko/syncs/progress", {
method: "PUT",
body: JSON.stringify(progressEvent),
})
.then(async (r) =>
console.log("Flushed Progress:", {
response: r,
json: await r.json(),
data: progressEvent,
})
)
.catch((e) =>
console.error("Progress Flush Failed:", {
error: e,
data: progressEvent,
})
);
} }
/** /**
@ -461,20 +618,17 @@ class EBookReader {
progressStatus.innerText = `${data.percentage}%`; progressStatus.innerText = `${data.percentage}%`;
progressBar.style.width = data.percentage + "%"; progressBar.style.width = data.percentage + "%";
chapterName.innerText = `${data.chapterName}`; chapterName.innerText = `${data.chapterName}`;
// Do Update
// console.log(data);
} }
/** /**
* Get XPath from current location * Get XPath from current location
**/ **/
async toPosition() { async toProgress() {
// Get DocFragment (current book spline index) // Get DocFragment (current book spline index)
let currentPos = await this.rendition.currentLocation(); let currentPos = await this.rendition.currentLocation();
let docFragmentIndex = currentPos.start.index + 1; let docFragmentIndex = currentPos.start.index + 1;
// Base Position // Base Progress
let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body"; let newPos = "/body/DocFragment[" + docFragmentIndex + "]/body";
// Get first visible node // Get first visible node
@ -482,7 +636,7 @@ class EBookReader {
let currentNode = contents.range(currentPos.start.cfi).startContainer let currentNode = contents.range(currentPos.start.cfi).startContainer
.parentNode; .parentNode;
// Walk upwards and build position until body // Walk upwards and build progress until body
let childPos = ""; let childPos = "";
while (currentNode.nodeName != "BODY") { while (currentNode.nodeName != "BODY") {
let relativeIndex = let relativeIndex =
@ -499,32 +653,32 @@ class EBookReader {
currentNode = currentNode.parentNode; currentNode = currentNode.parentNode;
} }
// Return derived position // Return derived progress
return newPos + childPos; return newPos + childPos;
} }
/** /**
* Get CFI from XPath * Get CFI from XPath
**/ **/
async fromPosition(position) { async fromProgress(progress) {
// Position Reference - Example: /body/DocFragment[15]/body/div[10]/text().184 // Progress Reference - Example: /body/DocFragment[15]/body/div[10]/text().184
// //
// - /body/DocFragment[15] = 15th item in book spline // - /body/DocFragment[15] = 15th item in book spline
// - [...]/body/div[10] = 10th child div under body (direct descendents only) // - [...]/body/div[10] = 10th child div under body (direct descendents only)
// - [...]/text().184 = text node of parent, character offset @ 184 chars? // - [...]/text().184 = text node of parent, character offset @ 184 chars?
// No Position // No Progress
if (!position || position == "") return; if (!progress || progress == "") return;
// Match Document Fragment Index // Match Document Fragment Index
let fragMatch = position.match(/^\/body\/DocFragment\[(\d+)\]/); let fragMatch = progress.match(/^\/body\/DocFragment\[(\d+)\]/);
if (!fragMatch) { if (!fragMatch) {
console.warn("No Position Match"); console.warn("No Progress Match");
return; return;
} }
// Match Item Index // Match Item Index
let indexMatch = position.match(/\.(\d+)$/); let indexMatch = progress.match(/\.(\d+)$/);
let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0; let itemIndex = indexMatch ? parseInt(indexMatch[1]) : 0;
// Get Spine Item // Get Spine Item
@ -536,7 +690,7 @@ class EBookReader {
// Derive XPath & Namespace // Derive XPath & Namespace
let namespaceURI = docItem.document.documentElement.namespaceURI; let namespaceURI = docItem.document.documentElement.namespaceURI;
let remainingXPath = position let remainingXPath = progress
// Replace with new base // Replace with new base
.replace(fragMatch[0], "/html") .replace(fragMatch[0], "/html")
// Replace `.0` Ending Indexes // Replace `.0` Ending Indexes

1260
assets/reader/platform.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,21 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<link rel="manifest" href="{{ .RelBase }}./manifest.json" /> <link rel="manifest" href="{{ .RelBase }}./manifest.json" />
<meta <meta name="theme-color" content="#D2B48C" />
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta
id="viewport" id="viewport"
name="viewport" name="viewport"
content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{block "title" .}}{{end}}</title> <title>Book Manager - {{block "title" .}}{{end}}</title>
@ -37,10 +28,14 @@
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
#bottom-bar {
padding-bottom: env(safe-area-inset-bottom);
}
</style> </style>
</head> </head>
<body class="bg-gray-100 dark:bg-gray-800"> <body class="bg-gray-100 dark:bg-gray-800 h-[100dvh]">
<main class="relative h-[100dvh] overflow-hidden"> <main class="relative overflow-hidden">
<div <div
id="top-bar" id="top-bar"
class="-top-32 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 flex items-center justify-around w-full h-32 px-2" class="-top-32 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 flex items-center justify-around w-full h-32 px-2"
@ -114,7 +109,7 @@
<div <div
id="bottom-bar" id="bottom-bar"
class="-bottom-24 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 items-center flex h-24 w-full overflow-y-scroll snap-x snap-mandatory no-scrollbar" class="-bottom-28 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 items-center flex w-full overflow-y-scroll snap-x snap-mandatory no-scrollbar"
> >
<div <div
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2" class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"

View File

@ -1,6 +1,7 @@
{{template "base.html" .}} {{define "title"}}Reader{{end}} {{define "header"}} {{template "base.html" .}} {{define "title"}}Reader{{end}} {{define "header"}}
<a href="../">Documents</a> <a href="../">Documents</a>
{{end}} {{define "content"}} {{end}} {{define "content"}}
<script src="../../assets/reader/platform.js"></script>
<script src="../../assets/reader/jszip.min.js"></script> <script src="../../assets/reader/jszip.min.js"></script>
<script src="../../assets/reader/epub.min.js"></script> <script src="../../assets/reader/epub.min.js"></script>
<script src="../../assets/reader/index.js"></script> <script src="../../assets/reader/index.js"></script>
@ -8,6 +9,7 @@
<div id="hiddden-viewer" class="hidden"></div> <div id="hiddden-viewer" class="hidden"></div>
<script> <script>
let currentReader = new EBookReader("./file", { let currentReader = new EBookReader("./file", {
id: "{{ .Data.ID }}",
words: {{ .Data.Words }}, words: {{ .Data.Words }},
pages: {{ .Data.Pages }}, pages: {{ .Data.Pages }},
progress: "{{ .Progress }}", progress: "{{ .Progress }}",