mirror of
https://github.com/dmilin1/dekindled.git
synced 2026-10-18 23:07:12 +00:00
init
This commit is contained in:
commit
7d2ac5b2ae
12 changed files with 2837 additions and 0 deletions
225
README.md
Normal file
225
README.md
Normal 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
766
background.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
BIN
icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
83
inject.js
Normal file
83
inject.js
Normal 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
147
interceptor.js
Normal 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
50
manifest.json
Normal 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
177
options.html
Normal 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
170
options.js
Normal 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
3
showdown.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
177
test-page.html
Normal file
177
test-page.html
Normal 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
794
viewer-inject.js
Normal 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;
|
||||
">×</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
245
zip-utils.js
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue