// ==UserScript==
// @name LearnableMeta to Anki Exporter
// @namespace https://learnablemeta.com/
// @version 1.2.0
// @description Export LearnableMeta maps to Anki txt files with offline images
// @match https://learnablemeta.com/maps/*
// @grant GM_addStyle
// @grant GM_download
// @grant GM_setValue
// @grant GM_getValue
// @connect *
// @run-at document-end
// @author BennoGHG
// @license MIT
// ==/UserScript==
(function() {
'use strict';
function $(s) { return document.querySelector(s); }
function $$(s) { return Array.from(document.querySelectorAll(s)); }
function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); }
// Actual GeoGuessr-style dark theme
GM_addStyle([
'@import url("https://fonts.googleapis.com/css2?family=Neo+Sans:wght@300;400;500;600;700&display=swap");',
/* Main Window - GeoGuessr dark theme */
'#lm-window { position: fixed; top: 20px; right: 20px; width: 340px; min-width: 300px; max-width: 380px;',
'background: #1a1a1a; color: #ffffff; font-family: "Neo Sans", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;',
'border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.1);',
'z-index: 2147483647; overflow: hidden; transition: all 0.25s ease;',
'border: none; user-select: none; }',
'#lm-window.dragging { transition: none; cursor: move; }',
'#lm-window.hidden { transform: translateX(400px); opacity: 0; pointer-events: none; }',
/* Header - GeoGuessr style */
'#lm-header { background: #2c2c2c; padding: 12px 16px; cursor: move; user-select: none;',
'display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #3a3a3a; }',
'#lm-header:active { cursor: grabbing; }',
'#lm-title { font-size: 14px; font-weight: 600; color: #ffffff; letter-spacing: 0.5px; }',
'#lm-hide-btn { background: transparent; border: 1px solid #555; color: #ccc; width: 20px; height: 20px;',
'border-radius: 3px; cursor: pointer; display: flex; align-items: center; justify-content: center;',
'font-size: 12px; transition: all 0.2s ease; font-weight: 400; }',
'#lm-hide-btn:hover { background: #404040; border-color: #777; color: #fff; }',
/* Show Button */
'#lm-show-btn { position: fixed; top: 20px; right: 20px; background: #1a1a1a;',
'border: 1px solid #555; color: #fff; width: 40px; height: 40px; border-radius: 6px; cursor: pointer;',
'display: none; align-items: center; justify-content: center; font-size: 14px; z-index: 2147483646;',
'box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); transition: all 0.2s ease; font-weight: 600; }',
'#lm-show-btn:hover { background: #2c2c2c; border-color: #777; }',
/* Content */
'#lm-content { padding: 16px; display: flex; flex-direction: column; gap: 12px; }',
/* Sections */
'.lm-section { background: #242424; padding: 12px; border-radius: 6px; border: 1px solid #3a3a3a; }',
'.lm-section label { font-size: 11px; margin-bottom: 6px; display: block; color: #999;',
'font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }',
/* Input Fields - GeoGuessr style */
'.lm-section input[type=text] { width: 100%; padding: 8px 10px; border-radius: 4px;',
'border: 1px solid #555; background: #1a1a1a; color: #ffffff; box-sizing: border-box;',
'font-family: inherit; transition: all 0.2s ease; font-size: 13px; }',
'.lm-section input[type=text]:focus { outline: none; border-color: #4fc3f7; background: #1e1e1e; }',
'.lm-section input[type=range] { width: 100%; margin: 6px 0; accent-color: #4fc3f7;',
'height: 4px; border-radius: 2px; background: #3a3a3a; }',
/* Buttons - GeoGuessr style */
'.lm-button { padding: 10px 12px; border: 1px solid; border-radius: 4px; font-size: 12px; cursor: pointer;',
'margin-bottom: 6px; width: 100%; font-family: inherit; font-weight: 500;',
'transition: all 0.2s ease; text-transform: none; letter-spacing: 0.3px; }',
'.lm-button.primary { background: #4fc3f7; color: #000; border-color: #4fc3f7; }',
'.lm-button.primary:hover { background: #29b6f6; border-color: #29b6f6; }',
'.lm-button.secondary { background: transparent; color: #4fc3f7; border-color: #4fc3f7; }',
'.lm-button.secondary:hover { background: #4fc3f7; color: #000; }',
'.lm-button.warning { background: transparent; color: #ff9800; border-color: #ff9800; }',
'.lm-button.warning:hover { background: #ff9800; color: #000; }',
'.lm-button:disabled { opacity: 0.4; cursor: not-allowed; }',
'.lm-button:disabled:hover { background: transparent !important; color: inherit !important; }',
/* Progress Bar */
'.lm-progress-bar { width: 100%; height: 4px; background: #3a3a3a; border-radius: 2px; overflow: hidden; margin: 8px 0; }',
'.lm-progress-fill { height: 100%; background: #4fc3f7; width: 0%; transition: width 0.3s ease; border-radius: 2px; }',
/* Slider */
'.lm-slider-container { display: flex; align-items: center; gap: 10px; margin-top: 6px; }',
'.lm-slider-value { background: #4fc3f7; color: #000; padding: 2px 6px;',
'border-radius: 3px; font-size: 11px; font-weight: 600; min-width: 18px; text-align: center; }',
/* Status */
'#lm-status { font-size: 11px; color: #4fc3f7; background: rgba(79, 195, 247, 0.1); padding: 8px;',
'border-radius: 4px; border: 1px solid rgba(79, 195, 247, 0.2); font-weight: 400; }',
'#lm-meta-count { font-size: 10px; color: #888; text-align: center; margin-top: 4px; }',
'#lm-credits { font-size: 9px; color: #666; text-align: center; padding: 8px 0 4px 0;',
'border-top: 1px solid #3a3a3a; margin-top: 8px; }',
/* Drag functionality */
'body.lm-dragging { cursor: move !important; user-select: none !important; }',
/* Animation */
'@keyframes slideInRight { from { opacity: 0; transform: translateX(50px); }',
'to { opacity: 1; transform: translateX(0); } }',
'#lm-window { animation: slideInRight 0.3s ease-out; }',
/* Responsive */
'@media (max-width: 768px) { #lm-window { width: calc(100vw - 40px); right: 20px; } }'
].join('\n'));
function updateStatus(message) {
var status = document.getElementById('lm-status');
if (status) status.textContent = message;
}
function updateProgress(current, total) {
var progressFill = document.querySelector('.lm-progress-fill');
var metaCount = document.getElementById('lm-meta-count');
if (progressFill) {
var percent = total > 0 ? (current / total) * 100 : 0;
progressFill.style.width = percent + '%';
}
if (metaCount) {
metaCount.textContent = 'Processing: ' + current + '/' + total;
}
}
function sanitizeFilename(filename) {
if (!filename) return 'LearnableMeta_Export';
return filename
.replace(/[<>:"/\\|?*]/g, '')
.replace(/[^\w\s\-\.]/g, '')
.replace(/\s+/g, '_')
.replace(/_{2,}/g, '_')
.replace(/^_+|_+$/g, '')
.substring(0, 100) || 'LearnableMeta_Export';
}
function downloadFile(content, filename, mimeType) {
var sanitizedFilename = sanitizeFilename(filename);
console.log('📥 Downloading:', sanitizedFilename);
try {
var blob = new Blob([content], { type: mimeType });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = sanitizedFilename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(url), 1000);
updateStatus('✅ Downloaded: ' + sanitizedFilename);
} catch (error) {
console.error('❌ Download failed:', error);
updateStatus('❌ Download failed: ' + error.message);
}
}
// NEW: Function to download image and convert to base64
function downloadImageAsBase64(url) {
return new Promise(function(resolve, reject) {
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch image: ' + response.status);
}
return response.blob();
})
.then(blob => {
var reader = new FileReader();
reader.onload = function() {
resolve(reader.result); // This is the base64 data URL
};
reader.onerror = function() {
reject(new Error('Failed to convert image to base64'));
};
reader.readAsDataURL(blob);
})
.catch(error => {
console.warn('Failed to download image:', url, error);
reject(error);
});
});
}
// NEW: Function to download image as file
function downloadImageAsFile(url, filename, folderName) {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch image: ' + response.status);
}
return response.blob();
})
.then(blob => {
// Get file extension from URL or use jpg as default
var extension = url.split('.').pop().split('?')[0] || 'jpg';
var fullFilename = (folderName ? folderName + '/' : '') + filename + '.' + extension;
// Download the file
var blobUrl = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = blobUrl;
link.download = fullFilename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
return fullFilename; // Return the filename for reference in Anki
});
}
// NEW: Function to save files to user-selected folder
function saveFilesToFolder(files, folderName) {
return new Promise(function(resolve, reject) {
console.log('Checking folder picker support...');
console.log('showDirectoryPicker available:', 'showDirectoryPicker' in window);
console.log('Files to save:', files.length);
// Check if File System Access API is supported
if ('showDirectoryPicker' in window) {
console.log('Using File System Access API...');
// Use modern folder picker
window.showDirectoryPicker({
mode: 'readwrite',
startIn: 'downloads'
}).then(function(dirHandle) {
console.log('Folder selected:', dirHandle.name);
updateStatus('📁 Creating subfolder: ' + folderName);
// Create subfolder with deck name
return dirHandle.getDirectoryHandle(folderName, { create: true });
}).then(function(subFolderHandle) {
console.log('Subfolder created:', subFolderHandle.name);
updateStatus('💾 Saving files to folder...');
// Save each file to the subfolder
var savePromises = files.map(function(file, index) {
return subFolderHandle.getFileHandle(file.filename, { create: true })
.then(function(fileHandle) {
return fileHandle.createWritable();
})
.then(function(writable) {
return writable.write(file.blob).then(function() {
return writable.close();
});
})
.then(function() {
console.log('Saved file:', file.filename);
updateStatus('💾 Saved ' + (index + 1) + '/' + files.length + ' files...');
});
});
return Promise.all(savePromises);
}).then(function() {
console.log('All files saved successfully');
resolve(true);
}).catch(function(error) {
console.error('File System Access API error:', error);
if (error.name === 'AbortError') {
reject(new Error('Folder selection was cancelled'));
} else {
console.warn('Falling back to regular downloads');
downloadFilesAsFallback(files, folderName).then(resolve).catch(reject);
}
});
} else {
console.log('File System Access API not supported, using fallback');
// Fallback: download files with folder prefix
downloadFilesAsFallback(files, folderName).then(resolve).catch(reject);
}
});
}
// CRC32 calculation for ZIP files
function calculateCRC32(data) {
var crcTable = [];
for (var i = 0; i < 256; i++) {
var crc = i;
for (var j = 0; j < 8; j++) {
crc = (crc & 1) ? (0xEDB88320 ^ (crc >>> 1)) : (crc >>> 1);
}
crcTable[i] = crc;
}
var crc = 0 ^ (-1);
for (var i = 0; i < data.length; i++) {
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xFF];
}
return (crc ^ (-1)) >>> 0;
}
// Simplified ZIP file creation with proper CRC32
function createZipFile(files, folderName) {
return new Promise(function(resolve, reject) {
try {
updateStatus('📦 Creating ZIP file...');
var zipParts = [];
var centralDirEntries = [];
var offset = 0;
// Process each file
Promise.all(files.map(function(file, index) {
return file.blob.arrayBuffer().then(function(buffer) {
var fileData = new Uint8Array(buffer);
var fileName = folderName + '/' + file.filename;
var fileNameBytes = new TextEncoder().encode(fileName);
var crc32 = calculateCRC32(fileData);
updateStatus('📦 Adding to ZIP: ' + (index + 1) + '/' + files.length);
// Local file header (30 bytes + filename)
var localHeader = new ArrayBuffer(30 + fileNameBytes.length);
var view = new DataView(localHeader);
view.setUint32(0, 0x04034b50, true); // Local file header signature
view.setUint16(4, 10, true); // Version needed to extract
view.setUint16(6, 0, true); // General purpose bit flag
view.setUint16(8, 0, true); // Compression method (stored)
view.setUint16(10, 0, true); // Last mod file time
view.setUint16(12, 0, true); // Last mod file date
view.setUint32(14, crc32, true); // CRC-32
view.setUint32(18, fileData.length, true); // Compressed size
view.setUint32(22, fileData.length, true); // Uncompressed size
view.setUint16(26, fileNameBytes.length, true); // File name length
view.setUint16(28, 0, true); // Extra field length
// Add filename to header
new Uint8Array(localHeader, 30).set(fileNameBytes);
// Store for central directory
centralDirEntries.push({
fileName: fileName,
fileNameBytes: fileNameBytes,
crc32: crc32,
size: fileData.length,
offset: offset
});
var localHeaderBytes = new Uint8Array(localHeader);
zipParts.push(localHeaderBytes);
zipParts.push(fileData);
offset += localHeaderBytes.length + fileData.length;
return { localHeaderBytes, fileData };
});
})).then(function() {
// Create central directory
var centralDirOffset = offset;
var centralDirSize = 0;
centralDirEntries.forEach(function(entry) {
var centralHeader = new ArrayBuffer(46 + entry.fileNameBytes.length);
var view = new DataView(centralHeader);
view.setUint32(0, 0x02014b50, true); // Central directory signature
view.setUint16(4, 10, true); // Version made by
view.setUint16(6, 10, true); // Version needed to extract
view.setUint16(8, 0, true); // General purpose bit flag
view.setUint16(10, 0, true); // Compression method
view.setUint16(12, 0, true); // Last mod file time
view.setUint16(14, 0, true); // Last mod file date
view.setUint32(16, entry.crc32, true); // CRC-32
view.setUint32(20, entry.size, true); // Compressed size
view.setUint32(24, entry.size, true); // Uncompressed size
view.setUint16(28, entry.fileNameBytes.length, true); // File name length
view.setUint16(30, 0, true); // Extra field length
view.setUint16(32, 0, true); // File comment length
view.setUint16(34, 0, true); // Disk number start
view.setUint16(36, 0, true); // Internal file attributes
view.setUint32(38, 0, true); // External file attributes
view.setUint32(42, entry.offset, true); // Relative offset of local header
// Add filename
new Uint8Array(centralHeader, 46).set(entry.fileNameBytes);
var centralHeaderBytes = new Uint8Array(centralHeader);
zipParts.push(centralHeaderBytes);
centralDirSize += centralHeaderBytes.length;
});
// End of central directory record
var endRecord = new ArrayBuffer(22);
var endView = new DataView(endRecord);
endView.setUint32(0, 0x06054b50, true); // End of central dir signature
endView.setUint16(4, 0, true); // Number of this disk
endView.setUint16(6, 0, true); // Number of the disk with the start of the central directory
endView.setUint16(8, files.length, true); // Total number of entries in the central directory on this disk
endView.setUint16(10, files.length, true); // Total number of entries in the central directory
endView.setUint32(12, centralDirSize, true); // Size of the central directory
endView.setUint32(16, centralDirOffset, true); // Offset of start of central directory
endView.setUint16(20, 0, true); // ZIP file comment length
zipParts.push(new Uint8Array(endRecord));
// Combine all parts into final ZIP
var totalSize = zipParts.reduce(function(sum, part) {
return sum + part.byteLength;
}, 0);
var zipArray = new Uint8Array(totalSize);
var pos = 0;
zipParts.forEach(function(part) {
zipArray.set(part, pos);
pos += part.byteLength;
});
updateStatus('📦 ZIP file created successfully');
resolve(new Blob([zipArray], { type: 'application/zip' }));
});
} catch (error) {
console.error('ZIP creation error:', error);
reject(error);
}
});
}
// Fallback function for browsers without folder picker
function downloadFilesAsFallback(files, folderName) {
return new Promise(function(resolve, reject) {
updateStatus('📦 Creating ZIP file as fallback...');
createZipFile(files, folderName)
.then(function(zipBlob) {
// Download the ZIP file
var zipUrl = URL.createObjectURL(zipBlob);
var link = document.createElement('a');
link.href = zipUrl;
link.download = folderName + '.zip';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(zipUrl), 1000);
updateStatus('📦 ZIP file downloaded successfully');
resolve(false); // Indicate fallback was used
})
.catch(function(error) {
console.error('ZIP creation failed:', error);
// Final fallback: individual file downloads
files.forEach(function(file, index) {
setTimeout(function() {
var link = document.createElement('a');
link.href = file.url;
link.download = folderName + '_' + file.filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, index * 150);
});
resolve(false);
});
});
}
// NEW: Function to convert image to PNG and return blob
function convertImageToPNG(url, filename) {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch image: ' + response.status);
}
return response.blob();
})
.then(blob => {
return new Promise(function(resolve, reject) {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
// Create canvas to convert to PNG
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// Draw image on canvas preserving transparency
ctx.drawImage(img, 0, 0);
// Convert to PNG blob
canvas.toBlob(function(pngBlob) {
resolve({
blob: pngBlob,
filename: filename + '.png'
});
}, 'image/png', 0.95);
};
img.onerror = function() {
reject(new Error('Failed to load image for PNG conversion'));
};
// Load image from blob
var imageUrl = URL.createObjectURL(blob);
img.src = imageUrl;
setTimeout(() => URL.revokeObjectURL(imageUrl), 5000);
});
});
}
// NEW: Function to convert image to PNG and return file info (no download)
function convertImageToPNGFile(url, filename) {
return convertImageToPNG(url, filename)
.then(function(result) {
return {
blob: result.blob,
filename: result.filename,
url: URL.createObjectURL(result.blob)
};
});
}
// NEW: Function to convert image to PNG and download (legacy)
function downloadImageAsPNG(url, filename, folderName) {
return convertImageToPNG(url, filename)
.then(function(result) {
var fullFilename = (folderName ? folderName + '/' : '') + result.filename;
// Download the PNG file
var blobUrl = URL.createObjectURL(result.blob);
var link = document.createElement('a');
link.href = blobUrl;
link.download = fullFilename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
return fullFilename;
});
}
// NEW: Function to download meta description as text file
function downloadDescriptionAsText(title, description, folderName) {
var content = description;
var filename = (folderName ? folderName + '/' : '') + sanitizeFilename(title) + '_description.txt';
var blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(url), 1000);
return filename;
}
function waitForTable() {
return new Promise(function(resolve) {
var attempts = 0;
var maxAttempts = 20; // 10 seconds max
function checkTable() {
attempts++;
var table = document.querySelector('table');
if (table && table.querySelectorAll('td').length > 0) {
resolve({
cells: table.querySelectorAll('td'),
hasCheckboxes: document.querySelectorAll('input[type="checkbox"]').length > 0,
checkboxes: document.querySelectorAll('input[type="checkbox"]')
});
} else if (attempts >= maxAttempts) {
throw new Error('Table not found after ' + maxAttempts + ' attempts');
} else {
setTimeout(checkTable, 500);
}
}
checkTable();
});
}
function isMetaSelected(metaName, checkboxes) {
if (!checkboxes || checkboxes.length === 0) return true;
for (var i = 0; i < checkboxes.length; i++) {
var parent = checkboxes[i].closest('tr, div, li') || checkboxes[i].parentElement;
if (parent && parent.textContent.includes(metaName)) {
return checkboxes[i].checked;
}
}
return true;
}
function countSelectedMetas(tableCells, checkboxes) {
var selectedCount = 0, totalCount = 0;
for (var i = 0; i < tableCells.length; i++) {
var metaName = tableCells[i].textContent.trim();
if (metaName) {
totalCount++;
if (isMetaSelected(metaName, checkboxes)) selectedCount++;
}
}
return { selected: selectedCount, total: totalCount };
}
function findContentDiv(metaName) {
var divs = document.querySelectorAll('div');
for (var i = 0; i < divs.length; i++) {
if (divs[i].textContent.includes(metaName) &&
(divs[i].querySelector('img') || divs[i].querySelector('p'))) {
return divs[i];
}
}
return null;
}
function extractImages(container, maxImages) {
var imgs = container.querySelectorAll('img');
var result = [];
var seenUrls = {}; // Track URLs we've already added
for (var i = 0; i < imgs.length && result.length < maxImages; i++) {
var src = imgs[i].src;
if (!src || src.indexOf('http') !== 0) continue;
if (src.includes('logo') || src.includes('icon') || src.includes('nav') ||
src.includes('menu') || src.includes('header') || src.includes('_app/')) continue;
if ((imgs[i].width > 0 && imgs[i].width < 50) ||
(imgs[i].height > 0 && imgs[i].height < 50)) continue;
// Only add if we haven't seen this URL before
if (!seenUrls[src]) {
seenUrls[src] = true;
result.push(src);
}
}
return result;
}
function extractDescription(container) {
var elements = container.querySelectorAll('p, li, div, span');
var bestDescription = '';
var bestScore = 0;
for (var i = 0; i < elements.length; i++) {
var text = elements[i].textContent.trim();
if (text.length < 20 || text.length > 1000) continue;
var lower = text.toLowerCase();
if (lower.includes('meta list') || lower.includes('home') ||
lower.includes('plonkit.net') || lower.includes('www.') ||
text === 'Play' || text === 'Maps') continue;
var score = 0;
if (text.includes('.') || text.includes('!')) score += 10;
if (lower.includes('note:')) score += 20;
if (text.length > 50) score += text.length / 10;
if (lower.includes('used') || lower.includes('typically') ||
lower.includes('common') || lower.includes('found')) score += 5;
if (score > bestScore) {
bestScore = score;
bestDescription = text;
}
}
return bestDescription.replace(/Meta List[^.]*\./gi, '')
.replace(/Play\s*/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
function cleanDescription(description) {
if (!description) return '';
// Stop at footer patterns instead of removing them
var footerPatterns = [
/more\s+Infos?:/gi,
/Images?\s*\(\d+\)/gi,
/Google\s+Docs?/gi,
/AtomoMC/gi,
/Plonk\s+it/gi
];
var cleaned = description;
// Find the earliest footer pattern and cut off there
var earliestIndex = cleaned.length;
for (var i = 0; i < footerPatterns.length; i++) {
var match = cleaned.search(footerPatterns[i]);
if (match !== -1 && match < earliestIndex) {
earliestIndex = match;
}
}
// Cut off at the footer
if (earliestIndex < cleaned.length) {
cleaned = cleaned.substring(0, earliestIndex);
}
// Basic cleanup
cleaned = cleaned.replace(/",LearnableMeta/g, '')
.replace(/,LearnableMeta/g, '')
.replace(/LearnableMeta$/g, '')
.replace(/\s+/g, ' ')
.trim();
// Remove trailing punctuation if it's just hanging there
cleaned = cleaned.replace(/[,;:\s]+$/, '');
return cleaned;
}
function cleanMetaTitle(title) {
return title ? title.replace(/\s*\(\d+\)\s*$/, '').trim() : '';
}
function setButtonsEnabled(enabled) {
var buttons = document.querySelectorAll('.lm-button');
for (var i = 0; i < buttons.length; i++) {
buttons[i].disabled = !enabled;
}
}
// MODIFIED: Enhanced scraping with offline image support
function scrapeMetas(maxImages, downloadMode, deckName) {
updateStatus('⏳ Waiting for meta table...');
setButtonsEnabled(false);
return waitForTable().then(function(tableData) {
var tableCells = tableData.cells;
var checkboxes = tableData.hasCheckboxes ? Array.from(tableData.checkboxes) : null;
var metas = [], processedCount = 0;
var counts = countSelectedMetas(tableCells, checkboxes);
updateStatus(checkboxes ?
'🔍 Processing ' + counts.selected + '/' + counts.total + ' selected metas...' :
'🔍 Processing all ' + counts.total + ' metas...');
function processNextCell(index) {
if (index >= tableCells.length) {
updateStatus('✅ Found ' + metas.length + ' metas with content');
setButtonsEnabled(true);
return Promise.resolve(metas);
}
var cell = tableCells[index];
var metaName = cell.textContent.trim();
if (!metaName || !isMetaSelected(metaName, checkboxes)) {
return processNextCell(index + 1);
}
processedCount++;
updateProgress(processedCount, counts.selected || counts.total);
updateStatus('📝 Processing (' + processedCount + '): ' + metaName);
cell.scrollIntoView({ behavior: 'smooth', block: 'center' });
cell.click();
return sleep(800).then(function() {
try {
var contentDiv = findContentDiv(metaName);
if (contentDiv) {
var imageUrls = extractImages(contentDiv, maxImages);
var description = cleanDescription(extractDescription(contentDiv));
var cleanTitle = cleanMetaTitle(metaName);
if (imageUrls.length > 0 || description) {
var metaData = {
title: cleanTitle,
imageUrls: imageUrls,
description: description || cleanTitle,
images: [] // Will be populated with processed images
};
// Process images based on download mode
if (downloadMode === 'base64' && imageUrls.length > 0) {
updateStatus('🖼️ Converting images to base64: ' + cleanTitle);
return Promise.all(imageUrls.map(downloadImageAsBase64))
.then(function(base64Images) {
metaData.images = base64Images.filter(img => img);
if (metaData.images.length > 0 || description) {
metas.push(metaData);
}
return processNextCell(index + 1);
})
.catch(function(error) {
console.warn('Failed to convert images for:', cleanTitle, error);
metas.push(metaData); // Add without images
return processNextCell(index + 1);
});
} else if (downloadMode === 'files' && imageUrls.length > 0) {
updateStatus('📁 Downloading image files: ' + cleanTitle);
var folderName = sanitizeFilename(deckName || 'LearnableMeta');
var promises = imageUrls.map(function(url, imgIndex) {
var filename = sanitizeFilename(cleanTitle) + '_' + imgIndex;
return downloadImageAsFile(url, filename, folderName)
.catch(function(error) {
console.warn('Failed to download image:', url, error);
return null;
});
});
return Promise.all(promises)
.then(function(filenames) {
metaData.imageFiles = filenames.filter(f => f);
if (metaData.imageFiles.length > 0 || description) {
metas.push(metaData);
}
return processNextCell(index + 1);
});
} else if (downloadMode === 'png') {
// PNG mode - download images as PNG and description as TXT
var folderName = sanitizeFilename(deckName || 'LearnableMeta');
var promises = [];
// Download images if any
if (imageUrls.length > 0) {
updateStatus('🖼️ Converting to PNG: ' + cleanTitle);
promises = imageUrls.map(function(url, imgIndex) {
var filename = sanitizeFilename(cleanTitle) + '_' + imgIndex;
return downloadImageAsPNG(url, filename, folderName)
.catch(function(error) {
console.warn('Failed to convert image to PNG:', url, error);
return null;
});
});
}
return Promise.all(promises)
.then(function(filenames) {
metaData.imageFiles = filenames.filter(f => f);
// Download description as text file
if (description) {
updateStatus('📝 Downloading description: ' + cleanTitle);
try {
var descFile = downloadDescriptionAsText(cleanTitle, description, folderName);
metaData.descriptionFile = descFile;
} catch (error) {
console.warn('Failed to download description:', error);
}
}
if (metaData.imageFiles.length > 0 || description) {
metas.push(metaData);
}
return processNextCell(index + 1);
});
} else {
// Online mode - just use URLs
metaData.images = imageUrls;
metas.push(metaData);
return processNextCell(index + 1);
}
}
}
} catch (error) {
console.warn('⚠️ Error processing meta:', metaName, error);
}
return processNextCell(index + 1);
});
}
return processNextCell(0);
}).catch(function(error) {
setButtonsEnabled(true);
throw error;
});
}
// MODIFIED: Enhanced export with offline image support
function createPerfectTxtExport(deckName, metas, downloadMode) {
updateStatus('📝 Creating production-ready Anki file...');
var timestamp = new Date().toLocaleString();
var instructions = [
'# 🎯 PRODUCTION ANKI IMPORT FILE (' + downloadMode.toUpperCase() + ' IMAGES)',
'# ===============================================',
'# Deck: ' + deckName,
'# Cards: ' + metas.length,
'# Created: ' + timestamp,
'# Format: Premium styled cards with responsive design',
'# Images: ' + (downloadMode === 'base64' ? 'Embedded offline base64' :
downloadMode === 'files' ? 'Downloaded files (import separately)' : 'Online URLs'),
'# Quality: Production ready with error handling',
'#',
'# 📥 IMPORT INSTRUCTIONS:',
'# 1. Open Anki Desktop',
downloadMode === 'files' ? '# 2. Import downloaded image files to your media folder first' : '',
'# ' + (downloadMode === 'files' ? '3' : '2') + '. File → Import',
'# ' + (downloadMode === 'files' ? '4' : '3') + '. Select this TXT file',
'# ' + (downloadMode === 'files' ? '5' : '4') + '. Import Settings:',
'# • Type: "Text separated by tabs or semicolons"',
'# • Field separator: Tab',
'# • Field 1 → Front',
'# • Field 2 → Back',
'# • Field 3 → Tags',
'# • ✅ Allow HTML in fields',
'# • Deck: "' + deckName + '"',
'# ' + (downloadMode === 'files' ? '6' : '5') + '. Click Import',
'#',
'# 🎨 CARD DESIGN:',
'# • Mobile-responsive layout',
'# • High-quality image display',
'# • Professional typography',
'# • Optimized for learning',
downloadMode === 'base64' ? '# • Fully offline capable' : '',
'#'
].filter(line => line).join('\n') + '\n';
var csvContent = instructions + 'Front\tBack\tTags\n';
for (var i = 0; i < metas.length; i++) {
var meta = metas[i];
var front = '';
var hasImages = false;
if (downloadMode === 'base64' && meta.images && meta.images.length > 0) {
// Use base64 embedded images
hasImages = true;
var imageStyles = 'max-width: 100%; max-height: 400px; width: auto; height: auto; display: block; margin: 15px auto; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); object-fit: contain;';
var imageHtml = meta.images.map(function(base64Data) {
return '<img src="' + base64Data + '" alt="' + meta.title + '" style="' + imageStyles + '" loading="lazy">';
}).join('');
front = '<div style="text-align: center; padding: 25px; background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); border-radius: 16px; margin: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.08); min-height: 200px; display: flex; flex-direction: column; justify-content: center;">' + imageHtml + '</div>';
} else if ((downloadMode === 'files' || downloadMode === 'png') && meta.imageFiles && meta.imageFiles.length > 0) {
// Use downloaded file references (PNG or original format)
hasImages = true;
var imageStyles = 'max-width: 100%; max-height: 400px; width: auto; height: auto; display: block; margin: 15px auto; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); object-fit: contain;';
var imageHtml = meta.imageFiles.map(function(filename) {
return '<img src="' + filename + '" alt="' + meta.title + '" style="' + imageStyles + '" loading="lazy">';
}).join('');
front = '<div style="text-align: center; padding: 25px; background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); border-radius: 16px; margin: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.08); min-height: 200px; display: flex; flex-direction: column; justify-content: center;">' + imageHtml + '</div>';
} else if (downloadMode === 'online' && meta.imageUrls && meta.imageUrls.length > 0) {
// Use online URLs (original behavior)
hasImages = true;
var imageStyles = 'max-width: 100%; max-height: 400px; width: auto; height: auto; display: block; margin: 15px auto; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); object-fit: contain;';
var imageHtml = meta.imageUrls.map(function(url) {
return '<img src="' + url + '" alt="' + meta.title + '" style="' + imageStyles + '" loading="lazy">';
}).join('');
front = '<div style="text-align: center; padding: 25px; background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); border-radius: 16px; margin: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.08); min-height: 200px; display: flex; flex-direction: column; justify-content: center;">' + imageHtml + '</div>';
}
if (!hasImages) {
front = '<div style="text-align: center; padding: 60px 20px; background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); border-radius: 16px; margin: 12px; color: #1e40af; font-size: 48px; min-height: 200px; display: flex; align-items: center; justify-content: center;">🗺️<div style="font-size: 16px; margin-top: 10px; color: #64748b;">No image available</div></div>';
}
var back = '<div style="font-family: Inter, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; max-width: 700px; margin: 0 auto; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 20px 40px rgba(0,0,0,0.1); border: 1px solid #e5e7eb;"><div style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); color: white; padding: 32px 24px; text-align: center;"><h1 style="margin: 0; font-size: 28px; font-weight: 700; text-shadow: 0 2px 4px rgba(0,0,0,0.2); line-height: 1.2;">' + meta.title + '</h1></div><div style="padding: 32px 24px; line-height: 1.7; color: #374151;"><div style="background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); border-left: 4px solid #3b82f6; padding: 24px; border-radius: 0 12px 12px 0; font-size: 16px; box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); margin-bottom: 16px;">' + meta.description + '</div><div style="font-size: 12px; color: #9ca3af; text-align: center; padding-top: 16px; border-top: 1px solid #e5e7eb;">LearnableMeta Export (' + downloadMode + ')</div></div></div>';
var tags = 'LearnableMeta ' + deckName.replace(/\s+/g, '_') + ' geography visual_learning ' + downloadMode + '_images';
csvContent += front + '\t' + back + '\t' + tags + '\n';
}
var filename = sanitizeFilename(deckName) + '_' + downloadMode + '.txt';
downloadFile(csvContent, filename, 'text/plain;charset=utf-8');
}
function checkSelection() {
updateStatus('🔍 Analyzing selection...');
setButtonsEnabled(false);
waitForTable().then(function(tableData) {
var counts = countSelectedMetas(tableData.cells, tableData.checkboxes);
setButtonsEnabled(true);
if (tableData.hasCheckboxes) {
updateStatus('📊 Selection: ' + counts.selected + ' of ' + counts.total + ' metas');
alert('Selection Status:\n\n' +
'✅ Selected: ' + counts.selected + ' metas\n' +
'📊 Total available: ' + counts.total + ' metas\n\n' +
(counts.selected === 0 ?
'⚠️ Please select some metas to export!' :
'🚀 Ready to export ' + counts.selected + ' selected metas!'));
} else {
updateStatus('📊 Will process all ' + counts.total + ' metas');
alert('Export Status:\n\n' +
'📊 Found: ' + counts.total + ' metas\n' +
'🚀 Will process all metas when exported\n\n' +
'No selection controls detected.');
}
}).catch(function(error) {
setButtonsEnabled(true);
updateStatus('❌ Error: ' + error.message);
alert('Error checking selection:\n' + error.message);
});
}
// MODIFIED: Export functions for different modes
function exportWithBase64() {
var deckName = document.getElementById('lm-deck').value.trim() || 'LearnableMeta';
var maxImages = parseInt(document.getElementById('lm-range').value) || 2;
updateStatus('🚀 Starting export with embedded images...');
scrapeMetas(maxImages, 'base64', deckName).then(function(metas) {
if (metas.length === 0) {
updateStatus('⚠️ No content found to export');
alert('Export Failed:\n\nNo metas with content were found.\n\nTips:\n• Make sure metas are loaded\n• Check if any metas are selected\n• Verify the page has content');
return;
}
updateStatus('📦 Creating download file with embedded images...');
createPerfectTxtExport(deckName, metas, 'base64');
setTimeout(function() {
updateStatus('🎉 Export completed successfully!');
}, 1000);
}).catch(function(error) {
console.error('💥 Export failed:', error);
updateStatus('❌ Export failed: ' + error.message);
alert('Export Error:\n\n' + error.message + '\n\nPlease try again or check the console for details.');
});
}
function exportWithFiles() {
var deckName = document.getElementById('lm-deck').value.trim() || 'LearnableMeta';
var maxImages = parseInt(document.getElementById('lm-range').value) || 2;
updateStatus('🚀 Starting export with separate image files...');
scrapeMetas(maxImages, 'files', deckName).then(function(metas) {
if (metas.length === 0) {
updateStatus('⚠️ No content found to export');
alert('Export Failed:\n\nNo metas with content were found.\n\nTips:\n• Make sure metas are loaded\n• Check if any metas are selected\n• Verify the page has content');
return;
}
updateStatus('📦 Creating Anki file with image references...');
createPerfectTxtExport(deckName, metas, 'files');
setTimeout(function() {
updateStatus('🎉 Export completed! Images downloaded separately.');
alert('Export Complete!\n\nThe Anki file has been created and images have been downloaded as separate files.\n\nTo import:\n1. Copy image files to your Anki media folder\n2. Import the TXT file into Anki');
}, 1000);
}).catch(function(error) {
console.error('💥 Export failed:', error);
updateStatus('❌ Export failed: ' + error.message);
alert('Export Error:\n\n' + error.message + '\n\nPlease try again or check the console for details.');
});
}
// NEW: Scrape metas and collect all files for raw export
function scrapeMetasForRawFiles(maxImages, deckName) {
updateStatus('⏳ Waiting for meta table...');
setButtonsEnabled(false);
var allFiles = []; // Collect all files here
return waitForTable().then(function(tableData) {
var tableCells = tableData.cells;
var checkboxes = tableData.hasCheckboxes ? Array.from(tableData.checkboxes) : null;
var metas = [], processedCount = 0;
var counts = countSelectedMetas(tableCells, checkboxes);
updateStatus(checkboxes ?
'🔍 Processing ' + counts.selected + '/' + counts.total + ' selected metas...' :
'🔍 Processing all ' + counts.total + ' metas...');
function processNextCell(index) {
if (index >= tableCells.length) {
updateStatus('✅ Found ' + metas.length + ' metas with content');
setButtonsEnabled(true);
return Promise.resolve({ metas: metas, allFiles: allFiles });
}
var cell = tableCells[index];
var metaName = cell.textContent.trim();
if (!metaName || !isMetaSelected(metaName, checkboxes)) {
return processNextCell(index + 1);
}
processedCount++;
updateProgress(processedCount, counts.selected || counts.total);
updateStatus('📝 Processing (' + processedCount + '): ' + metaName);
cell.scrollIntoView({ behavior: 'smooth', block: 'center' });
cell.click();
return sleep(800).then(function() {
try {
var contentDiv = findContentDiv(metaName);
if (contentDiv) {
var imageUrls = extractImages(contentDiv, maxImages);
var description = cleanDescription(extractDescription(contentDiv));
var cleanTitle = cleanMetaTitle(metaName);
if (imageUrls.length > 0 || description) {
var metaData = {
title: cleanTitle,
imageUrls: imageUrls,
description: description || cleanTitle,
imageFiles: []
};
var promises = [];
// Convert images to PNG files
if (imageUrls.length > 0) {
updateStatus('🖼️ Converting to PNG: ' + cleanTitle);
promises = imageUrls.map(function(url, imgIndex) {
var filename = sanitizeFilename(cleanTitle) + '_' + imgIndex;
return convertImageToPNGFile(url, filename)
.then(function(fileInfo) {
allFiles.push(fileInfo);
return fileInfo.filename;
})
.catch(function(error) {
console.warn('Failed to convert image to PNG:', url, error);
return null;
});
});
}
return Promise.all(promises)
.then(function(filenames) {
metaData.imageFiles = filenames.filter(f => f);
// Create description file
if (description) {
updateStatus('📝 Creating description file: ' + cleanTitle);
var descContent = description;
var descFilename = sanitizeFilename(cleanTitle) + '_description.txt';
var descBlob = new Blob([descContent], { type: 'text/plain;charset=utf-8' });
allFiles.push({
blob: descBlob,
filename: descFilename,
url: URL.createObjectURL(descBlob)
});
metaData.descriptionFile = descFilename;
}
if (metaData.imageFiles.length > 0 || description) {
metas.push(metaData);
}
return processNextCell(index + 1);
});
}
}
} catch (error) {
console.warn('⚠️ Error processing meta:', metaName, error);
}
return processNextCell(index + 1);
});
}
return processNextCell(0);
}).catch(function(error) {
setButtonsEnabled(true);
throw error;
});
}
function exportWithPNG() {
var deckName = document.getElementById('lm-deck').value.trim() || 'LearnableMeta';
var maxImages = parseInt(document.getElementById('lm-range').value) || 2;
updateStatus('🚀 Starting export with PNG conversion...');
scrapeMetasForRawFiles(maxImages, deckName).then(function(result) {
if (result.metas.length === 0) {
updateStatus('⚠️ No content found to export');
alert('Export Failed:\n\nNo metas with content were found.\n\nTips:\n• Make sure metas are loaded\n• Check if any metas are selected\n• Verify the page has content');
return;
}
updateStatus('📦 Creating Anki file and organizing raw files...');
// Create the Anki TXT file
createPerfectTxtExport(deckName, result.metas, 'png');
// Save all raw files to user-selected folder
if (result.allFiles.length > 0) {
updateStatus('📁 Choose folder to save raw files...');
saveFilesToFolder(result.allFiles, sanitizeFilename(deckName))
.then(function(usedFolderPicker) {
if (usedFolderPicker) {
updateStatus('🎉 Export completed! Raw files saved to selected folder.');
alert('Raw Files Export Complete!\n\n✅ Images converted to PNG format\n✅ Descriptions saved as TXT files\n✅ All files saved to "' + deckName + '" folder in your chosen directory\n\nTo import:\n1. Copy files from the saved folder to your Anki media folder\n2. Import the TXT file into Anki');
} else {
updateStatus('🎉 Export completed! Raw files packaged in ZIP.');
alert('Raw Files Export Complete!\n\n✅ Images converted to PNG format\n✅ Descriptions saved as TXT files\n✅ All files packaged in "' + deckName + '.zip"\n\nNote: Your browser doesn\'t support folder picker, so files were packaged in a ZIP file.\n\nTo import:\n1. Extract the ZIP file to see the organized folder\n2. Copy files from the extracted folder to your Anki media folder\n3. Import the TXT file into Anki');
}
})
.catch(function(error) {
if (error.message.includes('cancelled')) {
updateStatus('❌ Export cancelled - folder not selected.');
alert('Export cancelled: No folder was selected for saving raw files.');
} else {
updateStatus('❌ Failed to save raw files: ' + error.message);
alert('Failed to save raw files:\n\n' + error.message + '\n\nPlease try again or check browser permissions.');
}
});
} else {
setTimeout(function() {
updateStatus('🎉 Export completed! No raw files to save.');
alert('Export Complete!\n\nAnki TXT file created successfully.');
}, 1000);
}
}).catch(function(error) {
console.error('💥 Export failed:', error);
updateStatus('❌ Export failed: ' + error.message);
alert('Export Error:\n\n' + error.message + '\n\nPlease try again or check the console for details.');
});
}
function testFolderPicker() {
updateStatus('🧪 Testing folder picker capability...');
if ('showDirectoryPicker' in window) {
updateStatus('📁 Please select a folder to test...');
window.showDirectoryPicker({
mode: 'readwrite',
startIn: 'downloads'
}).then(function(dirHandle) {
updateStatus('✅ Folder picker works! Selected: ' + dirHandle.name);
alert('✅ Folder Picker Test Successful!\n\nSelected folder: ' + dirHandle.name + '\n\nYour browser supports folder selection for raw file exports.');
}).catch(function(error) {
if (error.name === 'AbortError') {
updateStatus('❌ Test cancelled - no folder selected.');
alert('Test cancelled: No folder was selected.');
} else {
updateStatus('❌ Folder picker failed: ' + error.message);
alert('❌ Folder Picker Test Failed!\n\nError: ' + error.message + '\n\nYour browser may not support folder selection or permissions were denied.');
}
});
} else {
updateStatus('❌ Folder picker not supported by browser.');
alert('❌ Folder Picker Not Supported!\n\nYour browser doesn\'t support folder selection.\n\nRaw file exports will use fallback mode (files with prefixes).\n\nSupported browsers: Chrome, Edge (latest versions)');
}
}
function exportOnline() {
var deckName = document.getElementById('lm-deck').value.trim() || 'LearnableMeta';
var maxImages = parseInt(document.getElementById('lm-range').value) || 2;
updateStatus('🚀 Starting export with online images...');
scrapeMetas(maxImages, 'online', deckName).then(function(metas) {
if (metas.length === 0) {
updateStatus('⚠️ No content found to export');
alert('Export Failed:\n\nNo metas with content were found.\n\nTips:\n• Make sure metas are loaded\n• Check if any metas are selected\n• Verify the page has content');
return;
}
updateStatus('📦 Creating download file...');
createPerfectTxtExport(deckName, metas, 'online');
setTimeout(function() {
updateStatus('🎉 Export completed successfully!');
}, 1000);
}).catch(function(error) {
console.error('💥 Export failed:', error);
updateStatus('❌ Export failed: ' + error.message);
alert('Export Error:\n\n' + error.message + '\n\nPlease try again or check the console for details.');
});
}
function createPanel() {
var mapTitle = (document.querySelector('h1, h2, title') || {}).textContent || 'LearnableMeta';
// Clean up the map title
mapTitle = mapTitle.replace(/LearnableMeta\s*[-|]\s*/gi, '').trim();
var window = document.createElement('div');
window.id = 'lm-window';
var showBtn = document.createElement('button');
showBtn.id = 'lm-show-btn';
showBtn.innerHTML = 'A';
showBtn.title = 'Show Anki Exporter';
window.innerHTML = [
'<div id="lm-header">',
'<div id="lm-title">Anki Exporter</div>',
'<button id="lm-hide-btn" title="Hide Window">×</button>',
'</div>',
'<div id="lm-content">',
'<div class="lm-section">',
'<label>Deck Name</label>',
'<input id="lm-deck" type="text" value="' + sanitizeFilename(mapTitle) + '" placeholder="Enter deck name">',
'</div>',
'<div class="lm-section">',
'<label>Images per Card</label>',
'<div class="lm-slider-container">',
'<input id="lm-range" type="range" min="0" max="5" value="2">',
'<span id="lm-slider-value" class="lm-slider-value">2</span>',
'</div></div>',
'<div class="lm-section">',
'<button id="lm-export-png" class="lm-button primary">Export Raw Files ZIP</button>',
'<button id="lm-export-online" class="lm-button secondary">Export to Anki</button>',
'<button id="lm-check-selection" class="lm-button warning">Check Selection</button>',
'<div class="lm-progress-bar"><div class="lm-progress-fill"></div></div>',
'<div id="lm-meta-count"></div>',
'</div>',
'<div id="lm-status">Ready to export LearnableMeta content</div>',
'<div id="lm-credits">Made by BennoGHG</div>',
'</div>'
].join('');
document.body.appendChild(window);
document.body.appendChild(showBtn);
// FIXED DRAG FUNCTIONALITY
var isDragging = false;
var dragOffset = { x: 0, y: 0 };
var header = document.getElementById('lm-header');
header.addEventListener('mousedown', startDrag);
function startDrag(e) {
if (e.target.id === 'lm-hide-btn') return;
isDragging = true;
var rect = window.getBoundingClientRect();
dragOffset.x = e.clientX - rect.left;
dragOffset.y = e.clientY - rect.top;
window.classList.add('dragging');
document.body.classList.add('lm-dragging');
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
}
function onDrag(e) {
if (!isDragging) return;
var newX = e.clientX - dragOffset.x;
var newY = e.clientY - dragOffset.y;
var maxX = document.documentElement.clientWidth - window.offsetWidth;
var maxY = document.documentElement.clientHeight - window.offsetHeight;
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
window.style.left = newX + 'px';
window.style.top = newY + 'px';
}
function stopDrag() {
isDragging = false;
window.classList.remove('dragging');
document.body.classList.remove('lm-dragging');
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
}
// FIXED RESIZE FUNCTIONALITY
var isResizing = false;
var resizeType = '';
var resizeStart = { x: 0, y: 0, width: 0, height: 0 };
var resizeSE = window.querySelector('.lm-resize-se');
var resizeS = window.querySelector('.lm-resize-s');
var resizeE = window.querySelector('.lm-resize-e');
if (resizeSE) resizeSE.addEventListener('mousedown', function(e) { startResize(e, 'se'); });
if (resizeS) resizeS.addEventListener('mousedown', function(e) { startResize(e, 's'); });
if (resizeE) resizeE.addEventListener('mousedown', function(e) { startResize(e, 'e'); });
function startResize(e, type) {
isResizing = true;
resizeType = type;
var rect = window.getBoundingClientRect();
resizeStart.x = e.clientX;
resizeStart.y = e.clientY;
resizeStart.width = rect.width;
resizeStart.height = rect.height;
window.classList.add('resizing');
document.body.classList.add('lm-resizing');
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', stopResize);
e.preventDefault();
e.stopPropagation();
}
function onResize(e) {
if (!isResizing) return;
var deltaX = e.clientX - resizeStart.x;
var deltaY = e.clientY - resizeStart.y;
var newWidth = resizeStart.width;
var newHeight = resizeStart.height;
if (resizeType.includes('e')) {
newWidth = Math.max(320, Math.min(600, resizeStart.width + deltaX));
}
if (resizeType.includes('s')) {
newHeight = Math.max(450, Math.min(window.innerHeight * 0.9, resizeStart.height + deltaY));
}
window.style.width = newWidth + 'px';
window.style.height = newHeight + 'px';
}
function stopResize() {
isResizing = false;
window.classList.remove('resizing');
document.body.classList.remove('lm-resizing');
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', stopResize);
}
// Hide/Show functionality
var hideBtn = document.getElementById('lm-hide-btn');
var isHidden = false;
hideBtn.addEventListener('click', function() {
if (!isHidden) {
window.classList.add('hidden');
showBtn.style.display = 'flex';
isHidden = true;
}
});
showBtn.addEventListener('click', function() {
if (isHidden) {
window.classList.remove('hidden');
showBtn.style.display = 'none';
isHidden = false;
}
});
// Event listeners for NEW export modes
document.getElementById('lm-range').addEventListener('input', function(e) {
document.getElementById('lm-slider-value').textContent = e.target.value;
});
document.getElementById('lm-export-png').addEventListener('click', exportWithPNG);
document.getElementById('lm-export-online').addEventListener('click', exportOnline);
document.getElementById('lm-check-selection').addEventListener('click', checkSelection);
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'h' && !isHidden) {
e.preventDefault();
hideBtn.click();
} else if (e.key === 'e' && !isHidden) {
e.preventDefault();
exportWithPNG(); // Default to PNG export
}
}
});
}
// Initialize with error handling
setTimeout(function() {
try {
createPanel();
updateStatus('Ready to export LearnableMeta content');
console.log('LearnableMeta Anki Exporter v1.2 loaded successfully');
} catch (error) {
console.error('❌ Failed to initialize Anki Exporter:', error);
}
}, 1000);
})();