mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
A board member could store entity-encoded markup in a card title. It stays inert on the live board (Blaze escapes it; the +viewer sanitizer neutralizes handlers and keeps the entity payload as text), but the exported index.html embedded a click handler that read the card title/body via .textContent — which DECODES HTML entities — and then concatenated those values into content.innerHTML. That SECOND parse revived the tag (e.g. `<img src=x onerror=...>`) and executed it when a recipient clicked the card in the export, disclosing all data in that document (including cards added after the attacker's board membership was removed). The modal is now built with DOM nodes and the card title/body are assigned through textContent, never innerHTML, so they are inserted as inert text. Regression: tests/exportHTMLXss.test.cjs (title/body reach only textContent; no titleText/allText flows into any innerHTML; a textContent assignment does not revive an entity-decoded <img>). (CWE-79 stored XSS, GitHub Security Advisory GHSA-8r5p-4q9j-f5jx.) Thanks to koyokr and xet7 !
672 lines
25 KiB
JavaScript
672 lines
25 KiB
JavaScript
const JSZip = require('jszip');
|
|
|
|
window.ExportHtml = Popup => {
|
|
const saveAs = function(blob, filename) {
|
|
const dl = document.createElement('a');
|
|
dl.href = window.URL.createObjectURL(blob);
|
|
dl.onclick = event => document.body.removeChild(event.target);
|
|
dl.style.display = 'none';
|
|
dl.target = '_blank';
|
|
dl.download = filename;
|
|
document.body.appendChild(dl);
|
|
dl.click();
|
|
};
|
|
|
|
const asyncForEach = async function(array, callback) {
|
|
for (let index = 0; index < array.length; index++) {
|
|
await callback(array[index], index, array);
|
|
}
|
|
};
|
|
|
|
// The board is rendered with infinite scroll — each list only keeps ~10 cards
|
|
// in the DOM (client/components/lists/listBody.js). A naive DOM-clone export
|
|
// would therefore drop every card past the fold. Before cloning we lift every
|
|
// list's `cardlimit` so the whole board renders, then wait for Blaze to flush.
|
|
const expandAllCardsForExport = async () => {
|
|
let changed = false;
|
|
Array.from(document.querySelectorAll('.list-body')).forEach(el => {
|
|
try {
|
|
let view = Blaze.getView(el);
|
|
while (view) {
|
|
if (view.template && view.template.viewName === 'Template.listBody' && view.templateInstance) {
|
|
const inst = view.templateInstance();
|
|
if (inst && inst.cardlimit && inst.cardlimit.get() < 1e6) {
|
|
inst.cardlimit.set(1e6); // render all cards in this list
|
|
changed = true;
|
|
}
|
|
break;
|
|
}
|
|
view = view.parentView;
|
|
}
|
|
} catch (e) { /* best effort per list */ }
|
|
});
|
|
if (changed) {
|
|
// Let the reactive re-render settle before we snapshot the DOM.
|
|
await new Promise(resolve => Tracker.afterFlush(() => setTimeout(resolve, 800)));
|
|
}
|
|
};
|
|
|
|
// Write the zip to disk WITHOUT holding the whole archive in memory. When the
|
|
// browser supports the File System Access API we pipe JSZip's internal stream
|
|
// straight to the chosen file, chunk by chunk, applying backpressure so a
|
|
// board with many attachments/covers never buffers the entire .zip in RAM.
|
|
// Older browsers fall back to the previous in-memory blob download.
|
|
const saveZipStreaming = async (zip, filename) => {
|
|
const genOpts = {
|
|
type: 'uint8array',
|
|
streamFiles: true,
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 },
|
|
};
|
|
if (typeof window.showSaveFilePicker === 'function') {
|
|
let handle = null;
|
|
try {
|
|
handle = await window.showSaveFilePicker({
|
|
suggestedName: filename,
|
|
types: [{ description: 'Zip archive', accept: { 'application/zip': ['.zip'] } }],
|
|
});
|
|
} catch (e) {
|
|
// User dismissed the save dialog — nothing to do.
|
|
if (e && e.name === 'AbortError') return;
|
|
handle = null; // any other failure → fall back to blob download
|
|
}
|
|
if (handle) {
|
|
const writable = await handle.createWritable();
|
|
await new Promise((resolve, reject) => {
|
|
const helper = zip.generateInternalStream(genOpts);
|
|
helper
|
|
.on('data', data => {
|
|
// Pause the zip stream until this chunk is flushed to disk.
|
|
helper.pause();
|
|
writable.write(data).then(() => helper.resume(), reject);
|
|
})
|
|
.on('error', reject)
|
|
.on('end', () => { writable.close().then(resolve, reject); })
|
|
.resume();
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
// Fallback: build the blob in memory (older browsers only).
|
|
const content = await zip.generateAsync({
|
|
type: 'blob',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 },
|
|
});
|
|
saveAs(content, filename);
|
|
};
|
|
|
|
const getPageHtmlString = (clonedElement = null) => {
|
|
const element = clonedElement || window.document.querySelector('html');
|
|
return `<!doctype html>${element.outerHTML}`;
|
|
};
|
|
|
|
const removeAnchors = htmlString => {
|
|
const replaceOpenAnchor = htmlString.replace(
|
|
new RegExp('<a ', 'gim'),
|
|
'<span ',
|
|
);
|
|
return replaceOpenAnchor.replace(new RegExp('</a', 'gim'), '</span');
|
|
};
|
|
|
|
const ensureSidebarRemoved = (element = null) => {
|
|
const target = element || document;
|
|
const sidebar = target.querySelector('.board-sidebar.sidebar');
|
|
if (sidebar) {
|
|
sidebar.remove();
|
|
}
|
|
};
|
|
|
|
const cleanBoardHtmlClone = (clonedElement) => {
|
|
// Work on cloned element only, not live DOM
|
|
Array.from(clonedElement.querySelectorAll('script')).forEach(elem =>
|
|
elem.remove(),
|
|
);
|
|
Array.from(
|
|
clonedElement.querySelectorAll('link:not([rel="stylesheet"])'),
|
|
).forEach(elem => elem.remove());
|
|
|
|
const headerQuickAccess = clonedElement.querySelector('#header-quick-access');
|
|
if (headerQuickAccess) headerQuickAccess.remove();
|
|
|
|
Array.from(
|
|
clonedElement.querySelectorAll('#header-main-bar .board-header-btns'),
|
|
).forEach(elem => elem.remove());
|
|
Array.from(
|
|
clonedElement.querySelectorAll('.js-pop-over, .pop-over'),
|
|
).forEach(elem => elem.remove());
|
|
Array.from(clonedElement.querySelectorAll('.list-composer')).forEach(elem =>
|
|
elem.remove(),
|
|
);
|
|
Array.from(
|
|
clonedElement.querySelectorAll(
|
|
'.list-composer,.js-card-composer, .js-add-card',
|
|
),
|
|
).forEach(elem => elem.remove());
|
|
|
|
// Remove edit/action buttons from cards but keep card content
|
|
Array.from(clonedElement.querySelectorAll('.js-edit, .js-delete, .js-open-card')).forEach(elem =>
|
|
elem.remove(),
|
|
);
|
|
|
|
// Keep card details structure for viewing - don't remove or hide
|
|
// The interactive script will handle showing/hiding them
|
|
|
|
Array.from(clonedElement.querySelectorAll('[href]:not(link)')).forEach(elem =>
|
|
elem.attributes.removeNamedItem('href'),
|
|
);
|
|
Array.from(clonedElement.querySelectorAll('[href]')).forEach(elem => {
|
|
// eslint-disable-next-line no-self-assign
|
|
elem.href = elem.href;
|
|
// eslint-disable-next-line no-self-assign
|
|
elem.src = elem.src;
|
|
});
|
|
Array.from(clonedElement.querySelectorAll('.is-editable')).forEach(elem => {
|
|
elem.classList.remove('is-editable');
|
|
});
|
|
};
|
|
|
|
const addJsonExportToZip = async (zip, boardSlug, zipDirName) => {
|
|
const downloadJSONLink = document.querySelector('.download-json-link');
|
|
const downloadJSONURL = downloadJSONLink.href;
|
|
const response = await fetch(downloadJSONURL);
|
|
const responseBody = await response.text();
|
|
zip.file(`${zipDirName}/data/${boardSlug}.json`, responseBody);
|
|
};
|
|
|
|
const getBoardSlug = () => {
|
|
// Use board ID from session instead of URL slug to keep filenames short
|
|
const boardId = Session.get('currentBoard');
|
|
if (boardId) {
|
|
return boardId;
|
|
}
|
|
// Fallback to URL slug if session is not available
|
|
return window.location.href.split('/').pop();
|
|
};
|
|
|
|
const getBoardTitle = () => {
|
|
// Extract from page header - most reliable method
|
|
const headerTitle = document.querySelector('h1.header-board-menu');
|
|
if (headerTitle && headerTitle.textContent) {
|
|
const title = headerTitle.textContent.trim();
|
|
// Filter out translation keys like "{{_ 'templates'}}"
|
|
if (title && !title.includes('{{')) {
|
|
return title;
|
|
}
|
|
}
|
|
|
|
// Fallback: use board slug if header title extraction fails
|
|
return getBoardSlug();
|
|
};
|
|
|
|
const sanitizeFilename = (filename) => {
|
|
// Remove or replace invalid filename characters
|
|
// Keep alphanumeric, hyphens, underscores, and spaces
|
|
return filename
|
|
.replace(/[^a-zA-Z0-9\s\-_]/g, '') // Remove special chars
|
|
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
|
.substring(0, 100) // Limit to 100 chars
|
|
.trim();
|
|
};
|
|
|
|
const getStylesheetList = () => {
|
|
return Array.from(
|
|
document.querySelectorAll('link[href][rel="stylesheet"]'),
|
|
);
|
|
};
|
|
|
|
const getSrcAttached = () => {
|
|
return Array.from(document.querySelectorAll('[src]'));
|
|
};
|
|
|
|
const downloadStylesheets = async (stylesheets, zip, clonedHtmlElement, zipDirName) => {
|
|
await asyncForEach(stylesheets, async elem => {
|
|
const response = await fetch(elem.href);
|
|
let responseBody = await response.text();
|
|
|
|
// Check if this is a Font Awesome CSS file
|
|
const isFontAwesomeCSS = elem.href && (
|
|
elem.href.includes('fontawesome') ||
|
|
elem.href.includes('font-awesome') ||
|
|
elem.href.includes('fa-') ||
|
|
elem.href.includes('wekan-fontawesome')
|
|
);
|
|
|
|
if (isFontAwesomeCSS) {
|
|
// For Font Awesome: preserve @font-face but update paths to local files
|
|
responseBody = responseBody.replace(
|
|
/url\(["']?(?:\.\.\/)?webfonts\/([^"')]+)["']?\)/gi,
|
|
'url("../webfonts/$1")'
|
|
);
|
|
// Also handle upstream package paths for Font Awesome
|
|
responseBody = responseBody.replace(
|
|
/url\(["']?[^"']*packages\/[^/]+\/upstream\/webfonts\/([^"')]+)["']?\)/gi,
|
|
'url("../webfonts/$1")'
|
|
);
|
|
} else {
|
|
// For non-Font-Awesome CSS: remove @font-face rules to eliminate external font dependencies
|
|
responseBody = responseBody.replace(
|
|
/@font-face\s*\{[^}]*\}/gi,
|
|
''
|
|
);
|
|
|
|
// Replace non-Font-Awesome font families with system fonts (sans-serif)
|
|
// But preserve Font Awesome families
|
|
responseBody = responseBody.replace(
|
|
/font-family\s*:\s*[^;]*(?:Roboto|Lato|Open Sans)[^;]*;/gi,
|
|
'font-family: sans-serif;'
|
|
);
|
|
}
|
|
|
|
// Fix relative paths for assets
|
|
let finalResponse = responseBody;
|
|
|
|
// Replace upstream package paths
|
|
finalResponse = finalResponse.replace(
|
|
new RegExp('packages/[^/]+/upstream/', 'gim'),
|
|
'../',
|
|
);
|
|
|
|
const filename = elem.href
|
|
.split('/')
|
|
.pop()
|
|
.split('?')
|
|
.shift();
|
|
const fileFullPath = `${zipDirName}/style/${filename}`;
|
|
zip.file(fileFullPath, finalResponse);
|
|
|
|
// Update the cloned HTML element, not the live one
|
|
if (clonedHtmlElement) {
|
|
const clonedElements = clonedHtmlElement.querySelectorAll(`link[href="${elem.href}"]`);
|
|
clonedElements.forEach(clonedElem => {
|
|
clonedElem.href = `./style/${filename}`;
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const downloadSrcAttached = async (elements, zip, boardSlug, clonedHtmlElement, zipDirName) => {
|
|
await asyncForEach(elements, async elem => {
|
|
const response = await fetch(elem.src);
|
|
const responseBody = await response.blob();
|
|
const filename = elem.src
|
|
.split('/')
|
|
.pop()
|
|
.split('?')
|
|
.shift();
|
|
const fileFullPath = `${zipDirName}/${elem.tagName.toLowerCase()}/${filename}`;
|
|
zip.file(fileFullPath, responseBody);
|
|
|
|
// Update the cloned HTML element, not the live one
|
|
if (clonedHtmlElement) {
|
|
const clonedElements = clonedHtmlElement.querySelectorAll(`[src="${elem.src}"]`);
|
|
clonedElements.forEach(clonedElem => {
|
|
clonedElem.src = `./${elem.tagName.toLowerCase()}/${filename}`;
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const removeCssUrlSurround = url => {
|
|
const working = url || '';
|
|
return working
|
|
.split('url(')
|
|
.join('')
|
|
.split('")')
|
|
.join('')
|
|
.split('"')
|
|
.join('')
|
|
.split("')")
|
|
.join('')
|
|
.split("'")
|
|
.join('')
|
|
.split(')')
|
|
.join('');
|
|
};
|
|
|
|
const getCardCovers = () => {
|
|
return Array.from(document.querySelectorAll('.minicard-cover')).filter(
|
|
elem => elem.style['background-image'],
|
|
);
|
|
};
|
|
|
|
const getWebFonts = () => {
|
|
let fonts = [];
|
|
|
|
for (let sheet of document.styleSheets) {
|
|
// Get the base URL of the stylesheet
|
|
let baseUrl = sheet.href ? new URL(sheet.href).origin : window.location.origin;
|
|
|
|
try {
|
|
for (let rule of sheet.cssRules) {
|
|
if (rule.type === CSSRule.FONT_FACE_RULE) {
|
|
let src = rule.style.getPropertyValue('src');
|
|
let urlMatch = src.match(/url\(["']?(.+?)["']?\)/);
|
|
if (urlMatch) {
|
|
let fontUrl = urlMatch[1];
|
|
let fontFamily = rule.style.getPropertyValue('font-family').replace(/["']/g, '');
|
|
|
|
// Resolve the URL relative to the stylesheet's base URL
|
|
let resolvedUrl = new URL(fontUrl, baseUrl);
|
|
fonts.push({
|
|
url: resolvedUrl.href,
|
|
family: fontFamily
|
|
}); // Using .href to get the absolute URL
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log('Access to stylesheet blocked:', e);
|
|
}
|
|
}
|
|
|
|
return fonts;
|
|
};
|
|
|
|
const downloadFonts = async(elements, zip, zipDirName) => {
|
|
let fontIndex = 1;
|
|
await asyncForEach(elements, async (fontObj, idx) => {
|
|
const elem = fontObj.url;
|
|
const fontFamily = fontObj.family || `font${fontIndex}`;
|
|
|
|
const response = await fetch(elem);
|
|
const responseBody = await response.blob();
|
|
|
|
// Try to extract a better filename
|
|
let filename = fontFamily.replace(/[^a-z0-9-]/gi, '').substring(0, 50) || `font${fontIndex++}`;
|
|
|
|
// If filename is empty after sanitization, use index
|
|
if (!filename || filename.length === 0) {
|
|
filename = `font${fontIndex++}`;
|
|
}
|
|
|
|
// First, try to extract filename from URL path (for normal URLs)
|
|
if (!elem.startsWith('data:')) {
|
|
const pathname = new URL(elem).pathname;
|
|
const urlFilename = pathname.split('/').pop().split('?')[0].split('#')[0];
|
|
if (urlFilename && urlFilename.length > 0 && !urlFilename.match(/^[a-f0-9]+$/i)) {
|
|
// Only use URL filename if it's not a hash
|
|
filename = urlFilename;
|
|
}
|
|
}
|
|
|
|
// Detect format from content-type or file extension
|
|
let extension = 'woff2';
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType) {
|
|
if (contentType.includes('font/woff')) extension = 'woff';
|
|
else if (contentType.includes('font/ttf')) extension = 'ttf';
|
|
else if (contentType.includes('application/x-font-ttf')) extension = 'ttf';
|
|
else if (contentType.includes('application/x-font-opentype')) extension = 'otf';
|
|
}
|
|
|
|
// Ensure filename has extension
|
|
if (!filename.includes('.')) {
|
|
filename = `${filename}.${extension}`;
|
|
}
|
|
|
|
const fileFullPath = `${zipDirName}/webfonts/${filename}`;
|
|
zip.file(fileFullPath, responseBody);
|
|
});
|
|
}
|
|
|
|
const downloadFontAwesomeFonts = async (zip, zipDirName) => {
|
|
// List of Font Awesome webfont files to include
|
|
const fontAwesomeFiles = [
|
|
'fa-solid-900.woff2',
|
|
'fa-solid-900.ttf',
|
|
'fa-brands-400.woff2',
|
|
'fa-brands-400.ttf',
|
|
'fa-regular-400.woff2',
|
|
'fa-regular-400.ttf',
|
|
'fa-v4compatibility.woff2',
|
|
'fa-v4compatibility.ttf',
|
|
];
|
|
|
|
try {
|
|
await asyncForEach(fontAwesomeFiles, async (filename) => {
|
|
try {
|
|
// Try to fetch from the Meteor bundle webfont location
|
|
const fontUrls = [
|
|
`/packages/wekan-fontawesome/webfonts/${filename}`,
|
|
`/webfonts/${filename}`,
|
|
`./webfonts/${filename}`,
|
|
`../packages/wekan-fontawesome/webfonts/${filename}`,
|
|
];
|
|
|
|
let response = null;
|
|
let fetchedUrl = null;
|
|
|
|
for (const url of fontUrls) {
|
|
try {
|
|
response = await fetch(url);
|
|
if (response.ok) {
|
|
fetchedUrl = url;
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
// Try next URL
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (response && response.ok) {
|
|
const responseBody = await response.blob();
|
|
const fileFullPath = `${zipDirName}/webfonts/${filename}`;
|
|
zip.file(fileFullPath, responseBody);
|
|
console.log(`✓ Font Awesome font included: ${filename} from ${fetchedUrl}`);
|
|
} else {
|
|
console.log(`✗ Font Awesome font not found: ${filename}`);
|
|
}
|
|
} catch (e) {
|
|
console.log(`Error downloading Font Awesome font ${filename}:`, e);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.log('Error in downloadFontAwesomeFonts:', e);
|
|
}
|
|
}
|
|
|
|
const downloadCardCovers = async (elements, zip, boardSlug, clonedHtmlElement, zipDirName) => {
|
|
await asyncForEach(elements, async elem => {
|
|
const response = await fetch(
|
|
removeCssUrlSurround(elem.style['background-image']),
|
|
);
|
|
const responseBody = await response.blob();
|
|
const filename = removeCssUrlSurround(elem.style['background-image'])
|
|
.split('/')
|
|
.pop()
|
|
.split('?')
|
|
.shift()
|
|
.split('#')
|
|
.shift();
|
|
const fileFullPath = `${zipDirName}/covers/${filename}`;
|
|
zip.file(fileFullPath, responseBody);
|
|
|
|
// Update the cloned HTML element, not the live one
|
|
if (clonedHtmlElement) {
|
|
const bgImage = removeCssUrlSurround(elem.style['background-image']);
|
|
const clonedElements = clonedHtmlElement.querySelectorAll('.minicard-cover');
|
|
clonedElements.forEach(clonedElem => {
|
|
if (removeCssUrlSurround(clonedElem.style['background-image']) === bgImage) {
|
|
clonedElem.style.backgroundImage = `url('./covers/${filename}')`;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const addBoardHTMLToZip = (boardSlug, zip, clonedElement, zipDirName) => {
|
|
ensureSidebarRemoved(clonedElement);
|
|
|
|
// Get the HTML string
|
|
let htmlString = removeAnchors(getPageHtmlString(clonedElement));
|
|
|
|
// Inject font styling with Font Awesome exception
|
|
// Apply sans-serif to most elements, but preserve Font Awesome icon fonts
|
|
const fontStyleTag = `<style>
|
|
* { font-family: sans-serif !important; }
|
|
body { font-family: sans-serif !important; }
|
|
.fa, .fas, .far, .fab, .fa-solid, .fa-regular, .fa-brands, [class*="fa-"] {
|
|
font-family: 'Font Awesome 6 Free', 'Font Awesome 6 Brands' !important;
|
|
}
|
|
</style>`;
|
|
htmlString = htmlString.replace(
|
|
/<\/head>/i,
|
|
`${fontStyleTag}</head>`
|
|
);
|
|
|
|
// Inject interactive script into the HTML before closing body tag
|
|
const scriptContent = `
|
|
// Make cards interactive in exported HTML
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
console.log('Initializing card interactions...');
|
|
|
|
// Create modal/popup for card details
|
|
let modal = document.getElementById('card-details-modal');
|
|
if (!modal) {
|
|
modal = document.createElement('div');
|
|
modal.id = 'card-details-modal';
|
|
modal.style.cssText = 'display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;border:1px solid #ccc;border-radius:8px;padding:20px;max-width:600px;width:90%;max-height:80vh;overflow-y:auto;z-index:10000;box-shadow:0 4px 20px rgba(0,0,0,0.3);';
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
// Add click handlers to cards to show/hide details
|
|
const cards = document.querySelectorAll('.minicard');
|
|
console.log('Found ' + cards.length + ' cards');
|
|
|
|
cards.forEach((card, idx) => {
|
|
card.style.cursor = 'pointer';
|
|
card.dataset.cardIndex = idx;
|
|
|
|
card.addEventListener('click', function(e) {
|
|
// Ignore clicks on buttons or action elements
|
|
if (e.target.closest('button, a, .js-') || e.target.closest('[role="button"]')) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Extract card title
|
|
const title = this.querySelector('.card-title, .minicard-title, [class*="title"]');
|
|
const titleText = title ? title.textContent.trim() : 'Card ' + this.dataset.cardIndex;
|
|
|
|
// Get all text content from the card
|
|
const allText = this.textContent.trim();
|
|
|
|
// Show modal with card details. Build it with DOM nodes and assign
|
|
// the card title/body through textContent — NEVER innerHTML. Those
|
|
// values come from .textContent, which DECODES HTML entities, so a
|
|
// card title holding entity-encoded markup (e.g.
|
|
// "<img src=x onerror=...>") decodes to a live tag; assigning it
|
|
// to innerHTML would re-parse and execute it — a stored XSS that fires
|
|
// when a recipient clicks the card in the exported HTML
|
|
// (GHSA-8r5p-4q9j-f5jx). textContent inserts them as inert text.
|
|
while (modal.firstChild) { modal.removeChild(modal.firstChild); }
|
|
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.textContent = 'Close';
|
|
closeBtn.style.cssText = 'position:absolute;top:10px;right:10px;padding:5px 10px;background:#f0f0f0;border:1px solid #ccc;border-radius:4px;cursor:pointer;';
|
|
closeBtn.onclick = () => { modal.style.display = 'none'; document.body.style.overflow = 'auto'; };
|
|
modal.appendChild(closeBtn);
|
|
|
|
const content = document.createElement('div');
|
|
|
|
const titleEl = document.createElement('h2');
|
|
titleEl.style.marginBottom = '10px';
|
|
titleEl.textContent = titleText;
|
|
content.appendChild(titleEl);
|
|
|
|
const hr = document.createElement('hr');
|
|
hr.style.margin = '10px 0';
|
|
content.appendChild(hr);
|
|
|
|
const bodyEl = document.createElement('div');
|
|
bodyEl.style.cssText = 'white-space:pre-wrap;word-wrap:break-word;font-family:sans-serif;font-size:13px;max-height:400px;overflow-y:auto;line-height:1.4;';
|
|
bodyEl.textContent = allText;
|
|
content.appendChild(bodyEl);
|
|
|
|
const footEl = document.createElement('p');
|
|
footEl.style.cssText = 'color:#666;font-size:12px;margin-top:20px;';
|
|
const footEm = document.createElement('em');
|
|
footEm.textContent = 'Card #' + this.dataset.cardIndex + ' from exported board';
|
|
footEl.appendChild(footEm);
|
|
content.appendChild(footEl);
|
|
|
|
modal.appendChild(content);
|
|
|
|
modal.style.display = 'block';
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
console.log('Showing card details for card ' + this.dataset.cardIndex);
|
|
});
|
|
});
|
|
|
|
// Add style for cards
|
|
if (!document.getElementById('card-open-styles')) {
|
|
const style = document.createElement('style');
|
|
style.id = 'card-open-styles';
|
|
style.textContent = '* { font-family: sans-serif !important; } body { font-family: sans-serif !important; } .minicard { transition: all 0.3s ease; cursor: pointer; } .minicard:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15) !important; transform: translateY(-2px); }';
|
|
document.head.appendChild(style);
|
|
console.log('Added card styles and sans-serif font');
|
|
}
|
|
});
|
|
`;
|
|
|
|
// Inject script before closing body tag
|
|
htmlString = htmlString.replace(
|
|
/<\/body>/i,
|
|
`<script>${scriptContent}</script></body>`
|
|
);
|
|
|
|
const htmlOutputPath = `${zipDirName}/index.html`;
|
|
zip.file(
|
|
htmlOutputPath,
|
|
new Blob([htmlString], {
|
|
type: 'application/html',
|
|
}),
|
|
);
|
|
};
|
|
|
|
return async () => {
|
|
const zip = new JSZip();
|
|
const boardSlug = getBoardSlug();
|
|
const boardTitle = sanitizeFilename(getBoardTitle());
|
|
const zipFilename = boardTitle || boardSlug;
|
|
const zipDirName = zipFilename; // Directory name inside the ZIP
|
|
|
|
// Render EVERY card first (infinite scroll otherwise leaves most cards out
|
|
// of the DOM), then snapshot.
|
|
await expandAllCardsForExport();
|
|
|
|
// Clone the HTML element to process for export without modifying live DOM
|
|
const htmlElement = window.document.querySelector('html');
|
|
const clonedHtmlElement = htmlElement.cloneNode(true);
|
|
|
|
await addJsonExportToZip(zip, boardSlug, zipDirName);
|
|
Popup.back();
|
|
|
|
// Process the cloned element only
|
|
cleanBoardHtmlClone(clonedHtmlElement);
|
|
|
|
// Pass cloned element and zipDirName to all download functions so they update the clone, not the live DOM
|
|
await downloadStylesheets(getStylesheetList(), zip, clonedHtmlElement, zipDirName);
|
|
await downloadSrcAttached(getSrcAttached(), zip, boardSlug, clonedHtmlElement, zipDirName);
|
|
await downloadCardCovers(getCardCovers(), zip, boardSlug, clonedHtmlElement, zipDirName);
|
|
|
|
// Download Font Awesome webfont files for icon support in exported HTML
|
|
await downloadFontAwesomeFonts(zip, zipDirName);
|
|
|
|
// Note: General fonts are not exported to keep export lightweight
|
|
// downloadFonts(getWebFonts(), zip, zipDirName); -- skipped
|
|
|
|
addBoardHTMLToZip(boardSlug, zip, clonedHtmlElement, zipDirName);
|
|
|
|
// Stream the .zip straight to disk (low memory) with a blob fallback.
|
|
await saveZipStreaming(zip, `${zipFilename}.zip`);
|
|
// No page reload - impersonation session is preserved!
|
|
};
|
|
};
|