Files
arcum42-ComfyUI_SageUtils/js/reports/reportGenerator.js
T
2025-09-26 00:42:14 -07:00

959 lines
44 KiB
JavaScript

/**
* HTML Report Generator for SageUtils Cache Browser
* Handles generation of styled HTML reports with model information
*/
import {
getModelUrl,
hasUpdateAvailable,
getUpdateStyle,
formatTriggerWords
} from '../shared/civitai.js';
import { notifications } from '../shared/notifications.js';
import {
DEFAULT_THUMBNAIL_WIDTH,
DEFAULT_THUMBNAIL_HEIGHT,
DEFAULT_BATCH_SIZE,
REPORT_COLUMN_CONFIG,
getThumbnailStyle
} from '../shared/constants.js';
import {
getFileSize,
escapeHtml,
formatFileSize,
getBaseModelStyle
} from './modelFormatters.js';
import {
deduplicateModels
} from './modelDeduplication.js';
import {
groupModelsByCivitaiId,
generateGroupInfo
} from './modelSorting.js';
import {
generateHtmlDocumentStart,
generateHtmlDocumentEnd,
generateCssStyles,
generateClientSideJs,
generateModelStatsSection,
generateTableSection
} from './htmlTemplates.js';
/**
* Extract folder type from file path
* @param {string} filePath - The file path to analyze
* @returns {string} The folder type (checkpoints, loras, etc.)
*/
function getFolderTypeFromPath(filePath) {
if (!filePath) return 'unknown';
if (filePath.includes('/checkpoints/')) return 'checkpoints';
if (filePath.includes('/loras/')) return 'loras';
if (filePath.includes('/vae/')) return 'vae';
if (filePath.includes('/text_encoders/') || filePath.includes('/clip/')) return 'text_encoders';
if (filePath.includes('/diffusion_models/') || filePath.includes('/unet/')) return 'diffusion_models';
if (filePath.includes('/embeddings/')) return 'embeddings';
if (filePath.includes('/clip_vision/')) return 'clip_vision';
if (filePath.includes('/style_models/')) return 'style_models';
if (filePath.includes('/controlnet/') || filePath.includes('/t2i_adapter/')) return 'controlnet';
if (filePath.includes('/upscale_models/')) return 'upscale_models';
if (filePath.includes('/hypernetworks/')) return 'hypernetworks';
return 'unknown';
}
/**
* Get display name for folder type
* @param {string} folderType - The folder type key
* @returns {string} Human-readable folder name
*/
function getFolderDisplayName(folderType) {
const folderNames = {
'checkpoints': 'Checkpoints',
'loras': 'LoRA',
'vae': 'VAE',
'text_encoders': 'Text Encoders',
'diffusion_models': 'Diffusion Models',
'embeddings': 'Embeddings',
'clip_vision': 'CLIP Vision',
'style_models': 'Style Models',
'controlnet': 'ControlNet',
'upscale_models': 'Upscale Models',
'hypernetworks': 'Hypernetworks',
'unknown': 'Unknown'
};
return folderNames[folderType] || 'Unknown';
}
/**
* 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>`;
/**
* Generate HTML table rows for models
* @param {Array} models - Array of model objects with filePath, hash, and info
* @param {Object} options - Generation options
* @param {Array} [options.groupInfo] - Array of grouping information for each model
* @param {Array} [options.visibleColumns] - Array of visible column configurations
* @returns {Promise<string>} - HTML table rows
*/
export async function generateTableRows(models, options = {}) {
// First, create a cache for file sizes to avoid duplicate API calls
const fileSizeCache = new Map();
const { groupInfo = [], visibleColumns = null } = options;
const rows = await Promise.all(models.map(async ({ filePath, hash, info }, index) => {
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';
// Improved file size handling with caching and better fallback logic
let fileSizeRaw = null;
// Try to get file size from model info first (check multiple possible fields)
if (info) {
fileSizeRaw = info.file_size || info.fileSize || info.size;
// Ensure we have a valid number
if (fileSizeRaw != null) {
fileSizeRaw = parseInt(fileSizeRaw);
if (isNaN(fileSizeRaw) || fileSizeRaw <= 0) {
fileSizeRaw = null;
}
}
}
// If no valid size from info, try filesystem (with caching)
if (!fileSizeRaw && filePath) {
if (fileSizeCache.has(filePath)) {
fileSizeRaw = fileSizeCache.get(filePath);
} else {
try {
fileSizeRaw = await getFileSize(filePath);
fileSizeCache.set(filePath, fileSizeRaw);
// Small delay to prevent overwhelming the filesystem
await new Promise(resolve => setTimeout(resolve, 10));
} catch (error) {
console.debug(`Failed to get file size for ${filePath}:`, error);
fileSizeCache.set(filePath, null);
}
}
}
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);
// Determine grouping CSS classes first
const groupData = groupInfo[index] || {};
// Determine whether to show "Update available" message
// If this model is part of a group that already has the latest version, don't show the message
const shouldShowUpdateMessage = updateAvailable && !(groupData.isGroupMember && groupData.groupHasLatestVersion);
// Apply update styling only if we're showing the update message
const civitaiStyle = shouldShowUpdateMessage ? 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 = `<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;">
<img src="${escapeHtml(firstImage.url)}"
style="${getThumbnailStyle()}"
alt="Example image"
loading="lazy"
title="Click to expand/collapse"
onclick="toggleImageExpand(this)"
onerror="this.style.display='none'">
</div>`;
}
} else if (hash) {
// Check if we should attempt to load from Civitai based on previous attempts
let shouldAttemptCivitai = true;
if (info) {
// Check civitai status - could be string or boolean, any case
const civitaiStatus = info.civitai;
if (civitaiStatus !== undefined && civitaiStatus !== null) {
if (typeof civitaiStatus === 'string') {
shouldAttemptCivitai = civitaiStatus.toLowerCase() === 'true';
} else if (typeof civitaiStatus === 'boolean') {
shouldAttemptCivitai = civitaiStatus;
}
}
// Also check failed count - if there have been multiple failures, don't try
const failedCount = info.civitai_failed_count || info.civitaiFailedCount || 0;
if (failedCount > 0) {
shouldAttemptCivitai = false;
}
}
// Determine the reason for not attempting Civitai for better messaging
let noImageReason = "No image available";
if (info) {
const failedCount = info.civitai_failed_count || info.civitaiFailedCount || 0;
const civitaiStatus = info.civitai;
if (failedCount > 0) {
noImageReason = "Not available on Civitai";
} else if (civitaiStatus === false || (typeof civitaiStatus === 'string' && civitaiStatus.toLowerCase() === 'false')) {
noImageReason = "Not available on Civitai";
}
}
if (shouldAttemptCivitai) {
// Auto-load Civitai images with proper sizing
const civitaiImageUrl = `https://civitai.com/api/v1/model-versions/by-hash/${encodeURIComponent(hash)}`;
exampleImageContent = `<div style="width:100%;height:${DEFAULT_THUMBNAIL_HEIGHT}px;background:#f5f5f5;display:flex;align-items:center;justify-content:center;border-radius:4px;font-size:11px;color:#999;transition:all 0.3s ease;"
data-civitai-hash="${escapeHtml(hash)}"
data-civitai-url="${escapeHtml(civitaiImageUrl)}"
class="auto-load-image">
<span style="text-align:center;">Loading...</span>
</div>`;
} else {
// Don't attempt Civitai load, create compact container immediately
exampleImageContent = `<div style="width:auto;height:auto;min-width:60px;min-height:20px;padding:8px;display:flex;align-items:center;justify-content:center;border-radius:4px;font-size:11px;color:#999;background:#f9f9f9;">
<span style="text-align:center;">${noImageReason}</span>
</div>`;
}
}
// Determine grouping CSS classes
const groupClasses = [];
if (groupData.isGroupFirst) {
groupClasses.push('model-group-first');
}
if (groupData.isGroupMember) {
groupClasses.push('model-group');
}
if (groupData.isGroupLast) {
groupClasses.push('model-group-last');
}
const groupClassAttribute = groupClasses.length > 0 ? ` class="${groupClasses.join(' ')}"` : '';
// Determine if image cell should be compact
let imageCellClass = "image-cell";
let shouldAttemptCivitai = true;
if (info && hash) {
// Check civitai status - could be string or boolean, any case
const civitaiStatus = info.civitai;
if (civitaiStatus !== undefined && civitaiStatus !== null) {
if (typeof civitaiStatus === 'string') {
shouldAttemptCivitai = civitaiStatus.toLowerCase() === 'true';
} else if (typeof civitaiStatus === 'boolean') {
shouldAttemptCivitai = civitaiStatus;
}
}
// Also check failed count - if there have been multiple failures, don't try
const failedCount = info.civitai_failed_count || info.civitaiFailedCount || 0;
if (failedCount > 0) {
shouldAttemptCivitai = false;
}
}
// If no cached image and not attempting Civitai, make cell compact
const hasExistingImage = info && info.images && Array.isArray(info.images) && info.images.length > 0;
if (!hasExistingImage && !shouldAttemptCivitai) {
imageCellClass += " compact";
}
// Generate table cells based on visible columns
const tableCells = [];
// If no column configuration is provided, use default order (all columns visible)
const columnsToRender = visibleColumns || [
{ key: 'name' }, { key: 'basemodel' }, { key: 'type' }, { key: 'folder' }, { key: 'triggers' },
{ key: 'image' }, { key: 'civitai' }, { key: 'versionid' }, { key: 'hash' },
{ key: 'size' }, { key: 'lastused' }, { key: 'path' }
];
columnsToRender.forEach(column => {
switch (column.key) {
case 'name':
tableCells.push(`<td style="text-align:center;${nameStyle}">${escapeHtml(modelName)}</td>`);
break;
case 'basemodel':
tableCells.push(`<td style="text-align:center;">${escapeHtml(baseModel)}</td>`);
break;
case 'type':
tableCells.push(`<td style="text-align:center;">${escapeHtml(modelType)}</td>`);
break;
case 'folder':
const folderType = getFolderTypeFromPath(filePath);
const folderDisplayName = getFolderDisplayName(folderType);
tableCells.push(`<td style="text-align:center;">${escapeHtml(folderDisplayName)}</td>`);
break;
case 'triggers':
tableCells.push(`<td style="text-align:center;">${triggerCellContent}</td>`);
break;
case 'image':
tableCells.push(`<td class="${imageCellClass}">${exampleImageContent}</td>`);
break;
case 'civitai':
tableCells.push(`<td style="text-align:center;${civitaiStyle}">
<a href="${civitaiUrl}" target="_blank">${modelId}</a>
${shouldShowUpdateMessage ? '<br><br><i>Update available</i>' : ''}
</td>`);
break;
case 'versionid':
const versionId = (info && info.id) || 'Unknown';
if (versionId !== 'Unknown' && versionId !== '' && versionId != null && modelId !== 'Unknown' && modelId !== '' && modelId != null) {
const versionUrl = getModelUrl(modelId, versionId);
tableCells.push(`<td style="text-align:center;${civitaiStyle}"><a href="${versionUrl}" target="_blank">${escapeHtml(String(versionId))}</a></td>`);
} else {
tableCells.push(`<td style="text-align:center;${civitaiStyle}">${escapeHtml(String(versionId))}</td>`);
}
break;
case 'hash':
tableCells.push(`<td style="text-align:center;">${escapeHtml(modelHash.substring(0, 12))}...</td>`);
break;
case 'size':
tableCells.push(`<td style="text-align:center;" sorttable_customkey="${fileSizeBytes}">${escapeHtml(fileSize)}</td>`);
break;
case 'lastused':
tableCells.push(`<td style="text-align:center;" sorttable_customkey="${lastUsedTimestamp}">${formattedLastUsed}</td>`);
break;
case 'path':
tableCells.push(`<td style="text-align:left;">${escapeHtml(filePath)}</td>`);
break;
}
});
return `
<tr${groupClassAttribute}>
${tableCells.join('\n ')}
</tr>
`;
}));
return rows.join('');
}
/**
* Generate HTML table rows for models with progress tracking and optimizations
* @param {Array} models - Array of model objects with filePath, hash, and info
* @param {Object} options - Generation options
* @param {Function} [options.progressCallback] - Progress callback function
* @param {Array} [options.groupInfo] - Array of grouping information for each model
* @param {Array} [options.visibleColumns] - Array of visible column configurations
* @returns {Promise<string>} - HTML table rows
*/
export async function generateTableRowsWithProgress(models, options = {}) {
const { progressCallback, groupInfo = [], visibleColumns = null } = options;
// First, create a cache for file sizes to avoid duplicate API calls
const fileSizeCache = new Map();
const batchSize = DEFAULT_BATCH_SIZE; // Process models in batches to prevent blocking
const rows = [];
for (let i = 0; i < models.length; i += batchSize) {
const batch = models.slice(i, i + batchSize);
const batchProgress = ((i / models.length) * 100);
if (progressCallback) {
progressCallback(batchProgress, `Processing models ${i + 1}-${Math.min(i + batchSize, models.length)} of ${models.length}...`);
}
// Process batch in parallel for better performance
const batchRows = await Promise.all(batch.map(async ({ filePath, hash, info }, batchIndex) => {
const actualIndex = i + batchIndex; // Calculate the actual index in the full array
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';
// Improved file size handling with caching and better fallback logic
let fileSizeRaw = null;
// Try to get file size from model info first (check multiple possible fields)
if (info) {
fileSizeRaw = info.file_size || info.fileSize || info.size;
// Ensure we have a valid number
if (fileSizeRaw != null) {
fileSizeRaw = parseInt(fileSizeRaw);
if (isNaN(fileSizeRaw) || fileSizeRaw <= 0) {
fileSizeRaw = null;
}
}
}
// If no valid size from info, try filesystem (with caching) - but batch these requests
if (!fileSizeRaw && filePath && !fileSizeCache.has(filePath)) {
try {
fileSizeRaw = await getFileSize(filePath);
fileSizeCache.set(filePath, fileSizeRaw);
} catch (error) {
console.debug(`Failed to get file size for ${filePath}:`, error);
fileSizeCache.set(filePath, null);
}
} else if (fileSizeCache.has(filePath)) {
fileSizeRaw = fileSizeCache.get(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);
// Determine grouping CSS classes first
const groupData = groupInfo[actualIndex] || {};
// Determine whether to show "Update available" message
// If this model is part of a group that already has the latest version, don't show the message
const shouldShowUpdateMessage = updateAvailable && !(groupData.isGroupMember && groupData.groupHasLatestVersion);
// Apply update styling only if we're showing the update message
const civitaiStyle = shouldShowUpdateMessage ? 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 = `<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;">
<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'">
</div>`;
}
} else if (hash) {
// Check if we should attempt to load from Civitai based on previous attempts
let shouldAttemptCivitai = true;
if (info) {
// Check civitai status - could be string or boolean, any case
const civitaiStatus = info.civitai;
if (civitaiStatus !== undefined && civitaiStatus !== null) {
if (typeof civitaiStatus === 'string') {
shouldAttemptCivitai = civitaiStatus.toLowerCase() === 'true';
} else if (typeof civitaiStatus === 'boolean') {
shouldAttemptCivitai = civitaiStatus;
}
}
// Also check failed count - if there have been multiple failures, don't try
const failedCount = info.civitai_failed_count || info.civitaiFailedCount || 0;
if (failedCount > 0) {
shouldAttemptCivitai = false;
}
}
// Determine the reason for not attempting Civitai for better messaging
let noImageReason = "No image available";
if (info) {
const failedCount = info.civitai_failed_count || info.civitaiFailedCount || 0;
const civitaiStatus = info.civitai;
if (failedCount > 0) {
noImageReason = "Not available on Civitai";
} else if (civitaiStatus === false || (typeof civitaiStatus === 'string' && civitaiStatus.toLowerCase() === 'false')) {
noImageReason = "Not available on Civitai";
}
}
if (shouldAttemptCivitai) {
// Auto-load Civitai images with proper sizing
const civitaiImageUrl = `https://civitai.com/api/v1/model-versions/by-hash/${encodeURIComponent(hash)}`;
exampleImageContent = `<div style="width:100%;height:100px;background:#f5f5f5;display:flex;align-items:center;justify-content:center;border-radius:4px;font-size:11px;color:#999;transition:all 0.3s ease;"
data-civitai-hash="${escapeHtml(hash)}"
data-civitai-url="${escapeHtml(civitaiImageUrl)}"
class="auto-load-image">
<span style="text-align:center;">Loading...</span>
</div>`;
} else {
// Don't attempt Civitai load, create compact container immediately
exampleImageContent = `<div style="width:auto;height:auto;min-width:60px;min-height:20px;padding:8px;display:flex;align-items:center;justify-content:center;border-radius:4px;font-size:11px;color:#999;background:#f9f9f9;">
<span style="text-align:center;">${noImageReason}</span>
</div>`;
}
}
// Determine grouping CSS classes
const groupClasses = [];
if (groupData.isGroupFirst) {
groupClasses.push('model-group-first');
}
if (groupData.isGroupMember) {
groupClasses.push('model-group');
}
if (groupData.isGroupLast) {
groupClasses.push('model-group-last');
}
const groupClassAttribute = groupClasses.length > 0 ? ` class="${groupClasses.join(' ')}"` : '';
// Determine if image cell should be compact
let imageCellClass = "image-cell";
let shouldAttemptCivitai = true;
if (info && hash) {
// Check civitai status - could be string or boolean, any case
const civitaiStatus = info.civitai;
if (civitaiStatus !== undefined && civitaiStatus !== null) {
if (typeof civitaiStatus === 'string') {
shouldAttemptCivitai = civitaiStatus.toLowerCase() === 'true';
} else if (typeof civitaiStatus === 'boolean') {
shouldAttemptCivitai = civitaiStatus;
}
}
// Also check failed count - if there have been multiple failures, don't try
const failedCount = info.civitai_failed_count || info.civitaiFailedCount || 0;
if (failedCount > 0) {
shouldAttemptCivitai = false;
}
}
// If no cached image and not attempting Civitai, make cell compact
const hasExistingImage = info && info.images && Array.isArray(info.images) && info.images.length > 0;
if (!hasExistingImage && !shouldAttemptCivitai) {
imageCellClass += " compact";
}
// Generate table cells based on visible columns
const tableCells = [];
// If no column configuration is provided, use default order (all columns visible)
const columnsToRender = visibleColumns || [
{ key: 'name' }, { key: 'basemodel' }, { key: 'type' }, { key: 'folder' }, { key: 'triggers' },
{ key: 'image' }, { key: 'civitai' }, { key: 'versionid' }, { key: 'hash' },
{ key: 'size' }, { key: 'lastused' }, { key: 'path' }
];
columnsToRender.forEach(column => {
switch (column.key) {
case 'name':
tableCells.push(`<td style="text-align:center;${nameStyle}">${escapeHtml(modelName)}</td>`);
break;
case 'basemodel':
tableCells.push(`<td style="text-align:center;">${escapeHtml(baseModel)}</td>`);
break;
case 'type':
tableCells.push(`<td style="text-align:center;">${escapeHtml(modelType)}</td>`);
break;
case 'folder':
const folderType = getFolderTypeFromPath(filePath);
const folderDisplayName = getFolderDisplayName(folderType);
tableCells.push(`<td style="text-align:center;">${escapeHtml(folderDisplayName)}</td>`);
break;
case 'triggers':
tableCells.push(`<td style="text-align:center;">${triggerCellContent}</td>`);
break;
case 'image':
tableCells.push(`<td class="${imageCellClass}">${exampleImageContent}</td>`);
break;
case 'civitai':
tableCells.push(`<td style="text-align:center;${civitaiStyle}">
<a href="${civitaiUrl}" target="_blank">${modelId}</a>
${shouldShowUpdateMessage ? '<br><br><i>Update available</i>' : ''}
</td>`);
break;
case 'versionid':
const versionId = (info && info.id) || 'Unknown';
if (versionId !== 'Unknown' && versionId !== '' && versionId != null && modelId !== 'Unknown' && modelId !== '' && modelId != null) {
const versionUrl = getModelUrl(modelId, versionId);
tableCells.push(`<td style="text-align:center;${civitaiStyle}"><a href="${versionUrl}" target="_blank">${escapeHtml(String(versionId))}</a></td>`);
} else {
tableCells.push(`<td style="text-align:center;${civitaiStyle}">${escapeHtml(String(versionId))}</td>`);
}
break;
case 'hash':
tableCells.push(`<td style="text-align:center;">${escapeHtml(modelHash.substring(0, 12))}...</td>`);
break;
case 'size':
tableCells.push(`<td style="text-align:center;" sorttable_customkey="${fileSizeBytes}">${escapeHtml(fileSize)}</td>`);
break;
case 'lastused':
tableCells.push(`<td style="text-align:center;" sorttable_customkey="${lastUsedTimestamp}">${formattedLastUsed}</td>`);
break;
case 'path':
tableCells.push(`<td style="text-align:left;">${escapeHtml(filePath)}</td>`);
break;
}
});
return `
<tr${groupClassAttribute}>
${tableCells.join('\n ')}
</tr>
`;
}));
rows.push(...batchRows);
// Small delay to prevent UI blocking
await new Promise(resolve => setTimeout(resolve, 1));
}
return rows.join('');
}
/**
* Generate complete HTML report with progress tracking and optimizations
* @param {Object} options - Report generation options
* @param {Array} options.models - Array of model objects with filePath, hash, and info
* @param {Function} [options.progressCallback] - Progress callback function
* @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
* @param {string} [options.sortBy] - Current sort selection from model tab
* @param {string} [options.folderFilter] - Current folder filter from model tab (all, checkpoints, loras, vae, etc.)
* @returns {Promise<string>} - Complete HTML document
*/
export async function generateHtmlContentWithProgress(options) {
const {
models,
progressCallback,
sortedFiles,
checkpoints,
loras,
filterDescription = '',
searchDescription = '',
lastUsedDescription = '',
sortDescription = '',
sortBy = 'name',
folderFilter = 'all'
} = options;
// Column visibility configuration (now centralized in constants.js)
// This controls which columns appear in the generated reports
// To hide additional columns, set visible: false in the REPORT_COLUMN_CONFIG
// To show the Hash column, set visible: true for the Hash entry in constants.js
const COLUMN_CONFIG = REPORT_COLUMN_CONFIG;
// Get visible columns and their original indices for mapping
const visibleColumns = COLUMN_CONFIG.map((col, index) => ({ ...col, originalIndex: index }))
.filter(col => col.visible);
// Create mapping from original column indices to visible column indices
const originalToVisibleIndex = {};
visibleColumns.forEach((col, visibleIndex) => {
originalToVisibleIndex[col.originalIndex] = visibleIndex;
});
// Process models array if provided (new format)
let allModels, checkpointModels, loraModels;
if (progressCallback) {
progressCallback(5, 'Preparing model data...');
}
if (models && Array.isArray(models)) {
// New format: process models array with deduplication
const deduplicatedModels = deduplicateModels(models);
console.debug(`Deduplicated ${models.length} models to ${deduplicatedModels.length} unique entries`);
if (progressCallback) {
progressCallback(10, 'Categorizing models...');
}
allModels = deduplicatedModels;
// Categorize models by folder type instead of using unreliable Civitai model type
checkpointModels = deduplicatedModels.filter(model => {
const filePath = model.filePath || '';
return getFolderTypeFromPath(filePath) === 'checkpoints';
});
loraModels = deduplicatedModels.filter(model => {
const filePath = model.filePath || '';
return getFolderTypeFromPath(filePath) === 'loras';
});
} else {
// Legacy format: use provided arrays (also deduplicate if they're model objects)
if (sortedFiles && sortedFiles.length > 0 && sortedFiles[0].filePath) {
allModels = deduplicateModels(sortedFiles);
} else {
allModels = sortedFiles || [];
}
if (checkpoints && checkpoints.length > 0 && checkpoints[0].filePath) {
checkpointModels = deduplicateModels(checkpoints);
} else {
checkpointModels = checkpoints || [];
}
if (loras && loras.length > 0 && loras[0].filePath) {
loraModels = deduplicateModels(loras);
} else {
loraModels = loras || [];
}
}
// Apply folder-based filtering based on the selected filter from the models tab
if (folderFilter !== 'all') {
if (progressCallback) {
progressCallback(11, `Filtering models by folder: ${folderFilter}...`);
}
// Filter all models by the selected folder type
allModels = allModels.filter(model => {
const filePath = model.filePath || '';
return getFolderTypeFromPath(filePath) === folderFilter;
});
// Update categorized lists based on the filtered results
checkpointModels = allModels.filter(model => {
const filePath = model.filePath || '';
return getFolderTypeFromPath(filePath) === 'checkpoints';
});
loraModels = allModels.filter(model => {
const filePath = model.filePath || '';
return getFolderTypeFromPath(filePath) === 'loras';
});
}
// Apply sorting and grouping based on the sort criteria from the models tab
if (progressCallback) {
progressCallback(12, 'Sorting and grouping models...');
}
// Group models by Civitai ID and apply sorting within groups
allModels = groupModelsByCivitaiId(allModels, sortBy);
checkpointModels = groupModelsByCivitaiId(checkpointModels, sortBy);
loraModels = groupModelsByCivitaiId(loraModels, sortBy);
const currentDateTime = new Date().toLocaleString();
// Helper function to generate table headers based on visible columns
if (progressCallback) {
progressCallback(15, 'Building HTML structure...');
}
const cssStyles = generateCssStyles();
const clientJs = generateClientSideJs(sortBy);
const htmlStart = generateHtmlDocumentStart(currentDateTime, cssStyles, clientJs);
let htmlContent = htmlStart + generateModelStatsSection(currentDateTime, filterDescription, searchDescription, lastUsedDescription, sortDescription, allModels, checkpointModels, loraModels, folderFilter);
// Determine which sections to show based on folder filter
const shouldShowSection = (sectionFolderType, modelArray) => {
if (folderFilter !== 'all') {
// If a specific folder is selected, only show that section
return folderFilter === sectionFolderType && modelArray.length > 0;
} else {
// Show all sections that have models
return modelArray.length > 0;
}
};
// Add LoRAs section if it should be shown
if (shouldShowSection('loras', loraModels)) {
if (progressCallback) {
progressCallback(25, `Processing ${loraModels.length} LoRA models...`);
}
// Generate grouping information for LoRA models
const loraGroupInfo = generateGroupInfo(loraModels);
const loraRows = await generateTableRowsWithProgress(loraModels, {
groupInfo: loraGroupInfo,
visibleColumns: visibleColumns,
progressCallback: progressCallback ? (progress, message) => {
// Map LoRA progress to 25-50% range
const adjustedProgress = 25 + (progress * 0.25);
progressCallback(adjustedProgress, `LoRAs: ${message}`);
} : null
});
htmlContent += generateTableSection('LoRA Models', loraModels.length, loraRows, visibleColumns);
}
// Add Checkpoints section if it should be shown
if (shouldShowSection('checkpoints', checkpointModels)) {
if (progressCallback) {
progressCallback(50, `Processing ${checkpointModels.length} checkpoint models...`);
}
// Generate grouping information for Checkpoint models
const checkpointGroupInfo = generateGroupInfo(checkpointModels);
const checkpointRows = await generateTableRowsWithProgress(checkpointModels, {
groupInfo: checkpointGroupInfo,
visibleColumns: visibleColumns,
progressCallback: progressCallback ? (progress, message) => {
// Map Checkpoint progress to 50-80% range
const adjustedProgress = 50 + (progress * 0.30);
progressCallback(adjustedProgress, `Checkpoints: ${message}`);
} : null
});
htmlContent += generateTableSection('Checkpoint Models', checkpointModels.length, checkpointRows, visibleColumns);
}
// Add sections for other folder types if they should be shown
const otherFolderTypes = [
{ key: 'vae', displayName: 'VAE Models' },
{ key: 'text_encoders', displayName: 'Text Encoders' },
{ key: 'diffusion_models', displayName: 'Diffusion Models' },
{ key: 'embeddings', displayName: 'Embeddings' },
{ key: 'clip_vision', displayName: 'CLIP Vision' },
{ key: 'style_models', displayName: 'Style Models' },
{ key: 'controlnet', displayName: 'ControlNet' },
{ key: 'upscale_models', displayName: 'Upscale Models' },
{ key: 'hypernetworks', displayName: 'Hypernetworks' }
];
for (const folderType of otherFolderTypes) {
// Filter models for this folder type
const folderModels = allModels.filter(model => {
const filePath = model.filePath || '';
return getFolderTypeFromPath(filePath) === folderType.key;
});
if (shouldShowSection(folderType.key, folderModels)) {
if (progressCallback) {
progressCallback(70, `Processing ${folderModels.length} ${folderType.displayName.toLowerCase()}...`);
}
// Generate grouping information for this folder type
const folderGroupInfo = generateGroupInfo(folderModels);
const folderRows = await generateTableRowsWithProgress(folderModels, {
groupInfo: folderGroupInfo,
visibleColumns: visibleColumns,
progressCallback: progressCallback ? (progress, message) => {
// Map progress to remaining range
const adjustedProgress = 70 + (progress * 0.15);
progressCallback(adjustedProgress, `${folderType.displayName}: ${message}`);
} : null
});
htmlContent += generateTableSection(folderType.displayName, folderModels.length, folderRows, visibleColumns);
}
}
htmlContent += generateHtmlDocumentEnd();
if (progressCallback) {
progressCallback(85, 'Finalizing HTML report...');
}
return htmlContent;
}
/**
* Generate complete HTML report (legacy wrapper)
* @param {Object} options - Report generation options
* @returns {Promise<string>} - Complete HTML document
*/
export async function generateHtmlContent(options) {
// Use the optimized version with progress tracking
return await generateHtmlContentWithProgress(options);
}
/**
* 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) {
// Use modern DOM manipulation instead of deprecated document.write()
const blob = new Blob([htmlContent], { type: 'text/html' });
const url = URL.createObjectURL(blob);
newWindow.location.href = url;
// Clean up the blob URL after a delay to ensure the page loads
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
return true;
} else {
notifications.warning('Unable to open new window. Please check your popup blocker settings.');
return false;
}
}
// Re-export utility functions for backward compatibility with existing imports
export { escapeHtml, formatFileSize } from './modelFormatters.js';