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 `${element.outerHTML}`; }; const removeAnchors = htmlString => { const replaceOpenAnchor = htmlString.replace( new RegExp(' { 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 = ``; htmlString = htmlString.replace( /<\/head>/i, `${fontStyleTag}` ); // 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, `` ); 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! }; };