Add ChatGPT conversation Markdown export
This commit is contained in:
@@ -201,9 +201,132 @@
|
||||
sendResponse({ items: harvestLinks(request.containerSelector) });
|
||||
} else if (request.action === 'scrapeData') {
|
||||
sendResponse({ items: scrapeData(request.selector, request.attribute) });
|
||||
} else if (request.action === 'exportConversationMarkdown') {
|
||||
exportConversationMarkdown().then(sendResponse).catch((error) => {
|
||||
sendResponse({ error: error.message || 'Could not export the conversation.' });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function exportConversationMarkdown() {
|
||||
// ponytail: read the message nodes already loaded by ChatGPT; add virtual-scroll
|
||||
// pagination only if ChatGPT starts unloading older messages from the DOM.
|
||||
const messageElements = [...document.querySelectorAll('[data-message-author-role]')]
|
||||
.filter((el) => ['user', 'assistant', 'system'].includes(el.dataset.messageAuthorRole));
|
||||
if (!messageElements.length) throw new Error('No ChatGPT messages were found on this page.');
|
||||
|
||||
const imagesByElement = new Map();
|
||||
const imagesByUrl = new Map();
|
||||
let imageNumber = 0;
|
||||
|
||||
for (const message of messageElements) {
|
||||
for (const image of message.querySelectorAll('img')) {
|
||||
const url = image.currentSrc || image.src;
|
||||
if (!url) continue;
|
||||
let imageFile = imagesByUrl.get(url);
|
||||
if (!imageFile) {
|
||||
imageFile = await readConversationImage(url, ++imageNumber);
|
||||
imagesByUrl.set(url, imageFile);
|
||||
}
|
||||
imagesByElement.set(image, imageFile);
|
||||
}
|
||||
}
|
||||
|
||||
const title = (document.querySelector('main h1')?.textContent || document.title || 'ChatGPT Conversation')
|
||||
.replace(/\s*\|\s*ChatGPT.*$/i, '').trim() || 'ChatGPT Conversation';
|
||||
const markdown = [`# ${title.replace(/^#+\s*/, '').replace(/\n/g, ' ')}`, ''];
|
||||
|
||||
messageElements.forEach((message) => {
|
||||
const role = message.dataset.messageAuthorRole === 'assistant' ? 'ChatGPT' :
|
||||
message.dataset.messageAuthorRole[0].toUpperCase() + message.dataset.messageAuthorRole.slice(1);
|
||||
const body = cleanConversationMarkdown(nodeToConversationMarkdown(message, imagesByElement));
|
||||
if (body) markdown.push(`## ${role}`, '', body, '');
|
||||
});
|
||||
|
||||
return { markdown: `${markdown.join('\n').trim()}\n`, images: [...imagesByUrl.values()] };
|
||||
}
|
||||
|
||||
async function readConversationImage(url, number) {
|
||||
const fallbackName = `image-${String(number).padStart(3, '0')}.${imageExtension(url)}`;
|
||||
try {
|
||||
let bytes;
|
||||
let mimeType = '';
|
||||
if (url.startsWith('data:')) {
|
||||
const match = url.match(/^data:([^,]*),(.*)$/s);
|
||||
if (!match) throw new Error('Invalid data URL');
|
||||
mimeType = (match[1].split(';')[0] || '').toLowerCase();
|
||||
bytes = match[1].includes(';base64')
|
||||
? base64ToBytes(match[2])
|
||||
: new TextEncoder().encode(decodeURIComponent(match[2]));
|
||||
} else {
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
mimeType = response.headers.get('content-type') || '';
|
||||
bytes = new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
const name = `image-${String(number).padStart(3, '0')}.${imageExtension(url, mimeType)}`;
|
||||
return { name, url, mimeType, data: bytesToBase64(bytes) };
|
||||
} catch (error) {
|
||||
return { name: fallbackName, url, error: error.message || 'download failed' };
|
||||
}
|
||||
}
|
||||
|
||||
function nodeToConversationMarkdown(node, imagesByElement) {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.nodeValue.replace(/\s+/g, ' ');
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (['button', 'svg', 'script', 'style', 'textarea'].includes(tag)) return '';
|
||||
if (tag === 'img') {
|
||||
const image = imagesByElement.get(node);
|
||||
return image ? `` : '';
|
||||
}
|
||||
if (tag === 'pre') return `\n\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n`;
|
||||
if (tag === 'br') return '\n';
|
||||
if (tag === 'a') {
|
||||
const text = cleanConversationMarkdown([...node.childNodes]
|
||||
.map((child) => nodeToConversationMarkdown(child, imagesByElement)).join(''));
|
||||
return text ? `[${text}](${node.href})` : node.href;
|
||||
}
|
||||
if (tag === 'code') return `\`${node.textContent.trim()}\``;
|
||||
|
||||
const children = [...node.childNodes]
|
||||
.map((child) => nodeToConversationMarkdown(child, imagesByElement)).join('');
|
||||
if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${children.trim()}\n\n`;
|
||||
if (tag === 'li') return `\n- ${children.trim()}\n`;
|
||||
if (['p', 'div', 'section', 'article', 'blockquote', 'ul', 'ol'].includes(tag)) {
|
||||
return `\n${children}\n`;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function cleanConversationMarkdown(text) {
|
||||
return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function imageExtension(url, mimeType = '') {
|
||||
const mimeExtensions = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
|
||||
'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp', 'image/avif': 'avif' };
|
||||
if (mimeExtensions[mimeType.split(';')[0].toLowerCase()]) return mimeExtensions[mimeType.split(';')[0].toLowerCase()];
|
||||
const match = url.match(/\.([a-z0-9]+)(?:[?#]|$)/i);
|
||||
return match && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(match[1].toLowerCase())
|
||||
? match[1].toLowerCase() : 'bin';
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
const binary = atob(value.replace(/\s/g, ''));
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function scanMedia(mediaType, customExtensions) {
|
||||
const extMap = {
|
||||
images: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'],
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="toolsPane" role="tabpanel">
|
||||
<div class="tool-card mb-3" id="conversationMarkdownCard" style="display: none;">
|
||||
<h6>💬 ChatGPT Conversation</h6>
|
||||
<button class="btn btn-sm btn-primary" id="downloadConversationMarkdownBtn">Download Conversation Markdown</button>
|
||||
<div id="conversationMarkdownStatus" class="tool-results small text-muted mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card mb-3">
|
||||
<h6>📥 Bulk Media Downloader</h6>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
|
||||
+103
@@ -609,11 +609,114 @@ function importRules() {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function setupToolsTab() {
|
||||
setupConversationMarkdown();
|
||||
setupMediaDownloader();
|
||||
setupLinkHarvester();
|
||||
setupDataScraper();
|
||||
}
|
||||
|
||||
async function setupConversationMarkdown() {
|
||||
const card = document.getElementById('conversationMarkdownCard');
|
||||
const button = document.getElementById('downloadConversationMarkdownBtn');
|
||||
const status = document.getElementById('conversationMarkdownStatus');
|
||||
const tab = await getActiveTab();
|
||||
if (!tab || !/^https:\/\/(chatgpt\.com|chat\.openai\.com)(?:\/|$)/i.test(tab.url || '')) return;
|
||||
|
||||
card.style.display = 'block';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
status.textContent = 'Reading conversation…';
|
||||
try {
|
||||
const result = await chrome.tabs.sendMessage(tab.id, { action: 'exportConversationMarkdown' });
|
||||
if (!result || result.error) throw new Error(result && result.error || 'Could not read this conversation.');
|
||||
if (!result.images || result.images.length === 0) {
|
||||
downloadBlob(result.markdown, 'chatgpt-conversation.md', 'text/markdown');
|
||||
status.textContent = 'Markdown downloaded.';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = `Preparing ${result.images.length} image(s)…`;
|
||||
const files = [{ name: 'conversation.md', bytes: new TextEncoder().encode(result.markdown) }];
|
||||
for (const image of result.images) {
|
||||
const bytes = image.data ? base64ToBytes(image.data) : await fetchConversationImage(image.url);
|
||||
files.push({ name: `images/${image.name}`, bytes });
|
||||
}
|
||||
downloadBlob(createStoredZip(files), 'chatgpt-conversation.zip', 'application/zip');
|
||||
status.textContent = 'ZIP downloaded.';
|
||||
} catch (error) {
|
||||
status.textContent = error.message || 'Could not download the conversation.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchConversationImage(url) {
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`Could not download image (${response.status}).`);
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function createStoredZip(files) {
|
||||
// ponytail: stored entries keep this dependency-free; add deflate only if archive size matters.
|
||||
const parts = [];
|
||||
const centralDirectory = [];
|
||||
let offset = 0;
|
||||
|
||||
files.forEach(({ name, bytes }) => {
|
||||
const nameBytes = new TextEncoder().encode(name);
|
||||
const checksum = crc32(bytes);
|
||||
const localHeader = new Uint8Array(30 + nameBytes.length);
|
||||
const localView = new DataView(localHeader.buffer);
|
||||
localView.setUint32(0, 0x04034b50, true);
|
||||
localView.setUint16(4, 20, true);
|
||||
localView.setUint32(14, checksum, true);
|
||||
localView.setUint32(18, bytes.length, true);
|
||||
localView.setUint32(22, bytes.length, true);
|
||||
localView.setUint16(26, nameBytes.length, true);
|
||||
localHeader.set(nameBytes, 30);
|
||||
parts.push(localHeader, bytes);
|
||||
|
||||
const centralHeader = new Uint8Array(46 + nameBytes.length);
|
||||
const centralView = new DataView(centralHeader.buffer);
|
||||
centralView.setUint32(0, 0x02014b50, true);
|
||||
centralView.setUint16(4, 20, true);
|
||||
centralView.setUint16(6, 20, true);
|
||||
centralView.setUint32(16, checksum, true);
|
||||
centralView.setUint32(20, bytes.length, true);
|
||||
centralView.setUint32(24, bytes.length, true);
|
||||
centralView.setUint16(28, nameBytes.length, true);
|
||||
centralView.setUint32(42, offset, true);
|
||||
centralHeader.set(nameBytes, 46);
|
||||
centralDirectory.push(centralHeader);
|
||||
offset += localHeader.length + bytes.length;
|
||||
});
|
||||
|
||||
const centralSize = centralDirectory.reduce((size, part) => size + part.length, 0);
|
||||
const end = new Uint8Array(22);
|
||||
const endView = new DataView(end.buffer);
|
||||
endView.setUint32(0, 0x06054b50, true);
|
||||
endView.setUint16(8, files.length, true);
|
||||
endView.setUint16(10, files.length, true);
|
||||
endView.setUint32(12, centralSize, true);
|
||||
endView.setUint32(16, offset, true);
|
||||
return new Blob([...parts, ...centralDirectory, end], { type: 'application/zip' });
|
||||
}
|
||||
|
||||
function crc32(bytes) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function setupMediaDownloader() {
|
||||
const mediaType = document.getElementById('mediaType');
|
||||
const mediaExtensions = document.getElementById('mediaExtensions');
|
||||
|
||||
@@ -223,9 +223,132 @@
|
||||
sendResponse({ items: harvestLinks(request.containerSelector) });
|
||||
} else if (request.action === 'scrapeData') {
|
||||
sendResponse({ items: scrapeData(request.selector, request.attribute) });
|
||||
} else if (request.action === 'exportConversationMarkdown') {
|
||||
exportConversationMarkdown().then(sendResponse).catch((error) => {
|
||||
sendResponse({ error: error.message || 'Could not export the conversation.' });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function exportConversationMarkdown() {
|
||||
// ponytail: read the message nodes already loaded by ChatGPT; add virtual-scroll
|
||||
// pagination only if ChatGPT starts unloading older messages from the DOM.
|
||||
const messageElements = [...document.querySelectorAll('[data-message-author-role]')]
|
||||
.filter((el) => ['user', 'assistant', 'system'].includes(el.dataset.messageAuthorRole));
|
||||
if (!messageElements.length) throw new Error('No ChatGPT messages were found on this page.');
|
||||
|
||||
const imagesByElement = new Map();
|
||||
const imagesByUrl = new Map();
|
||||
let imageNumber = 0;
|
||||
|
||||
for (const message of messageElements) {
|
||||
for (const image of message.querySelectorAll('img')) {
|
||||
const url = image.currentSrc || image.src;
|
||||
if (!url) continue;
|
||||
let imageFile = imagesByUrl.get(url);
|
||||
if (!imageFile) {
|
||||
imageFile = await readConversationImage(url, ++imageNumber);
|
||||
imagesByUrl.set(url, imageFile);
|
||||
}
|
||||
imagesByElement.set(image, imageFile);
|
||||
}
|
||||
}
|
||||
|
||||
const title = (document.querySelector('main h1')?.textContent || document.title || 'ChatGPT Conversation')
|
||||
.replace(/\s*\|\s*ChatGPT.*$/i, '').trim() || 'ChatGPT Conversation';
|
||||
const markdown = [`# ${title.replace(/^#+\s*/, '').replace(/\n/g, ' ')}`, ''];
|
||||
|
||||
messageElements.forEach((message) => {
|
||||
const role = message.dataset.messageAuthorRole === 'assistant' ? 'ChatGPT' :
|
||||
message.dataset.messageAuthorRole[0].toUpperCase() + message.dataset.messageAuthorRole.slice(1);
|
||||
const body = cleanConversationMarkdown(nodeToConversationMarkdown(message, imagesByElement));
|
||||
if (body) markdown.push(`## ${role}`, '', body, '');
|
||||
});
|
||||
|
||||
return { markdown: `${markdown.join('\n').trim()}\n`, images: [...imagesByUrl.values()] };
|
||||
}
|
||||
|
||||
async function readConversationImage(url, number) {
|
||||
const fallbackName = `image-${String(number).padStart(3, '0')}.${imageExtension(url)}`;
|
||||
try {
|
||||
let bytes;
|
||||
let mimeType = '';
|
||||
if (url.startsWith('data:')) {
|
||||
const match = url.match(/^data:([^,]*),(.*)$/s);
|
||||
if (!match) throw new Error('Invalid data URL');
|
||||
mimeType = (match[1].split(';')[0] || '').toLowerCase();
|
||||
bytes = match[1].includes(';base64')
|
||||
? base64ToBytes(match[2])
|
||||
: new TextEncoder().encode(decodeURIComponent(match[2]));
|
||||
} else {
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
mimeType = response.headers.get('content-type') || '';
|
||||
bytes = new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
const name = `image-${String(number).padStart(3, '0')}.${imageExtension(url, mimeType)}`;
|
||||
return { name, url, mimeType, data: bytesToBase64(bytes) };
|
||||
} catch (error) {
|
||||
return { name: fallbackName, url, error: error.message || 'download failed' };
|
||||
}
|
||||
}
|
||||
|
||||
function nodeToConversationMarkdown(node, imagesByElement) {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.nodeValue.replace(/\s+/g, ' ');
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (['button', 'svg', 'script', 'style', 'textarea'].includes(tag)) return '';
|
||||
if (tag === 'img') {
|
||||
const image = imagesByElement.get(node);
|
||||
return image ? `` : '';
|
||||
}
|
||||
if (tag === 'pre') return `\n\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n`;
|
||||
if (tag === 'br') return '\n';
|
||||
if (tag === 'a') {
|
||||
const text = cleanConversationMarkdown([...node.childNodes]
|
||||
.map((child) => nodeToConversationMarkdown(child, imagesByElement)).join(''));
|
||||
return text ? `[${text}](${node.href})` : node.href;
|
||||
}
|
||||
if (tag === 'code') return `\`${node.textContent.trim()}\``;
|
||||
|
||||
const children = [...node.childNodes]
|
||||
.map((child) => nodeToConversationMarkdown(child, imagesByElement)).join('');
|
||||
if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${children.trim()}\n\n`;
|
||||
if (tag === 'li') return `\n- ${children.trim()}\n`;
|
||||
if (['p', 'div', 'section', 'article', 'blockquote', 'ul', 'ol'].includes(tag)) {
|
||||
return `\n${children}\n`;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function cleanConversationMarkdown(text) {
|
||||
return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function imageExtension(url, mimeType = '') {
|
||||
const mimeExtensions = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
|
||||
'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp', 'image/avif': 'avif' };
|
||||
if (mimeExtensions[mimeType.split(';')[0].toLowerCase()]) return mimeExtensions[mimeType.split(';')[0].toLowerCase()];
|
||||
const match = url.match(/\.([a-z0-9]+)(?:[?#]|$)/i);
|
||||
return match && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'].includes(match[1].toLowerCase())
|
||||
? match[1].toLowerCase() : 'bin';
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
const binary = atob(value.replace(/\s/g, ''));
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function scanMedia(mediaType, customExtensions) {
|
||||
const extMap = {
|
||||
images: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'],
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="toolsPane" role="tabpanel">
|
||||
<div class="tool-card mb-3" id="conversationMarkdownCard" style="display: none;">
|
||||
<h6>💬 ChatGPT Conversation</h6>
|
||||
<button class="btn btn-sm btn-primary" id="downloadConversationMarkdownBtn">Download Conversation Markdown</button>
|
||||
<div id="conversationMarkdownStatus" class="tool-results small text-muted mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card mb-3">
|
||||
<h6>📥 Bulk Media Downloader</h6>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
|
||||
@@ -609,11 +609,114 @@ function importRules() {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function setupToolsTab() {
|
||||
setupConversationMarkdown();
|
||||
setupMediaDownloader();
|
||||
setupLinkHarvester();
|
||||
setupDataScraper();
|
||||
}
|
||||
|
||||
async function setupConversationMarkdown() {
|
||||
const card = document.getElementById('conversationMarkdownCard');
|
||||
const button = document.getElementById('downloadConversationMarkdownBtn');
|
||||
const status = document.getElementById('conversationMarkdownStatus');
|
||||
const tab = await getActiveTab();
|
||||
if (!tab || !/^https:\/\/(chatgpt\.com|chat\.openai\.com)(?:\/|$)/i.test(tab.url || '')) return;
|
||||
|
||||
card.style.display = 'block';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
status.textContent = 'Reading conversation…';
|
||||
try {
|
||||
const result = await browser.tabs.sendMessage(tab.id, { action: 'exportConversationMarkdown' });
|
||||
if (!result || result.error) throw new Error(result && result.error || 'Could not read this conversation.');
|
||||
if (!result.images || result.images.length === 0) {
|
||||
downloadBlob(result.markdown, 'chatgpt-conversation.md', 'text/markdown');
|
||||
status.textContent = 'Markdown downloaded.';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = `Preparing ${result.images.length} image(s)…`;
|
||||
const files = [{ name: 'conversation.md', bytes: new TextEncoder().encode(result.markdown) }];
|
||||
for (const image of result.images) {
|
||||
const bytes = image.data ? base64ToBytes(image.data) : await fetchConversationImage(image.url);
|
||||
files.push({ name: `images/${image.name}`, bytes });
|
||||
}
|
||||
downloadBlob(createStoredZip(files), 'chatgpt-conversation.zip', 'application/zip');
|
||||
status.textContent = 'ZIP downloaded.';
|
||||
} catch (error) {
|
||||
status.textContent = error.message || 'Could not download the conversation.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchConversationImage(url) {
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`Could not download image (${response.status}).`);
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function createStoredZip(files) {
|
||||
// ponytail: stored entries keep this dependency-free; add deflate only if archive size matters.
|
||||
const parts = [];
|
||||
const centralDirectory = [];
|
||||
let offset = 0;
|
||||
|
||||
files.forEach(({ name, bytes }) => {
|
||||
const nameBytes = new TextEncoder().encode(name);
|
||||
const checksum = crc32(bytes);
|
||||
const localHeader = new Uint8Array(30 + nameBytes.length);
|
||||
const localView = new DataView(localHeader.buffer);
|
||||
localView.setUint32(0, 0x04034b50, true);
|
||||
localView.setUint16(4, 20, true);
|
||||
localView.setUint32(14, checksum, true);
|
||||
localView.setUint32(18, bytes.length, true);
|
||||
localView.setUint32(22, bytes.length, true);
|
||||
localView.setUint16(26, nameBytes.length, true);
|
||||
localHeader.set(nameBytes, 30);
|
||||
parts.push(localHeader, bytes);
|
||||
|
||||
const centralHeader = new Uint8Array(46 + nameBytes.length);
|
||||
const centralView = new DataView(centralHeader.buffer);
|
||||
centralView.setUint32(0, 0x02014b50, true);
|
||||
centralView.setUint16(4, 20, true);
|
||||
centralView.setUint16(6, 20, true);
|
||||
centralView.setUint32(16, checksum, true);
|
||||
centralView.setUint32(20, bytes.length, true);
|
||||
centralView.setUint32(24, bytes.length, true);
|
||||
centralView.setUint16(28, nameBytes.length, true);
|
||||
centralView.setUint32(42, offset, true);
|
||||
centralHeader.set(nameBytes, 46);
|
||||
centralDirectory.push(centralHeader);
|
||||
offset += localHeader.length + bytes.length;
|
||||
});
|
||||
|
||||
const centralSize = centralDirectory.reduce((size, part) => size + part.length, 0);
|
||||
const end = new Uint8Array(22);
|
||||
const endView = new DataView(end.buffer);
|
||||
endView.setUint32(0, 0x06054b50, true);
|
||||
endView.setUint16(8, files.length, true);
|
||||
endView.setUint16(10, files.length, true);
|
||||
endView.setUint32(12, centralSize, true);
|
||||
endView.setUint32(16, offset, true);
|
||||
return new Blob([...parts, ...centralDirectory, end], { type: 'application/zip' });
|
||||
}
|
||||
|
||||
function crc32(bytes) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function setupMediaDownloader() {
|
||||
const mediaType = document.getElementById('mediaType');
|
||||
const mediaExtensions = document.getElementById('mediaExtensions');
|
||||
|
||||
Reference in New Issue
Block a user