mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
Real faults: a card's original-position description formatted a promise because it did not await the history; the attachment swipe distances and two drag-scroll locals were undeclared globals; a file that cannot be read during a GridFS upload left the upload open; api.py called exit() instead of sys.exit(); a rules test redefined a property and a security-log test kept an unreachable branch. Also 31 missing semicolons, unused imports in the card details, exporters and attachment API, unused Python imports, and comments on empty except clauses. The list-sync scanner test cut its source at the last Meteor.startup, which the new index startup of #6855 had moved. Thanks to GitHub CodeQL and xet7 !
682 lines
25 KiB
JavaScript
682 lines
25 KiB
JavaScript
export default 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)));
|
|
}
|
|
};
|
|
|
|
const createZipEntries = () => {
|
|
const entries = [];
|
|
return { entries, file(name, value) { entries.push({ name, value }); } };
|
|
};
|
|
|
|
// Write one entry at a time with the same small ZIP implementation used by
|
|
// document conversion. Only old browsers without the File System Access API
|
|
// retain the completed archive in memory for a Blob download.
|
|
const saveZipStreaming = async (zip, filename) => {
|
|
const { Zip, ZipDeflate, strToU8 } = await import('fflate');
|
|
let writable = null;
|
|
if (typeof window.showSaveFilePicker === 'function') {
|
|
try {
|
|
const handle = await window.showSaveFilePicker({
|
|
suggestedName: filename,
|
|
types: [{ description: 'Zip archive', accept: { 'application/zip': ['.zip'] } }],
|
|
});
|
|
writable = await handle.createWritable();
|
|
} catch (e) {
|
|
if (e && e.name === 'AbortError') return;
|
|
}
|
|
}
|
|
const chunks = [];
|
|
let writeChain = Promise.resolve();
|
|
let finish;
|
|
let fail;
|
|
const completed = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
|
|
const archive = new Zip((error, data, final) => {
|
|
if (error) { fail(error); return; }
|
|
if (writable) writeChain = writeChain.then(() => writable.write(data));
|
|
else chunks.push(data);
|
|
if (final) writeChain.then(finish, fail);
|
|
});
|
|
try {
|
|
for (const entry of zip.entries) {
|
|
const file = new ZipDeflate(entry.name, { level: 6 });
|
|
archive.add(file);
|
|
let bytes;
|
|
if (entry.value instanceof Blob) bytes = new Uint8Array(await entry.value.arrayBuffer());
|
|
else if (entry.value instanceof Uint8Array) bytes = entry.value;
|
|
else bytes = strToU8(String(entry.value));
|
|
file.push(bytes, true);
|
|
}
|
|
archive.end();
|
|
await completed;
|
|
if (writable) await writable.close();
|
|
else saveAs(new Blob(chunks, { type: 'application/zip' }), filename);
|
|
} catch (error) {
|
|
archive.terminate();
|
|
if (writable) await writable.abort(error).catch(() => {});
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
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();
|
|
|
|
// .board-header-btns-group is the wrapper that keeps the board bar's button
|
|
// groups on one flex line; remove it too so the export is not left with an
|
|
// empty div where the buttons were.
|
|
Array.from(
|
|
clonedElement.querySelectorAll(
|
|
'#header-main-bar .board-header-btns, #header-main-bar .board-header-btns-group',
|
|
),
|
|
).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) => {
|
|
// The board's own JSON export. It used to be read off a .download-json-link
|
|
// in the export popup, which the one shared export popup no longer has, so
|
|
// the archive stopped here; the URL is built the way that link built it.
|
|
const boardId = Session.get('currentBoard');
|
|
const params = new URLSearchParams({ authToken: Accounts._storedLoginToken() || '' });
|
|
const response = await fetch(`/api/boards/${encodeURIComponent(boardId)}/export?${params.toString()}`);
|
|
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 = createZipEntries();
|
|
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!
|
|
};
|
|
};
|