Files
arcum42-ComfyUI_SageUtils/js/shared/reportGenerator.js
T
2025-07-26 22:08:30 -07:00

677 lines
27 KiB
JavaScript

/**
* HTML Report Generator for SageUtils Cache Browser
* Handles generation of styled HTML reports with model information
*/
import {
getCivitaiImageApiUrl,
extractImageUrls,
getModelUrl,
hasUpdateAvailable,
getUpdateStyle,
formatTriggerWords,
NSFW_LEVELS
} from './civitai.js';
/**
* Get file size from backend
* @param {string} filePath - Path to the file
* @returns {Promise<number|null>} File size in bytes or null if failed
<h2>Generated: ${currentDateTime}</h2>
<div class="info">
Filters Applied: ${filterDescription}${searchDescription}${lastUsedDescription}${sortDescription}<br>
Total Models: ${allModels.length} (${checkpointModels.length} Checkpoints, ${loraModels.length} LoRAs)<br>
<span style="color: #28a745; font-weight: bold;">📷 Auto Images</span> - Cached images shown immediately, others load automatically<br>
<small style="color: #999; font-style: italic;">Click column headers to sort • Images load automatically from Civitai • Report format based on original design by tecknight</small>
</div>`;
/**
* Get file size from backend
* @param {string} filePath - Path to the file
* @returns {Promise<number|null>} File size in bytes or null if failed
*/
async function getFileSize(filePath) {
try {
const response = await fetch(`/sage_utils/file_size?path=${encodeURIComponent(filePath)}`);
if (response.ok) {
const data = await response.json();
return data.success ? data.file_size : null;
}
return null;
} catch (error) {
console.warn('Failed to get file size:', error);
return null;
}
}
/**
* Escape HTML special characters
* @param {string} text - Text to escape
* @returns {string} - Escaped HTML
*/
export function escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Format file size in human readable format
* @param {number} bytes - File size in bytes
* @returns {string} - Formatted file size
*/
export function formatFileSize(bytes) {
// Handle null, undefined, 0, or non-numeric values
if (!bytes || isNaN(bytes) || bytes <= 0) return 'Unknown';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
// Ensure we don't go out of bounds
const sizeIndex = Math.min(i, sizes.length - 1);
const formattedSize = Math.round(bytes / Math.pow(1024, sizeIndex) * 100) / 100;
return formattedSize + ' ' + sizes[sizeIndex];
}
/**
* Get CSS styling based on base model type
* @param {string} baseModel - Base model name
* @returns {string} - CSS style string
*/
export function getBaseModelStyle(baseModel) {
if (!baseModel) return 'color:green;background-color:orange;';
if (baseModel.startsWith("Flux")) {
return 'color:yellow;background-color:maroon;';
} else if (baseModel.startsWith("Pony")) {
return 'color:white;background-color:green;';
} else if (baseModel.startsWith("SDXL")) {
return 'color:yellow;background-color:green;';
} else if (baseModel.startsWith("SD ")) {
return 'color:white;background-color:blue;';
} else {
return 'color:black;background-color:lightgray;';
}
}
/**
* Generate HTML table rows for models
* @param {Array} models - Array of model objects with filePath, hash, and info
* @param {Object} options - Generation options
* @returns {Promise<string>} - HTML table rows
*/
export async function generateTableRows(models, options = {}) {
const rows = await Promise.all(models.map(async ({ filePath, hash, info }) => {
const modelName = (info && info.model && info.model.name) || (info && info.name) || filePath.split('/').pop() || 'Unknown';
const baseModel = (info && info.baseModel) || (info && info.base_model) || 'Unknown';
const modelType = (info && info.model && info.model.type) || 'Unknown';
const triggerWords = (info && info.trainedWords && Array.isArray(info.trainedWords)) ? info.trainedWords : [];
const modelId = (info && info.modelId) || 'Unknown';
const civitaiUrl = getModelUrl(modelId);
const updateAvailable = hasUpdateAvailable(info);
const lastUsed = (info && (info.lastUsed || info.last_accessed)) || 'Never';
// Try multiple possible file size fields, fallback to filesystem
let fileSizeRaw = info && (info.file_size || info.fileSize || info.size);
if (!fileSizeRaw && filePath) {
fileSizeRaw = await getFileSize(filePath);
}
const fileSize = formatFileSize(fileSizeRaw);
const modelHash = hash || 'Unknown';
// Format last used date
let formattedLastUsed = lastUsed;
if (lastUsed !== 'Never') {
try {
const date = new Date(lastUsed);
formattedLastUsed = date.toLocaleString();
} catch (e) {
formattedLastUsed = lastUsed;
}
}
const nameStyle = getBaseModelStyle(baseModel);
const civitaiStyle = getUpdateStyle(info);
// Format trigger words with copy functionality
const triggerCellContent = formatTriggerWords(triggerWords);
// Generate sortable attributes for special columns
const fileSizeBytes = fileSizeRaw || 0;
const lastUsedTimestamp = (lastUsed !== 'Never' && lastUsed !== 'Unknown') ?
new Date(lastUsed).getTime() : 0;
// Get example image - use cached data or generate direct Civitai URL
let exampleImageContent = '<i>No image</i>';
// First check if we have cached images in the model info
if (info && info.images && Array.isArray(info.images) && info.images.length > 0) {
const firstImage = info.images[0];
if (firstImage && firstImage.url) {
exampleImageContent = `<img src="${escapeHtml(firstImage.url)}"
style="width:150px;height:100px;object-fit:cover;border-radius:4px;cursor:pointer;transition:all 0.3s ease;"
alt="Example image"
loading="lazy"
title="Click to expand/collapse"
onclick="toggleImageExpand(this)"
onerror="this.style.display='none'">`;
}
} else if (hash) {
// Auto-load Civitai images with proper sizing
const civitaiImageUrl = `https://civitai.com/api/v1/model-versions/by-hash/${encodeURIComponent(hash)}`;
exampleImageContent = `<div style="width:150px;height:100px;background:#f5f5f5;display:flex;align-items:center;justify-content:center;border-radius:4px;font-size:11px;color:#999;"
data-civitai-hash="${escapeHtml(hash)}"
data-civitai-url="${escapeHtml(civitaiImageUrl)}"
class="auto-load-image">
<span style="text-align:center;">Loading...</span>
</div>`;
}
return `
<tr>
<td style="text-align:center;${nameStyle}">${escapeHtml(modelName)}</td>
<td style="text-align:center;">${escapeHtml(baseModel)}</td>
<td style="text-align:center;">${escapeHtml(modelType)}</td>
<td style="text-align:center;">${triggerCellContent}</td>
<td class="image-cell">${exampleImageContent}</td>
<td style="text-align:center;${civitaiStyle}">
<a href="${civitaiUrl}" target="_blank">${modelId}</a>
${updateAvailable ? '<br><br><i>Update available</i>' : ''}
</td>
<td style="text-align:center;">${escapeHtml(modelHash.substring(0, 12))}...</td>
<td style="text-align:center;" sorttable_customkey="${fileSizeBytes}">${escapeHtml(fileSize)}</td>
<td style="text-align:center;" sorttable_customkey="${lastUsedTimestamp}">${formattedLastUsed}</td>
<td style="text-align:left;">${escapeHtml(filePath)}</td>
</tr>
`;
}));
return rows.join('');
}
/**
* Generate complete HTML report
* @param {Object} options - Report generation options
* @param {Array} options.models - Array of model objects with filePath, hash, and info
* @param {Array} [options.sortedFiles] - Sorted array of file paths (legacy)
* @param {Array} [options.checkpoints] - Array of checkpoint models (legacy)
* @param {Array} [options.loras] - Array of LoRA models (legacy)
* @param {string} [options.filterDescription] - Description of applied filters
* @param {string} [options.searchDescription] - Description of search filter
* @param {string} [options.lastUsedDescription] - Description of last used filter
* @param {string} [options.sortDescription] - Description of sort criteria
* @returns {Promise<string>} - Complete HTML document
*/
export async function generateHtmlContent(options) {
const {
models,
sortedFiles,
checkpoints,
loras,
filterDescription = '',
searchDescription = '',
lastUsedDescription = '',
sortDescription = ''
} = options;
// Process models array if provided (new format)
let allModels, checkpointModels, loraModels;
if (models && Array.isArray(models)) {
// New format: process models array
allModels = models;
checkpointModels = models.filter(model => {
const info = model.info || {};
const filePath = model.filePath || '';
const isCheckpoint = info.model_type === 'Checkpoint' ||
filePath.toLowerCase().includes('.safetensors') ||
filePath.toLowerCase().includes('.ckpt');
return isCheckpoint;
});
loraModels = models.filter(model => {
const info = model.info || {};
const filePath = model.filePath || '';
const isLora = info.model_type === 'LORA' ||
filePath.toLowerCase().includes('lora');
return isLora;
});
} else {
// Legacy format: use provided arrays
allModels = sortedFiles || [];
checkpointModels = checkpoints || [];
loraModels = loras || [];
}
const currentDateTime = new Date().toLocaleString();
let htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>SageUtils Model Report - ${currentDateTime}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
h1 {
text-align: center;
color: white;
background-color: green;
padding: 15px;
border-radius: 8px;
margin-bottom: 10px;
}
h2 {
text-align: center;
color: darkblue;
background-color: yellow;
padding: 10px;
border-radius: 8px;
margin-bottom: 20px;
}
.info {
text-align: center;
color: #666;
margin-bottom: 20px;
font-style: italic;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
th {
border: 2px solid blue;
color: yellow;
background-color: blue;
font-size: 14px;
padding: 10px;
position: sticky;
top: 0;
z-index: 10;
}
td {
border: 2px solid maroon;
font-size: 12px;
padding: 8px;
vertical-align: top;
}
td.image-cell {
width: 160px;
height: 110px;
padding: 5px;
text-align: center;
vertical-align: middle;
}
.section-header {
background-color: #333;
color: white;
padding: 15px;
text-align: center;
font-size: 18px;
font-weight: bold;
margin-top: 30px;
border-radius: 8px;
}
button {
cursor: pointer;
border: none;
border-radius: 4px;
padding: 4px 8px;
}
a {
color: blue;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Sortable table styles */
table.sortable th:not(.sorttable_nosort) {
cursor: pointer;
user-select: none;
position: relative;
}
table.sortable th:not(.sorttable_nosort):hover {
background-color: #5555ff;
}
table.sortable th.sorttable_sorted {
background-color: #00aa00;
}
table.sortable th.sorttable_sorted_reverse {
background-color: #aa0000;
}
table.sortable th.sorttable_nosort {
background-color: #666;
cursor: default;
}
.sort-indicator {
font-size: 14px;
margin-left: 5px;
}
</style>
<script>
// Custom sortable table implementation
let sortDirection = {};
function makeSortable() {
const tables = document.querySelectorAll('table.sortable');
tables.forEach(table => {
const headers = table.querySelectorAll('th');
headers.forEach((header, index) => {
if (!header.classList.contains('sorttable_nosort')) {
header.style.cursor = 'pointer';
header.addEventListener('click', () => sortTable(table, index));
// Add sort indicator
const indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.innerHTML = ' ⇅';
indicator.style.opacity = '0.5';
header.appendChild(indicator);
}
});
});
}
function sortTable(table, columnIndex) {
const tableId = table.id || 'table_' + Math.random().toString(36).substr(2, 9);
if (!table.id) table.id = tableId;
const sortKey = tableId + '_' + columnIndex;
const currentDirection = sortDirection[sortKey] || 'asc';
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
sortDirection[sortKey] = newDirection;
const tbody = table.querySelector('tbody') || table;
const rows = Array.from(tbody.querySelectorAll('tr')).slice(1); // Skip header row
// Clear all header styles and indicators
const headers = table.querySelectorAll('th');
headers.forEach(header => {
header.classList.remove('sorttable_sorted', 'sorttable_sorted_reverse');
const indicator = header.querySelector('.sort-indicator');
if (indicator) {
indicator.innerHTML = ' ⇅';
indicator.style.opacity = '0.5';
}
});
// Update current header style and indicator
const currentHeader = headers[columnIndex];
if (currentHeader) {
currentHeader.classList.add(newDirection === 'asc' ? 'sorttable_sorted' : 'sorttable_sorted_reverse');
const indicator = currentHeader.querySelector('.sort-indicator');
if (indicator) {
indicator.innerHTML = newDirection === 'asc' ? ' ↑' : ' ↓';
indicator.style.opacity = '1';
}
}
rows.sort((a, b) => {
const cellA = a.cells[columnIndex];
const cellB = b.cells[columnIndex];
if (!cellA || !cellB) return 0;
// Check for custom sort key
let valueA = cellA.getAttribute('sorttable_customkey');
let valueB = cellB.getAttribute('sorttable_customkey');
if (valueA && valueB) {
// Numeric comparison for custom keys
valueA = parseFloat(valueA);
valueB = parseFloat(valueB);
} else {
// Text comparison
valueA = cellA.textContent.trim();
valueB = cellB.textContent.trim();
// Try to parse as numbers for better sorting
const numA = parseFloat(valueA.replace(/[^\\d.-]/g, ''));
const numB = parseFloat(valueB.replace(/[^\\d.-]/g, ''));
if (!isNaN(numA) && !isNaN(numB)) {
valueA = numA;
valueB = numB;
}
}
let comparison = 0;
if (valueA < valueB) {
comparison = -1;
} else if (valueA > valueB) {
comparison = 1;
}
return newDirection === 'desc' ? -comparison : comparison;
});
// Re-append sorted rows
rows.forEach(row => tbody.appendChild(row));
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
console.log('Text copied to clipboard');
} catch (err) {
console.error('Failed to copy: ', err);
}
}
function toggleImageExpand(img) {
if (!img.isExpanded) {
// Expand
img.originalStyle = img.style.cssText;
img.style.cssText = 'width:auto;height:auto;max-width:90vw;max-height:80vh;object-fit:contain;border-radius:4px;cursor:pointer;transition:all 0.3s ease;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,0.5);background:white;';
img.isExpanded = true;
img.setAttribute('data-expanded', 'true');
// Add backdrop
const backdrop = document.createElement('div');
backdrop.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:9998;';
backdrop.addEventListener('click', () => {
toggleImageExpand(img);
});
document.body.appendChild(backdrop);
img.backdrop = backdrop;
} else {
// Collapse
img.style.cssText = img.originalStyle || 'width:150px;height:100px;object-fit:cover;border-radius:4px;cursor:pointer;transition:all 0.3s ease;';
img.isExpanded = false;
img.removeAttribute('data-expanded');
// Remove backdrop
if (img.backdrop) {
document.body.removeChild(img.backdrop);
delete img.backdrop;
}
}
}
async function loadCivitaiImage(element) {
// Check if the current element has the attributes, if not check parent
let hash = element.getAttribute('data-civitai-hash');
let apiUrl = element.getAttribute('data-civitai-url');
let targetElement = element;
if (!hash || !apiUrl) {
// Try parent element
targetElement = element.parentElement;
hash = targetElement.getAttribute('data-civitai-hash');
apiUrl = targetElement.getAttribute('data-civitai-url');
}
if (!hash || !apiUrl) return;
try {
const response = await fetch(apiUrl);
if (response.ok) {
const data = await response.json();
const images = data.images || [];
// Find first appropriate image (NSFW level <= 1)
const appropriateImage = images.find(img => (img.nsfwLevel || 0) <= 1);
if (appropriateImage && appropriateImage.url) {
const imgElement = document.createElement('img');
imgElement.src = appropriateImage.url;
imgElement.style.cssText = 'width:150px;height:100px;object-fit:cover;border-radius:4px;cursor:pointer;transition:all 0.3s ease;';
imgElement.alt = 'Model example image';
imgElement.loading = 'lazy';
imgElement.title = 'Click to expand/collapse';
imgElement.onerror = function() {
this.parentElement.innerHTML = '<span style="color:#999;font-size:11px;">No image</span>';
};
// Add click handler for expand/collapse
imgElement.addEventListener('click', function(e) {
e.stopPropagation();
toggleImageExpand(this);
});
targetElement.innerHTML = '';
targetElement.appendChild(imgElement);
} else {
targetElement.innerHTML = '<span style="color:#999;font-size:11px;">No image</span>';
}
} else {
targetElement.innerHTML = '<span style="color:#999;font-size:11px;">Not available</span>';
}
} catch (error) {
console.debug('Failed to load Civitai image:', error);
targetElement.innerHTML = '<span style="color:#999;font-size:11px;">Load failed</span>';
}
}
// Auto-load all images when page loads
function autoLoadImages() {
const imageContainers = document.querySelectorAll('.auto-load-image');
imageContainers.forEach((container, index) => {
// Stagger the loading to avoid overwhelming the API
setTimeout(() => {
loadCivitaiImage(container);
}, index * 200); // 200ms delay between each image
});
}
// Initialize everything when page loads
function initializePage() {
makeSortable();
autoLoadImages();
}
// Load functionality after page content is ready
document.addEventListener('DOMContentLoaded', initializePage);
// Also run immediately in case DOMContentLoaded already fired
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
// Add ESC key support to close expanded images
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const expandedImages = document.querySelectorAll('img[data-expanded="true"]');
expandedImages.forEach(img => {
if (img.isExpanded) {
toggleImageExpand(img);
}
});
}
});
</script>
</head>
<body>
<h1>SageUtils Model Report</h1>
<h2>Generated: ${currentDateTime}</h2>
<div class="info">
Filters Applied: ${filterDescription}${searchDescription}${lastUsedDescription}${sortDescription}<br>
Total Models: ${allModels.length} (${checkpointModels.length} Checkpoints, ${loraModels.length} LoRAs)<br>
<small style="color: #999; font-style: italic;">Click column headers to sort • Images load automatically from Civitai</small>
</div>
`;
// Add LoRAs section if any exist
if (loraModels.length > 0) {
const loraRows = await generateTableRows(loraModels);
htmlContent += `
<div class="section-header">LoRA Models (${loraModels.length})</div>
<table class="sortable">
<tr>
<th style="width:200px;"><b>Model Name</b></th>
<th style="width:100px;"><b>Base Model</b></th>
<th style="width:80px;"><b>Type</b></th>
<th style="width:175px;" class="sorttable_nosort"><b>Trigger Words</b></th>
<th style="width:200px;" class="sorttable_nosort"><b>Example Image</b></th>
<th style="width:100px;"><b>Civitai ID</b></th>
<th style="width:100px;"><b>Hash</b></th>
<th style="width:80px;"><b>File Size</b></th>
<th style="width:120px;"><b>Last Used</b></th>
<th style="width:250px;"><b>Full Path</b></th>
</tr>
${loraRows}
</table>
`;
}
// Add Checkpoints section if any exist
if (checkpointModels.length > 0) {
const checkpointRows = await generateTableRows(checkpointModels);
htmlContent += `
<div class="section-header">Checkpoint Models (${checkpointModels.length})</div>
<table class="sortable">
<tr>
<th style="width:200px;"><b>Model Name</b></th>
<th style="width:100px;"><b>Base Model</b></th>
<th style="width:80px;"><b>Type</b></th>
<th style="width:175px;" class="sorttable_nosort"><b>Trigger Words</b></th>
<th style="width:200px;" class="sorttable_nosort"><b>Example Image</b></th>
<th style="width:100px;"><b>Civitai ID</b></th>
<th style="width:100px;"><b>Hash</b></th>
<th style="width:80px;"><b>File Size</b></th>
<th style="width:120px;"><b>Last Used</b></th>
<th style="width:250px;"><b>Full Path</b></th>
</tr>
${checkpointRows}
</table>
`;
}
htmlContent += `
</body>
</html>
`;
return htmlContent;
}
/**
* Open HTML content in a new browser tab
* @param {string} htmlContent - Complete HTML content
* @param {string} title - Window title
* @returns {boolean} - Success status
*/
export function openHtmlReport(htmlContent, title) {
const newWindow = window.open('', '_blank');
if (newWindow) {
newWindow.document.write(htmlContent);
newWindow.document.close();
newWindow.document.title = title;
return true;
} else {
alert('Unable to open new window. Please check your popup blocker settings.');
return false;
}
}