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 ${i + 1}

`, + 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

+ +
+ + +
+ Get your API key from OpenAI Platform. + Your key is stored securely in your browser and never shared. +
+
+
+ +
+ + +
+ Default title to use for generated EPUB files. You can override this during conversion. +
+
+ +
+ + +
+ 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. +
+ +
+ +
+ + +
+ +
+
+ + + + \ 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:':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='"}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]??(?:[ \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="";-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=''+r+'"}return e=(e=(e=(e=(e=(e=d.converter._dispatch("images.before",e,r,d)).replace(/!\[([^\]]*?)] ?(?:\n *)?\[([\s\S]*?)]()()()()()/g,l)).replace(/!\[([^\]]*?)][ \t]*()\([ \t]??(?: =([*\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]??(?: =([*\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=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)+"\n",c="ul"===(s="ul"===s?"ol":"ul")?i:l,e(r.slice(t))):u+="\n\n<"+s+a+">\n"+p(r,!!o)+"\n"}(n):(e=h(n,s),u="\n\n<"+s+e+">\n"+p(n,!!o)+"\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]*?(?: =([*\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

+ + + + + + +

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

+
+
+ + + +
+ `; + + // 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: +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ `; + + 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