This commit is contained in:
dmilin1 2025-06-07 14:47:25 -07:00
commit 7d2ac5b2ae
12 changed files with 2837 additions and 0 deletions

225
README.md Normal file
View file

@ -0,0 +1,225 @@
# 📚 DeKindled - Chrome Extension for Base64 Blob Extraction
A Chrome extension designed to build epubs from Amazon's Kindle Reader website.
It injects into the Kindle Reader website to capture and extract base64 blob content, before the blobs get revoked.
Inspired by my desire to read the books I've purchased through [Inkwell](https://inkwell.app), an ebook reader app I built for myself.
Thanks to [Showdown](https://github.com/showdownjs/showdown) for their markdown to HTML converter.
# DeKindled Quick Start Guide
## 🚀 Getting Started with DeKindled
### 1. Install DeKindled
- Open Chrome and go to `chrome://extensions/`
- Enable "Developer mode" (top right)
- Click "Load unpacked" and select the DeKindled folder
- You should see the DeKindled extension installed
### 2. Open Your Kindle Book
- Go to [read.amazon.com](https://read.amazon.com)
- Sign in and open a book
- Look for the "📚 DeKindled Active" indicator (bottom-right corner)
### 3. Capture Content
- If the book didn't open on the first page, go to it, then reload the page.
- Click the extension icon to open the overlay.
- Click "Start Auto-Scan" and DeKindled will click through the book, capturing content as it goes.
- When the page counter at the bottom stops increasing, the scan is complete.
### 4. Convert to EPUB
- Click "Convert to EPUB" in the top right corner of the overlay
- You'll be asked for the book title and author, then your OpenAI API key
- Background processing will begin converting the images to Markdown which will then be converted to EPUB
- Once completed, a download prompt will appear
## ⚠️ Important Notes
- DeKindled captures content as it's loaded by the Kindle reader. If you go backwards or change display settings, your content will be out of order!
## ⚙️ Advanced Configuration
### 🎛️ Customizing the Analysis Prompt
DeKindled uses OpenAI's vision API to convert scanned book pages to markdown. You can customize the prompt that controls how the AI interprets and converts the content.
#### **Accessing Options:**
1. **Right-click** the DeKindled extension icon in Chrome
2. Select **"Options"** from the context menu
3. Or go to `chrome://extensions/` → DeKindled → **"Extension options"**
#### **Modifying the Analysis Prompt:**
1. In the options page, scroll to the **"Analysis Prompt"** section
2. The large text area contains the prompt sent to OpenAI for each page
3. **Edit the prompt** to customize how the AI processes book pages:
- Change instructions for formatting (e.g., preserve indentation differently)
- Modify chapter detection logic
- Add specific instructions for handling tables, quotes, or special formatting
- Adjust the tone or style of conversion
#### **Default Prompt Behavior:**
The default prompt instructs the AI to:
- Convert images to clean markdown
- Detect chapter titles and mark them with `--- NEW CHAPTER: [Title] ---`
- Handle paragraph indentation by adding line breaks
- Preserve all visible text content
- Use proper markdown formatting
#### **Resetting the Prompt:**
- Click **"Reset to Default"** to restore the original prompt
- Confirm the action to replace your custom prompt
- Changes are automatically saved
#### **Tips for Custom Prompts:**
- **Test incrementally**: Make small changes and test with a few pages
- **Keep the chapter marker**: The `--- NEW CHAPTER: [Title] ---` format is required for proper EPUB structure
- **Preserve key instructions**: Don't remove the core instruction to convert everything to markdown
- **Be specific**: Clear, detailed instructions work better than vague ones
- **Consider your content**: Tailor the prompt to the types of books you typically convert
> **⚠️ Advanced Feature**: Modifying the prompt requires understanding of how AI language models work. Incorrect prompts may result in poor conversion quality or failed processing.
# DeKindled Technical Details
## 🎯 Problem Solved
Web readers often generate blob URLs for content (like images, text) that get revoked immediately after creation. This extension:
- **Captures blob data instantly** when `URL.createObjectURL()` is called
- **Stores base64 content** before blobs get revoked
- **Provides easy download** of captured content
## ✨ Key Features
### 🚀 Real-time Base64 Capture
- Intercepts `URL.createObjectURL()` calls
- Immediately reads and stores blob content as base64
- Visual indicators show capture success/failure
- Works before blob URLs get revoked
### 💾 Persistent Storage
- Stores base64 data in memory during session
- Maps blob URLs to their base64 content
- Survives blob URL revocation
- Debug info shows storage status
### 🎨 User-friendly Interface
- Overlay viewer accessible via extension icon
- Real-time stats (captured, stored, total size)
- Storage status indicators
## 🛠️ How It Works
### 1. **Blob Interception**
```javascript
// When any page creates a blob URL:
URL.createObjectURL = function(blob) {
const url = originalCreateObjectURL(blob);
// Immediately read and store base64 data
readBlobAsBase64(blob).then(base64Data => {
window.__dekindled.blobData.set(url, {
base64: base64Data,
type: blob.type,
size: blob.size
});
});
return url;
}
```
### 2. **Base64 Storage**
- Uses `FileReader.readAsDataURL()` to convert blobs to base64
- Stores in a `Map` with blob URL as key
- Maintains metadata (type, size, timestamp)
- Survives blob revocation
### 3. **Smart Downloads**
- Downloads from stored base64 data when available
- Falls back to URL access if storage failed
- Batch processing for multiple files
- Proper file extensions based on MIME types
## 📦 Installation
1. **Clone this repository:**
```bash
git clone https://github.com/yourusername/dekindled.git
cd dekindled
```
2. **Load the extension in Chrome:**
- Open `chrome://extensions/`
- Enable "Developer mode"
- Click "Load unpacked"
- Select the `dekindled` folder
3. **Grant permissions:**
- The extension needs access to the Kindle Reader website to intercept blob creation
- Downloads permission for saving captured content
### Files Structure
- `manifest.json` - Extension configuration
- `background.js` - Service worker for extension management
- `inject.js` - Content script injector
- `interceptor.js` - **Core blob interception and base64 storage**
- `viewer-inject.js` - **Overlay UI for managing captured content**
### Key Functions
```javascript
// Store base64 data immediately
window.__dekindled.blobData.set(url, {
base64: dataURL,
type: blob.type,
size: blob.size
});
// Download from stored base64
window.__dekindled.downloadStoredBlob(url, filename);
// Download all stored content
window.__dekindled.downloadAllStored();
```
## 🧪 Testing
Use the included `test-page.html` to verify functionality:
```bash
# Open test-page.html in Chrome with extension loaded
# Should capture test blobs and show visual indicators
```
## 📈 Troubleshooting
### No Blobs Captured
- Ensure extension is active (blue indicator in bottom-right)
- Check browser console for DeKindled messages
- Try refreshing the page after enabling extension
### Storage Shows "Missing"
- Blob was created but base64 reading failed
- Check console for FileReader errors
- May indicate binary/unsupported blob type
### Downloads Fail
- Try "Try URL" button for recently created blobs
- Check if blob URL is still valid
- Verify download permissions are granted
## 🤝 Contributing
1. Fork the repository
2. Create feature branch (`git checkout -b feature/amazing-feature`)
3. Commit changes (`git commit -m 'Add amazing feature'`)
4. Push to branch (`git push origin feature/amazing-feature`)
5. Open Pull Request
## 📄 License
This project is licensed under the MIT License
---
**Note**: This extension is designed for legitimate content preservation and so you can read your content on any device or app you choose. Buy your books and support the authors!

766
background.js Normal file
View file

@ -0,0 +1,766 @@
// DeKindled - Background service worker
// Import ZIP utility and Showdown
importScripts('zip-utils.js');
importScripts('showdown.min.js');
// Extension installed/updated
chrome.runtime.onInstalled.addListener(() => {
console.log('DeKindled Extension Installed/Updated');
console.log('Ready to extract content from web readers.');
});
// Conversion state management
const activeConversions = new Map();
// Log when extension icon is clicked
chrome.action.onClicked.addListener((tab) => {
console.log('DeKindled icon clicked on tab:', tab.url);
// Inject the viewer overlay into the current tab
chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['viewer-inject.js'],
world: 'MAIN'
}).then(() => {
console.log('DeKindled viewer overlay injected');
}).catch(error => {
console.error('Failed to inject viewer:', error);
// Fallback: open blob-viewer.html if injection fails
chrome.tabs.create({
url: chrome.runtime.getURL('blob-viewer.html'),
index: tab.index + 1
});
});
});
// Listen for messages from content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'blobCaptured') {
console.log(`[DeKindled] Content captured from ${sender.tab?.url || 'unknown'}`);
sendResponse({ received: true });
} else if (message.action === 'initEpubConversion') {
handleInitEpubConversion(message, sender, sendResponse);
return true; // Keep sendResponse callback alive for async operation
} else if (message.action === 'sendPagesChunk') {
handleSendPagesChunk(message, sender, sendResponse);
return true; // Keep sendResponse callback alive for async operation
} else if (message.action === 'startEpubProcessing') {
handleStartEpubProcessing(message, sender, sendResponse);
return true; // Keep sendResponse callback alive for async operation
}
return false;
});
// Handle initialization of EPUB conversion
async function handleInitEpubConversion(message, sender, sendResponse) {
try {
const conversionId = generateUUID();
// Initialize conversion state
activeConversions.set(conversionId, {
id: conversionId,
tabId: sender.tab.id,
bookTitle: message.bookTitle,
bookAuthor: message.bookAuthor,
totalPages: message.totalPages,
totalChunks: message.totalChunks,
receivedChunks: 0,
pages: [],
status: 'initialized',
timestamp: Date.now()
});
console.log(`[DeKindled] Initialized conversion ${conversionId} for "${message.bookTitle}" with ${message.totalPages} pages in ${message.totalChunks} chunks`);
sendResponse({
success: true,
conversionId: conversionId,
message: 'Conversion initialized successfully'
});
} catch (error) {
console.error('[DeKindled] Error initializing conversion:', error);
sendResponse({
success: false,
error: error.message
});
}
}
// Handle receiving pages chunks
async function handleSendPagesChunk(message, sender, sendResponse) {
try {
const { conversionId, chunkIndex, totalChunks, pages } = message;
const conversion = activeConversions.get(conversionId);
if (!conversion) {
throw new Error(`Conversion ${conversionId} not found`);
}
if (conversion.tabId !== sender.tab.id) {
throw new Error(`Conversion ${conversionId} belongs to different tab`);
}
// Add pages from this chunk
conversion.pages.push(...pages);
conversion.receivedChunks++;
console.log(`[DeKindled] Received chunk ${chunkIndex + 1}/${totalChunks} for conversion ${conversionId} (${pages.length} pages, ${conversion.pages.length} total)`);
// Validate chunk completion
if (conversion.receivedChunks === conversion.totalChunks) {
if (conversion.pages.length !== conversion.totalPages) {
console.warn(`[DeKindled] Page count mismatch for conversion ${conversionId}: expected ${conversion.totalPages}, got ${conversion.pages.length}`);
} else {
console.log(`[DeKindled] All chunks received for conversion ${conversionId}, ready for processing`);
}
conversion.status = 'chunks_complete';
}
sendResponse({
success: true,
receivedChunks: conversion.receivedChunks,
totalChunks: conversion.totalChunks,
message: `Chunk ${chunkIndex + 1}/${totalChunks} received successfully`
});
} catch (error) {
console.error('[DeKindled] Error receiving chunk:', error);
sendResponse({
success: false,
error: error.message
});
}
}
// Handle starting EPUB processing
async function handleStartEpubProcessing(message, sender, sendResponse) {
try {
const { conversionId } = message;
const conversion = activeConversions.get(conversionId);
if (!conversion) {
throw new Error(`Conversion ${conversionId} not found`);
}
if (conversion.tabId !== sender.tab.id) {
throw new Error(`Conversion ${conversionId} belongs to different tab`);
}
if (conversion.status !== 'chunks_complete') {
throw new Error(`Conversion ${conversionId} is not ready for processing (status: ${conversion.status})`);
}
conversion.status = 'processing';
console.log(`[DeKindled] Starting processing for conversion ${conversionId} with ${conversion.pages.length} pages`);
// Send immediate acknowledgment
sendResponse({
success: true,
message: 'Processing started successfully'
});
// Start the actual conversion asynchronously
processEpubConversion(conversion);
} catch (error) {
console.error('[DeKindled] Error starting processing:', error);
sendResponse({
success: false,
error: error.message
});
}
}
// Process EPUB conversion (async, doesn't use sendResponse)
async function processEpubConversion(conversion) {
try {
console.log(`[DeKindled] Starting EPUB conversion for ${conversion.pages.length} pages`);
const pagesToProcess = conversion.pages;
console.log(`[DeKindled] Processing ${pagesToProcess.length} pages`);
// Get OpenAI API key from storage
const result = await chrome.storage.sync.get(['openaiApiKey']);
if (!result.openaiApiKey) {
// Prompt user for API key
const apiKey = await promptForApiKey(conversion.tabId);
if (!apiKey) {
chrome.tabs.sendMessage(conversion.tabId, {
action: 'conversionComplete',
success: false,
error: 'OpenAI API key required'
}).catch(console.error);
return;
}
}
const apiKey = result.openaiApiKey || await chrome.storage.sync.get(['openaiApiKey']).then(r => r.openaiApiKey);
// Progress callback to send updates to viewer
const progressCallback = (current, total) => {
chrome.tabs.sendMessage(conversion.tabId, {
action: 'conversionProgress',
current: current,
total: total
}).catch(error => {
console.warn('[DeKindled] Failed to send progress update:', error);
});
};
// Convert images to XHTML chapters
const chapters = await convertImagesToChapters(pagesToProcess, apiKey, progressCallback);
// Generate EPUB file
const epubBlob = await generateEpubFile(chapters, conversion.bookTitle, conversion.bookAuthor);
// Convert blob to data URL for download (URL.createObjectURL not available in service workers)
const arrayBuffer = await epubBlob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Convert uint8Array to base64 using chunked approach to avoid stack overflow
const base64String = uint8ArrayToBase64(uint8Array);
const dataUrl = `data:application/epub+zip;base64,${base64String}`;
const filename = `${sanitizeFilename(conversion.bookTitle)} - ${sanitizeFilename(conversion.bookAuthor || 'Unknown Author')}.epub`;
await chrome.downloads.download({
url: dataUrl,
filename: filename,
conflictAction: 'uniquify'
});
// Send completion notification
chrome.tabs.sendMessage(conversion.tabId, {
action: 'conversionComplete',
success: true,
filename: filename
}).catch(console.error);
// Clean up conversion state
activeConversions.delete(conversion.id);
console.log(`[DeKindled] Conversion ${conversion.id} completed successfully`);
} catch (error) {
console.error('[DeKindled] Conversion error:', error);
// Send error notification
chrome.tabs.sendMessage(conversion.tabId, {
action: 'conversionComplete',
success: false,
error: error.message
}).catch(console.error);
// Clean up conversion state
activeConversions.delete(conversion.id);
}
}
// Prompt user for OpenAI API key
async function promptForApiKey(tabId) {
return new Promise((resolve) => {
chrome.scripting.executeScript({
target: { tabId: tabId },
func: () => {
const apiKey = prompt(
'🔑 Enter your OpenAI API Key:\n\n' +
'This is needed to convert images to text for your EPUB.\n' +
'Your key will be stored securely in your browser.\n\n' +
'Get your API key from: https://platform.openai.com/api-keys'
);
return apiKey;
}
}).then((results) => {
const apiKey = results[0].result;
if (apiKey) {
chrome.storage.sync.set({ openaiApiKey: apiKey });
}
resolve(apiKey);
}).catch(() => resolve(null));
});
}
// Convert images to XHTML chapters using OpenAI Vision API
async function convertImagesToChapters(pages, apiKey, progressCallback) {
const sections = []; // Will contain the final grouped sections
let currentSection = null;
// Initialize Showdown converter
const converter = new showdown.Converter({
headerLevelStart: 1,
simpleLineBreaks: true,
literalMidWordUnderscores: true,
strikethrough: true,
tables: true,
tasklists: true
});
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
// Send progress update
if (progressCallback) {
progressCallback(i + 1, pages.length);
}
try {
console.log(`[DeKindled] Analyzing page ${i + 1} for markdown conversion`);
// Retry loop for API call
let markdownContent = null;
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`[DeKindled] API attempt ${attempt}/${maxRetries} for page ${i + 1}`);
// Call OpenAI Vision API
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'gpt-4.1-mini-2025-04-14',
messages: [
{
role: 'user',
content: [
{
type: 'image_url',
image_url: {
url: page.base64
}
},
{
type: 'text',
text: await buildAnalysisPrompt()
},
]
}
],
max_tokens: 5000,
temperature: 0 + ((attempt - 1) * 0.2),
/**
* Weird, but the model behaves differently when providing this even if it's
* empty. This makes things work more like the playground.
*/
tools: [],
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(`OpenAI API error: ${errorData.error?.message || response.statusText}`);
}
const data = await response.json();
// Parse and clean the response
const cleanedResponse = parseAIResponse(data.choices[0].message.content);
markdownContent = cleanedResponse.trim().replaceAll(' \n', '\n\n');
console.log(`[DeKindled] Successfully got markdown content on attempt ${attempt} for page ${i + 1}`);
break; // Success, exit retry loop
} catch (apiError) {
console.error(`[DeKindled] API error on attempt ${attempt} for page ${i + 1}:`, apiError);
if (attempt === maxRetries) {
// All retries failed, create fallback
markdownContent = `*[API error for page ${i + 1}: ${apiError.message} - content may be missing]*`;
} else {
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
}
// Check for chapter markers
const chapterMarkerRegex = /^--- NEW CHAPTER: (.+?) ---$/m;
const chapterMatch = markdownContent.match(chapterMarkerRegex);
let isNewChapter = false;
let chapterTitle = null;
let contentToProcess = markdownContent;
if (chapterMatch) {
isNewChapter = true;
chapterTitle = chapterMatch[1].trim();
// Remove the chapter marker from the content
contentToProcess = markdownContent.replace(chapterMarkerRegex, '').trim();
console.log(`[DeKindled] Found new chapter: "${chapterTitle}"`);
}
// Start new section if needed
if (isNewChapter || currentSection === null || (i === 0 && !currentSection)) {
// Finalize previous section if it exists (convert markdown to HTML)
if (currentSection && currentSection.markdownContent.trim()) {
try {
currentSection.content = converter.makeHtml(currentSection.markdownContent);
console.log(`[DeKindled] Converted section "${currentSection.title}" from markdown to HTML`);
} catch (conversionError) {
console.error(`[DeKindled] Markdown conversion error for section "${currentSection.title}":`, conversionError);
// Fallback: wrap in paragraph tags
currentSection.content = `<p>${currentSection.markdownContent.replace(/\n/g, '<br>')}</p>`;
}
sections.push(currentSection);
}
// Determine section title
const sectionTitle = chapterTitle ||
(isNewChapter ? `Chapter ${sections.length + 1}` : `Section ${sections.length + 1}`);
// Start new section
currentSection = {
id: `section-${sections.length + 1}`,
title: sectionTitle,
content_type: isNewChapter ? 'chapter' : 'section',
markdownContent: contentToProcess, // Store markdown content
content: '', // Will be filled when section is complete
page_numbers: [i + 1]
};
console.log(`[DeKindled] Started new section: "${sectionTitle}"`);
} else {
// Continue current section - intelligently append with space or newlines
if (currentSection && contentToProcess.trim()) {
const separator = shouldContinueWithSpace(currentSection.markdownContent, contentToProcess) ? ' ' : '\n\n';
currentSection.markdownContent += separator + contentToProcess;
currentSection.page_numbers.push(i + 1);
console.log(`[DeKindled] Added page ${i + 1} to section: "${currentSection.title}" with ${separator === ' ' ? 'space' : 'paragraph break'}`);
} else if (contentToProcess.trim()) {
// Edge case: no current section but not starting new one
currentSection = {
id: `section-1`,
title: `Section 1`,
content_type: 'section',
markdownContent: contentToProcess,
content: '',
page_numbers: [i + 1]
};
}
}
// Add delay to respect rate limits
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error(`[DeKindled] Error analyzing page ${i + 1}:`, error);
// Create fallback section for this page
const fallbackSection = {
id: `error-page-${i + 1}`,
title: `Page ${i + 1} (Error)`,
content_type: 'error',
markdownContent: '',
content: `<p><em>Error processing page ${i + 1}: ${error.message}</em></p><p><img src="data:${page.type};base64,${page.base64.split(',')[1]}" alt="Page ${i + 1}" style="max-width: 100%; height: auto;" /></p>`,
page_numbers: [i + 1]
};
sections.push(fallbackSection);
}
}
// Don't forget the last section - convert its markdown to HTML
if (currentSection && currentSection.markdownContent.trim()) {
try {
currentSection.content = converter.makeHtml(currentSection.markdownContent);
console.log(`[DeKindled] Converted final section "${currentSection.title}" from markdown to HTML`);
} catch (conversionError) {
console.error(`[DeKindled] Markdown conversion error for final section "${currentSection.title}":`, conversionError);
// Fallback: wrap in paragraph tags
currentSection.content = `<p>${currentSection.markdownContent.replace(/\n/g, '<br>')}</p>`;
}
sections.push(currentSection);
}
console.log(`[DeKindled] Created ${sections.length} sections from ${pages.length} pages`);
sections.forEach(section => {
console.log(`[DeKindled] Section: "${section.title}" (${section.content_type}) - Pages: ${section.page_numbers.join(', ')}`);
});
return sections;
}
function parseAIResponse(rawContent) {
if (!rawContent || typeof rawContent !== 'string') {
return rawContent;
}
// Remove leading/trailing whitespace
let content = rawContent.trim();
// Pattern to match markdown code blocks: ```html, ```xhtml, ```xml, ```json, ```markdown, or just ```
const codeBlockPattern = /^```(?:html|xhtml|xml|json|markdown)?\s*\n([\s\S]*?)\n```$/;
const match = content.match(codeBlockPattern);
if (match) {
// Extract content from inside the code block
content = match[1].trim();
console.log('[DeKindled] Removed markdown code block wrapper from OpenAI response');
}
// Also handle single backticks (less common but possible)
const singleBacktickPattern = /^`([\s\S]*?)`$/;
const singleMatch = content.match(singleBacktickPattern);
if (singleMatch) {
content = singleMatch[1].trim();
console.log('[DeKindled] Removed single backtick wrapper from OpenAI response');
}
// Additional cleanup: remove any extra leading/trailing markdown artifacts
content = content
.replace(/^#+\s*/, '') // Remove leading markdown headers
.replace(/^\*\*.*?\*\*\s*/, '') // Remove bold markdown at start
.trim();
return content;
}
// Generate EPUB file
async function generateEpubFile(chapters, bookTitle, bookAuthor) {
const ZipClass = await importJSZip();
const zip = new ZipClass();
// Create EPUB structure
zip.file('mimetype', 'application/epub+zip');
// META-INF folder
const metaInf = zip.folder('META-INF');
metaInf.file('container.xml', `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`);
// OEBPS folder
const oebps = zip.folder('OEBPS');
// Generate content.opf
const manifestItems = chapters.map(chapter =>
` <item id="${chapter.id}" href="${chapter.id}.xhtml" media-type="application/xhtml+xml"/>`
).join('\n');
const spineItems = chapters.map(chapter =>
` <itemref idref="${chapter.id}"/>`
).join('\n');
oebps.file('content.opf', `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${generateUUID()}</dc:identifier>
<dc:title>${escapeXml(bookTitle)}</dc:title>
<dc:creator>${escapeXml(bookAuthor || 'Unknown Author')}</dc:creator>
<dc:language>en</dc:language>
<dc:date>${new Date().toISOString().split('T')[0]}</dc:date>
<meta property="dcterms:modified">${new Date().toISOString()}</meta>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
${manifestItems}
</manifest>
<spine toc="ncx">
${spineItems}
</spine>
</package>`);
// Generate toc.ncx
const navPoints = chapters.map((chapter, index) =>
` <navPoint id="${chapter.id}" playOrder="${index + 1}">
<navLabel><text>${escapeXml(chapter.title)}</text></navLabel>
<content src="${chapter.id}.xhtml"/>
</navPoint>`
).join('\n');
oebps.file('toc.ncx', `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="${generateUUID()}"/>
<meta name="dtb:depth" content="1"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle><text>${escapeXml(bookTitle)}</text></docTitle>
<navMap>
${navPoints}
</navMap>
</ncx>`);
// Generate chapter XHTML files
chapters.forEach(chapter => {
const xhtmlContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>${escapeXml(chapter.title)}</title>
</head>
<body>
${chapter.content}
</body>
</html>`;
oebps.file(`${chapter.id}.xhtml`, xhtmlContent);
});
// Generate the EPUB file
const epubBlob = await zip.generateAsync({
type: 'blob',
mimeType: 'application/epub+zip',
compression: 'DEFLATE',
compressionOptions: { level: 9 }
});
return epubBlob;
}
// Utility functions
function sanitizeFilename(filename) {
return filename.replace(/[^a-z0-9\-_\s]/gi, '').replace(/\s+/g, '_').substring(0, 100);
}
function escapeXml(text) {
return text.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Import JSZip dynamically
async function importJSZip() {
return SimpleZip;
}
// Build analysis prompt based on the current section
async function buildAnalysisPrompt() {
// Default prompt value
const defaultPrompt = `You are a book preservation assistant. Your job is to look at scanned images of books and rewrite them as markdown. You will be provided with a page from a book. Your job is to convert the page into valid markdown. We don't want to lose any data, so make sure to include all the text you see in the image.
Indentations should be handled by adding a new line between paragraphs. Do not add indents. If multiple indents appear in a row, they should have gaps between them.
--- example ---
"Character A talking," he said.
"Character B talking," she said. "This is more stuff that is said"
"Well that makes sense," he replied.
Now here's more text in a big long paragraph.
--- end example ---
Important! If you notice the page has a chapter title, write the following to mark the start of a new chapter (Exclude the brackets):
--- NEW CHAPTER: [Chapter Title] ---
# Chapter Number if it exists
Chapter Title`;
try {
const result = await chrome.storage.sync.get(['analysisPrompt']);
return result.analysisPrompt || defaultPrompt;
} catch (error) {
console.warn('[DeKindled] Failed to load analysis prompt from storage, using default:', error);
return defaultPrompt;
}
}
// Convert uint8Array to base64 using chunked approach to avoid stack overflow
function uint8ArrayToBase64(uint8Array) {
console.log(`[DeKindled] Converting ${uint8Array.length} bytes to base64 using chunked approach`);
let binaryString = '';
const chunkSize = 16384; // 16KB chunks - better browser compatibility
try {
for (let i = 0; i < uint8Array.length; i += chunkSize) {
const chunk = uint8Array.slice(i, i + chunkSize);
binaryString += String.fromCharCode.apply(null, chunk);
}
console.log(`[DeKindled] Successfully converted to binary string, applying base64 encoding`);
return btoa(binaryString);
} catch (error) {
console.error('[DeKindled] Error in chunked base64 conversion:', error);
throw new Error(`Base64 conversion failed: ${error.message}`);
}
}
// Function to determine if we should append with space (mid-sentence) or newlines (new paragraph)
function shouldContinueWithSpace(previousContent, newContent) {
if (!previousContent || !newContent) {
return false; // Default to new paragraph if either is empty
}
// Get the last 50 characters of previous content, trimmed
const prevTrimmed = previousContent.trim();
const prevEnd = prevTrimmed.slice(-50).trim();
// Get the first 50 characters of new content, trimmed
const newTrimmed = newContent.trim();
const newStart = newTrimmed.slice(0, 50).trim();
if (!prevEnd || !newStart) {
return false;
}
// Check for common abbreviations that end with periods but don't end sentences
const commonAbbrevs = /\b(dr|mr|mrs|ms|prof|vs|etc|inc|ltd|corp|co|st|ave|blvd|rd|jr|sr|phd|md|ba|ma|bs|ms|am|pm|vol|no|pg|ch|sec|min|max|approx|est|fig|ref|ed|eds|trans|repr|orig|pub|univ|dept|govt|assoc|org|admin|tech|info|bio|geo|psych|econ|hist|lit|math|sci|eng|med|law|bus|art|mus|phil|relig|sociol|anthro|archaeol|astron|biol|bot|chem|comp|ecol|geol|meteorol|oceanol|phys|stat|zool)\.\s*$/i;
if (commonAbbrevs.test(prevEnd)) {
return true; // Continue with space after abbreviations
}
// If previous content ends with sentence-ending punctuation (including within quotes), start new paragraph
const sentenceEnders = /[.!?:;]['"""''‚„‹›«»]?\s*$|[.!?:;]\s*['"""''‚„‹›«»]\s*$|\.{3,}\s*$/;
if (sentenceEnders.test(prevEnd)) {
return false;
}
// If previous content ends with comma, dash, or ellipsis (but not sentence-ending ellipsis), likely continuing
const continuationPunctuation = /[,\-—…]['"""''‚„‹›«»]?\s*$|[,\-—…]\s*['"""''‚„‹›«»]\s*$/;
if (continuationPunctuation.test(prevEnd)) {
return true;
}
// If new content starts with lowercase letter, likely continuing sentence
if (/^[a-z]/.test(newStart)) {
return true;
}
// If new content starts with common continuation words (even if capitalized)
const continuationWords = /^(and|but|or|so|yet|for|nor|the|a|an|he|she|it|they|we|you|i|his|her|its|their|our|your|my|this|that|these|those|then|now|here|there|when|where|who|what|why|how|however|therefore|thus|meanwhile|furthermore|moreover|nevertheless|nonetheless|otherwise|likewise|similarly|consequently|accordingly|subsequently|eventually|finally|initially|originally|previously|recently|currently|presently|immediately|suddenly|gradually|slowly|quickly|briefly|shortly|soon|later|earlier|before|after|during|while|since|until|unless|although|though|whereas|because|if|when|where|as|than|like|unlike|despite|regarding|concerning|including|excluding|except|besides|among|between|within|without|beyond|beneath|above|below|across|through|throughout|around|toward|towards|against|along|beside|behind|ahead|inside|outside|nearby|far|close|near)\b/i;
if (continuationWords.test(newStart)) {
return true;
}
// If new content starts with closing punctuation (quotes, parentheses, brackets), likely continuing
if (/^['"""''‚„‹›«»)\]}>]/.test(newStart)) {
return true;
}
// If previous content doesn't end with a word character (and not already handled above), probably start new paragraph
if (!/\w['"""''‚„‹›«»]?\s*$/.test(prevEnd)) {
return false;
}
// Default: if new content starts with capital letter and doesn't match continuation patterns,
// it's probably a new sentence/paragraph
if (/^[A-Z]/.test(newStart)) {
return false;
}
// When in doubt, continue with space (conservative approach for mid-sentence cases)
return true;
}

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

83
inject.js Normal file
View file

@ -0,0 +1,83 @@
// DeKindled - Inject blob interceptor script as early as possible
console.log('[DeKindled] Injecting content interceptor...');
// Inject script tag with chrome-extension:// URL (CSP-safe)
const script = document.createElement('script');
script.src = chrome.runtime.getURL('interceptor.js');
script.type = 'text/javascript';
// Inject the script as early as possible
if (document.documentElement) {
document.documentElement.appendChild(script);
console.log('[DeKindled] Interceptor injected');
} else {
// If documentElement doesn't exist yet, wait for it
const observer = new MutationObserver((mutations, obs) => {
if (document.documentElement) {
document.documentElement.appendChild(script);
console.log('[DeKindled] Interceptor injected (delayed)');
obs.disconnect();
}
});
observer.observe(document, { childList: true, subtree: true });
}
// Message bridge: Listen for custom events from main world and forward to background
window.addEventListener('dekindled-message', async (event) => {
const { data } = event.detail;
console.log('[DeKindled] Forwarding message to background:', data);
try {
const response = await chrome.runtime.sendMessage(data);
// Send response back to main world
window.dispatchEvent(new CustomEvent('dekindled-response', {
detail: {
id: event.detail.id,
response: response
}
}));
} catch (error) {
console.error('[DeKindled] Error forwarding message:', error);
// Send error back to main world
window.dispatchEvent(new CustomEvent('dekindled-response', {
detail: {
id: event.detail.id,
error: error.message
}
}));
}
});
// Listen for progress messages from background script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'conversionProgress') {
// Forward progress update to main world
window.dispatchEvent(new CustomEvent('dekindled-progress', {
detail: {
current: message.current,
total: message.total
}
}));
} else if (message.action === 'conversionComplete') {
// Forward completion notification to main world
window.dispatchEvent(new CustomEvent('dekindled-complete', {
detail: {
success: message.success,
filename: message.filename,
error: message.error
}
}));
}
});
// Fallback: Use chrome.scripting API if main injection fails
setTimeout(() => {
chrome.runtime.sendMessage({ action: 'injectInterceptor' }, (response) => {
if (response && response.success) {
console.log('[DeKindled] Interceptor injected via scripting API fallback');
}
});
}, 100);

147
interceptor.js Normal file
View file

@ -0,0 +1,147 @@
// DeKindled - Blob URL Interceptor
// Captures blob URLs and their base64 content for extraction
(function() {
// Guard against multiple script injection
if (window.__dekindled && window.__dekindled._initialized) {
console.log('[DeKindled] Interceptor already initialized, skipping duplicate injection');
return;
}
// Create storage for blob data
window.__dekindled = window.__dekindled || {
blobs: [],
blobData: new Map(), // Store actual blob content
_initialized: true // Mark as initialized
};
console.log('[DeKindled] Initializing blob interceptor...');
// Logging function
function logBlob(type, details) {
// Store in array for viewing
window.__dekindled.blobs.push({
type,
details,
timestamp: new Date().toISOString()
});
// Visual indicator for blob creation
if (type === 'Blob URL Created') {
[...document.getElementsByClassName('dekindled-indicator')].forEach(indicator => indicator.remove());
const indicator = document.createElement('div');
indicator.className = 'dekindled-indicator';
indicator.style.cssText = 'position:fixed;top:10px;left:10px;background:#d32f2f;color:white;padding:10px;z-index:999999;font-family:monospace;font-size:12px;border-radius:4px;box-shadow:0 2px 5px rgba(0,0,0,0.3);';
indicator.textContent = '📚 DeKindled: Captured ' + (details.type || 'blob') + ' ✓';
document.body.appendChild(indicator);
setTimeout(() => indicator.remove(), 3000);
}
}
// Function to read blob as base64
async function readBlobAsBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// Check if URL.createObjectURL is already overridden
if (URL.createObjectURL._dekindledOverridden) {
console.log('[DeKindled] URL.createObjectURL already overridden, skipping');
return;
}
// Store original functions
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
// Override createObjectURL
URL.createObjectURL = function(object) {
const url = originalCreateObjectURL.apply(this, arguments);
try {
const details = {
url: url,
objectType: object?.constructor?.name || 'Unknown',
size: object?.size || 0,
type: object?.type || 'Unknown',
timestamp: new Date().toISOString(),
stored: false
};
// If it's a Blob, immediately read its content as base64
if (object instanceof Blob) {
readBlobAsBase64(object).then(base64Data => {
// Store the base64 data
window.__dekindled.blobData.set(url, {
base64: base64Data,
type: object.type,
size: object.size,
timestamp: details.timestamp
});
details.stored = true;
console.log(`[DeKindled] Captured base64 data for ${url}:`, {
type: object.type,
size: object.size,
dataLength: base64Data.length
});
// Update the visual indicator
const indicators = document.querySelectorAll('div[style*="DeKindled: Captured"]');
const latestIndicator = indicators[indicators.length - 1];
if (latestIndicator) {
latestIndicator.textContent = '📚 DeKindled: Captured ' + (object.type || 'blob') + ' ✓';
latestIndicator.style.background = '#2e7d32';
}
}).catch(error => {
console.error('[DeKindled] Failed to read blob as base64:', error);
});
}
logBlob('Blob URL Created', details);
} catch (e) {
console.error('[DeKindled] Error in createObjectURL override:', e);
// Fallback visual alert
const alert = document.createElement('div');
alert.style.cssText = 'position:fixed;top:50px;left:10px;background:orange;color:black;padding:10px;z-index:999999;border-radius:4px;';
alert.textContent = 'DeKindled: Captured blob (error logging)';
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 3000);
}
return url;
};
// Mark the override to prevent double-overriding
URL.createObjectURL._dekindledOverridden = true;
// Override revokeObjectURL
URL.revokeObjectURL = function(url) {
logBlob('Blob URL Revoked', {
url: url,
timestamp: new Date().toISOString(),
hadData: window.__dekindled.blobData.has(url)
});
console.log(`[DeKindled] Blob URL revoked: ${url}, had stored data: ${window.__dekindled.blobData.has(url)}`);
return originalRevokeObjectURL.apply(this, arguments);
};
// Ensure overrides are set on window object
window.URL = URL;
if (window.webkitURL) {
window.webkitURL = URL;
}
// Create a visible indicator that script is running
const statusDiv = document.createElement('div');
statusDiv.style.cssText = 'position:fixed;bottom:10px;right:10px;background:#1976d2;color:white;padding:5px 10px;z-index:999999;font-size:10px;border-radius:4px;';
statusDiv.textContent = '📚 DeKindled Active';
document.body.appendChild(statusDiv);
console.log('[DeKindled] Content extraction ready - capturing base64 blob data');
})();

50
manifest.json Normal file
View file

@ -0,0 +1,50 @@
{
"manifest_version": 3,
"name": "DeKindled",
"version": "1.0",
"description": "Extract and save blob content from web readers like Kindle",
"icons": {
"16": "icon.png",
"32": "icon.png",
"48": "icon.png",
"128": "icon.png",
"1024": "icon.png"
},
"permissions": [
"scripting",
"activeTab",
"downloads",
"storage"
],
"host_permissions": [
"https://read.amazon.com/*",
"https://api.openai.com/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "DeKindled - Click to manage captured content",
"default_icon": {
"16": "icon.png",
"32": "icon.png",
"48": "icon.png",
"128": "icon.png"
}
},
"options_page": "options.html",
"content_scripts": [
{
"matches": ["https://read.amazon.com/*"],
"js": ["inject.js"],
"run_at": "document_start",
"all_frames": true
}
],
"web_accessible_resources": [
{
"resources": ["interceptor.js", "viewer-inject.js", "zip-utils.js"],
"matches": ["https://read.amazon.com/*"]
}
]
}

177
options.html Normal file
View file

@ -0,0 +1,177 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DeKindled Options</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 40px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #1976d2;
margin-bottom: 30px;
display: flex;
align-items: center;
gap: 10px;
}
.section {
margin-bottom: 30px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
input[type="text"]:focus, input[type="password"]:focus {
outline: none;
border-color: #1976d2;
}
textarea {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
line-height: 1.4;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: #1976d2;
}
.help-text {
font-size: 12px;
color: #666;
margin-top: 5px;
line-height: 1.4;
}
.help-text a {
color: #1976d2;
text-decoration: none;
}
.help-text a:hover {
text-decoration: underline;
}
button {
background: #1976d2;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
button:hover {
background: #1565c0;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.reset-btn {
background: #6c757d;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
margin-top: 8px;
}
.reset-btn:hover {
background: #5a6268;
}
.status {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
font-size: 14px;
}
.status.success {
background: #e8f5e8;
color: #2e7d32;
border: 1px solid #4caf50;
}
.status.error {
background: #fce8e8;
color: #d32f2f;
border: 1px solid #f44336;
}
.api-key-status {
margin-top: 10px;
font-size: 12px;
}
.api-key-status.valid {
color: #2e7d32;
}
.api-key-status.invalid {
color: #d32f2f;
}
</style>
</head>
<body>
<div class="container">
<h1>📚 DeKindled Options</h1>
<div class="section">
<label for="apiKey">OpenAI API Key:</label>
<input type="password" id="apiKey" placeholder="sk-...">
<div class="help-text">
Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank">OpenAI Platform</a>.
Your key is stored securely in your browser and never shared.
</div>
<div id="apiKeyStatus" class="api-key-status"></div>
</div>
<div class="section">
<label for="bookTitle">Default Book Title:</label>
<input type="text" id="bookTitle" placeholder="My DeKindled Book" value="DeKindled Book">
<div class="help-text">
Default title to use for generated EPUB files. You can override this during conversion.
</div>
</div>
<div class="section">
<label for="analysisPrompt">Analysis Prompt:</label>
<textarea id="analysisPrompt" rows="12" placeholder="Enter the prompt used for analyzing book pages..."></textarea>
<div class="help-text">
The prompt sent to OpenAI for analyzing each book page. This controls how the AI interprets and converts the scanned text to markdown.
<br><strong>Advanced setting:</strong> Modify only if you want to customize the conversion behavior.
</div>
<button id="resetPromptBtn" class="reset-btn">Reset to Default</button>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button id="saveBtn">Save Settings</button>
<button id="testBtn" disabled>Test API Key</button>
</div>
<div id="status"></div>
</div>
<script src="options.js"></script>
</body>
</html>

170
options.js Normal file
View file

@ -0,0 +1,170 @@
// DeKindled Options Page Script
// DOM elements
const apiKeyInput = document.getElementById('apiKey');
const bookTitleInput = document.getElementById('bookTitle');
const analysisPromptInput = document.getElementById('analysisPrompt');
const saveBtn = document.getElementById('saveBtn');
const testBtn = document.getElementById('testBtn');
const resetPromptBtn = document.getElementById('resetPromptBtn');
const status = document.getElementById('status');
const apiKeyStatus = document.getElementById('apiKeyStatus');
// Default prompt value
const DEFAULT_ANALYSIS_PROMPT = `You are a book preservation assistant. Your job is to look at scanned images of books and rewrite them as markdown. You will be provided with a page from a book. Your job is to convert the page into valid markdown. We don't want to lose any data, so make sure to include all the text you see in the image.
Indentations should be handled by adding a new line between paragraphs. Do not add indents. If multiple indents appear in a row, they should have gaps between them.
--- example ---
"Character A talking," he said.
"Character B talking," she said. "This is more stuff that is said"
"Well that makes sense," he replied.
Now here's more text in a big long paragraph.
--- end example ---
Important! If you notice the page has a chapter title, write the following to mark the start of a new chapter (Exclude the brackets):
--- NEW CHAPTER: [Chapter Title] ---
# Chapter Number if it exists
Chapter Title`;
// Load saved settings
async function loadSettings() {
try {
const result = await chrome.storage.sync.get(['openaiApiKey', 'defaultBookTitle', 'analysisPrompt']);
if (result.openaiApiKey) {
apiKeyInput.value = result.openaiApiKey;
apiKeyStatus.textContent = '✓ API key saved';
apiKeyStatus.className = 'api-key-status valid';
testBtn.disabled = false;
}
if (result.defaultBookTitle) {
bookTitleInput.value = result.defaultBookTitle;
}
// Load analysis prompt or use default
analysisPromptInput.value = result.analysisPrompt || DEFAULT_ANALYSIS_PROMPT;
} catch (error) {
showStatus('Error loading settings: ' + error.message, 'error');
}
}
// Save settings
async function saveSettings() {
try {
const settings = {
openaiApiKey: apiKeyInput.value.trim(),
defaultBookTitle: bookTitleInput.value.trim() || 'DeKindled Book',
analysisPrompt: analysisPromptInput.value.trim() || DEFAULT_ANALYSIS_PROMPT
};
await chrome.storage.sync.set(settings);
if (settings.openaiApiKey) {
apiKeyStatus.textContent = '✓ API key saved';
apiKeyStatus.className = 'api-key-status valid';
testBtn.disabled = false;
} else {
apiKeyStatus.textContent = '';
testBtn.disabled = true;
}
showStatus('Settings saved successfully!', 'success');
} catch (error) {
showStatus('Error saving settings: ' + error.message, 'error');
}
}
// Test API key
async function testApiKey() {
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
showStatus('Please enter an API key first', 'error');
return;
}
testBtn.disabled = true;
testBtn.textContent = 'Testing...';
try {
const response = await fetch('https://api.openai.com/v1/models', {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
if (response.ok) {
const data = await response.json();
const hasVisionModel = data.data.some(model =>
model.id.includes('gpt-4-vision') || model.id.includes('gpt-4o')
);
if (hasVisionModel) {
showStatus('✅ API key is valid and has access to vision models!', 'success');
apiKeyStatus.textContent = '✓ API key verified';
apiKeyStatus.className = 'api-key-status valid';
} else {
showStatus('⚠️ API key is valid but may not have access to vision models. Check your OpenAI plan.', 'error');
}
} else {
const errorData = await response.json();
throw new Error(errorData.error?.message || `HTTP ${response.status}`);
}
} catch (error) {
showStatus('❌ API key test failed: ' + error.message, 'error');
apiKeyStatus.textContent = '✗ API key invalid';
apiKeyStatus.className = 'api-key-status invalid';
} finally {
testBtn.disabled = false;
testBtn.textContent = 'Test API Key';
}
}
// Show status message
function showStatus(message, type) {
status.textContent = message;
status.className = `status ${type}`;
status.style.display = 'block';
if (type === 'success') {
setTimeout(() => {
status.style.display = 'none';
}, 3000);
}
}
// Reset analysis prompt to default
function resetPrompt() {
if (confirm('Are you sure you want to reset the analysis prompt to default? This will overwrite your current prompt.')) {
analysisPromptInput.value = DEFAULT_ANALYSIS_PROMPT;
saveSettings(); // Auto-save after reset
showStatus('Analysis prompt reset to default!', 'success');
}
}
// Event listeners
saveBtn.addEventListener('click', saveSettings);
testBtn.addEventListener('click', testApiKey);
resetPromptBtn.addEventListener('click', resetPrompt);
// Enable test button when API key is entered
apiKeyInput.addEventListener('input', () => {
testBtn.disabled = !apiKeyInput.value.trim();
});
// Auto-save book title
bookTitleInput.addEventListener('blur', saveSettings);
// Auto-save analysis prompt
analysisPromptInput.addEventListener('blur', saveSettings);
// Load settings on page load
document.addEventListener('DOMContentLoaded', loadSettings);

3
showdown.min.js vendored Normal file

File diff suppressed because one or more lines are too long

177
test-page.html Normal file
View file

@ -0,0 +1,177 @@
<!DOCTYPE html>
<html>
<head>
<title>DeKindled Test Page</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
h1 {
color: #1976d2;
}
h1::before {
content: '📚 ';
}
button {
background: #1976d2;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 5px;
transition: background 0.2s;
}
button:hover {
background: #1565c0;
}
#output {
margin-top: 20px;
padding: 15px;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
min-height: 100px;
}
.blob-url {
font-family: monospace;
background: #e3f2fd;
padding: 2px 5px;
border-radius: 3px;
word-break: break-all;
color: #1976d2;
}
.info-box {
background: #e3f2fd;
border: 1px solid #1976d2;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
.highlight {
background: #fff3cd;
padding: 10px;
border: 1px solid #ffc107;
border-radius: 4px;
margin: 10px 0;
}
</style>
</head>
<body>
<h1>DeKindled Test Page</h1>
<div class="info-box">
<p>Use this page to test if DeKindled is properly capturing blob content.</p>
<p>When you create blobs, you should see:</p>
<ul>
<li>Red popups at the top-left showing captured content</li>
<li>"📚 DeKindled Active" indicator at bottom-right</li>
<li>Captured blobs stored in <code>window.__dekindled.blobs</code></li>
</ul>
</div>
<div class="highlight">
<strong>📌 To view captured content:</strong> Click the DeKindled extension icon in your browser toolbar. An overlay will appear showing all captured blobs with download options.
</div>
<h2>Test Blob Creation</h2>
<button onclick="createTextBlob()">Create Text Blob</button>
<button onclick="createImageBlob()">Create Image Blob</button>
<button onclick="createLargeBlob()">Create Large Blob (1MB)</button>
<button onclick="revokeLastBlob()">Revoke Last Blob</button>
<button onclick="viewCaptured()">View Captured Count</button>
<h2>Test Results</h2>
<div id="output">
<p>Click the buttons above to create test blobs...</p>
</div>
<script>
let lastBlobUrl = null;
const output = document.getElementById('output');
function log(message) {
const p = document.createElement('p');
p.innerHTML = message;
output.appendChild(p);
console.log(message);
}
function createTextBlob() {
const blob = new Blob(['Hello from DeKindled! This is test content.'], { type: 'text/plain' });
lastBlobUrl = URL.createObjectURL(blob);
log(`✅ Created text blob: <span class="blob-url">${lastBlobUrl}</span>`);
log(`💡 Click the extension icon to see and download this blob`);
}
function createImageBlob() {
// Create a simple 100x100 pixel image
const canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 100;
const ctx = canvas.getContext('2d');
// Draw DeKindled logo colors
ctx.fillStyle = '#1976d2';
ctx.fillRect(0, 0, 100, 100);
ctx.fillStyle = 'white';
ctx.font = '40px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('📚', 50, 50);
canvas.toBlob(blob => {
lastBlobUrl = URL.createObjectURL(blob);
log(`✅ Created image blob: <span class="blob-url">${lastBlobUrl}</span>`);
log(`💡 Click the extension icon to see and download this blob`);
}, 'image/png');
}
function createLargeBlob() {
const largeArray = new Uint8Array(1024 * 1024); // 1MB
for (let i = 0; i < largeArray.length; i++) {
largeArray[i] = Math.floor(Math.random() * 256);
}
const blob = new Blob([largeArray], { type: 'application/octet-stream' });
lastBlobUrl = URL.createObjectURL(blob);
log(`✅ Created large blob (1MB): <span class="blob-url">${lastBlobUrl}</span>`);
log(`💡 Click the extension icon to see and download this blob`);
}
function revokeLastBlob() {
if (lastBlobUrl) {
URL.revokeObjectURL(lastBlobUrl);
log(`❌ Revoked blob: <span class="blob-url">${lastBlobUrl}</span>`);
lastBlobUrl = null;
} else {
log('⚠️ No blob to revoke. Create one first!');
}
}
function viewCaptured() {
if (window.__dekindled && window.__dekindled.blobs) {
const count = window.__dekindled.blobs.length;
const active = Array.from(window.__dekindled.blobMap.keys()).length;
log(`📊 Captured: ${count} total events, ${active} active blobs`);
log(`💡 Click the DeKindled extension icon to view and download them`);
console.log('DeKindled data:', window.__dekindled);
} else {
log('⚠️ No DeKindled data found. Make sure the extension is active.');
}
}
// Initial status
setTimeout(() => {
if (window.__dekindled) {
log('✅ DeKindled extension is active and ready!');
log('📌 Create some blobs, then click the extension icon to view them.');
} else {
log('⚠️ Waiting for DeKindled extension to initialize...');
}
}, 500);
</script>
</body>
</html>

794
viewer-inject.js Normal file
View file

@ -0,0 +1,794 @@
// DeKindled - Inject blob viewer overlay into current page
(function() {
// Check if viewer already exists
if (document.getElementById('dekindled-viewer-overlay')) {
document.getElementById('dekindled-viewer-overlay').style.display = 'flex';
return;
}
// Current page state
let currentPageIndex = 0;
let pages = [];
// Create overlay container
const overlay = document.createElement('div');
overlay.id = 'dekindled-viewer-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 999999;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
// Create header
const header = document.createElement('div');
header.style.cssText = `
background: #1976d2;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
`;
header.innerHTML = `
<div>
<h2 style="margin: 0; font-size: 20px;">📚 DeKindled</h2>
<p id="page-info" style="margin: 5px 0 0 0; opacity: 0.9; font-size: 14px;">Loading...</p>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button id="dekindled-auto-scan" style="
background: #2196f3;
border: none;
color: white;
font-size: 14px;
cursor: pointer;
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
transition: background 0.2s;
">Start Auto-Scan</button>
<button id="dekindled-convert" style="
background: #4caf50;
border: none;
color: white;
font-size: 14px;
cursor: pointer;
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
transition: background 0.2s;
" disabled>Convert to EPUB</button>
<button id="dekindled-close" style="
background: transparent;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
">&times;</button>
</div>
`;
// Create main reader area
const readerArea = document.createElement('div');
readerArea.style.cssText = `
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
min-height: 0;
`;
// Create navigation arrows
const prevButton = document.createElement('button');
prevButton.id = 'prev-page';
prevButton.style.cssText = `
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.9);
border: none;
width: 60px;
height: 60px;
border-radius: 50%;
font-size: 36px;
cursor: pointer;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
`;
prevButton.innerHTML = '‹';
prevButton.onclick = () => navigatePage(-1);
const nextButton = document.createElement('button');
nextButton.id = 'next-page';
nextButton.style.cssText = `
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.9);
border: none;
width: 60px;
height: 60px;
border-radius: 50%;
font-size: 36px;
cursor: pointer;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
`;
nextButton.innerHTML = '›';
nextButton.onclick = () => navigatePage(1);
// Create page display area
const pageDisplay = document.createElement('div');
pageDisplay.id = 'page-display';
pageDisplay.style.cssText = `
width: calc(100vw - 160px);
height: calc(100vh - 140px);
max-width: 1200px;
max-height: 800px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 8px;
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.5);
overflow: hidden;
margin: 0 auto;
`;
// Create page image
const pageImage = document.createElement('img');
pageImage.id = 'current-page-image';
pageImage.style.cssText = `
width: 100%;
height: 100%;
object-fit: contain;
display: block;
background: white;
`;
// Create footer with page controls
const footer = document.createElement('div');
footer.style.cssText = `
background: #333;
color: white;
padding: 10px 20px;
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
flex-shrink: 0;
`;
const pageSlider = document.createElement('input');
pageSlider.type = 'range';
pageSlider.id = 'page-slider';
pageSlider.style.cssText = `
flex: 1;
max-width: 400px;
height: 6px;
background: #555;
border-radius: 3px;
outline: none;
`;
pageSlider.oninput = (e) => {
currentPageIndex = parseInt(e.target.value);
displayCurrentPage();
};
const pageCounter = document.createElement('div');
pageCounter.id = 'page-counter';
pageCounter.style.cssText = `
min-width: 120px;
text-align: center;
font-size: 14px;
font-family: monospace;
`;
footer.appendChild(pageSlider);
footer.appendChild(pageCounter);
// Assemble the reader
pageDisplay.appendChild(pageImage);
readerArea.appendChild(prevButton);
readerArea.appendChild(pageDisplay);
readerArea.appendChild(nextButton);
overlay.appendChild(header);
overlay.appendChild(readerArea);
overlay.appendChild(footer);
document.body.appendChild(overlay);
// Navigation function
function navigatePage(direction) {
if (pages.length === 0) return;
currentPageIndex += direction;
if (currentPageIndex < 0) currentPageIndex = 0;
if (currentPageIndex >= pages.length) currentPageIndex = pages.length - 1;
displayCurrentPage();
}
// Display current page
function displayCurrentPage() {
if (pages.length === 0) {
pageImage.style.display = 'none';
document.getElementById('page-info').textContent = 'No pages captured';
document.getElementById('page-counter').textContent = '0 / 0';
prevButton.style.display = 'none';
nextButton.style.display = 'none';
pageSlider.style.display = 'none';
// Disable convert button when no pages
const convertBtn = document.getElementById('dekindled-convert');
convertBtn.disabled = true;
convertBtn.style.background = '#ccc';
convertBtn.style.cursor = 'not-allowed';
return;
}
const currentPage = pages[currentPageIndex];
// Update image
pageImage.src = currentPage.data.base64;
// Update page info
const isImage = currentPage.data.type && currentPage.data.type.startsWith('image/');
document.getElementById('page-info').innerHTML = `
Page ${currentPageIndex + 1} of ${pages.length} •
${currentPage.data.type} •
${formatSize(currentPage.data.size)} •
${new Date(currentPage.data.timestamp).toLocaleTimeString()}
`;
// Update page counter
document.getElementById('page-counter').textContent = `${currentPageIndex + 1} / ${pages.length}`;
// Update slider
pageSlider.max = pages.length - 1;
pageSlider.value = currentPageIndex;
// Show/hide navigation buttons
prevButton.style.opacity = currentPageIndex > 0 ? '1' : '0.3';
nextButton.style.opacity = currentPageIndex < pages.length - 1 ? '1' : '0.3';
prevButton.style.cursor = currentPageIndex > 0 ? 'pointer' : 'default';
nextButton.style.cursor = currentPageIndex < pages.length - 1 ? 'pointer' : 'default';
// Enable convert button when pages are available
const convertBtn = document.getElementById('dekindled-convert');
convertBtn.disabled = false;
convertBtn.style.background = '#4caf50';
convertBtn.style.cursor = 'pointer';
}
// Load pages from blobData
function loadPages() {
const dekindled = window.__dekindled;
if (!dekindled || !dekindled.blobData) {
pages = [];
displayCurrentPage();
return;
}
// Convert blobData Map to array, maintaining order
const newPages = Array.from(dekindled.blobData.entries()).map(([url, data]) => ({
url,
data
}));
// Only update if pages have actually changed
const pagesChanged = pages.length !== newPages.length ||
pages.some((page, index) => page.url !== newPages[index]?.url);
if (pagesChanged) {
const previousPageCount = pages.length;
pages = newPages;
// Preserve current page index if possible
if (previousPageCount === 0) {
// First load - start at first page
currentPageIndex = 0;
} else if (currentPageIndex >= pages.length) {
// Current page is out of bounds - go to last page
currentPageIndex = Math.max(0, pages.length - 1);
}
// Otherwise keep the current page index
displayCurrentPage();
}
}
// Close function
function closeOverlay() {
// Stop auto-scanning if active
stopAutoScan();
overlay.style.display = 'none';
}
// Message bridge to communicate with content script
function sendMessageToBackground(message) {
return new Promise((resolve, reject) => {
const messageId = Math.random().toString(36).substr(2, 9);
// Listen for response
const responseHandler = (event) => {
if (event.detail.id === messageId) {
window.removeEventListener('dekindled-response', responseHandler);
if (event.detail.error) {
reject(new Error(event.detail.error));
} else {
resolve(event.detail.response);
}
}
};
window.addEventListener('dekindled-response', responseHandler);
// Send message
window.dispatchEvent(new CustomEvent('dekindled-message', {
detail: {
id: messageId,
data: message
}
}));
// Timeout after 10 seconds (only waiting for acknowledgment, not full conversion)
setTimeout(() => {
window.removeEventListener('dekindled-response', responseHandler);
reject(new Error('Message timeout'));
}, 10000); // Reduced to 10 seconds since we only wait for acknowledgment
});
}
// Show book metadata dialog
function showBookMetadataDialog() {
return new Promise((resolve, reject) => {
// Create modal overlay
const modalOverlay = document.createElement('div');
modalOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000000;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
// Create modal dialog
const modal = document.createElement('div');
modal.style.cssText = `
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.5);
min-width: 400px;
max-width: 500px;
`;
// Get current page title as default
const defaultTitle = document.title.replace(/\s*-\s*Kindle.*$/i, '').trim() || 'DeKindled Book';
modal.innerHTML = `
<h2 style="margin: 0 0 20px 0; color: #1976d2;">📚 Book Information</h2>
<p style="margin: 0 0 20px 0; color: #666; font-size: 14px;">
Please enter the book details for your EPUB file:
</p>
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 5px; font-weight: 500;">Title:</label>
<input type="text" id="book-title-input" value="${defaultTitle}" style="
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
">
</div>
<div style="margin-bottom: 30px;">
<label style="display: block; margin-bottom: 5px; font-weight: 500;">Author:</label>
<input type="text" id="book-author-input" placeholder="Enter author name" style="
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
">
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button id="modal-cancel" style="
background: #6c757d;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
">Cancel</button>
<button id="modal-ok" style="
background: #1976d2;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
">Start Conversion</button>
</div>
`;
modalOverlay.appendChild(modal);
document.body.appendChild(modalOverlay);
// Focus on title input
const titleInput = modal.querySelector('#book-title-input');
titleInput.focus();
titleInput.select();
// Handle button clicks
modal.querySelector('#modal-cancel').onclick = () => {
document.body.removeChild(modalOverlay);
reject(new Error('User cancelled'));
};
modal.querySelector('#modal-ok').onclick = () => {
const title = modal.querySelector('#book-title-input').value.trim();
const author = modal.querySelector('#book-author-input').value.trim();
if (!title) {
alert('Please enter a book title.');
return;
}
document.body.removeChild(modalOverlay);
resolve({ title, author: author || 'Unknown Author' });
};
// Handle Enter key
const handleKeydown = (e) => {
if (e.key === 'Enter') {
modal.querySelector('#modal-ok').click();
} else if (e.key === 'Escape') {
modal.querySelector('#modal-cancel').click();
}
};
modal.querySelector('#book-title-input').addEventListener('keydown', handleKeydown);
modal.querySelector('#book-author-input').addEventListener('keydown', handleKeydown);
// Add hover effects
const cancelBtn = modal.querySelector('#modal-cancel');
const okBtn = modal.querySelector('#modal-ok');
cancelBtn.onmouseenter = () => cancelBtn.style.background = '#5a6268';
cancelBtn.onmouseleave = () => cancelBtn.style.background = '#6c757d';
okBtn.onmouseenter = () => okBtn.style.background = '#1565c0';
okBtn.onmouseleave = () => okBtn.style.background = '#1976d2';
});
}
// Conversion function
async function convertToEpub() {
const convertBtn = document.getElementById('dekindled-convert');
const originalText = convertBtn.textContent;
const pageInfo = document.getElementById('page-info');
const originalPageInfo = pageInfo.innerHTML;
try {
// Update button to show progress
convertBtn.disabled = true;
convertBtn.textContent = 'Starting Conversion...';
convertBtn.style.background = '#ff9800';
// Show book metadata dialog first
let bookMetadata;
try {
bookMetadata = await showBookMetadataDialog();
} catch (dialogError) {
// User cancelled the dialog
convertBtn.textContent = originalText;
convertBtn.style.background = '#4caf50';
convertBtn.disabled = false;
return;
}
// Prepare the pages data
const pagesData = pages.map((page, index) => ({
index: index + 1,
base64: page.data.base64,
type: page.data.type,
size: page.data.size
}));
convertBtn.textContent = 'Sending Pages...';
pageInfo.innerHTML = '<strong>📤 Sending pages to background process...</strong>';
// Send pages in chunks to avoid message size limits
const CHUNK_SIZE = 5; // Send 5 pages at a time
const chunks = [];
for (let i = 0; i < pagesData.length; i += CHUNK_SIZE) {
chunks.push(pagesData.slice(i, i + CHUNK_SIZE));
}
console.log(`[DeKindled] Sending ${pagesData.length} pages in ${chunks.length} chunks of max ${CHUNK_SIZE} pages each`);
// Send initial message to start the conversion process
const initResponse = await sendMessageToBackground({
action: 'initEpubConversion',
totalPages: pagesData.length,
totalChunks: chunks.length,
bookTitle: bookMetadata.title,
bookAuthor: bookMetadata.author
});
if (!initResponse.success) {
throw new Error(initResponse.error || 'Failed to initialize conversion');
}
const conversionId = initResponse.conversionId;
console.log(`[DeKindled] Conversion initialized with ID: ${conversionId}`);
// Send chunks one by one
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
convertBtn.textContent = `Sending Chunk ${i + 1}/${chunks.length}...`;
pageInfo.innerHTML = `<strong>📤 Sending chunk ${i + 1} of ${chunks.length}</strong> (${chunk.length} pages)`;
const chunkResponse = await sendMessageToBackground({
action: 'sendPagesChunk',
conversionId: conversionId,
chunkIndex: i,
totalChunks: chunks.length,
pages: chunk
});
if (!chunkResponse.success) {
throw new Error(chunkResponse.error || `Failed to send chunk ${i + 1}`);
}
console.log(`[DeKindled] Successfully sent chunk ${i + 1}/${chunks.length}`);
// Small delay to prevent overwhelming the background script
await new Promise(resolve => setTimeout(resolve, 100));
}
// Start processing once all chunks are sent
convertBtn.textContent = 'Starting Processing...';
pageInfo.innerHTML = '<strong>🔄 All pages sent, starting conversion...</strong>';
const processResponse = await sendMessageToBackground({
action: 'startEpubProcessing',
conversionId: conversionId
});
if (!processResponse.success) {
throw new Error(processResponse.error || 'Failed to start processing');
}
console.log('[DeKindled] All chunks sent successfully, processing started');
// Progress and completion will be handled by event listeners
} catch (error) {
console.error('Conversion startup error:', error);
convertBtn.textContent = 'Failed to Start ❌';
convertBtn.style.background = '#d32f2f';
pageInfo.innerHTML = `<strong>❌ Startup Error:</strong> ${error.message}`;
setTimeout(() => {
convertBtn.textContent = originalText;
convertBtn.style.background = '#4caf50';
convertBtn.disabled = false;
pageInfo.innerHTML = originalPageInfo;
}, 3000);
}
}
// Auto-scan functionality
let autoScanInterval = null;
let isAutoScanning = false;
function startAutoScan() {
if (isAutoScanning) return;
isAutoScanning = true;
const autoScanBtn = document.getElementById('dekindled-auto-scan');
autoScanBtn.textContent = 'Stop Auto-Scan';
autoScanBtn.style.background = '#f44336';
console.log('[DeKindled] Starting auto-scan...');
autoScanInterval = setInterval(() => {
try {
// Try to find the right navigation element
const rightChevron = document.querySelector('.kr-chevron-container-right');
if (rightChevron) {
// Dispatch synthetic keyboard event
rightChevron.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowRight',
code: 'ArrowRight',
keyCode: 39,
which: 39,
bubbles: true,
cancelable: true,
}));
console.log('[DeKindled] Auto-scan: navigated to next page');
} else {
console.warn('[DeKindled] Auto-scan: could not find navigation element');
}
} catch (error) {
console.error('[DeKindled] Auto-scan error:', error);
}
}, 300);
}
function stopAutoScan() {
if (!isAutoScanning) return;
isAutoScanning = false;
if (autoScanInterval) {
clearInterval(autoScanInterval);
autoScanInterval = null;
}
const autoScanBtn = document.getElementById('dekindled-auto-scan');
autoScanBtn.textContent = 'Start Auto-Scan';
autoScanBtn.style.background = '#2196f3';
console.log('[DeKindled] Auto-scan stopped');
}
function toggleAutoScan() {
if (isAutoScanning) {
stopAutoScan();
} else {
startAutoScan();
}
}
// Event handlers
document.getElementById('dekindled-close').onclick = closeOverlay;
document.getElementById('dekindled-convert').onclick = convertToEpub;
document.getElementById('dekindled-auto-scan').onclick = toggleAutoScan;
// Keyboard navigation
const keyHandler = (e) => {
if (overlay.style.display === 'none') return;
// Don't interfere with input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
switch(e.key) {
case 'Escape':
closeOverlay();
break;
case ' ':
case 'Spacebar':
e.preventDefault();
toggleAutoScan();
break;
}
};
document.addEventListener('keydown', keyHandler);
overlay._keyHandler = keyHandler;
// Global functions
window.__dekindledRefresh = function() {
loadPages();
};
function formatSize(size) {
if (!size || size === 'Unknown') return 'Unknown';
const bytes = parseInt(size);
if (isNaN(bytes)) return size;
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
// Initial load
loadPages();
// Auto-refresh every 2 seconds to pick up new pages
setInterval(loadPages, 2000);
// Listen for conversion progress updates
window.addEventListener('dekindled-progress', (event) => {
const { current, total } = event.detail;
// Update the UI with progress
const convertBtn = document.getElementById('dekindled-convert');
const pageInfo = document.getElementById('page-info');
if (convertBtn && (convertBtn.textContent.includes('Converting') || convertBtn.textContent.includes('Starting'))) {
convertBtn.textContent = `Converting Page ${current}/${total}...`;
pageInfo.innerHTML = `<strong>🔄 Converting Page ${current} of ${total}</strong> • Please wait...`;
}
});
// Listen for conversion completion
window.addEventListener('dekindled-complete', (event) => {
const { success, filename, error } = event.detail;
const convertBtn = document.getElementById('dekindled-convert');
const pageInfo = document.getElementById('page-info');
const originalText = 'Convert to EPUB';
// Only handle if we're currently converting
if (convertBtn && convertBtn.disabled) {
if (success) {
convertBtn.textContent = 'Conversion Complete! ✅';
convertBtn.style.background = '#2e7d32';
pageInfo.innerHTML = `<strong>✅ EPUB Generated:</strong> ${filename}`;
setTimeout(() => {
convertBtn.textContent = originalText;
convertBtn.style.background = '#4caf50';
convertBtn.disabled = false;
const currentPage = pages[currentPageIndex];
if (currentPage) {
pageInfo.innerHTML = `
Page ${currentPageIndex + 1} of ${pages.length} •
${currentPage.data.type} •
${formatSize(currentPage.data.size)} •
${new Date(currentPage.data.timestamp).toLocaleTimeString()}
`;
}
}, 5000);
} else {
convertBtn.textContent = 'Conversion Failed ❌';
convertBtn.style.background = '#d32f2f';
pageInfo.innerHTML = `<strong>❌ Conversion Error:</strong> ${error}`;
setTimeout(() => {
convertBtn.textContent = originalText;
convertBtn.style.background = '#4caf50';
convertBtn.disabled = false;
const currentPage = pages[currentPageIndex];
if (currentPage) {
pageInfo.innerHTML = `
Page ${currentPageIndex + 1} of ${pages.length} •
${currentPage.data.type} •
${formatSize(currentPage.data.size)} •
${new Date(currentPage.data.timestamp).toLocaleTimeString()}
`;
}
}, 5000);
}
}
});
})();

245
zip-utils.js Normal file
View file

@ -0,0 +1,245 @@
// Simple ZIP implementation for EPUB generation in Chrome extension service workers
// Based on minimal ZIP file format specification
class SimpleZip {
constructor() {
this.files = [];
this.folders = new Set();
}
file(path, content) {
// Ensure parent folders exist
const parts = path.split('/');
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
currentPath += (currentPath ? '/' : '') + parts[i];
this.folders.add(currentPath);
}
this.files.push({
path: path,
content: typeof content === 'string' ? new TextEncoder().encode(content) : content,
isFolder: false
});
}
folder(name) {
this.folders.add(name);
return {
file: (path, content) => {
this.file(`${name}/${path}`, content);
}
};
}
async generateAsync(options = {}) {
const zipData = [];
const centralDirectory = [];
let offset = 0;
// Add folders first
for (const folderPath of this.folders) {
const folderData = this.createFileEntry(folderPath + '/', new Uint8Array(0), true);
zipData.push(folderData.localFileHeader);
centralDirectory.push(this.createCentralDirectoryEntry(
folderPath + '/',
new Uint8Array(0),
offset,
true
));
offset += folderData.localFileHeader.length;
}
// Add files
for (const file of this.files) {
const fileData = this.createFileEntry(file.path, file.content, false);
zipData.push(fileData.localFileHeader);
centralDirectory.push(this.createCentralDirectoryEntry(
file.path,
file.content,
offset,
false
));
offset += fileData.localFileHeader.length;
}
// Central directory
const centralDirStart = offset;
let centralDirSize = 0;
for (const entry of centralDirectory) {
zipData.push(entry);
centralDirSize += entry.length;
}
// End of central directory
const totalEntries = this.folders.size + this.files.length;
const endOfCentralDir = this.createEndOfCentralDirectory(
totalEntries,
centralDirSize,
centralDirStart
);
zipData.push(endOfCentralDir);
// Combine all data
const totalLength = zipData.reduce((sum, data) => sum + data.length, 0);
const result = new Uint8Array(totalLength);
let pos = 0;
for (const data of zipData) {
result.set(data, pos);
pos += data.length;
}
return new Blob([result], { type: options.mimeType || 'application/zip' });
}
createFileEntry(filename, content, isFolder) {
const nameBytes = new TextEncoder().encode(filename);
const crc32 = isFolder ? 0 : this.calculateCRC32(content);
// Local file header
const header = new Uint8Array(30 + nameBytes.length + content.length);
const view = new DataView(header.buffer);
// Local file header signature
view.setUint32(0, 0x04034b50, true);
// Version needed to extract
view.setUint16(4, 20, true);
// General purpose bit flag
view.setUint16(6, 0, true);
// Compression method (0 = no compression)
view.setUint16(8, 0, true);
// File last modification time
view.setUint16(10, 0, true);
// File last modification date
view.setUint16(12, 0, true);
// CRC-32
view.setUint32(14, crc32, true);
// Compressed size
view.setUint32(18, content.length, true);
// Uncompressed size
view.setUint32(22, content.length, true);
// File name length
view.setUint16(26, nameBytes.length, true);
// Extra field length
view.setUint16(28, 0, true);
// File name
header.set(nameBytes, 30);
// File content
if (content.length > 0) {
header.set(content, 30 + nameBytes.length);
}
return { localFileHeader: header };
}
createCentralDirectoryEntry(filename, content, offset, isFolder) {
const nameBytes = new TextEncoder().encode(filename);
const crc32 = isFolder ? 0 : this.calculateCRC32(content);
const entry = new Uint8Array(46 + nameBytes.length);
const view = new DataView(entry.buffer);
// Central directory file header signature
view.setUint32(0, 0x02014b50, true);
// Version made by
view.setUint16(4, 20, true);
// Version needed to extract
view.setUint16(6, 20, true);
// General purpose bit flag
view.setUint16(8, 0, true);
// Compression method
view.setUint16(10, 0, true);
// File last modification time
view.setUint16(12, 0, true);
// File last modification date
view.setUint16(14, 0, true);
// CRC-32
view.setUint32(16, crc32, true);
// Compressed size
view.setUint32(20, content.length, true);
// Uncompressed size
view.setUint32(24, content.length, true);
// File name length
view.setUint16(28, nameBytes.length, true);
// Extra field length
view.setUint16(30, 0, true);
// File comment length
view.setUint16(32, 0, true);
// Disk number where file starts
view.setUint16(34, 0, true);
// Internal file attributes
view.setUint16(36, 0, true);
// External file attributes
view.setUint32(38, isFolder ? 0x10 : 0x20, true);
// Relative offset of local file header
view.setUint32(42, offset, true);
// File name
entry.set(nameBytes, 46);
return entry;
}
createEndOfCentralDirectory(totalEntries, centralDirSize, centralDirStart) {
const entry = new Uint8Array(22);
const view = new DataView(entry.buffer);
// End of central directory signature
view.setUint32(0, 0x06054b50, true);
// Number of this disk
view.setUint16(4, 0, true);
// Disk where central directory starts
view.setUint16(6, 0, true);
// Number of central directory records on this disk
view.setUint16(8, totalEntries, true);
// Total number of central directory records
view.setUint16(10, totalEntries, true);
// Size of central directory
view.setUint32(12, centralDirSize, true);
// Offset of start of central directory
view.setUint32(16, centralDirStart, true);
// Comment length
view.setUint16(20, 0, true);
return entry;
}
calculateCRC32(data) {
// Simple CRC32 implementation
let crc = 0xFFFFFFFF;
const table = this.getCRC32Table();
for (let i = 0; i < data.length; i++) {
const byte = data[i];
crc = (crc >>> 8) ^ table[(crc ^ byte) & 0xFF];
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
getCRC32Table() {
if (!this._crc32Table) {
this._crc32Table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let crc = i;
for (let j = 0; j < 8; j++) {
crc = (crc & 1) ? (0xEDB88320 ^ (crc >>> 1)) : (crc >>> 1);
}
this._crc32Table[i] = crc;
}
}
return this._crc32Table;
}
}
// Export for use in service worker
if (typeof self !== 'undefined' && self.importScripts) {
self.SimpleZip = SimpleZip;
}