The Open Source kanban (built with Meteor). Keep variable/table/field names camelCase. For translations, only add Pull Request changes to wekan/i18n/en.i18n.json , other translations are done at https://transifex.com/wekan/wekan only.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
wekan/client/lib/exportHTML.js

556 lines
20 KiB

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);
}
};
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
modal.innerHTML = '<button style="position:absolute;top:10px;right:10px;padding:5px 10px;background:#f0f0f0;border:1px solid #ccc;border-radius:4px;cursor:pointer;">Close</button>';
modal.querySelector('button').onclick = () => { modal.style.display = 'none'; document.body.style.overflow = 'auto'; };
const content = document.createElement('div');
content.innerHTML = '<h2 style="margin-bottom:10px;">' + titleText + '</h2><hr style="margin:10px 0;"><div style="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;">' + allText + '</div><p style="color:#666;font-size:12px;margin-top:20px;"><em>Card #' + this.dataset.cardIndex + ' from exported board</em></p>';
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
// 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);
const content = await zip.generateAsync({ type: 'blob' });
saveAs(content, `${zipFilename}.zip`);
// No page reload - impersonation session is preserved!
};
};