1
0
Fork 0
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:
Cursor Agent 2026-05-31 13:49:36 +00:00
parent 42fd405534
commit 1f20df8e76
No known key found for this signature in database
13 changed files with 1441 additions and 0 deletions

70
kindle/README.md Normal file
View 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 0100, or 01)
- 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 Amazons 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
View 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;
}
}

View 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
View 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 */
}

View 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:*/*"
]
}
}

View 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
View 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
View 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
View 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
View 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),
};
}

View 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
View 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
View 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 };
}