commit 7d2ac5b2ae67956efc6b6c7e58dc5ecbd3ce8a84
Author: dmilin1
Date: Sat Jun 7 14:47:25 2025 -0700
init
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ba1d00a
--- /dev/null
+++ b/README.md
@@ -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!
\ No newline at end of file
diff --git a/background.js b/background.js
new file mode 100644
index 0000000..eb8f4d0
--- /dev/null
+++ b/background.js
@@ -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 = `${currentSection.markdownContent.replace(/\n/g, ' ')}
`;
+ }
+ 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: `Error processing page ${i + 1}: ${error.message}
`,
+ 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 = `${currentSection.markdownContent.replace(/\n/g, ' ')}
`;
+ }
+ 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', `
+
+
+
+
+ `);
+
+ // OEBPS folder
+ const oebps = zip.folder('OEBPS');
+
+ // Generate content.opf
+ const manifestItems = chapters.map(chapter =>
+ ` `
+ ).join('\n');
+
+ const spineItems = chapters.map(chapter =>
+ ` `
+ ).join('\n');
+
+ oebps.file('content.opf', `
+
+
+ ${generateUUID()}
+ ${escapeXml(bookTitle)}
+ ${escapeXml(bookAuthor || 'Unknown Author')}
+ en
+ ${new Date().toISOString().split('T')[0]}
+ ${new Date().toISOString()}
+
+
+
+${manifestItems}
+
+
+${spineItems}
+
+ `);
+
+ // Generate toc.ncx
+ const navPoints = chapters.map((chapter, index) =>
+ `
+ ${escapeXml(chapter.title)}
+
+ `
+ ).join('\n');
+
+ oebps.file('toc.ncx', `
+
+
+
+
+
+
+
+ ${escapeXml(bookTitle)}
+
+${navPoints}
+
+ `);
+
+ // Generate chapter XHTML files
+ chapters.forEach(chapter => {
+ const xhtmlContent = `
+
+
+
+ ${escapeXml(chapter.title)}
+
+
+${chapter.content}
+
+`;
+
+ 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, ''');
+}
+
+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;
+}
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..97dd112
Binary files /dev/null and b/icon.png differ
diff --git a/inject.js b/inject.js
new file mode 100644
index 0000000..dd6d262
--- /dev/null
+++ b/inject.js
@@ -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);
\ No newline at end of file
diff --git a/interceptor.js b/interceptor.js
new file mode 100644
index 0000000..fca18ef
--- /dev/null
+++ b/interceptor.js
@@ -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');
+})();
\ No newline at end of file
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..6d192dc
--- /dev/null
+++ b/manifest.json
@@ -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/*"]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/options.html b/options.html
new file mode 100644
index 0000000..5cf32e2
--- /dev/null
+++ b/options.html
@@ -0,0 +1,177 @@
+
+
+
+
+ DeKindled Options
+
+
+
+
+
📚 DeKindled Options
+
+
+
OpenAI API Key:
+
+
+ Get your API key from
OpenAI Platform .
+ Your key is stored securely in your browser and never shared.
+
+
+
+
+
+
Default Book Title:
+
+
+ Default title to use for generated EPUB files. You can override this during conversion.
+
+
+
+
+
Analysis Prompt:
+
+
+ The prompt sent to OpenAI for analyzing each book page. This controls how the AI interprets and converts the scanned text to markdown.
+ Advanced setting: Modify only if you want to customize the conversion behavior.
+
+
Reset to Default
+
+
+
+ Save Settings
+ Test API Key
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/options.js b/options.js
new file mode 100644
index 0000000..4bf463c
--- /dev/null
+++ b/options.js
@@ -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);
\ No newline at end of file
diff --git a/showdown.min.js b/showdown.min.js
new file mode 100644
index 0000000..cf721d9
--- /dev/null
+++ b/showdown.min.js
@@ -0,0 +1,3 @@
+/*! showdown v 2.1.0 - 21-04-2022 */
+!function(){function a(e){"use strict";var r={omitExtraWLInCodeBlocks:{defaultValue:!1,describe:"Omit the default extra whiteline added to code blocks",type:"boolean"},noHeaderId:{defaultValue:!1,describe:"Turn on/off generated header id",type:"boolean"},prefixHeaderId:{defaultValue:!1,describe:"Add a prefix to the generated header ids. Passing a string will prefix that string to the header id. Setting to true will add a generic 'section-' prefix",type:"string"},rawPrefixHeaderId:{defaultValue:!1,describe:'Setting this option to true will prevent showdown from modifying the prefix. This might result in malformed IDs (if, for instance, the " char is used in the prefix)',type:"boolean"},ghCompatibleHeaderId:{defaultValue:!1,describe:"Generate header ids compatible with github style (spaces are replaced with dashes, a bunch of non alphanumeric chars are removed)",type:"boolean"},rawHeaderId:{defaultValue:!1,describe:"Remove only spaces, ' and \" from generated header ids (including prefixes), replacing them with dashes (-). WARNING: This might result in malformed ids",type:"boolean"},headerLevelStart:{defaultValue:!1,describe:"The header blocks level start",type:"integer"},parseImgDimensions:{defaultValue:!1,describe:"Turn on/off image dimension parsing",type:"boolean"},simplifiedAutoLink:{defaultValue:!1,describe:"Turn on/off GFM autolink style",type:"boolean"},excludeTrailingPunctuationFromURLs:{defaultValue:!1,describe:"Excludes trailing punctuation from links generated with autoLinking",type:"boolean"},literalMidWordUnderscores:{defaultValue:!1,describe:"Parse midword underscores as literal underscores",type:"boolean"},literalMidWordAsterisks:{defaultValue:!1,describe:"Parse midword asterisks as literal asterisks",type:"boolean"},strikethrough:{defaultValue:!1,describe:"Turn on/off strikethrough support",type:"boolean"},tables:{defaultValue:!1,describe:"Turn on/off tables support",type:"boolean"},tablesHeaderId:{defaultValue:!1,describe:"Add an id to table headers",type:"boolean"},ghCodeBlocks:{defaultValue:!0,describe:"Turn on/off GFM fenced code blocks support",type:"boolean"},tasklists:{defaultValue:!1,describe:"Turn on/off GFM tasklist support",type:"boolean"},smoothLivePreview:{defaultValue:!1,describe:"Prevents weird effects in live previews due to incomplete input",type:"boolean"},smartIndentationFix:{defaultValue:!1,describe:"Tries to smartly fix indentation in es6 strings",type:"boolean"},disableForced4SpacesIndentedSublists:{defaultValue:!1,describe:"Disables the requirement of indenting nested sublists by 4 spaces",type:"boolean"},simpleLineBreaks:{defaultValue:!1,describe:"Parses simple line breaks as (GFM Style)",type:"boolean"},requireSpaceBeforeHeadingText:{defaultValue:!1,describe:"Makes adding a space between `#` and the header text mandatory (GFM Style)",type:"boolean"},ghMentions:{defaultValue:!1,describe:"Enables github @mentions",type:"boolean"},ghMentionsLink:{defaultValue:"https://github.com/{u}",describe:"Changes the link generated by @mentions. Only applies if ghMentions option is enabled.",type:"string"},encodeEmails:{defaultValue:!0,describe:"Encode e-mail addresses through the use of Character Entities, transforming ASCII e-mail addresses into its equivalent decimal entities",type:"boolean"},openLinksInNewWindow:{defaultValue:!1,describe:"Open all links in new windows",type:"boolean"},backslashEscapesHTMLTags:{defaultValue:!1,describe:"Support for HTML Tag escaping. ex: foo
",type:"boolean"},emoji:{defaultValue:!1,describe:"Enable emoji support. Ex: `this is a :smile: emoji`",type:"boolean"},underline:{defaultValue:!1,describe:"Enable support for underline. Syntax is double or triple underscores: `__underline word__`. With this option enabled, underscores no longer parses into `` and ``",type:"boolean"},ellipsis:{defaultValue:!0,describe:"Replaces three dots with the ellipsis unicode character",type:"boolean"},completeHTMLDocument:{defaultValue:!1,describe:"Outputs a complete html document, including ``, `` and `` tags",type:"boolean"},metadata:{defaultValue:!1,describe:"Enable support for document metadata (defined at the top of the document between `«««` and `»»»` or between `---` and `---`).",type:"boolean"},splitAdjacentBlockquotes:{defaultValue:!1,describe:"Split adjacent blockquote blocks",type:"boolean"}};if(!1===e)return JSON.parse(JSON.stringify(r));var t,a={};for(t in r)r.hasOwnProperty(t)&&(a[t]=r[t].defaultValue);return a}var x={},t={},d={},p=a(!0),h="vanilla",_={github:{omitExtraWLInCodeBlocks:!0,simplifiedAutoLink:!0,excludeTrailingPunctuationFromURLs:!0,literalMidWordUnderscores:!0,strikethrough:!0,tables:!0,tablesHeaderId:!0,ghCodeBlocks:!0,tasklists:!0,disableForced4SpacesIndentedSublists:!0,simpleLineBreaks:!0,requireSpaceBeforeHeadingText:!0,ghCompatibleHeaderId:!0,ghMentions:!0,backslashEscapesHTMLTags:!0,emoji:!0,splitAdjacentBlockquotes:!0},original:{noHeaderId:!0,ghCodeBlocks:!1},ghost:{omitExtraWLInCodeBlocks:!0,parseImgDimensions:!0,simplifiedAutoLink:!0,excludeTrailingPunctuationFromURLs:!0,literalMidWordUnderscores:!0,strikethrough:!0,tables:!0,tablesHeaderId:!0,ghCodeBlocks:!0,tasklists:!0,smoothLivePreview:!0,simpleLineBreaks:!0,requireSpaceBeforeHeadingText:!0,ghMentions:!1,encodeEmails:!0},vanilla:a(!0),allOn:function(){"use strict";var e,r=a(!0),t={};for(e in r)r.hasOwnProperty(e)&&(t[e]=!0);return t}()};function g(e,r){"use strict";var t=r?"Error in "+r+" extension->":"Error in unnamed extension",a={valid:!0,error:""};x.helper.isArray(e)||(e=[e]);for(var n=0;n").replace(/&/g,"&")};function u(e,r,t,a){"use strict";var n,s,o,i=-1<(a=a||"").indexOf("g"),l=new RegExp(r+"|"+t,"g"+a.replace(/g/g,"")),c=new RegExp(r,a.replace(/g/g,"")),u=[];do{for(n=0;p=l.exec(e);)if(c.test(p[0]))n++||(o=(s=l.lastIndex)-p[0].length);else if(n&&!--n){var d=p.index+p[0].length,p={left:{start:o,end:s},match:{start:s,end:p.index},right:{start:p.index,end:d},wholeMatch:{start:o,end:d}};if(u.push(p),!i)return u}}while(n&&(l.lastIndex=s));return u}function s(u){"use strict";return function(e,r,t,a,n,s,o){var i=t=t.replace(x.helper.regexes.asteriskDashAndColon,x.helper.escapeCharactersCallback),l="",c="",r=r||"",o=o||"";return/^www\./i.test(t)&&(t=t.replace(/^www\./i,"http://www.")),u.excludeTrailingPunctuationFromURLs&&s&&(l=s),r+'"+i+" "+l+o}}function o(n,s){"use strict";return function(e,r,t){var a="mailto:";return r=r||"",t=x.subParser("unescapeSpecialChars")(t,n,s),n.encodeEmails?(a=x.helper.encodeEmailAddress(a+t),t=x.helper.encodeEmailAddress(t)):a+=t,r+''+t+" "}}x.helper.matchRecursiveRegExp=function(e,r,t,a){"use strict";for(var n=u(e,r,t,a),s=[],o=0;o>=0,t=String(t||" "),e.length>r?String(e):((r-=e.length)>t.length&&(t+=t.repeat(r/t.length)),String(e)+t.slice(0,r))},"undefined"==typeof console&&(console={warn:function(e){"use strict";alert(e)},log:function(e){"use strict";alert(e)},error:function(e){"use strict";throw e}}),x.helper.regexes={asteriskDashAndColon:/([*_:~])/g},x.helper.emojis={"+1":"👍","-1":"👎",100:"💯",1234:"🔢","1st_place_medal":"🥇","2nd_place_medal":"🥈","3rd_place_medal":"🥉","8ball":"🎱",a:"🅰️",ab:"🆎",abc:"🔤",abcd:"🔡",accept:"🉑",aerial_tramway:"🚡",airplane:"✈️",alarm_clock:"⏰",alembic:"⚗️",alien:"👽",ambulance:"🚑",amphora:"🏺",anchor:"⚓️",angel:"👼",anger:"💢",angry:"😠",anguished:"😧",ant:"🐜",apple:"🍎",aquarius:"♒️",aries:"♈️",arrow_backward:"◀️",arrow_double_down:"⏬",arrow_double_up:"⏫",arrow_down:"⬇️",arrow_down_small:"🔽",arrow_forward:"▶️",arrow_heading_down:"⤵️",arrow_heading_up:"⤴️",arrow_left:"⬅️",arrow_lower_left:"↙️",arrow_lower_right:"↘️",arrow_right:"➡️",arrow_right_hook:"↪️",arrow_up:"⬆️",arrow_up_down:"↕️",arrow_up_small:"🔼",arrow_upper_left:"↖️",arrow_upper_right:"↗️",arrows_clockwise:"🔃",arrows_counterclockwise:"🔄",art:"🎨",articulated_lorry:"🚛",artificial_satellite:"🛰",astonished:"😲",athletic_shoe:"👟",atm:"🏧",atom_symbol:"⚛️",avocado:"🥑",b:"🅱️",baby:"👶",baby_bottle:"🍼",baby_chick:"🐤",baby_symbol:"🚼",back:"🔙",bacon:"🥓",badminton:"🏸",baggage_claim:"🛄",baguette_bread:"🥖",balance_scale:"⚖️",balloon:"🎈",ballot_box:"🗳",ballot_box_with_check:"☑️",bamboo:"🎍",banana:"🍌",bangbang:"‼️",bank:"🏦",bar_chart:"📊",barber:"💈",baseball:"⚾️",basketball:"🏀",basketball_man:"⛹️",basketball_woman:"⛹️♀️",bat:"🦇",bath:"🛀",bathtub:"🛁",battery:"🔋",beach_umbrella:"🏖",bear:"🐻",bed:"🛏",bee:"🐝",beer:"🍺",beers:"🍻",beetle:"🐞",beginner:"🔰",bell:"🔔",bellhop_bell:"🛎",bento:"🍱",biking_man:"🚴",bike:"🚲",biking_woman:"🚴♀️",bikini:"👙",biohazard:"☣️",bird:"🐦",birthday:"🎂",black_circle:"⚫️",black_flag:"🏴",black_heart:"🖤",black_joker:"🃏",black_large_square:"⬛️",black_medium_small_square:"◾️",black_medium_square:"◼️",black_nib:"✒️",black_small_square:"▪️",black_square_button:"🔲",blonde_man:"👱",blonde_woman:"👱♀️",blossom:"🌼",blowfish:"🐡",blue_book:"📘",blue_car:"🚙",blue_heart:"💙",blush:"😊",boar:"🐗",boat:"⛵️",bomb:"💣",book:"📖",bookmark:"🔖",bookmark_tabs:"📑",books:"📚",boom:"💥",boot:"👢",bouquet:"💐",bowing_man:"🙇",bow_and_arrow:"🏹",bowing_woman:"🙇♀️",bowling:"🎳",boxing_glove:"🥊",boy:"👦",bread:"🍞",bride_with_veil:"👰",bridge_at_night:"🌉",briefcase:"💼",broken_heart:"💔",bug:"🐛",building_construction:"🏗",bulb:"💡",bullettrain_front:"🚅",bullettrain_side:"🚄",burrito:"🌯",bus:"🚌",business_suit_levitating:"🕴",busstop:"🚏",bust_in_silhouette:"👤",busts_in_silhouette:"👥",butterfly:"🦋",cactus:"🌵",cake:"🍰",calendar:"📆",call_me_hand:"🤙",calling:"📲",camel:"🐫",camera:"📷",camera_flash:"📸",camping:"🏕",cancer:"♋️",candle:"🕯",candy:"🍬",canoe:"🛶",capital_abcd:"🔠",capricorn:"♑️",car:"🚗",card_file_box:"🗃",card_index:"📇",card_index_dividers:"🗂",carousel_horse:"🎠",carrot:"🥕",cat:"🐱",cat2:"🐈",cd:"💿",chains:"⛓",champagne:"🍾",chart:"💹",chart_with_downwards_trend:"📉",chart_with_upwards_trend:"📈",checkered_flag:"🏁",cheese:"🧀",cherries:"🍒",cherry_blossom:"🌸",chestnut:"🌰",chicken:"🐔",children_crossing:"🚸",chipmunk:"🐿",chocolate_bar:"🍫",christmas_tree:"🎄",church:"⛪️",cinema:"🎦",circus_tent:"🎪",city_sunrise:"🌇",city_sunset:"🌆",cityscape:"🏙",cl:"🆑",clamp:"🗜",clap:"👏",clapper:"🎬",classical_building:"🏛",clinking_glasses:"🥂",clipboard:"📋",clock1:"🕐",clock10:"🕙",clock1030:"🕥",clock11:"🕚",clock1130:"🕦",clock12:"🕛",clock1230:"🕧",clock130:"🕜",clock2:"🕑",clock230:"🕝",clock3:"🕒",clock330:"🕞",clock4:"🕓",clock430:"🕟",clock5:"🕔",clock530:"🕠",clock6:"🕕",clock630:"🕡",clock7:"🕖",clock730:"🕢",clock8:"🕗",clock830:"🕣",clock9:"🕘",clock930:"🕤",closed_book:"📕",closed_lock_with_key:"🔐",closed_umbrella:"🌂",cloud:"☁️",cloud_with_lightning:"🌩",cloud_with_lightning_and_rain:"⛈",cloud_with_rain:"🌧",cloud_with_snow:"🌨",clown_face:"🤡",clubs:"♣️",cocktail:"🍸",coffee:"☕️",coffin:"⚰️",cold_sweat:"😰",comet:"☄️",computer:"💻",computer_mouse:"🖱",confetti_ball:"🎊",confounded:"😖",confused:"😕",congratulations:"㊗️",construction:"🚧",construction_worker_man:"👷",construction_worker_woman:"👷♀️",control_knobs:"🎛",convenience_store:"🏪",cookie:"🍪",cool:"🆒",policeman:"👮",copyright:"©️",corn:"🌽",couch_and_lamp:"🛋",couple:"👫",couple_with_heart_woman_man:"💑",couple_with_heart_man_man:"👨❤️👨",couple_with_heart_woman_woman:"👩❤️👩",couplekiss_man_man:"👨❤️💋👨",couplekiss_man_woman:"💏",couplekiss_woman_woman:"👩❤️💋👩",cow:"🐮",cow2:"🐄",cowboy_hat_face:"🤠",crab:"🦀",crayon:"🖍",credit_card:"💳",crescent_moon:"🌙",cricket:"🏏",crocodile:"🐊",croissant:"🥐",crossed_fingers:"🤞",crossed_flags:"🎌",crossed_swords:"⚔️",crown:"👑",cry:"😢",crying_cat_face:"😿",crystal_ball:"🔮",cucumber:"🥒",cupid:"💘",curly_loop:"➰",currency_exchange:"💱",curry:"🍛",custard:"🍮",customs:"🛃",cyclone:"🌀",dagger:"🗡",dancer:"💃",dancing_women:"👯",dancing_men:"👯♂️",dango:"🍡",dark_sunglasses:"🕶",dart:"🎯",dash:"💨",date:"📅",deciduous_tree:"🌳",deer:"🦌",department_store:"🏬",derelict_house:"🏚",desert:"🏜",desert_island:"🏝",desktop_computer:"🖥",male_detective:"🕵️",diamond_shape_with_a_dot_inside:"💠",diamonds:"♦️",disappointed:"😞",disappointed_relieved:"😥",dizzy:"💫",dizzy_face:"😵",do_not_litter:"🚯",dog:"🐶",dog2:"🐕",dollar:"💵",dolls:"🎎",dolphin:"🐬",door:"🚪",doughnut:"🍩",dove:"🕊",dragon:"🐉",dragon_face:"🐲",dress:"👗",dromedary_camel:"🐪",drooling_face:"🤤",droplet:"💧",drum:"🥁",duck:"🦆",dvd:"📀","e-mail":"📧",eagle:"🦅",ear:"👂",ear_of_rice:"🌾",earth_africa:"🌍",earth_americas:"🌎",earth_asia:"🌏",egg:"🥚",eggplant:"🍆",eight_pointed_black_star:"✴️",eight_spoked_asterisk:"✳️",electric_plug:"🔌",elephant:"🐘",email:"✉️",end:"🔚",envelope_with_arrow:"📩",euro:"💶",european_castle:"🏰",european_post_office:"🏤",evergreen_tree:"🌲",exclamation:"❗️",expressionless:"😑",eye:"👁",eye_speech_bubble:"👁🗨",eyeglasses:"👓",eyes:"👀",face_with_head_bandage:"🤕",face_with_thermometer:"🤒",fist_oncoming:"👊",factory:"🏭",fallen_leaf:"🍂",family_man_woman_boy:"👪",family_man_boy:"👨👦",family_man_boy_boy:"👨👦👦",family_man_girl:"👨👧",family_man_girl_boy:"👨👧👦",family_man_girl_girl:"👨👧👧",family_man_man_boy:"👨👨👦",family_man_man_boy_boy:"👨👨👦👦",family_man_man_girl:"👨👨👧",family_man_man_girl_boy:"👨👨👧👦",family_man_man_girl_girl:"👨👨👧👧",family_man_woman_boy_boy:"👨👩👦👦",family_man_woman_girl:"👨👩👧",family_man_woman_girl_boy:"👨👩👧👦",family_man_woman_girl_girl:"👨👩👧👧",family_woman_boy:"👩👦",family_woman_boy_boy:"👩👦👦",family_woman_girl:"👩👧",family_woman_girl_boy:"👩👧👦",family_woman_girl_girl:"👩👧👧",family_woman_woman_boy:"👩👩👦",family_woman_woman_boy_boy:"👩👩👦👦",family_woman_woman_girl:"👩👩👧",family_woman_woman_girl_boy:"👩👩👧👦",family_woman_woman_girl_girl:"👩👩👧👧",fast_forward:"⏩",fax:"📠",fearful:"😨",feet:"🐾",female_detective:"🕵️♀️",ferris_wheel:"🎡",ferry:"⛴",field_hockey:"🏑",file_cabinet:"🗄",file_folder:"📁",film_projector:"📽",film_strip:"🎞",fire:"🔥",fire_engine:"🚒",fireworks:"🎆",first_quarter_moon:"🌓",first_quarter_moon_with_face:"🌛",fish:"🐟",fish_cake:"🍥",fishing_pole_and_fish:"🎣",fist_raised:"✊",fist_left:"🤛",fist_right:"🤜",flags:"🎏",flashlight:"🔦",fleur_de_lis:"⚜️",flight_arrival:"🛬",flight_departure:"🛫",floppy_disk:"💾",flower_playing_cards:"🎴",flushed:"😳",fog:"🌫",foggy:"🌁",football:"🏈",footprints:"👣",fork_and_knife:"🍴",fountain:"⛲️",fountain_pen:"🖋",four_leaf_clover:"🍀",fox_face:"🦊",framed_picture:"🖼",free:"🆓",fried_egg:"🍳",fried_shrimp:"🍤",fries:"🍟",frog:"🐸",frowning:"😦",frowning_face:"☹️",frowning_man:"🙍♂️",frowning_woman:"🙍",middle_finger:"🖕",fuelpump:"⛽️",full_moon:"🌕",full_moon_with_face:"🌝",funeral_urn:"⚱️",game_die:"🎲",gear:"⚙️",gem:"💎",gemini:"♊️",ghost:"👻",gift:"🎁",gift_heart:"💝",girl:"👧",globe_with_meridians:"🌐",goal_net:"🥅",goat:"🐐",golf:"⛳️",golfing_man:"🏌️",golfing_woman:"🏌️♀️",gorilla:"🦍",grapes:"🍇",green_apple:"🍏",green_book:"📗",green_heart:"💚",green_salad:"🥗",grey_exclamation:"❕",grey_question:"❔",grimacing:"😬",grin:"😁",grinning:"😀",guardsman:"💂",guardswoman:"💂♀️",guitar:"🎸",gun:"🔫",haircut_woman:"💇",haircut_man:"💇♂️",hamburger:"🍔",hammer:"🔨",hammer_and_pick:"⚒",hammer_and_wrench:"🛠",hamster:"🐹",hand:"✋",handbag:"👜",handshake:"🤝",hankey:"💩",hatched_chick:"🐥",hatching_chick:"🐣",headphones:"🎧",hear_no_evil:"🙉",heart:"❤️",heart_decoration:"💟",heart_eyes:"😍",heart_eyes_cat:"😻",heartbeat:"💓",heartpulse:"💗",hearts:"♥️",heavy_check_mark:"✔️",heavy_division_sign:"➗",heavy_dollar_sign:"💲",heavy_heart_exclamation:"❣️",heavy_minus_sign:"➖",heavy_multiplication_x:"✖️",heavy_plus_sign:"➕",helicopter:"🚁",herb:"🌿",hibiscus:"🌺",high_brightness:"🔆",high_heel:"👠",hocho:"🔪",hole:"🕳",honey_pot:"🍯",horse:"🐴",horse_racing:"🏇",hospital:"🏥",hot_pepper:"🌶",hotdog:"🌭",hotel:"🏨",hotsprings:"♨️",hourglass:"⌛️",hourglass_flowing_sand:"⏳",house:"🏠",house_with_garden:"🏡",houses:"🏘",hugs:"🤗",hushed:"😯",ice_cream:"🍨",ice_hockey:"🏒",ice_skate:"⛸",icecream:"🍦",id:"🆔",ideograph_advantage:"🉐",imp:"👿",inbox_tray:"📥",incoming_envelope:"📨",tipping_hand_woman:"💁",information_source:"ℹ️",innocent:"😇",interrobang:"⁉️",iphone:"📱",izakaya_lantern:"🏮",jack_o_lantern:"🎃",japan:"🗾",japanese_castle:"🏯",japanese_goblin:"👺",japanese_ogre:"👹",jeans:"👖",joy:"😂",joy_cat:"😹",joystick:"🕹",kaaba:"🕋",key:"🔑",keyboard:"⌨️",keycap_ten:"🔟",kick_scooter:"🛴",kimono:"👘",kiss:"💋",kissing:"😗",kissing_cat:"😽",kissing_closed_eyes:"😚",kissing_heart:"😘",kissing_smiling_eyes:"😙",kiwi_fruit:"🥝",koala:"🐨",koko:"🈁",label:"🏷",large_blue_circle:"🔵",large_blue_diamond:"🔷",large_orange_diamond:"🔶",last_quarter_moon:"🌗",last_quarter_moon_with_face:"🌜",latin_cross:"✝️",laughing:"😆",leaves:"🍃",ledger:"📒",left_luggage:"🛅",left_right_arrow:"↔️",leftwards_arrow_with_hook:"↩️",lemon:"🍋",leo:"♌️",leopard:"🐆",level_slider:"🎚",libra:"♎️",light_rail:"🚈",link:"🔗",lion:"🦁",lips:"👄",lipstick:"💄",lizard:"🦎",lock:"🔒",lock_with_ink_pen:"🔏",lollipop:"🍭",loop:"➿",loud_sound:"🔊",loudspeaker:"📢",love_hotel:"🏩",love_letter:"💌",low_brightness:"🔅",lying_face:"🤥",m:"Ⓜ️",mag:"🔍",mag_right:"🔎",mahjong:"🀄️",mailbox:"📫",mailbox_closed:"📪",mailbox_with_mail:"📬",mailbox_with_no_mail:"📭",man:"👨",man_artist:"👨🎨",man_astronaut:"👨🚀",man_cartwheeling:"🤸♂️",man_cook:"👨🍳",man_dancing:"🕺",man_facepalming:"🤦♂️",man_factory_worker:"👨🏭",man_farmer:"👨🌾",man_firefighter:"👨🚒",man_health_worker:"👨⚕️",man_in_tuxedo:"🤵",man_judge:"👨⚖️",man_juggling:"🤹♂️",man_mechanic:"👨🔧",man_office_worker:"👨💼",man_pilot:"👨✈️",man_playing_handball:"🤾♂️",man_playing_water_polo:"🤽♂️",man_scientist:"👨🔬",man_shrugging:"🤷♂️",man_singer:"👨🎤",man_student:"👨🎓",man_teacher:"👨🏫",man_technologist:"👨💻",man_with_gua_pi_mao:"👲",man_with_turban:"👳",tangerine:"🍊",mans_shoe:"👞",mantelpiece_clock:"🕰",maple_leaf:"🍁",martial_arts_uniform:"🥋",mask:"😷",massage_woman:"💆",massage_man:"💆♂️",meat_on_bone:"🍖",medal_military:"🎖",medal_sports:"🏅",mega:"📣",melon:"🍈",memo:"📝",men_wrestling:"🤼♂️",menorah:"🕎",mens:"🚹",metal:"🤘",metro:"🚇",microphone:"🎤",microscope:"🔬",milk_glass:"🥛",milky_way:"🌌",minibus:"🚐",minidisc:"💽",mobile_phone_off:"📴",money_mouth_face:"🤑",money_with_wings:"💸",moneybag:"💰",monkey:"🐒",monkey_face:"🐵",monorail:"🚝",moon:"🌔",mortar_board:"🎓",mosque:"🕌",motor_boat:"🛥",motor_scooter:"🛵",motorcycle:"🏍",motorway:"🛣",mount_fuji:"🗻",mountain:"⛰",mountain_biking_man:"🚵",mountain_biking_woman:"🚵♀️",mountain_cableway:"🚠",mountain_railway:"🚞",mountain_snow:"🏔",mouse:"🐭",mouse2:"🐁",movie_camera:"🎥",moyai:"🗿",mrs_claus:"🤶",muscle:"💪",mushroom:"🍄",musical_keyboard:"🎹",musical_note:"🎵",musical_score:"🎼",mute:"🔇",nail_care:"💅",name_badge:"📛",national_park:"🏞",nauseated_face:"🤢",necktie:"👔",negative_squared_cross_mark:"❎",nerd_face:"🤓",neutral_face:"😐",new:"🆕",new_moon:"🌑",new_moon_with_face:"🌚",newspaper:"📰",newspaper_roll:"🗞",next_track_button:"⏭",ng:"🆖",no_good_man:"🙅♂️",no_good_woman:"🙅",night_with_stars:"🌃",no_bell:"🔕",no_bicycles:"🚳",no_entry:"⛔️",no_entry_sign:"🚫",no_mobile_phones:"📵",no_mouth:"😶",no_pedestrians:"🚷",no_smoking:"🚭","non-potable_water":"🚱",nose:"👃",notebook:"📓",notebook_with_decorative_cover:"📔",notes:"🎶",nut_and_bolt:"🔩",o:"⭕️",o2:"🅾️",ocean:"🌊",octopus:"🐙",oden:"🍢",office:"🏢",oil_drum:"🛢",ok:"🆗",ok_hand:"👌",ok_man:"🙆♂️",ok_woman:"🙆",old_key:"🗝",older_man:"👴",older_woman:"👵",om:"🕉",on:"🔛",oncoming_automobile:"🚘",oncoming_bus:"🚍",oncoming_police_car:"🚔",oncoming_taxi:"🚖",open_file_folder:"📂",open_hands:"👐",open_mouth:"😮",open_umbrella:"☂️",ophiuchus:"⛎",orange_book:"📙",orthodox_cross:"☦️",outbox_tray:"📤",owl:"🦉",ox:"🐂",package:"📦",page_facing_up:"📄",page_with_curl:"📃",pager:"📟",paintbrush:"🖌",palm_tree:"🌴",pancakes:"🥞",panda_face:"🐼",paperclip:"📎",paperclips:"🖇",parasol_on_ground:"⛱",parking:"🅿️",part_alternation_mark:"〽️",partly_sunny:"⛅️",passenger_ship:"🛳",passport_control:"🛂",pause_button:"⏸",peace_symbol:"☮️",peach:"🍑",peanuts:"🥜",pear:"🍐",pen:"🖊",pencil2:"✏️",penguin:"🐧",pensive:"😔",performing_arts:"🎭",persevere:"😣",person_fencing:"🤺",pouting_woman:"🙎",phone:"☎️",pick:"⛏",pig:"🐷",pig2:"🐖",pig_nose:"🐽",pill:"💊",pineapple:"🍍",ping_pong:"🏓",pisces:"♓️",pizza:"🍕",place_of_worship:"🛐",plate_with_cutlery:"🍽",play_or_pause_button:"⏯",point_down:"👇",point_left:"👈",point_right:"👉",point_up:"☝️",point_up_2:"👆",police_car:"🚓",policewoman:"👮♀️",poodle:"🐩",popcorn:"🍿",post_office:"🏣",postal_horn:"📯",postbox:"📮",potable_water:"🚰",potato:"🥔",pouch:"👝",poultry_leg:"🍗",pound:"💷",rage:"😡",pouting_cat:"😾",pouting_man:"🙎♂️",pray:"🙏",prayer_beads:"📿",pregnant_woman:"🤰",previous_track_button:"⏮",prince:"🤴",princess:"👸",printer:"🖨",purple_heart:"💜",purse:"👛",pushpin:"📌",put_litter_in_its_place:"🚮",question:"❓",rabbit:"🐰",rabbit2:"🐇",racehorse:"🐎",racing_car:"🏎",radio:"📻",radio_button:"🔘",radioactive:"☢️",railway_car:"🚃",railway_track:"🛤",rainbow:"🌈",rainbow_flag:"🏳️🌈",raised_back_of_hand:"🤚",raised_hand_with_fingers_splayed:"🖐",raised_hands:"🙌",raising_hand_woman:"🙋",raising_hand_man:"🙋♂️",ram:"🐏",ramen:"🍜",rat:"🐀",record_button:"⏺",recycle:"♻️",red_circle:"🔴",registered:"®️",relaxed:"☺️",relieved:"😌",reminder_ribbon:"🎗",repeat:"🔁",repeat_one:"🔂",rescue_worker_helmet:"⛑",restroom:"🚻",revolving_hearts:"💞",rewind:"⏪",rhinoceros:"🦏",ribbon:"🎀",rice:"🍚",rice_ball:"🍙",rice_cracker:"🍘",rice_scene:"🎑",right_anger_bubble:"🗯",ring:"💍",robot:"🤖",rocket:"🚀",rofl:"🤣",roll_eyes:"🙄",roller_coaster:"🎢",rooster:"🐓",rose:"🌹",rosette:"🏵",rotating_light:"🚨",round_pushpin:"📍",rowing_man:"🚣",rowing_woman:"🚣♀️",rugby_football:"🏉",running_man:"🏃",running_shirt_with_sash:"🎽",running_woman:"🏃♀️",sa:"🈂️",sagittarius:"♐️",sake:"🍶",sandal:"👡",santa:"🎅",satellite:"📡",saxophone:"🎷",school:"🏫",school_satchel:"🎒",scissors:"✂️",scorpion:"🦂",scorpius:"♏️",scream:"😱",scream_cat:"🙀",scroll:"📜",seat:"💺",secret:"㊙️",see_no_evil:"🙈",seedling:"🌱",selfie:"🤳",shallow_pan_of_food:"🥘",shamrock:"☘️",shark:"🦈",shaved_ice:"🍧",sheep:"🐑",shell:"🐚",shield:"🛡",shinto_shrine:"⛩",ship:"🚢",shirt:"👕",shopping:"🛍",shopping_cart:"🛒",shower:"🚿",shrimp:"🦐",signal_strength:"📶",six_pointed_star:"🔯",ski:"🎿",skier:"⛷",skull:"💀",skull_and_crossbones:"☠️",sleeping:"😴",sleeping_bed:"🛌",sleepy:"😪",slightly_frowning_face:"🙁",slightly_smiling_face:"🙂",slot_machine:"🎰",small_airplane:"🛩",small_blue_diamond:"🔹",small_orange_diamond:"🔸",small_red_triangle:"🔺",small_red_triangle_down:"🔻",smile:"😄",smile_cat:"😸",smiley:"😃",smiley_cat:"😺",smiling_imp:"😈",smirk:"😏",smirk_cat:"😼",smoking:"🚬",snail:"🐌",snake:"🐍",sneezing_face:"🤧",snowboarder:"🏂",snowflake:"❄️",snowman:"⛄️",snowman_with_snow:"☃️",sob:"😭",soccer:"⚽️",soon:"🔜",sos:"🆘",sound:"🔉",space_invader:"👾",spades:"♠️",spaghetti:"🍝",sparkle:"❇️",sparkler:"🎇",sparkles:"✨",sparkling_heart:"💖",speak_no_evil:"🙊",speaker:"🔈",speaking_head:"🗣",speech_balloon:"💬",speedboat:"🚤",spider:"🕷",spider_web:"🕸",spiral_calendar:"🗓",spiral_notepad:"🗒",spoon:"🥄",squid:"🦑",stadium:"🏟",star:"⭐️",star2:"🌟",star_and_crescent:"☪️",star_of_david:"✡️",stars:"🌠",station:"🚉",statue_of_liberty:"🗽",steam_locomotive:"🚂",stew:"🍲",stop_button:"⏹",stop_sign:"🛑",stopwatch:"⏱",straight_ruler:"📏",strawberry:"🍓",stuck_out_tongue:"😛",stuck_out_tongue_closed_eyes:"😝",stuck_out_tongue_winking_eye:"😜",studio_microphone:"🎙",stuffed_flatbread:"🥙",sun_behind_large_cloud:"🌥",sun_behind_rain_cloud:"🌦",sun_behind_small_cloud:"🌤",sun_with_face:"🌞",sunflower:"🌻",sunglasses:"😎",sunny:"☀️",sunrise:"🌅",sunrise_over_mountains:"🌄",surfing_man:"🏄",surfing_woman:"🏄♀️",sushi:"🍣",suspension_railway:"🚟",sweat:"😓",sweat_drops:"💦",sweat_smile:"😅",sweet_potato:"🍠",swimming_man:"🏊",swimming_woman:"🏊♀️",symbols:"🔣",synagogue:"🕍",syringe:"💉",taco:"🌮",tada:"🎉",tanabata_tree:"🎋",taurus:"♉️",taxi:"🚕",tea:"🍵",telephone_receiver:"📞",telescope:"🔭",tennis:"🎾",tent:"⛺️",thermometer:"🌡",thinking:"🤔",thought_balloon:"💭",ticket:"🎫",tickets:"🎟",tiger:"🐯",tiger2:"🐅",timer_clock:"⏲",tipping_hand_man:"💁♂️",tired_face:"😫",tm:"™️",toilet:"🚽",tokyo_tower:"🗼",tomato:"🍅",tongue:"👅",top:"🔝",tophat:"🎩",tornado:"🌪",trackball:"🖲",tractor:"🚜",traffic_light:"🚥",train:"🚋",train2:"🚆",tram:"🚊",triangular_flag_on_post:"🚩",triangular_ruler:"📐",trident:"🔱",triumph:"😤",trolleybus:"🚎",trophy:"🏆",tropical_drink:"🍹",tropical_fish:"🐠",truck:"🚚",trumpet:"🎺",tulip:"🌷",tumbler_glass:"🥃",turkey:"🦃",turtle:"🐢",tv:"📺",twisted_rightwards_arrows:"🔀",two_hearts:"💕",two_men_holding_hands:"👬",two_women_holding_hands:"👭",u5272:"🈹",u5408:"🈴",u55b6:"🈺",u6307:"🈯️",u6708:"🈷️",u6709:"🈶",u6e80:"🈵",u7121:"🈚️",u7533:"🈸",u7981:"🈲",u7a7a:"🈳",umbrella:"☔️",unamused:"😒",underage:"🔞",unicorn:"🦄",unlock:"🔓",up:"🆙",upside_down_face:"🙃",v:"✌️",vertical_traffic_light:"🚦",vhs:"📼",vibration_mode:"📳",video_camera:"📹",video_game:"🎮",violin:"🎻",virgo:"♍️",volcano:"🌋",volleyball:"🏐",vs:"🆚",vulcan_salute:"🖖",walking_man:"🚶",walking_woman:"🚶♀️",waning_crescent_moon:"🌘",waning_gibbous_moon:"🌖",warning:"⚠️",wastebasket:"🗑",watch:"⌚️",water_buffalo:"🐃",watermelon:"🍉",wave:"👋",wavy_dash:"〰️",waxing_crescent_moon:"🌒",wc:"🚾",weary:"😩",wedding:"💒",weight_lifting_man:"🏋️",weight_lifting_woman:"🏋️♀️",whale:"🐳",whale2:"🐋",wheel_of_dharma:"☸️",wheelchair:"♿️",white_check_mark:"✅",white_circle:"⚪️",white_flag:"🏳️",white_flower:"💮",white_large_square:"⬜️",white_medium_small_square:"◽️",white_medium_square:"◻️",white_small_square:"▫️",white_square_button:"🔳",wilted_flower:"🥀",wind_chime:"🎐",wind_face:"🌬",wine_glass:"🍷",wink:"😉",wolf:"🐺",woman:"👩",woman_artist:"👩🎨",woman_astronaut:"👩🚀",woman_cartwheeling:"🤸♀️",woman_cook:"👩🍳",woman_facepalming:"🤦♀️",woman_factory_worker:"👩🏭",woman_farmer:"👩🌾",woman_firefighter:"👩🚒",woman_health_worker:"👩⚕️",woman_judge:"👩⚖️",woman_juggling:"🤹♀️",woman_mechanic:"👩🔧",woman_office_worker:"👩💼",woman_pilot:"👩✈️",woman_playing_handball:"🤾♀️",woman_playing_water_polo:"🤽♀️",woman_scientist:"👩🔬",woman_shrugging:"🤷♀️",woman_singer:"👩🎤",woman_student:"👩🎓",woman_teacher:"👩🏫",woman_technologist:"👩💻",woman_with_turban:"👳♀️",womans_clothes:"👚",womans_hat:"👒",women_wrestling:"🤼♀️",womens:"🚺",world_map:"🗺",worried:"😟",wrench:"🔧",writing_hand:"✍️",x:"❌",yellow_heart:"💛",yen:"💴",yin_yang:"☯️",yum:"😋",zap:"⚡️",zipper_mouth_face:"🤐",zzz:"💤",octocat:' ',showdown:"S "},x.Converter=function(e){"use strict";var r,t,n={},i=[],l=[],o={},a=h,s={parsed:{},raw:"",format:""};for(r in e=e||{},p)p.hasOwnProperty(r)&&(n[r]=p[r]);if("object"!=typeof e)throw Error("Converter expects the passed parameter to be an object, but "+typeof e+" was passed instead.");for(t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);function c(e,r){if(r=r||null,x.helper.isString(e)){if(r=e=x.helper.stdExtName(e),x.extensions[e]){console.warn("DEPRECATION WARNING: "+e+" is an old extension that uses a deprecated loading method.Please inform the developer that the extension should be updated!");var t=x.extensions[e],a=e;if("function"==typeof t&&(t=t(new x.Converter)),x.helper.isArray(t)||(t=[t]),!(a=g(t,a)).valid)throw Error(a.error);for(var n=0;n [ \t]+,">¨NBSP;<"),!r){if(!window||!window.document)throw new Error("HTMLParser is undefined. If in a webworker or nodejs environment, you need to provide a WHATWG DOM and HTML such as JSDOM");r=window.document}for(var r=r.createElement("div"),t=(r.innerHTML=e,{preList:function(e){for(var r=e.querySelectorAll("pre"),t=[],a=0;a'}else t.push(r[a].innerHTML),r[a].innerHTML="",r[a].setAttribute("prenum",a.toString());return t}(r)}),a=(!function e(r){for(var t=0;t? ?(['"].*['"])?\)$/m))a="";else if(!a){if(a="#"+(t=t||r.toLowerCase().replace(/ ?\n/g," ")),x.helper.isUndefined(l.gUrls[t]))return e;a=l.gUrls[t],x.helper.isUndefined(l.gTitles[t])||(o=l.gTitles[t])}return e='"+r+" "}return e=(e=(e=(e=(e=l.converter._dispatch("anchors.before",e,i,l)).replace(/\[((?:\[[^\]]*]|[^\[\]])*)] ?(?:\n *)?\[(.*?)]()()()()/g,r)).replace(/\[((?:\[[^\]]*]|[^\[\]])*)]()[ \t]*\([ \t]?<([^>]*)>(?:[ \t]*((["'])([^"]*?)\5))?[ \t]?\)/g,r)).replace(/\[((?:\[[^\]]*]|[^\[\]])*)]()[ \t]*\([ \t]?([\S]+?(?:\([\S]*?\)[\S]*?)?)>?(?:[ \t]*((["'])([^"]*?)\5))?[ \t]?\)/g,r)).replace(/\[([^\[\]]+)]()()()()()/g,r),i.ghMentions&&(e=e.replace(/(^|\s)(\\)?(@([a-z\d]+(?:[a-z\d.-]+?[a-z\d]+)*))/gim,function(e,r,t,a,n){if("\\"===t)return r+a;if(!x.helper.isString(i.ghMentionsLink))throw new Error("ghMentionsLink option must be a string");t="";return r+'"+a+" "})),e=l.converter._dispatch("anchors.after",e,i,l)});var i=/([*~_]+|\b)(((https?|ftp|dict):\/\/|www\.)[^'">\s]+?\.[^'">\s]+?)()(\1)?(?=\s|$)(?!["<>])/gi,l=/([*~_]+|\b)(((https?|ftp|dict):\/\/|www\.)[^'">\s]+\.[^'">\s]+?)([.!?,()\[\]])?(\1)?(?=\s|$)(?!["<>])/gi,c=/()<(((https?|ftp|dict):\/\/|www\.)[^'">\s]+)()>()/gi,m=/(^|\s)(?:mailto:)?([A-Za-z0-9!#$%&'*+-/=?^_`{|}~.]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)(?=$|\s)/gim,f=/<()(?:mailto:)?([-.\w]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi;x.subParser("autoLinks",function(e,r,t){"use strict";return e=(e=(e=t.converter._dispatch("autoLinks.before",e,r,t)).replace(c,s(r))).replace(f,o(r,t)),e=t.converter._dispatch("autoLinks.after",e,r,t)}),x.subParser("simplifiedAutoLinks",function(e,r,t){"use strict";return r.simplifiedAutoLink?(e=t.converter._dispatch("simplifiedAutoLinks.before",e,r,t),e=(e=r.excludeTrailingPunctuationFromURLs?e.replace(l,s(r)):e.replace(i,s(r))).replace(m,o(r,t)),t.converter._dispatch("simplifiedAutoLinks.after",e,r,t)):e}),x.subParser("blockGamut",function(e,r,t){"use strict";return e=t.converter._dispatch("blockGamut.before",e,r,t),e=x.subParser("blockQuotes")(e,r,t),e=x.subParser("headers")(e,r,t),e=x.subParser("horizontalRule")(e,r,t),e=x.subParser("lists")(e,r,t),e=x.subParser("codeBlocks")(e,r,t),e=x.subParser("tables")(e,r,t),e=x.subParser("hashHTMLBlocks")(e,r,t),e=x.subParser("paragraphs")(e,r,t),e=t.converter._dispatch("blockGamut.after",e,r,t)}),x.subParser("blockQuotes",function(e,r,t){"use strict";e=t.converter._dispatch("blockQuotes.before",e,r,t);var a=/(^ {0,3}>[ \t]?.+\n(.+\n)*\n*)+/gm;return r.splitAdjacentBlockquotes&&(a=/^ {0,3}>[\s\S]*?(?:\n\n)/gm),e=(e+="\n\n").replace(a,function(e){return e=(e=(e=e.replace(/^[ \t]*>[ \t]?/gm,"")).replace(/¨0/g,"")).replace(/^[ \t]+$/gm,""),e=x.subParser("githubCodeBlocks")(e,r,t),e=(e=(e=x.subParser("blockGamut")(e,r,t)).replace(/(^|\n)/g,"$1 ")).replace(/(\s*[^\r]+?<\/pre>)/gm,function(e,r){return r.replace(/^ /gm,"¨0").replace(/¨0/g,"")}),x.subParser("hashBlock")("\n"+e+"\n ",r,t)}),e=t.converter._dispatch("blockQuotes.after",e,r,t)}),x.subParser("codeBlocks",function(e,n,s){"use strict";e=s.converter._dispatch("codeBlocks.before",e,n,s);return e=(e=(e+="¨0").replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=¨0))/g,function(e,r,t){var a="\n",r=x.subParser("outdent")(r,n,s);return r=x.subParser("encodeCode")(r,n,s),r=""+(r=(r=(r=x.subParser("detab")(r,n,s)).replace(/^\n+/g,"")).replace(/\n+$/g,""))+(a=n.omitExtraWLInCodeBlocks?"":a)+" ",x.subParser("hashBlock")(r,n,s)+t})).replace(/¨0/,""),e=s.converter._dispatch("codeBlocks.after",e,n,s)}),x.subParser("codeSpans",function(e,n,s){"use strict";return e=(e=void 0===(e=s.converter._dispatch("codeSpans.before",e,n,s))?"":e).replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm,function(e,r,t,a){return a=(a=a.replace(/^([ \t]*)/g,"")).replace(/[ \t]*$/g,""),a=r+""+(a=x.subParser("encodeCode")(a,n,s))+"",a=x.subParser("hashHTMLSpans")(a,n,s)}),e=s.converter._dispatch("codeSpans.after",e,n,s)}),x.subParser("completeHTMLDocument",function(e,r,t){"use strict";if(!r.completeHTMLDocument)return e;e=t.converter._dispatch("completeHTMLDocument.before",e,r,t);var a,n="html",s="\n",o="",i=' \n',l="",c="";for(a in void 0!==t.metadata.parsed.doctype&&(s="\n","html"!==(n=t.metadata.parsed.doctype.toString().toLowerCase())&&"html5"!==n||(i=' ')),t.metadata.parsed)if(t.metadata.parsed.hasOwnProperty(a))switch(a.toLowerCase()){case"doctype":break;case"title":o=""+t.metadata.parsed.title+" \n";break;case"charset":i="html"===n||"html5"===n?' \n':' \n';break;case"language":case"lang":l=' lang="'+t.metadata.parsed[a]+'"',c+=' \n';break;default:c+=' \n'}return e=s+"\n\n"+o+i+c+"\n\n"+e.trim()+"\n\n",e=t.converter._dispatch("completeHTMLDocument.after",e,r,t)}),x.subParser("detab",function(e,r,t){"use strict";return e=(e=(e=(e=(e=(e=t.converter._dispatch("detab.before",e,r,t)).replace(/\t(?=\t)/g," ")).replace(/\t/g,"¨A¨B")).replace(/¨B(.+?)¨A/g,function(e,r){for(var t=r,a=4-t.length%4,n=0;n/g,">"),e=t.converter._dispatch("encodeAmpsAndAngles.after",e,r,t)}),x.subParser("encodeBackslashEscapes",function(e,r,t){"use strict";return e=(e=(e=t.converter._dispatch("encodeBackslashEscapes.before",e,r,t)).replace(/\\(\\)/g,x.helper.escapeCharactersCallback)).replace(/\\([`*_{}\[\]()>#+.!~=|:-])/g,x.helper.escapeCharactersCallback),e=t.converter._dispatch("encodeBackslashEscapes.after",e,r,t)}),x.subParser("encodeCode",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("encodeCode.before",e,r,t)).replace(/&/g,"&").replace(//g,">").replace(/([*_{}\[\]\\=~-])/g,x.helper.escapeCharactersCallback),e=t.converter._dispatch("encodeCode.after",e,r,t)}),x.subParser("escapeSpecialCharsWithinTagAttributes",function(e,r,t){"use strict";return e=(e=(e=t.converter._dispatch("escapeSpecialCharsWithinTagAttributes.before",e,r,t)).replace(/<\/?[a-z\d_:-]+(?:[\s]+[\s\S]+?)?>/gi,function(e){return e.replace(/(.)<\/?code>(?=.)/g,"$1`").replace(/([\\`*_~=|])/g,x.helper.escapeCharactersCallback)})).replace(/-]|-[^>])(?:[^-]|-[^-])*)--)>/gi,function(e){return e.replace(/([\\`*_~=|])/g,x.helper.escapeCharactersCallback)}),e=t.converter._dispatch("escapeSpecialCharsWithinTagAttributes.after",e,r,t)}),x.subParser("githubCodeBlocks",function(e,s,o){"use strict";return s.ghCodeBlocks?(e=o.converter._dispatch("githubCodeBlocks.before",e,s,o),e=(e=(e+="¨0").replace(/(?:^|\n)(?: {0,3})(```+|~~~+)(?: *)([^\s`~]*)\n([\s\S]*?)\n(?: {0,3})\1/g,function(e,r,t,a){var n=s.omitExtraWLInCodeBlocks?"":"\n";return a=x.subParser("encodeCode")(a,s,o),a=""+(a=(a=(a=x.subParser("detab")(a,s,o)).replace(/^\n+/g,"")).replace(/\n+$/g,""))+n+" ",a=x.subParser("hashBlock")(a,s,o),"\n\n¨G"+(o.ghCodeBlocks.push({text:e,codeblock:a})-1)+"G\n\n"})).replace(/¨0/,""),o.converter._dispatch("githubCodeBlocks.after",e,s,o)):e}),x.subParser("hashBlock",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("hashBlock.before",e,r,t)).replace(/(^\n+|\n+$)/g,""),e="\n\n¨K"+(t.gHtmlBlocks.push(e)-1)+"K\n\n",e=t.converter._dispatch("hashBlock.after",e,r,t)}),x.subParser("hashCodeTags",function(e,n,s){"use strict";e=s.converter._dispatch("hashCodeTags.before",e,n,s);return e=x.helper.replaceRecursiveRegExp(e,function(e,r,t,a){t=t+x.subParser("encodeCode")(r,n,s)+a;return"¨C"+(s.gHtmlSpans.push(t)-1)+"C"},"]*>","","gim"),e=s.converter._dispatch("hashCodeTags.after",e,n,s)}),x.subParser("hashElement",function(e,r,t){"use strict";return function(e,r){return r=(r=(r=r.replace(/\n\n/g,"\n")).replace(/^\n/,"")).replace(/\n+$/g,""),r="\n\n¨K"+(t.gHtmlBlocks.push(r)-1)+"K\n\n"}}),x.subParser("hashHTMLBlocks",function(e,r,n){"use strict";e=n.converter._dispatch("hashHTMLBlocks.before",e,r,n);function t(e,r,t,a){return-1!==t.search(/\bmarkdown\b/)&&(e=t+n.converter.makeHtml(r)+a),"\n\n¨K"+(n.gHtmlBlocks.push(e)-1)+"K\n\n"}var a=["pre","div","h1","h2","h3","h4","h5","h6","blockquote","table","dl","ol","ul","script","noscript","form","fieldset","iframe","math","style","section","header","footer","nav","article","aside","address","audio","canvas","figure","hgroup","output","video","p"];r.backslashEscapesHTMLTags&&(e=e.replace(/\\<(\/?[^>]+?)>/g,function(e,r){return"<"+r+">"}));for(var s=0;s ]*>)","im"),i="<"+a[s]+"\\b[^>]*>",l=""+a[s]+">";-1!==(c=x.helper.regexIndexOf(e,o));){var c=x.helper.splitAtIndex(e,c),u=x.helper.replaceRecursiveRegExp(c[1],t,i,l,"im");if(u===c[1])break;e=c[0].concat(u)}return e=e.replace(/(\n {0,3}(<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g,x.subParser("hashElement")(e,r,n)),e=(e=x.helper.replaceRecursiveRegExp(e,function(e){return"\n\n¨K"+(n.gHtmlBlocks.push(e)-1)+"K\n\n"},"^ {0,3}\x3c!--","--\x3e","gm")).replace(/(?:\n\n)( {0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g,x.subParser("hashElement")(e,r,n)),e=n.converter._dispatch("hashHTMLBlocks.after",e,r,n)}),x.subParser("hashHTMLSpans",function(e,r,t){"use strict";function a(e){return"¨C"+(t.gHtmlSpans.push(e)-1)+"C"}return e=(e=(e=(e=(e=t.converter._dispatch("hashHTMLSpans.before",e,r,t)).replace(/<[^>]+?\/>/gi,a)).replace(/<([^>]+?)>[\s\S]*?<\/\1>/g,a)).replace(/<([^>]+?)\s[^>]+?>[\s\S]*?<\/\1>/g,a)).replace(/<[^>]+?>/gi,a),e=t.converter._dispatch("hashHTMLSpans.after",e,r,t)}),x.subParser("unhashHTMLSpans",function(e,r,t){"use strict";e=t.converter._dispatch("unhashHTMLSpans.before",e,r,t);for(var a=0;a]*>\\s*]*>","^ {0,3}\\s* ","gim"),e=s.converter._dispatch("hashPreCodeTags.after",e,n,s)}),x.subParser("headers",function(e,n,s){"use strict";e=s.converter._dispatch("headers.before",e,n,s);var o=isNaN(parseInt(n.headerLevelStart))?1:parseInt(n.headerLevelStart),r=n.smoothLivePreview?/^(.+)[ \t]*\n={2,}[ \t]*\n+/gm:/^(.+)[ \t]*\n=+[ \t]*\n+/gm,t=n.smoothLivePreview?/^(.+)[ \t]*\n-{2,}[ \t]*\n+/gm:/^(.+)[ \t]*\n-+[ \t]*\n+/gm,r=(e=(e=e.replace(r,function(e,r){var t=x.subParser("spanGamut")(r,n,s),r=n.noHeaderId?"":' id="'+i(r)+'"',r=""+t+" ";return x.subParser("hashBlock")(r,n,s)})).replace(t,function(e,r){var t=x.subParser("spanGamut")(r,n,s),r=n.noHeaderId?"":' id="'+i(r)+'"',a=o+1,r=""+t+" ";return x.subParser("hashBlock")(r,n,s)}),n.requireSpaceBeforeHeadingText?/^(#{1,6})[ \t]+(.+?)[ \t]*#*\n+/gm:/^(#{1,6})[ \t]*(.+?)[ \t]*#*\n+/gm);function i(e){var r=e=n.customizedHeaderId&&(r=e.match(/\{([^{]+?)}\s*$/))&&r[1]?r[1]:e,e=x.helper.isString(n.prefixHeaderId)?n.prefixHeaderId:!0===n.prefixHeaderId?"section-":"";return n.rawPrefixHeaderId||(r=e+r),r=(n.ghCompatibleHeaderId?r.replace(/ /g,"-").replace(/&/g,"").replace(/¨T/g,"").replace(/¨D/g,"").replace(/[&+$,\/:;=?@"#{}|^¨~\[\]`\\*)(%.!'<>]/g,""):n.rawHeaderId?r.replace(/ /g,"-").replace(/&/g,"&").replace(/¨T/g,"¨").replace(/¨D/g,"$").replace(/["']/g,"-"):r.replace(/[^\w]/g,"")).toLowerCase(),n.rawPrefixHeaderId&&(r=e+r),s.hashLinkCounts[r]?r=r+"-"+s.hashLinkCounts[r]++:s.hashLinkCounts[r]=1,r}return e=e.replace(r,function(e,r,t){var a=t,a=(n.customizedHeaderId&&(a=t.replace(/\s?\{([^{]+?)}\s*$/,"")),x.subParser("spanGamut")(a,n,s)),t=n.noHeaderId?"":' id="'+i(t)+'"',r=o-1+r.length,t=""+a+" ";return x.subParser("hashBlock")(t,n,s)}),e=s.converter._dispatch("headers.after",e,n,s)}),x.subParser("horizontalRule",function(e,r,t){"use strict";e=t.converter._dispatch("horizontalRule.before",e,r,t);var a=x.subParser("hashBlock")(" ",r,t);return e=(e=(e=e.replace(/^ {0,2}( ?-){3,}[ \t]*$/gm,a)).replace(/^ {0,2}( ?\*){3,}[ \t]*$/gm,a)).replace(/^ {0,2}( ?_){3,}[ \t]*$/gm,a),e=t.converter._dispatch("horizontalRule.after",e,r,t)}),x.subParser("images",function(e,r,d){"use strict";function l(e,r,t,a,n,s,o,i){var l=d.gUrls,c=d.gTitles,u=d.gDimensions;if(t=t.toLowerCase(),i=i||"",-1? ?(['"].*['"])?\)$/m))a="";else if(""===a||null===a){if(a="#"+(t=""!==t&&null!==t?t:r.toLowerCase().replace(/ ?\n/g," ")),x.helper.isUndefined(l[t]))return e;a=l[t],x.helper.isUndefined(c[t])||(i=c[t]),x.helper.isUndefined(u[t])||(n=u[t].width,s=u[t].height)}r=r.replace(/"/g,""").replace(x.helper.regexes.asteriskDashAndColon,x.helper.escapeCharactersCallback);e=' "}return e=(e=(e=(e=(e=(e=d.converter._dispatch("images.before",e,r,d)).replace(/!\[([^\]]*?)] ?(?:\n *)?\[([\s\S]*?)]()()()()()/g,l)).replace(/!\[([^\]]*?)][ \t]*()\([ \t]?(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(["'])([^"]*?)\6)?[ \t]?\)/g,function(e,r,t,a,n,s,o,i){return l(e,r,t,a=a.replace(/\s/g,""),n,s,0,i)})).replace(/!\[([^\]]*?)][ \t]*()\([ \t]?<([^>]*)>(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(?:(["'])([^"]*?)\6))?[ \t]?\)/g,l)).replace(/!\[([^\]]*?)][ \t]*()\([ \t]?([\S]+?(?:\([\S]*?\)[\S]*?)?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(["'])([^"]*?)\6)?[ \t]?\)/g,l)).replace(/!\[([^\[\]]+)]()()()()()/g,l),e=d.converter._dispatch("images.after",e,r,d)}),x.subParser("italicsAndBold",function(e,r,t){"use strict";return e=t.converter._dispatch("italicsAndBold.before",e,r,t),e=r.literalMidWordUnderscores?(e=(e=e.replace(/\b___(\S[\s\S]*?)___\b/g,function(e,r){return""+r+" "})).replace(/\b__(\S[\s\S]*?)__\b/g,function(e,r){return""+r+" "})).replace(/\b_(\S[\s\S]*?)_\b/g,function(e,r){return""+r+" "}):(e=(e=e.replace(/___(\S[\s\S]*?)___/g,function(e,r){return/\S$/.test(r)?""+r+" ":e})).replace(/__(\S[\s\S]*?)__/g,function(e,r){return/\S$/.test(r)?""+r+" ":e})).replace(/_([^\s_][\s\S]*?)_/g,function(e,r){return/\S$/.test(r)?""+r+" ":e}),e=r.literalMidWordAsterisks?(e=(e=e.replace(/([^*]|^)\B\*\*\*(\S[\s\S]*?)\*\*\*\B(?!\*)/g,function(e,r,t){return r+""+t+" "})).replace(/([^*]|^)\B\*\*(\S[\s\S]*?)\*\*\B(?!\*)/g,function(e,r,t){return r+""+t+" "})).replace(/([^*]|^)\B\*(\S[\s\S]*?)\*\B(?!\*)/g,function(e,r,t){return r+""+t+" "}):(e=(e=e.replace(/\*\*\*(\S[\s\S]*?)\*\*\*/g,function(e,r){return/\S$/.test(r)?""+r+" ":e})).replace(/\*\*(\S[\s\S]*?)\*\*/g,function(e,r){return/\S$/.test(r)?""+r+" ":e})).replace(/\*([^\s*][\s\S]*?)\*/g,function(e,r){return/\S$/.test(r)?""+r+" ":e}),e=t.converter._dispatch("italicsAndBold.after",e,r,t)}),x.subParser("lists",function(e,d,c){"use strict";function p(e,r){c.gListLevel++,e=e.replace(/\n{2,}$/,"\n");var t=/(\n)?(^ {0,3})([*+-]|\d+[.])[ \t]+((\[(x|X| )?])?[ \t]*[^\r]+?(\n{1,2}))(?=\n*(¨0| {0,3}([*+-]|\d+[.])[ \t]+))/gm,l=/\n[ \t]*\n(?!¨0)/.test(e+="¨0");return d.disableForced4SpacesIndentedSublists&&(t=/(\n)?(^ {0,3})([*+-]|\d+[.])[ \t]+((\[(x|X| )?])?[ \t]*[^\r]+?(\n{1,2}))(?=\n*(¨0|\2([*+-]|\d+[.])[ \t]+))/gm),e=(e=e.replace(t,function(e,r,t,a,n,s,o){o=o&&""!==o.trim();var n=x.subParser("outdent")(n,d,c),i="";return s&&d.tasklists&&(i=' class="task-list-item" style="list-style-type: none;"',n=n.replace(/^[ \t]*\[(x|X| )?]/m,function(){var e=' "})),n=n.replace(/^([-*+]|\d\.)[ \t]+[\S\n ]*/g,function(e){return"¨A"+e}),n=""+(n=(n=r||-1\n"})).replace(/¨0/g,""),c.gListLevel--,e=r?e.replace(/\s+$/,""):e}function h(e,r){if("ol"===r){r=e.match(/^ *(\d+)\./);if(r&&"1"!==r[1])return' start="'+r[1]+'"'}return""}function n(n,s,o){var e,i=d.disableForced4SpacesIndentedSublists?/^ ?\d+\.[ \t]/gm:/^ {0,3}\d+\.[ \t]/gm,l=d.disableForced4SpacesIndentedSublists?/^ ?[*+-][ \t]/gm:/^ {0,3}[*+-][ \t]/gm,c="ul"===s?i:l,u="";return-1!==n.search(c)?function e(r){var t=r.search(c),a=h(n,s);-1!==t?(u+="\n\n<"+s+a+">\n"+p(r.slice(0,t),!!o)+""+s+">\n",c="ul"===(s="ul"===s?"ol":"ul")?i:l,e(r.slice(t))):u+="\n\n<"+s+a+">\n"+p(r,!!o)+""+s+">\n"}(n):(e=h(n,s),u="\n\n<"+s+e+">\n"+p(n,!!o)+""+s+">\n"),u}return e=c.converter._dispatch("lists.before",e,d,c),e+="¨0",e=(e=c.gListLevel?e.replace(/^(( {0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(¨0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm,function(e,r,t){return n(r,-1"),i+="
",n.push(i))}for(s=n.length,o=0;o]*>\s*]*>/.test(c)&&(u=!0)}n[o]=c}return e=(e=(e=n.join("\n")).replace(/^\n+/g,"")).replace(/\n+$/g,""),t.converter._dispatch("paragraphs.after",e,r,t)}),x.subParser("runExtension",function(e,r,t,a){"use strict";return e.filter?r=e.filter(r,a.converter,t):e.regex&&((a=e.regex)instanceof RegExp||(a=new RegExp(a,"g")),r=r.replace(a,e.replace)),r}),x.subParser("spanGamut",function(e,r,t){"use strict";return e=t.converter._dispatch("spanGamut.before",e,r,t),e=x.subParser("codeSpans")(e,r,t),e=x.subParser("escapeSpecialCharsWithinTagAttributes")(e,r,t),e=x.subParser("encodeBackslashEscapes")(e,r,t),e=x.subParser("images")(e,r,t),e=x.subParser("anchors")(e,r,t),e=x.subParser("autoLinks")(e,r,t),e=x.subParser("simplifiedAutoLinks")(e,r,t),e=x.subParser("emoji")(e,r,t),e=x.subParser("underline")(e,r,t),e=x.subParser("italicsAndBold")(e,r,t),e=x.subParser("strikethrough")(e,r,t),e=x.subParser("ellipsis")(e,r,t),e=x.subParser("hashHTMLSpans")(e,r,t),e=x.subParser("encodeAmpsAndAngles")(e,r,t),r.simpleLineBreaks?/\n\n¨K/.test(e)||(e=e.replace(/\n+/g," \n")):e=e.replace(/ +\n/g," \n"),e=t.converter._dispatch("spanGamut.after",e,r,t)}),x.subParser("strikethrough",function(e,t,a){"use strict";return t.strikethrough&&(e=(e=a.converter._dispatch("strikethrough.before",e,t,a)).replace(/(?:~){2}([\s\S]+?)(?:~){2}/g,function(e,r){return r=r,""+(r=t.simplifiedAutoLink?x.subParser("simplifiedAutoLinks")(r,t,a):r)+""}),e=a.converter._dispatch("strikethrough.after",e,t,a)),e}),x.subParser("stripLinkDefinitions",function(i,l,c){"use strict";function e(e,r,t,a,n,s,o){return r=r.toLowerCase(),i.toLowerCase().split(r).length-1<2?e:(t.match(/^data:.+?\/.+?;base64,/)?c.gUrls[r]=t.replace(/\s/g,""):c.gUrls[r]=x.subParser("encodeAmpsAndAngles")(t,l,c),s?s+o:(o&&(c.gTitles[r]=o.replace(/"|'/g,""")),l.parseImgDimensions&&a&&n&&(c.gDimensions[r]={width:a,height:n}),""))}return i=(i=(i=(i+="¨0").replace(/^ {0,3}\[([^\]]+)]:[ \t]*\n?[ \t]*(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n\n|(?=¨0)|(?=\n\[))/gm,e)).replace(/^ {0,3}\[([^\]]+)]:[ \t]*\n?[ \t]*([^>\s]+)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n+|(?=¨0))/gm,e)).replace(/¨0/,"")}),x.subParser("tables",function(e,y,P){"use strict";if(!y.tables)return e;function r(e){for(var r=e.split("\n"),t=0;t"+(n=x.subParser("spanGamut")(n,y,P))+"\n"));for(t=0;t"+x.subParser("spanGamut")(i,y,P)+"\n"));h.push(_)}for(var m=d,f=h,b="\n\n\n",w=m.length,k=0;k\n \n\n",k=0;k\n";for(var v=0;v\n"}return b+=" \n
\n"}return e=(e=(e=(e=P.converter._dispatch("tables.before",e,y,P)).replace(/\\(\|)/g,x.helper.escapeCharactersCallback)).replace(/^ {0,3}\|?.+\|.+\n {0,3}\|?[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*:?[ \t]*(?:[-=]){2,}[\s\S]+?(?:\n\n|¨0)/gm,r)).replace(/^ {0,3}\|.+\|[ \t]*\n {0,3}\|[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*\n( {0,3}\|.+\|[ \t]*\n)*(?:\n|¨0)/gm,r),e=P.converter._dispatch("tables.after",e,y,P)}),x.subParser("underline",function(e,r,t){"use strict";return r.underline?(e=t.converter._dispatch("underline.before",e,r,t),e=(e=r.literalMidWordUnderscores?(e=e.replace(/\b___(\S[\s\S]*?)___\b/g,function(e,r){return""+r+" "})).replace(/\b__(\S[\s\S]*?)__\b/g,function(e,r){return""+r+" "}):(e=e.replace(/___(\S[\s\S]*?)___/g,function(e,r){return/\S$/.test(r)?""+r+" ":e})).replace(/__(\S[\s\S]*?)__/g,function(e,r){return/\S$/.test(r)?""+r+" ":e})).replace(/(_)/g,x.helper.escapeCharactersCallback),t.converter._dispatch("underline.after",e,r,t)):e}),x.subParser("unescapeSpecialChars",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("unescapeSpecialChars.before",e,r,t)).replace(/¨E(\d+)E/g,function(e,r){r=parseInt(r);return String.fromCharCode(r)}),e=t.converter._dispatch("unescapeSpecialChars.after",e,r,t)}),x.subParser("makeMarkdown.blockquote",function(e,r){"use strict";var t="";if(e.hasChildNodes())for(var a=e.childNodes,n=a.length,s=0;s ")}),x.subParser("makeMarkdown.codeBlock",function(e,r){"use strict";var t=e.getAttribute("language"),e=e.getAttribute("precodenum");return"```"+t+"\n"+r.preList[e]+"\n```"}),x.subParser("makeMarkdown.codeSpan",function(e){"use strict";return"`"+e.innerHTML+"`"}),x.subParser("makeMarkdown.emphasis",function(e,r){"use strict";var t="";if(e.hasChildNodes()){t+="*";for(var a=e.childNodes,n=a.length,s=0;s",e.hasAttribute("width")&&e.hasAttribute("height")&&(r+=" ="+e.getAttribute("width")+"x"+e.getAttribute("height")),e.hasAttribute("title")&&(r+=' "'+e.getAttribute("title")+'"'),r+=")"),r}),x.subParser("makeMarkdown.links",function(e,r){"use strict";var t="";if(e.hasChildNodes()&&e.hasAttribute("href")){for(var a=e.childNodes,n=a.length,t="[",s=0;s"),e.hasAttribute("title")&&(t+=' "'+e.getAttribute("title")+'"'),t+=")"}return t}),x.subParser("makeMarkdown.list",function(e,r,t){"use strict";var a="";if(!e.hasChildNodes())return"";for(var n=e.childNodes,s=n.length,o=e.getAttribute("start")||1,i=0;i"+r.preList[e]+""}),x.subParser("makeMarkdown.strikethrough",function(e,r){"use strict";var t="";if(e.hasChildNodes()){t+="~~";for(var a=e.childNodes,n=a.length,s=0;str>th"),s=e.querySelectorAll("tbody>tr"),o=0;o/g,"\\$1>")).replace(/^#/gm,"\\#")).replace(/^(\s*)([-=]{3,})(\s*)$/,"$1\\$2$3")).replace(/^( {0,3}\d+)\./gm,"$1\\.")).replace(/^( {0,3})([+-])/gm,"$1\\$2")).replace(/]([\s]*)\(/g,"\\]$1\\(")).replace(/^ {0,3}\[([\S \t]*?)]:/gm,"\\[$1]:")});"function"==typeof define&&define.amd?define(function(){"use strict";return x}):"undefined"!=typeof module&&module.exports?module.exports=x:this.showdown=x}.call(this);
+//# sourceMappingURL=showdown.min.js.map
diff --git a/test-page.html b/test-page.html
new file mode 100644
index 0000000..1f53d20
--- /dev/null
+++ b/test-page.html
@@ -0,0 +1,177 @@
+
+
+
+ DeKindled Test Page
+
+
+
+ DeKindled Test Page
+
+
+
Use this page to test if DeKindled is properly capturing blob content.
+
When you create blobs, you should see:
+
+ Red popups at the top-left showing captured content
+ "📚 DeKindled Active" indicator at bottom-right
+ Captured blobs stored in window.__dekindled.blobs
+
+
+
+
+ 📌 To view captured content: Click the DeKindled extension icon in your browser toolbar. An overlay will appear showing all captured blobs with download options.
+
+
+ Test Blob Creation
+ Create Text Blob
+ Create Image Blob
+ Create Large Blob (1MB)
+ Revoke Last Blob
+ View Captured Count
+
+ Test Results
+
+
Click the buttons above to create test blobs...
+
+
+
+
+
\ No newline at end of file
diff --git a/viewer-inject.js b/viewer-inject.js
new file mode 100644
index 0000000..cad9c13
--- /dev/null
+++ b/viewer-inject.js
@@ -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 = `
+
+
📚 DeKindled
+
Loading...
+
+
+ Start Auto-Scan
+ Convert to EPUB
+ ×
+
+ `;
+
+ // 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 = `
+ 📚 Book Information
+
+ Please enter the book details for your EPUB file:
+
+
+
+ Title:
+
+
+
+
+ Author:
+
+
+
+
+ Cancel
+ Start Conversion
+
+ `;
+
+ 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 = '📤 Sending pages to background process... ';
+
+ // 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 = `📤 Sending chunk ${i + 1} of ${chunks.length} (${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 = '🔄 All pages sent, starting conversion... ';
+
+ 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 = `❌ Startup Error: ${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 = `🔄 Converting Page ${current} of ${total} • 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 = `✅ EPUB Generated: ${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 = `❌ Conversion Error: ${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);
+ }
+ }
+ });
+})();
\ No newline at end of file
diff --git a/zip-utils.js b/zip-utils.js
new file mode 100644
index 0000000..0fea304
--- /dev/null
+++ b/zip-utils.js
@@ -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;
+}
\ No newline at end of file