Files
arcum42-ComfyUI_SageUtils/js/shared/reportGenerator.js
T
2025-09-04 21:40:32 -07:00

1375 lines
56 KiB
JavaScript

/**
* HTML Report Generator for SageUtils Cache Browser
* Handles generation of styled HTML reports with model information
*/
import {
getModelUrl,
hasUpdateAvailable,
getUpdateStyle,
formatTriggerWords
} from './civitai.js';
import {
MODEL_FILE_EXTENSIONS,
COMMON_MODEL_EXTENSIONS,
MODEL_TYPES,
DEFAULT_THUMBNAIL_WIDTH,
DEFAULT_THUMBNAIL_HEIGHT,
DEFAULT_BATCH_SIZE,
hasModelExtension,
hasCommonModelExtension,
isLikelyCheckpoint,
isLikelyLora,
getThumbnailStyle
} from './constants.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
* @param {Array} [options.groupInfo] - Array of grouping information for each model
* @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 = [] } = 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);
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="${getThumbnailStyle()}"
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:${DEFAULT_THUMBNAIL_WIDTH}px;height:${DEFAULT_THUMBNAIL_HEIGHT}px;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>`;
}
// Determine grouping CSS classes
const groupData = groupInfo[index] || {};
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(' ')}"` : '';
return `
<tr${groupClassAttribute}>
<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 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
* @returns {Promise<string>} - HTML table rows
*/
export async function generateTableRowsWithProgress(models, options = {}) {
const { progressCallback, groupInfo = [] } = 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);
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>`;
}
// Determine grouping CSS classes
const groupData = groupInfo[actualIndex] || {};
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(' ')}"` : '';
return `
<tr${groupClassAttribute}>
<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>
`;
}));
rows.push(...batchRows);
// Small delay to prevent UI blocking
await new Promise(resolve => setTimeout(resolve, 1));
}
return rows.join('');
}
/**
* Sort models array based on the specified criteria
* @param {Array} models - Array of model objects
* @param {string} sortBy - Sort criteria
* @returns {Array} - Sorted array of models
*/
function sortModels(models, sortBy) {
return models.sort((a, b) => {
const infoA = a.info || {};
const infoB = b.info || {};
switch (sortBy) {
case 'name': {
const nameA = (infoA.model && infoA.model.name) || infoA.name || a.filePath.split('/').pop() || '';
const nameB = (infoB.model && infoB.model.name) || infoB.name || b.filePath.split('/').pop() || '';
return nameA.localeCompare(nameB);
}
case 'name-desc': {
const nameA = (infoA.model && infoA.model.name) || infoA.name || a.filePath.split('/').pop() || '';
const nameB = (infoB.model && infoB.model.name) || infoB.name || b.filePath.split('/').pop() || '';
return nameB.localeCompare(nameA);
}
case 'lastused': {
const lastUsedA = (infoA.lastUsed || infoA.last_accessed) ? new Date(infoA.lastUsed || infoA.last_accessed) : new Date(0);
const lastUsedB = (infoB.lastUsed || infoB.last_accessed) ? new Date(infoB.lastUsed || infoB.last_accessed) : new Date(0);
return lastUsedB - lastUsedA; // Recent first
}
case 'lastused-desc': {
const lastUsedA = (infoA.lastUsed || infoA.last_accessed) ? new Date(infoA.lastUsed || infoA.last_accessed) : new Date(0);
const lastUsedB = (infoB.lastUsed || infoB.last_accessed) ? new Date(infoB.lastUsed || infoB.last_accessed) : new Date(0);
return lastUsedA - lastUsedB; // Oldest first
}
case 'size': {
const sizeA = infoA.file_size || 0;
const sizeB = infoB.file_size || 0;
return sizeA - sizeB; // Small to large
}
case 'size-desc': {
const sizeA = infoA.file_size || 0;
const sizeB = infoB.file_size || 0;
return sizeB - sizeA; // Large to small
}
case 'type': {
const typeA = (infoA.model && infoA.model.type) || infoA.model_type || 'ZZZ';
const typeB = (infoB.model && infoB.model.type) || infoB.model_type || 'ZZZ';
if (typeA !== typeB) {
return typeA.localeCompare(typeB);
}
// Secondary sort by name
const nameA = (infoA.model && infoA.model.name) || infoA.name || a.filePath.split('/').pop() || '';
const nameB = (infoB.model && infoB.model.name) || infoB.name || b.filePath.split('/').pop() || '';
return nameA.localeCompare(nameB);
}
default: {
// Default to name sorting
const nameA = (infoA.model && infoA.model.name) || infoA.name || a.filePath.split('/').pop() || '';
const nameB = (infoB.model && infoB.model.name) || infoB.name || b.filePath.split('/').pop() || '';
return nameA.localeCompare(nameB);
}
}
});
}
/**
* Group models by Civitai ID, keeping different versions of the same model together
* @param {Array} models - Array of model objects
* @param {string} sortBy - Sort criteria to apply within groups
* @returns {Array} - Array of models grouped and sorted
*/
function groupModelsByCivitaiId(models, sortBy) {
// First, group models by Civitai ID
const groupedModels = new Map();
const ungroupedModels = [];
models.forEach(model => {
const modelId = model.info && (model.info.modelId || model.info.model_id);
if (modelId && modelId !== 'Unknown' && modelId !== null) {
if (!groupedModels.has(modelId)) {
groupedModels.set(modelId, []);
}
groupedModels.get(modelId).push(model);
} else {
ungroupedModels.push(model);
}
});
// Sort models within each group
for (const [modelId, group] of groupedModels) {
groupedModels.set(modelId, sortModels(group, sortBy));
}
// Sort ungrouped models
const sortedUngrouped = sortModels(ungroupedModels, sortBy);
// Combine grouped and ungrouped models
// First, get all groups and sort them by the first model in each group
const sortedGroups = Array.from(groupedModels.entries()).sort((a, b) => {
const firstModelA = a[1][0];
const firstModelB = b[1][0];
// Use the same sorting logic as the main sort
const tempModels = [firstModelA, firstModelB];
const sorted = sortModels(tempModels, sortBy);
return sorted[0] === firstModelA ? -1 : 1;
});
// Flatten the result: grouped models first, then ungrouped models
const result = [];
sortedGroups.forEach(([modelId, group]) => {
result.push(...group);
});
result.push(...sortedUngrouped);
return result;
}
/**
* Generate grouping information for models based on Civitai ID
* @param {Array} models - Array of models (already grouped and sorted)
* @returns {Array} - Array of group info objects for each model
*/
function generateGroupInfo(models) {
const groupInfo = [];
let currentModelId = null;
let groupStartIndex = -1;
let groupSize = 0;
models.forEach((model, index) => {
const modelId = model.info && (model.info.modelId || model.info.model_id);
const effectiveModelId = (modelId && modelId !== 'Unknown' && modelId !== null) ? modelId : null;
if (effectiveModelId && effectiveModelId === currentModelId) {
// Continue current group
groupSize++;
groupInfo[index] = {
isGroupMember: true,
isGroupFirst: false,
isGroupLast: false,
groupSize: groupSize,
modelId: effectiveModelId
};
} else {
// End previous group if it had more than one model
if (groupSize > 1 && groupStartIndex !== -1) {
groupInfo[groupStartIndex].isGroupFirst = true;
groupInfo[index - 1].isGroupLast = true;
// Mark all models in the group
for (let i = groupStartIndex; i < index; i++) {
groupInfo[i].isGroupMember = true;
}
}
// Start new group or single model
currentModelId = effectiveModelId;
groupStartIndex = effectiveModelId ? index : -1;
groupSize = effectiveModelId ? 1 : 0;
groupInfo[index] = {
isGroupMember: effectiveModelId ? true : false,
isGroupFirst: false,
isGroupLast: false,
groupSize: groupSize,
modelId: effectiveModelId
};
}
});
// Handle the last group if needed
if (groupSize > 1 && groupStartIndex !== -1) {
groupInfo[groupStartIndex].isGroupFirst = true;
groupInfo[models.length - 1].isGroupLast = true;
// Mark all models in the final group
for (let i = groupStartIndex; i < models.length; i++) {
groupInfo[i].isGroupMember = true;
}
}
return groupInfo;
}
/**
* 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.modelTypeFilter] - Current model type filter from model tab
* @returns {Promise<string>} - Complete HTML document
*/
export async function generateHtmlContentWithProgress(options) {
const {
models,
progressCallback,
sortedFiles,
checkpoints,
loras,
filterDescription = '',
searchDescription = '',
lastUsedDescription = '',
sortDescription = '',
sortBy = 'name',
modelTypeFilter = 'all'
} = options;
// 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;
checkpointModels = deduplicatedModels.filter(model => {
const info = model.info || {};
const filePath = model.filePath || '';
return isLikelyCheckpoint(filePath, info);
});
loraModels = deduplicatedModels.filter(model => {
const info = model.info || {};
const filePath = model.filePath || '';
return isLikelyLora(filePath, info);
});
} 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 model type filtering based on the selected filter from the models tab
if (modelTypeFilter !== 'all') {
if (progressCallback) {
progressCallback(11, `Filtering models by type: ${modelTypeFilter}...`);
}
if (modelTypeFilter === 'Checkpoint') {
// Only keep checkpoint models
allModels = checkpointModels;
loraModels = []; // Clear LoRA models since we're only showing checkpoints
} else if (modelTypeFilter === 'LORA') {
// Only keep LoRA models
allModels = loraModels;
checkpointModels = []; // Clear checkpoint models since we're only showing 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();
if (progressCallback) {
progressCallback(15, 'Building HTML structure...');
}
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: ${DEFAULT_THUMBNAIL_WIDTH + 10}px;
height: ${DEFAULT_THUMBNAIL_HEIGHT + 10}px;
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: sticky;
}
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;
position: sticky;
top: 0;
z-index: 10;
}
.sort-indicator {
font-size: 14px;
margin-left: 5px;
}
/* Model grouping styles */
.model-group-first {
border-top: 3px solid #4CAF50 !important;
}
.model-group {
background-color: rgba(76, 175, 80, 0.05);
}
.model-group-last {
border-bottom: 3px solid #4CAF50 !important;
}
.civitai-group-header {
background-color: #e8f5e8;
font-weight: bold;
color: #2e7d2e;
border: 2px solid #4CAF50 !important;
}
</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 = getThumbnailStyle();
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}${modelTypeFilter === 'all' ? ` (${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 and filter allows
if (loraModels.length > 0 && (modelTypeFilter === 'all' || modelTypeFilter === 'LORA')) {
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,
progressCallback: progressCallback ? (progress, message) => {
// Map LoRA progress to 25-50% range
const adjustedProgress = 25 + (progress * 0.25);
progressCallback(adjustedProgress, `LoRAs: ${message}`);
} : null
});
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 and filter allows
if (checkpointModels.length > 0 && (modelTypeFilter === 'all' || modelTypeFilter === 'Checkpoint')) {
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,
progressCallback: progressCallback ? (progress, message) => {
// Map Checkpoint progress to 50-80% range
const adjustedProgress = 50 + (progress * 0.30);
progressCallback(adjustedProgress, `Checkpoints: ${message}`);
} : null
});
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>
`;
if (progressCallback) {
progressCallback(85, 'Finalizing HTML report...');
}
return htmlContent;
}
/**
* Deduplicate models array by multiple criteria to prevent showing the same physical file multiple times
* @param {Array} models - Array of model objects
* @returns {Array} - Deduplicated array of models
*/
function deduplicateModels(models) {
const seenModels = [];
const hashMap = new Map(); // Track by file hash
const pathMap = new Map(); // Track by normalized path
const sizeNameMap = new Map(); // Track by size + filename combination
models.forEach(model => {
const filePath = model.filePath;
const hash = model.hash;
const info = model.info || {};
if (!filePath) return; // Skip models without file path
// Normalize the file path to handle relative paths and resolve ../
const normalizedPath = normalizePath(filePath);
const fileName = normalizedPath.split('/').pop() || '';
const fileSize = info.file_size || info.fileSize || info.size || 0;
// Create unique identifiers for different deduplication strategies
const pathKey = normalizedPath;
const hashKey = hash && hash !== 'Unknown' ? hash : null;
const sizeNameKey = fileSize && fileName ? `${fileSize}_${fileName}` : null;
let isDuplicate = false;
let existingIndex = -1;
let duplicateType = '';
// Check for duplicates in order of reliability:
// 1. First check by file hash (most reliable)
if (hashKey && hashMap.has(hashKey)) {
isDuplicate = true;
existingIndex = hashMap.get(hashKey);
duplicateType = 'hash';
}
// 2. Then check by exact file path
else if (pathMap.has(pathKey)) {
isDuplicate = true;
existingIndex = pathMap.get(pathKey);
duplicateType = 'path';
}
// 3. Finally check by size + filename (catches different paths to same file)
else if (sizeNameKey && sizeNameMap.has(sizeNameKey) && fileSize > 0) {
// Additional verification: only treat as duplicate if it's a substantial file (>1MB)
// This helps avoid false positives with small config files that might have same size+name
if (fileSize > 1024 * 1024) {
isDuplicate = true;
existingIndex = sizeNameMap.get(sizeNameKey);
duplicateType = 'size-name';
}
}
if (isDuplicate && existingIndex !== -1 && seenModels[existingIndex]) {
// Merge with existing entry, preferring non-null/non-undefined values
const existing = seenModels[existingIndex];
// Merge info objects, preferring values that exist
const mergedInfo = { ...existing.info };
if (info) {
Object.keys(info).forEach(key => {
if (info[key] != null &&
(mergedInfo[key] == null ||
(key.includes('size') && mergedInfo[key] === 0) ||
(key.includes('Size') && mergedInfo[key] === 0))) {
mergedInfo[key] = info[key];
}
});
}
// Prefer the shorter, cleaner path for display
let preferredPath = existing.filePath;
if (filePath.length < existing.filePath.length ||
(!existing.filePath.includes('/') && filePath.includes('/'))) {
preferredPath = filePath;
}
// Update the existing entry with merged data
seenModels[existingIndex] = {
filePath: preferredPath,
hash: hash || existing.hash,
info: mergedInfo
};
console.debug(`Merged duplicate model (${duplicateType}): ${filePath} -> ${preferredPath}`);
} else {
// First occurrence, add to array and maps
const newEntry = { ...model, filePath: normalizedPath };
const newIndex = seenModels.length;
seenModels.push(newEntry);
pathMap.set(pathKey, newIndex);
if (hashKey) hashMap.set(hashKey, newIndex);
if (sizeNameKey) sizeNameMap.set(sizeNameKey, newIndex);
}
});
return seenModels;
}
/**
* Normalize a file path by resolving relative components and cleaning up the path
* @param {string} filePath - The file path to normalize
* @returns {string} - Normalized file path
*/
function normalizePath(filePath) {
if (!filePath || typeof filePath !== 'string') return '';
// Convert backslashes to forward slashes for consistency
let normalized = filePath.replace(/\\/g, '/');
// Remove duplicate slashes
normalized = normalized.replace(/\/+/g, '/');
// Split into components and resolve .. and . references
const parts = normalized.split('/');
const resolved = [];
for (const part of parts) {
if (part === '..') {
// Go up one directory (remove last component)
if (resolved.length > 0 && resolved[resolved.length - 1] !== '..') {
resolved.pop();
} else if (!normalized.startsWith('/')) {
// Only keep .. if we're dealing with relative paths
resolved.push(part);
}
} else if (part !== '.' && part !== '') {
// Add normal directory/file names (skip . and empty parts)
resolved.push(part);
}
}
// Reconstruct the path
const result = (normalized.startsWith('/') ? '/' : '') + resolved.join('/');
return result || '/';
}
/**
* Generate complete HTML report
* @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.modelTypeFilter] - Current model type filter from model tab
* @returns {Promise<string>} - Complete HTML document
*/
export async function generateHtmlContent(options) {
const {
models,
progressCallback,
sortedFiles,
checkpoints,
loras,
filterDescription = '',
searchDescription = '',
lastUsedDescription = '',
sortDescription = '',
sortBy = 'name',
modelTypeFilter = 'all'
} = 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 {
alert('Unable to open new window. Please check your popup blocker settings.');
return false;
}
}