mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-07 23:35:16 +00:00
Add Kindle reading progress dashboard and Chrome extension
- Client-side step chart (Chart.js) with flexible JSON parsing - localStorage history per book ASIN - MV3 extension for cookie/device token capture and Cloud Reader sync Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
42fd405534
commit
1f20df8e76
13 changed files with 1441 additions and 0 deletions
70
kindle/README.md
Normal file
70
kindle/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Kindle Reading Progress
|
||||
|
||||
Client-side web app that plots Kindle reading progress **P(t)** as a **step chart** (flat plateaus between sessions, vertical jumps while reading). Optional Chrome extension automates session capture on [read.amazon.com](https://read.amazon.com).
|
||||
|
||||
## Quick start (chart only)
|
||||
|
||||
1. Serve this folder locally (extension messaging requires HTTP, not `file://`):
|
||||
|
||||
```bash
|
||||
cd kindle
|
||||
python3 -m http.server 8080
|
||||
```
|
||||
|
||||
2. Open [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
3. Paste JSON and click **Generate progress graph**.
|
||||
|
||||
### Supported JSON shapes
|
||||
|
||||
- Array of points: `{ "timestamp": "...", "progress": 45 }` (progress 0–100, or 0–1)
|
||||
- Wrapper: `{ "progress_to_completion": [ ... ] }`
|
||||
- Kindle API snapshot: `{ "percentageRead": 13.1, "progress": { "syncDate": "..." } }`
|
||||
- Librera-style map: `{ "book.epub": { "t": 1565986186029, "p": 0.57 } }`
|
||||
|
||||
Use **days from start** on the X axis to match **t** in days from the first sync.
|
||||
|
||||
## Chrome extension (automated auth)
|
||||
|
||||
Amazon has no public Kindle API. Community tools reuse **browser session cookies** and a **device token** from Cloud Reader. This extension reads them via `chrome.cookies` and network hooks (no passwords stored).
|
||||
|
||||
### Install
|
||||
|
||||
1. Chrome → `chrome://extensions` → **Developer mode** → **Load unpacked** → select `kindle/extension`.
|
||||
2. Copy the **extension ID** from the popup or extensions page.
|
||||
3. Sign in at [read.amazon.com](https://read.amazon.com) (refresh once so `getDeviceToken` runs).
|
||||
4. Open the dashboard at `http://localhost:8080`, paste the extension ID, click **Detect extension**.
|
||||
5. **Sync from Kindle** appends current progress into browser history and the chart.
|
||||
|
||||
### Security
|
||||
|
||||
- Cookies stay in **extension storage** and your **browser localStorage** (history only, not raw cookies in the chart app).
|
||||
- Never commit cookie values or share exported files that include credentials.
|
||||
- Unofficial API use may conflict with Amazon’s terms of service; use at your own risk.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
kindle/
|
||||
index.html # Dashboard
|
||||
css/app.css
|
||||
js/
|
||||
parse.js # Normalize JSON → points
|
||||
chart.js # Chart.js stepped line
|
||||
storage.js # localStorage history
|
||||
extension-bridge.js
|
||||
app.js
|
||||
extension/
|
||||
manifest.json
|
||||
background.js # Cookies + device token + messaging
|
||||
content.js # In-page fetch to Kindle library API
|
||||
popup.html
|
||||
```
|
||||
|
||||
## Building reading history over time
|
||||
|
||||
Each **Sync** or **Save to browser history** appends a snapshot. Re-sync after reading sessions to grow a step curve. Export via **Export history** for backup.
|
||||
|
||||
## TLS / server-side note
|
||||
|
||||
Node clients (e.g. [kindle-api](https://github.com/transitive-bullshit/kindle-api)) often need a local TLS proxy because of Amazon fingerprinting. This project avoids that by running fetches **inside** the signed-in Cloud Reader tab via the content script.
|
||||
165
kindle/css/app.css
Normal file
165
kindle/css/app.css
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
:root {
|
||||
--bg: #f4f4f5;
|
||||
--surface: #ffffff;
|
||||
--text: #18181b;
|
||||
--muted: #71717a;
|
||||
--border: #e4e4e7;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--warn: #ca8a04;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 48px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--muted);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input[type='text'],
|
||||
select {
|
||||
width: 100%;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85rem;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 9px 16px;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: --primary-hover;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #e4e4e7;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #d4d4d8;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
#status {
|
||||
font-size: 0.9rem;
|
||||
margin-top: 8px;
|
||||
min-height: 1.25em;
|
||||
}
|
||||
|
||||
#status[data-type='error'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
#status[data-type='success'] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
#status[data-type='warn'] {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
#stats {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
font-size: 0.85rem;
|
||||
color: var(--warn);
|
||||
border-left: 3px solid var(--warn);
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
226
kindle/extension/background.js
Normal file
226
kindle/extension/background.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
const COOKIE_NAMES = ['ubid-main', 'at-main', 'x-main', 'sid', 'session-id'];
|
||||
const READ_ORIGINS = [
|
||||
'https://read.amazon.com',
|
||||
'https://read.amazon.co.uk',
|
||||
'https://read.amazon.ca',
|
||||
'https://read.amazon.de',
|
||||
];
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
let amazonOrigins = ['https://www.amazon.com'];
|
||||
|
||||
/** @type {{
|
||||
* ubid: string; at: string; xMain: string; sid: string; sessionId: string;
|
||||
* deviceToken: string; deviceType: string; amazonDomain: string;
|
||||
* }} */
|
||||
let extractedTokens = {
|
||||
ubid: '',
|
||||
at: '',
|
||||
xMain: '',
|
||||
sid: '',
|
||||
sessionId: '',
|
||||
deviceToken: '',
|
||||
deviceType: '',
|
||||
amazonDomain: 'amazon.com',
|
||||
};
|
||||
|
||||
function resolveAmazonBase() {
|
||||
return extractedTokens.amazonDomain.startsWith('http')
|
||||
? extractedTokens.amazonDomain
|
||||
: `https://www.${extractedTokens.amazonDomain}`;
|
||||
}
|
||||
|
||||
async function fetchAmazonCookies() {
|
||||
const base = resolveAmazonBase();
|
||||
const readBase = base.replace('www.', 'read.');
|
||||
|
||||
for (const origin of [base, readBase, ...READ_ORIGINS]) {
|
||||
for (const name of COOKIE_NAMES) {
|
||||
try {
|
||||
const cookie = await chrome.cookies.get({ url: origin, name });
|
||||
if (!cookie?.value) continue;
|
||||
if (name === 'ubid-main') extractedTokens.ubid = cookie.value;
|
||||
if (name === 'at-main') extractedTokens.at = cookie.value;
|
||||
if (name === 'x-main') extractedTokens.xMain = cookie.value;
|
||||
if (name === 'sid') extractedTokens.sid = cookie.value;
|
||||
if (name === 'session-id') extractedTokens.sessionId = cookie.value;
|
||||
} catch {
|
||||
/* ignore per-origin failures */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({
|
||||
kindleCredentials: { ...extractedTokens, updatedAt: Date.now() },
|
||||
});
|
||||
}
|
||||
|
||||
chrome.webRequest.onBeforeRequest.addListener(
|
||||
(details) => {
|
||||
try {
|
||||
const url = new URL(details.url);
|
||||
const serialNumber = url.searchParams.get('serialNumber');
|
||||
const deviceType = url.searchParams.get('deviceType');
|
||||
if (serialNumber) extractedTokens.deviceToken = serialNumber;
|
||||
if (deviceType) extractedTokens.deviceType = deviceType;
|
||||
if (serialNumber || deviceType) {
|
||||
fetchAmazonCookies();
|
||||
}
|
||||
if (url.hostname.includes('amazon.')) {
|
||||
extractedTokens.amazonDomain = url.hostname.replace(/^www\./, '');
|
||||
amazonOrigins = [`https://www.${extractedTokens.amazonDomain}`];
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed URLs */
|
||||
}
|
||||
},
|
||||
{
|
||||
urls: [
|
||||
'*://*.amazon.com/*/getDeviceToken*',
|
||||
'*://read.amazon.com/*',
|
||||
'*://read.amazon.co.uk/*',
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
async function getReadTab() {
|
||||
const tabs = await chrome.tabs.query({
|
||||
url: ['*://read.amazon.com/*', '*://read.amazon.co.uk/*', '*://read.amazon.ca/*'],
|
||||
});
|
||||
return tabs[0];
|
||||
}
|
||||
|
||||
async function messageContentScript(message) {
|
||||
const tab = await getReadTab();
|
||||
if (!tab?.id) {
|
||||
return {
|
||||
error:
|
||||
'Open Kindle Cloud Reader (read.amazon.com) in a tab while signed in, then try again.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await chrome.tabs.sendMessage(tab.id, message);
|
||||
} catch (e) {
|
||||
return {
|
||||
error: `Could not reach Kindle tab: ${e.message}. Refresh read.amazon.com.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const { kindleHistory } = await chrome.storage.local.get('kindleHistory');
|
||||
return kindleHistory && typeof kindleHistory === 'object' ? kindleHistory : {};
|
||||
}
|
||||
|
||||
async function saveHistory(history) {
|
||||
await chrome.storage.local.set({ kindleHistory: history });
|
||||
}
|
||||
|
||||
function appendPoint(history, asin, title, timestamp, progress) {
|
||||
const key = asin || '_default';
|
||||
const list = history[key] || [];
|
||||
const entry = {
|
||||
timestamp,
|
||||
progress,
|
||||
title: title || undefined,
|
||||
};
|
||||
const id = `${entry.timestamp}|${entry.progress}`;
|
||||
const filtered = list.filter((p) => `${p.timestamp}|${p.progress}` !== id);
|
||||
filtered.push(entry);
|
||||
filtered.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
return { ...history, [key]: filtered };
|
||||
}
|
||||
|
||||
async function handleMessage(request, sendResponse) {
|
||||
const action = request?.action;
|
||||
|
||||
if (action === 'ping') {
|
||||
sendResponse({ ok: true, extensionId: chrome.runtime.id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'getKindleCredentials') {
|
||||
await fetchAmazonCookies();
|
||||
sendResponse({ credentials: { ...extractedTokens } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'syncReadingProgress') {
|
||||
const contentRes = await messageContentScript({
|
||||
action: 'fetchProgress',
|
||||
asin: request.asin,
|
||||
});
|
||||
|
||||
if (contentRes?.error) {
|
||||
sendResponse({ message: contentRes.error });
|
||||
return;
|
||||
}
|
||||
|
||||
let history = await loadHistory();
|
||||
|
||||
if (contentRes?.library?.length) {
|
||||
for (const book of contentRes.library) {
|
||||
if (book.asin && book.percentageRead != null) {
|
||||
history = appendPoint(
|
||||
history,
|
||||
book.asin,
|
||||
book.title,
|
||||
book.syncDate || new Date().toISOString(),
|
||||
book.percentageRead
|
||||
);
|
||||
}
|
||||
}
|
||||
await saveHistory(history);
|
||||
sendResponse({ library: contentRes.library, history });
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentRes?.point && contentRes?.asin) {
|
||||
history = appendPoint(
|
||||
history,
|
||||
contentRes.asin,
|
||||
contentRes.title,
|
||||
contentRes.point.timestamp,
|
||||
contentRes.point.progress
|
||||
);
|
||||
await saveHistory(history);
|
||||
sendResponse({
|
||||
asin: contentRes.asin,
|
||||
title: contentRes.title,
|
||||
points: [contentRes.point],
|
||||
history,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendResponse({ message: 'No progress returned from Kindle tab.' });
|
||||
return;
|
||||
}
|
||||
|
||||
sendResponse({ error: 'Unknown action' });
|
||||
}
|
||||
|
||||
function listen(handler) {
|
||||
return (request, sender, sendResponse) => {
|
||||
handler(request, sendResponse).catch((err) => {
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
chrome.runtime.onMessageExternal.addListener(listen(handleMessage));
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
const allowed = [
|
||||
'ping',
|
||||
'getKindleCredentials',
|
||||
'syncReadingProgress',
|
||||
];
|
||||
if (!allowed.includes(request?.action)) return false;
|
||||
listen(handleMessage)(request, sender, sendResponse);
|
||||
return true;
|
||||
});
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
fetchAmazonCookies();
|
||||
});
|
||||
104
kindle/extension/content.js
Normal file
104
kindle/extension/content.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
const CLIENT_VERSION = '20000100';
|
||||
|
||||
function readBase() {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const res = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Kindle request failed (${res.status}): ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchLibrary() {
|
||||
const base = readBase();
|
||||
const url = `${base}/kindle-library/search?query=&libraryType=BOOKS&sortType=recency&querySize=50`;
|
||||
const body = await fetchJson(url);
|
||||
const items = body.itemsList || [];
|
||||
return items.map((item) => ({
|
||||
asin: item.asin,
|
||||
title: item.title,
|
||||
percentageRead:
|
||||
item.percentageRead != null
|
||||
? Number(item.percentageRead)
|
||||
: item.readingProgress?.percentageRead,
|
||||
syncDate: item.progress?.syncDate || new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchBookProgress(asin) {
|
||||
const base = readBase();
|
||||
const startUrl = `${base}/service/mobile/reader/startReading?asin=${encodeURIComponent(
|
||||
asin
|
||||
)}&clientVersion=${CLIENT_VERSION}`;
|
||||
const info = await fetchJson(startUrl);
|
||||
const metaUrl = info.metadataUrl;
|
||||
if (!metaUrl) throw new Error('No metadata URL in startReading response');
|
||||
|
||||
const metaRes = await fetch(metaUrl, { credentials: 'include' });
|
||||
const metaText = await metaRes.text();
|
||||
const jsonMatch = metaText.match(/\{[\s\S]*\}/);
|
||||
const meta = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
|
||||
|
||||
const position = info.lastPageReadData?.position ?? 0;
|
||||
const endPosition = meta.endPosition ?? info.endPosition ?? 1;
|
||||
const startPosition = meta.startPosition ?? 0;
|
||||
const rough = (startPosition + position) / endPosition;
|
||||
const percentageRead = Math.min(100, Math.max(0, Number((rough * 100).toFixed(2))));
|
||||
const syncTime = info.lastPageReadData?.syncTime;
|
||||
|
||||
return {
|
||||
asin,
|
||||
title: info.title,
|
||||
point: {
|
||||
timestamp: syncTime
|
||||
? new Date(syncTime).toISOString()
|
||||
: new Date().toISOString(),
|
||||
progress: percentageRead,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
if (request?.action !== 'fetchProgress') return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (request.asin) {
|
||||
const detail = await fetchBookProgress(request.asin);
|
||||
sendResponse(detail);
|
||||
return;
|
||||
}
|
||||
const library = await fetchLibrary();
|
||||
sendResponse({ library });
|
||||
} catch (e) {
|
||||
sendResponse({ error: e.message });
|
||||
}
|
||||
})();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Observe Whispersync-style payloads when the Cloud Reader loads them.
|
||||
const progressObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntriesByType('resource')) {
|
||||
const name = entry.name || '';
|
||||
if (!/progress/i.test(name)) continue;
|
||||
chrome.runtime.sendMessage({ action: 'networkProgressHint', url: name }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
progressObserver.observe({ type: 'resource', buffered: true });
|
||||
} catch {
|
||||
/* PerformanceObserver may be unavailable */
|
||||
}
|
||||
43
kindle/extension/manifest.json
Normal file
43
kindle/extension/manifest.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Kindle Chart Sync",
|
||||
"version": "1.0.0",
|
||||
"description": "Captures Amazon session tokens and syncs Kindle reading progress to your local chart dashboard.",
|
||||
"permissions": ["cookies", "storage", "tabs", "webRequest"],
|
||||
"host_permissions": [
|
||||
"*://*.amazon.com/*",
|
||||
"*://*.amazon.co.uk/*",
|
||||
"*://*.amazon.ca/*",
|
||||
"*://*.amazon.de/*",
|
||||
"*://*.amazon.fr/*",
|
||||
"*://*.amazon.co.jp/*",
|
||||
"*://read.amazon.com/*",
|
||||
"*://read.amazon.co.uk/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Kindle Chart Sync"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://read.amazon.com/*",
|
||||
"*://read.amazon.co.uk/*",
|
||||
"*://read.amazon.ca/*",
|
||||
"*://read.amazon.de/*"
|
||||
],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"externally_connectable": {
|
||||
"matches": [
|
||||
"http://127.0.0.1:*/*",
|
||||
"http://localhost:*/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
41
kindle/extension/popup.html
Normal file
41
kindle/extension/popup.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
width: 280px;
|
||||
margin: 12px;
|
||||
font-size: 13px;
|
||||
color: #18181b;
|
||||
}
|
||||
h1 { font-size: 15px; margin: 0 0 8px; }
|
||||
p { margin: 0 0 10px; color: #52525b; line-height: 1.4; }
|
||||
button {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
button.secondary { background: #e4e4e7; color: #18181b; }
|
||||
code { font-size: 11px; word-break: break-all; }
|
||||
#status { min-height: 2em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Kindle Chart Sync</h1>
|
||||
<p id="status">Checking session…</p>
|
||||
<p>Extension ID (paste into dashboard):</p>
|
||||
<code id="extId"></code>
|
||||
<button type="button" id="openKindle">Open Cloud Reader</button>
|
||||
<button type="button" id="refreshCookies" class="secondary">Refresh cookies</button>
|
||||
<button type="button" id="openDashboard">Open local dashboard</button>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
kindle/extension/popup.js
Normal file
29
kindle/extension/popup.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const statusEl = document.getElementById('status');
|
||||
const extIdEl = document.getElementById('extId');
|
||||
|
||||
extIdEl.textContent = chrome.runtime.id;
|
||||
|
||||
async function refreshStatus() {
|
||||
const res = await chrome.runtime.sendMessage({ action: 'getKindleCredentials' });
|
||||
const c = res?.credentials || {};
|
||||
const parts = [];
|
||||
if (c.ubid || c.sid) parts.push('cookies OK');
|
||||
else parts.push('cookies missing — sign in on Amazon');
|
||||
if (c.deviceToken) parts.push('device token OK');
|
||||
else parts.push('device token pending');
|
||||
statusEl.textContent = parts.join(' · ');
|
||||
}
|
||||
|
||||
document.getElementById('openKindle').addEventListener('click', () => {
|
||||
chrome.tabs.create({ url: 'https://read.amazon.com/kindle-library' });
|
||||
});
|
||||
|
||||
document.getElementById('refreshCookies').addEventListener('click', async () => {
|
||||
await refreshStatus();
|
||||
});
|
||||
|
||||
document.getElementById('openDashboard').addEventListener('click', () => {
|
||||
chrome.tabs.create({ url: 'http://localhost:8080/' });
|
||||
});
|
||||
|
||||
refreshStatus();
|
||||
77
kindle/index.html
Normal file
77
kindle/index.html
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kindle Reading Progress</title>
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Kindle Reading Progress</h1>
|
||||
<p class="lead">
|
||||
Plot reading progress P(t) as a step chart. Data stays in your browser unless you use the optional Chrome extension to sync from read.amazon.com.
|
||||
</p>
|
||||
|
||||
<section aria-labelledby="ext-heading">
|
||||
<h2 id="ext-heading">Chrome extension (optional)</h2>
|
||||
<p class="warning">
|
||||
Session cookies grant access to your Amazon account. They are stored in extension local storage only, never sent to a third-party server. Do not share exports that include cookie values.
|
||||
</p>
|
||||
<p class="hint">
|
||||
Install <code>kindle/extension</code>, visit read.amazon.com while signed in, then open this page at <code>http://localhost:8080</code> (required for extension messaging).
|
||||
</p>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label for="extensionId">Extension ID</label>
|
||||
<input type="text" id="extensionId" placeholder="From chrome://extensions" />
|
||||
</div>
|
||||
<div class="row" style="margin-top: 1.5rem">
|
||||
<button type="button" id="detectExtensionBtn" class="secondary">Detect extension</button>
|
||||
<button type="button" id="fetchCredentialsBtn" class="secondary">Refresh credentials</button>
|
||||
<button type="button" id="syncExtensionBtn">Sync from Kindle</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="credentialsStatus" class="hint"></p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="data-heading">
|
||||
<h2 id="data-heading">Reading data</h2>
|
||||
<label for="bookSelect">Saved books</label>
|
||||
<select id="bookSelect"></select>
|
||||
<p class="hint">
|
||||
Paste JSON: an array of <code>{ "timestamp", "progress" }</code>, a <code>progress_to_completion</code> payload, or Kindle API snapshots with <code>percentageRead</code> / <code>syncDate</code>.
|
||||
</p>
|
||||
<textarea id="jsonInput" spellcheck="false" aria-label="Reading progress JSON"></textarea>
|
||||
<div class="row">
|
||||
<button type="button" id="renderBtn">Generate progress graph</button>
|
||||
<button type="button" id="saveHistoryBtn" class="secondary">Save to browser history</button>
|
||||
<button type="button" id="exportHistoryBtn" class="secondary">Export history</button>
|
||||
<button type="button" id="clearInputBtn" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>
|
||||
<select id="xAxisMode">
|
||||
<option value="calendar">X: calendar time</option>
|
||||
<option value="days">X: days from start (t)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="showTrend" />
|
||||
Show trend overlay
|
||||
</label>
|
||||
</div>
|
||||
<p id="status"></p>
|
||||
<p id="stats"></p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="chart-heading">
|
||||
<h2 id="chart-heading">Progress chart</h2>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="progressChart" role="img" aria-label="Reading progress step chart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
245
kindle/js/app.js
Normal file
245
kindle/js/app.js
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { normalizeReadingData, toChartSeries } from './parse.js';
|
||||
import { renderProgressChart, destroyChart, computeStats } from './chart.js';
|
||||
import {
|
||||
loadHistory,
|
||||
saveHistory,
|
||||
loadSelectedBook,
|
||||
saveSelectedBook,
|
||||
loadCredentialsMeta,
|
||||
saveCredentialsMeta,
|
||||
historyToJson,
|
||||
appendSnapshot,
|
||||
} from './storage.js';
|
||||
import { extensionRequest, detectExtensionId, isExtensionAvailable } from './extension-bridge.js';
|
||||
|
||||
const jsonInput = document.getElementById('jsonInput');
|
||||
const bookSelect = document.getElementById('bookSelect');
|
||||
const xAxisMode = document.getElementById('xAxisMode');
|
||||
const showTrend = document.getElementById('showTrend');
|
||||
const statsEl = document.getElementById('stats');
|
||||
const statusEl = document.getElementById('status');
|
||||
const extensionIdInput = document.getElementById('extensionId');
|
||||
const credentialsStatus = document.getElementById('credentialsStatus');
|
||||
|
||||
let historyByBook = loadHistory();
|
||||
|
||||
function setStatus(message, type = 'info') {
|
||||
statusEl.textContent = message;
|
||||
statusEl.dataset.type = type;
|
||||
}
|
||||
|
||||
function refreshBookSelect() {
|
||||
const keys = Object.keys(historyByBook);
|
||||
const selected = loadSelectedBook();
|
||||
bookSelect.innerHTML = '<option value="">— All pasted data —</option>';
|
||||
for (const asin of keys) {
|
||||
const entries = historyByBook[asin];
|
||||
const title = entries?.[0]?.title || asin;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = asin;
|
||||
opt.textContent = `${title} (${entries?.length ?? 0} points)`;
|
||||
bookSelect.appendChild(opt);
|
||||
}
|
||||
if (selected && keys.includes(selected)) {
|
||||
bookSelect.value = selected;
|
||||
jsonInput.value = historyToJson(historyByBook, selected);
|
||||
}
|
||||
}
|
||||
|
||||
function getParsedPoints() {
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(jsonInput.value.trim() || '[]');
|
||||
} catch (e) {
|
||||
setStatus(`Invalid JSON: ${e.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { points, errors } = normalizeReadingData(raw);
|
||||
if (errors.length && !points.length) {
|
||||
setStatus(errors.join(' '), 'error');
|
||||
return null;
|
||||
}
|
||||
if (errors.length) setStatus(errors.join(' '), 'warn');
|
||||
else setStatus(`Loaded ${points.length} data point(s).`, 'success');
|
||||
return points;
|
||||
}
|
||||
|
||||
function renderFromInput() {
|
||||
const points = getParsedPoints();
|
||||
if (!points?.length) {
|
||||
destroyChart();
|
||||
statsEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = xAxisMode.value === 'days' ? 'daysFromStart' : 'calendar';
|
||||
const series = toChartSeries(points, mode);
|
||||
const bookTitle = bookSelect.selectedOptions[0]?.textContent?.split(' (')[0];
|
||||
|
||||
renderProgressChart(document.getElementById('progressChart'), {
|
||||
...series,
|
||||
bookTitle: bookSelect.value ? bookTitle : undefined,
|
||||
}, {
|
||||
xLabel: mode === 'daysFromStart' ? 'Days from first sync (t)' : 'Timeline',
|
||||
showTrend: showTrend.checked,
|
||||
});
|
||||
|
||||
const stats = computeStats(series.values);
|
||||
if (stats) {
|
||||
statsEl.textContent = `Started at ${stats.startPercent.toFixed(1)}% → now ${stats.currentPercent.toFixed(1)}% (+${stats.totalGain.toFixed(1)}%). Active reading jumps: ~${stats.readingSessions}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCurrentToHistory() {
|
||||
const points = getParsedPoints();
|
||||
if (!points?.length) return;
|
||||
|
||||
const asin = bookSelect.value || prompt('Book ASIN or short id for this series:', '_default');
|
||||
if (!asin) return;
|
||||
|
||||
const title = prompt('Book title (optional):', '') || undefined;
|
||||
let next = { ...historyByBook };
|
||||
for (const p of points) {
|
||||
next = appendSnapshot(next, asin, title, {
|
||||
timestamp: p.timestamp.toISOString(),
|
||||
progress: p.progress,
|
||||
});
|
||||
}
|
||||
historyByBook = next;
|
||||
saveHistory(historyByBook);
|
||||
saveSelectedBook(asin);
|
||||
refreshBookSelect();
|
||||
bookSelect.value = asin;
|
||||
setStatus(`Saved ${points.length} point(s) under “${asin}”.`, 'success');
|
||||
}
|
||||
|
||||
async function loadBookFromHistory() {
|
||||
const asin = bookSelect.value;
|
||||
saveSelectedBook(asin);
|
||||
if (!asin) return;
|
||||
jsonInput.value = historyToJson(historyByBook, asin);
|
||||
renderFromInput();
|
||||
}
|
||||
|
||||
async function tryExtensionCredentials() {
|
||||
try {
|
||||
const res = await extensionRequest('getKindleCredentials');
|
||||
const filled = res?.credentials;
|
||||
if (!filled?.ubid && !filled?.sid) {
|
||||
credentialsStatus.textContent =
|
||||
'Extension connected but cookies are empty. Open read.amazon.com while signed in.';
|
||||
return;
|
||||
}
|
||||
saveCredentialsMeta({
|
||||
hasCookies: true,
|
||||
hasDeviceToken: Boolean(filled.deviceToken),
|
||||
amazonDomain: filled.amazonDomain,
|
||||
});
|
||||
credentialsStatus.textContent = `Credentials captured (${filled.amazonDomain}). Device token: ${filled.deviceToken ? 'yes' : 'pending — refresh Kindle Cloud Reader'}.`;
|
||||
setStatus('Credentials loaded from extension (stored only in extension storage).', 'success');
|
||||
} catch (e) {
|
||||
credentialsStatus.textContent = e.message;
|
||||
setStatus(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFromExtension() {
|
||||
const asin = bookSelect.value;
|
||||
try {
|
||||
const res = await extensionRequest('syncReadingProgress', { asin: asin || undefined });
|
||||
if (res?.history) {
|
||||
historyByBook = { ...historyByBook, ...res.history };
|
||||
saveHistory(historyByBook);
|
||||
refreshBookSelect();
|
||||
}
|
||||
if (res?.asin) {
|
||||
saveSelectedBook(res.asin);
|
||||
bookSelect.value = res.asin;
|
||||
jsonInput.value = historyToJson(historyByBook, res.asin);
|
||||
renderFromInput();
|
||||
const last = res.points?.[res.points.length - 1];
|
||||
setStatus(
|
||||
`Synced “${res.title || res.asin}” at ${last?.progress?.toFixed?.(1) ?? '?'}%.`,
|
||||
'success'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res?.library?.length) {
|
||||
setStatus(`Library sync: ${res.library.length} book(s) updated.`, 'success');
|
||||
if (!asin && Object.keys(historyByBook).length) {
|
||||
const first = Object.keys(historyByBook)[0];
|
||||
bookSelect.value = first;
|
||||
jsonInput.value = historyToJson(historyByBook, first);
|
||||
renderFromInput();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStatus(res?.message || 'Sync completed with no new data.', 'info');
|
||||
} catch (e) {
|
||||
setStatus(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function detectExtension() {
|
||||
const manualId = extensionIdInput.value.trim();
|
||||
if (!manualId) {
|
||||
setStatus('Paste the extension ID from the Kindle Chart Sync popup, then click Detect again.', 'warn');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('kindle-extension-id', manualId);
|
||||
if (!isExtensionAvailable()) {
|
||||
setStatus('Open this app at http://localhost:8080 (not file://) so Chrome can connect to the extension.', 'warn');
|
||||
return;
|
||||
}
|
||||
const id = await detectExtensionId();
|
||||
if (id) {
|
||||
setStatus(`Extension connected: ${id}`, 'success');
|
||||
await tryExtensionCredentials();
|
||||
} else {
|
||||
setStatus('Could not reach extension. Check the ID and that Kindle Chart Sync is enabled.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('renderBtn').addEventListener('click', renderFromInput);
|
||||
document.getElementById('saveHistoryBtn').addEventListener('click', saveCurrentToHistory);
|
||||
document.getElementById('clearInputBtn').addEventListener('click', () => {
|
||||
jsonInput.value = '';
|
||||
destroyChart();
|
||||
statsEl.textContent = '';
|
||||
setStatus('');
|
||||
});
|
||||
document.getElementById('exportHistoryBtn').addEventListener('click', () => {
|
||||
const blob = new Blob([JSON.stringify(historyByBook, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'kindle-reading-history.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
bookSelect.addEventListener('change', loadBookFromHistory);
|
||||
xAxisMode.addEventListener('change', renderFromInput);
|
||||
showTrend.addEventListener('change', renderFromInput);
|
||||
document.getElementById('detectExtensionBtn').addEventListener('click', detectExtension);
|
||||
document.getElementById('fetchCredentialsBtn').addEventListener('click', tryExtensionCredentials);
|
||||
document.getElementById('syncExtensionBtn').addEventListener('click', syncFromExtension);
|
||||
|
||||
const meta = loadCredentialsMeta();
|
||||
if (meta) {
|
||||
credentialsStatus.textContent = `Last credential check: ${meta.updatedAt || 'unknown'}`;
|
||||
}
|
||||
|
||||
refreshBookSelect();
|
||||
|
||||
if (!jsonInput.value.trim()) {
|
||||
jsonInput.value = `[
|
||||
{"timestamp": "2026-05-01T08:00:00Z", "progress": 0},
|
||||
{"timestamp": "2026-05-05T20:30:00Z", "progress": 15},
|
||||
{"timestamp": "2026-05-12T22:15:00Z", "progress": 45},
|
||||
{"timestamp": "2026-05-20T07:45:00Z", "progress": 80},
|
||||
{"timestamp": "2026-05-25T23:00:00Z", "progress": 100}
|
||||
]`;
|
||||
}
|
||||
|
||||
const storedExtId = localStorage.getItem('kindle-extension-id');
|
||||
if (storedExtId) extensionIdInput.value = storedExtId;
|
||||
107
kindle/js/chart.js
Normal file
107
kindle/js/chart.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
let chartInstance = null;
|
||||
|
||||
export function destroyChart() {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {{ labels: string[], values: number[], bookTitle?: string }} series
|
||||
* @param {{ xLabel?: string, showTrend?: boolean }} options
|
||||
*/
|
||||
export function renderProgressChart(canvas, series, options = {}) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
destroyChart();
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
label: series.bookTitle ? `${series.bookTitle} (%)` : 'Book completion (%)',
|
||||
data: series.values,
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.08)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
stepped: true,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: '#dc2626',
|
||||
tension: 0,
|
||||
},
|
||||
];
|
||||
|
||||
if (options.showTrend && series.values.length >= 2) {
|
||||
datasets.push({
|
||||
label: 'Trend (linear)',
|
||||
data: series.values,
|
||||
borderColor: 'rgba(16, 185, 129, 0.6)',
|
||||
borderWidth: 1.5,
|
||||
borderDash: [6, 4],
|
||||
pointRadius: 0,
|
||||
stepped: false,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
});
|
||||
}
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: series.labels,
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: datasets.length > 1 },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context) {
|
||||
const v = context.parsed.y;
|
||||
return `${context.dataset.label}: ${v?.toFixed?.(1) ?? v}%`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
title: { display: true, text: 'Progress P(t) — %' },
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: options.xLabel || 'Timeline',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return chartInstance;
|
||||
}
|
||||
|
||||
export function computeStats(values) {
|
||||
if (!values.length) return null;
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const sessions = values.filter((v, i) => i === 0 || v > values[i - 1]).length;
|
||||
return {
|
||||
startPercent: first,
|
||||
currentPercent: last,
|
||||
totalGain: delta,
|
||||
readingSessions: Math.max(0, sessions - 1),
|
||||
};
|
||||
}
|
||||
51
kindle/js/extension-bridge.js
Normal file
51
kindle/js/extension-bridge.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export function isExtensionAvailable() {
|
||||
return typeof chrome !== 'undefined' && chrome?.runtime?.sendMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates stored extension ID via ping.
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function detectExtensionId() {
|
||||
if (!isExtensionAvailable()) return null;
|
||||
const stored = localStorage.getItem('kindle-extension-id');
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const res = await sendToExtension(stored, { action: 'ping' });
|
||||
if (res?.ok) return stored;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} extensionId
|
||||
* @param {{ action: string, [key: string]: unknown }} message
|
||||
*/
|
||||
export function sendToExtension(extensionId, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isExtensionAvailable()) {
|
||||
reject(new Error('Chrome extension APIs are not available in this page.'));
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage(extensionId, message, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function extensionRequest(action, payload = {}) {
|
||||
const extensionId =
|
||||
localStorage.getItem('kindle-extension-id') || (await detectExtensionId());
|
||||
if (!extensionId) {
|
||||
throw new Error(
|
||||
'Kindle Chart Sync extension not found. Load the extension and set its ID, or click “Detect extension”.'
|
||||
);
|
||||
}
|
||||
return sendToExtension(extensionId, { action, ...payload });
|
||||
}
|
||||
199
kindle/js/parse.js
Normal file
199
kindle/js/parse.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* Normalize Kindle / reading-progress JSON into { timestamp, progress } points.
|
||||
* Supports multiple payload shapes from pasted JSON, extension sync, or API snippets.
|
||||
*/
|
||||
|
||||
const PROGRESS_KEYS = [
|
||||
'progress',
|
||||
'percentageRead',
|
||||
'percentage',
|
||||
'percent',
|
||||
'P',
|
||||
'p',
|
||||
'completion',
|
||||
'progressPercent',
|
||||
];
|
||||
|
||||
const TIMESTAMP_KEYS = [
|
||||
'timestamp',
|
||||
'syncDate',
|
||||
'syncTime',
|
||||
'time',
|
||||
't',
|
||||
'date',
|
||||
'reportedAt',
|
||||
'epoch',
|
||||
];
|
||||
|
||||
function pick(obj, keys) {
|
||||
if (!obj || typeof obj !== 'object') return undefined;
|
||||
for (const key of keys) {
|
||||
if (obj[key] !== undefined && obj[key] !== null) return obj[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toProgress(value) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) return null;
|
||||
if (n <= 1 && n >= 0) return n * 100;
|
||||
return Math.min(100, Math.max(0, n));
|
||||
}
|
||||
|
||||
function toTimestamp(value) {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'number') {
|
||||
const ms = value < 1e12 ? value * 1000 : value;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function pointFromRecord(record) {
|
||||
const rawProgress = pick(record, PROGRESS_KEYS);
|
||||
const rawTime = pick(record, TIMESTAMP_KEYS);
|
||||
|
||||
if (rawProgress === undefined && record.lastPageReadData) {
|
||||
const lpr = record.lastPageReadData;
|
||||
const pos = lpr.position;
|
||||
const end = record.endPosition;
|
||||
if (typeof pos === 'number' && typeof end === 'number' && end > 0) {
|
||||
const pct = (pos / end) * 100;
|
||||
const ts = toTimestamp(lpr.syncTime ?? lpr.syncDate);
|
||||
if (ts) return { timestamp: ts, progress: pct };
|
||||
}
|
||||
}
|
||||
|
||||
const progress = toProgress(rawProgress);
|
||||
const timestamp = toTimestamp(rawTime);
|
||||
if (progress === null || !timestamp) return null;
|
||||
return { timestamp, progress };
|
||||
}
|
||||
|
||||
function extractArray(payload) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
|
||||
const nestedKeys = [
|
||||
'progress_to_completion',
|
||||
'progressToCompletion',
|
||||
'history',
|
||||
'snapshots',
|
||||
'points',
|
||||
'data',
|
||||
'readings',
|
||||
'entries',
|
||||
];
|
||||
|
||||
for (const key of nestedKeys) {
|
||||
if (Array.isArray(payload[key])) return payload[key];
|
||||
}
|
||||
|
||||
if (payload.progress && typeof payload.progress === 'object') {
|
||||
const p = pointFromRecord({
|
||||
...payload,
|
||||
syncDate: payload.progress.syncDate ?? payload.progress.syncTime,
|
||||
progress: payload.percentageRead ?? payload.progress.position,
|
||||
});
|
||||
return p ? [p] : [];
|
||||
}
|
||||
|
||||
const values = Object.values(payload);
|
||||
if (values.length && values.every((v) => v && typeof v === 'object' && ('p' in v || 'progress' in v || 't' in v))) {
|
||||
return values.map((v) => ({
|
||||
timestamp: v.t ?? v.timestamp,
|
||||
progress: v.p ?? v.progress,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw - parsed JSON
|
||||
* @returns {{ points: { timestamp: Date, progress: number }[], errors: string[] }}
|
||||
*/
|
||||
export function normalizeReadingData(raw) {
|
||||
const errors = [];
|
||||
const records = extractArray(raw);
|
||||
|
||||
if (!records.length) {
|
||||
errors.push('No reading progress points found. Expected an array or progress_to_completion field.');
|
||||
return { points: [], errors };
|
||||
}
|
||||
|
||||
const points = [];
|
||||
for (const record of records) {
|
||||
const point = pointFromRecord(record);
|
||||
if (point) points.push(point);
|
||||
}
|
||||
|
||||
if (!points.length) {
|
||||
errors.push('Could not map any records to timestamp + progress fields.');
|
||||
}
|
||||
|
||||
points.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const deduped = [];
|
||||
for (const p of points) {
|
||||
const last = deduped[deduped.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.timestamp.getTime() === p.timestamp.getTime() &&
|
||||
Math.abs(last.progress - p.progress) < 0.01
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
deduped.push(p);
|
||||
}
|
||||
|
||||
return { points: deduped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ timestamp: Date, progress: number }[]} points
|
||||
* @param {'calendar' | 'daysFromStart'} mode
|
||||
*/
|
||||
export function toChartSeries(points, mode = 'calendar') {
|
||||
if (!points.length) {
|
||||
return { labels: [], values: [], daysFromStart: [] };
|
||||
}
|
||||
|
||||
const start = points[0].timestamp.getTime();
|
||||
const labels = [];
|
||||
const values = [];
|
||||
const daysFromStart = [];
|
||||
|
||||
for (const p of points) {
|
||||
values.push(Number(p.progress.toFixed(2)));
|
||||
if (mode === 'daysFromStart') {
|
||||
const days = (p.timestamp.getTime() - start) / (1000 * 60 * 60 * 24);
|
||||
daysFromStart.push(Number(days.toFixed(2)));
|
||||
labels.push(days.toFixed(1));
|
||||
} else {
|
||||
labels.push(p.timestamp.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return { labels, values, daysFromStart };
|
||||
}
|
||||
|
||||
export function mergeHistory(existing, incoming) {
|
||||
const combined = [...(existing || []), ...(incoming || [])];
|
||||
const { points } = normalizeReadingData(
|
||||
combined.map((p) => ({
|
||||
timestamp: p.timestamp instanceof Date ? p.timestamp.toISOString() : p.timestamp,
|
||||
progress: p.progress,
|
||||
}))
|
||||
);
|
||||
return points;
|
||||
}
|
||||
84
kindle/js/storage.js
Normal file
84
kindle/js/storage.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
const HISTORY_KEY = 'kindle-reading-history';
|
||||
const CREDENTIALS_KEY = 'kindle-credentials-meta';
|
||||
const SELECTED_BOOK_KEY = 'kindle-selected-book';
|
||||
|
||||
export function loadHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHistory(historyByBook) {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(historyByBook));
|
||||
}
|
||||
|
||||
export function loadSelectedBook() {
|
||||
return localStorage.getItem(SELECTED_BOOK_KEY) || '';
|
||||
}
|
||||
|
||||
export function saveSelectedBook(asin) {
|
||||
if (asin) localStorage.setItem(SELECTED_BOOK_KEY, asin);
|
||||
else localStorage.removeItem(SELECTED_BOOK_KEY);
|
||||
}
|
||||
|
||||
export function loadCredentialsMeta() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CREDENTIALS_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCredentialsMeta(meta) {
|
||||
if (!meta) {
|
||||
localStorage.removeItem(CREDENTIALS_KEY);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
CREDENTIALS_KEY,
|
||||
JSON.stringify({
|
||||
...meta,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function historyToJson(historyByBook, asin) {
|
||||
if (asin && historyByBook[asin]) {
|
||||
return JSON.stringify(
|
||||
historyByBook[asin].map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
progress: p.progress,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
return JSON.stringify(historyByBook, null, 2);
|
||||
}
|
||||
|
||||
export function appendSnapshot(historyByBook, asin, title, point) {
|
||||
const key = asin || '_default';
|
||||
const list = historyByBook[key] || [];
|
||||
const entry = {
|
||||
timestamp: point.timestamp instanceof Date ? point.timestamp.toISOString() : point.timestamp,
|
||||
progress: point.progress,
|
||||
title: title || undefined,
|
||||
};
|
||||
const merged = [...list, entry];
|
||||
const seen = new Set();
|
||||
const deduped = merged.filter((item) => {
|
||||
const id = `${item.timestamp}|${item.progress}`;
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
deduped.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
return { ...historyByBook, [key]: deduped };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue