diff --git a/kindle/README.md b/kindle/README.md new file mode 100644 index 0000000..56a01dc --- /dev/null +++ b/kindle/README.md @@ -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. diff --git a/kindle/css/app.css b/kindle/css/app.css new file mode 100644 index 0000000..11c3d10 --- /dev/null +++ b/kindle/css/app.css @@ -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; + } +} diff --git a/kindle/extension/background.js b/kindle/extension/background.js new file mode 100644 index 0000000..ad114d3 --- /dev/null +++ b/kindle/extension/background.js @@ -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} */ +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(); +}); diff --git a/kindle/extension/content.js b/kindle/extension/content.js new file mode 100644 index 0000000..a05d00f --- /dev/null +++ b/kindle/extension/content.js @@ -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 */ +} diff --git a/kindle/extension/manifest.json b/kindle/extension/manifest.json new file mode 100644 index 0000000..7f0cca2 --- /dev/null +++ b/kindle/extension/manifest.json @@ -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:*/*" + ] + } +} diff --git a/kindle/extension/popup.html b/kindle/extension/popup.html new file mode 100644 index 0000000..4ca1db1 --- /dev/null +++ b/kindle/extension/popup.html @@ -0,0 +1,41 @@ + + + + + + + +

Kindle Chart Sync

+

Checking session…

+

Extension ID (paste into dashboard):

+ + + + + + + diff --git a/kindle/extension/popup.js b/kindle/extension/popup.js new file mode 100644 index 0000000..e36d306 --- /dev/null +++ b/kindle/extension/popup.js @@ -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(); diff --git a/kindle/index.html b/kindle/index.html new file mode 100644 index 0000000..b11b602 --- /dev/null +++ b/kindle/index.html @@ -0,0 +1,77 @@ + + + + + + Kindle Reading Progress + + + + +

Kindle Reading Progress

+

+ 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. +

+ +
+

Chrome extension (optional)

+

+ 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. +

+

+ Install kindle/extension, visit read.amazon.com while signed in, then open this page at http://localhost:8080 (required for extension messaging). +

+
+
+ + +
+
+ + + +
+
+

+
+ +
+

Reading data

+ + +

+ Paste JSON: an array of { "timestamp", "progress" }, a progress_to_completion payload, or Kindle API snapshots with percentageRead / syncDate. +

+ +
+ + + + +
+
+ + +
+

+

+
+ +
+

Progress chart

+
+ +
+
+ + + + diff --git a/kindle/js/app.js b/kindle/js/app.js new file mode 100644 index 0000000..27c4993 --- /dev/null +++ b/kindle/js/app.js @@ -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 = ''; + 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; diff --git a/kindle/js/chart.js b/kindle/js/chart.js new file mode 100644 index 0000000..67879c7 --- /dev/null +++ b/kindle/js/chart.js @@ -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), + }; +} diff --git a/kindle/js/extension-bridge.js b/kindle/js/extension-bridge.js new file mode 100644 index 0000000..706c2f4 --- /dev/null +++ b/kindle/js/extension-bridge.js @@ -0,0 +1,51 @@ +export function isExtensionAvailable() { + return typeof chrome !== 'undefined' && chrome?.runtime?.sendMessage; +} + +/** + * Validates stored extension ID via ping. + * @returns {Promise} + */ +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 }); +} diff --git a/kindle/js/parse.js b/kindle/js/parse.js new file mode 100644 index 0000000..b662c4c --- /dev/null +++ b/kindle/js/parse.js @@ -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; +} diff --git a/kindle/js/storage.js b/kindle/js/storage.js new file mode 100644 index 0000000..49a36d1 --- /dev/null +++ b/kindle/js/storage.js @@ -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 }; +}