916 lines
39 KiB
JavaScript
916 lines
39 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
|
|
} 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,
|
|
generateClientSideJs,
|
|
generateModelStatsSection,
|
|
generateTableSection
|
|
} from './htmlTemplates.js';
|
|
|
|
import {
|
|
loadHtmlTemplate,
|
|
renderHtmlTemplate
|
|
} from '../utils/htmlTemplateLoader.js';
|
|
|
|
let reportTableCellTemplate = null;
|
|
let reportTableRowTemplate = null;
|
|
|
|
async function getReportTableCellTemplate() {
|
|
if (!reportTableCellTemplate) {
|
|
reportTableCellTemplate = await loadHtmlTemplate('extensions/comfyui_sageutils/reports/partials/reportTableCell.html');
|
|
}
|
|
return reportTableCellTemplate;
|
|
}
|
|
|
|
async function getReportTableRowTemplate() {
|
|
if (!reportTableRowTemplate) {
|
|
reportTableRowTemplate = await loadHtmlTemplate('extensions/comfyui_sageutils/reports/partials/reportTableRow.html');
|
|
}
|
|
return reportTableRowTemplate;
|
|
}
|
|
|
|
async function renderReportTableCell(cellClass, content, sortKey = '') {
|
|
const template = await getReportTableCellTemplate();
|
|
return renderHtmlTemplate(template, { cellClass, content, sortKey });
|
|
}
|
|
|
|
async function renderReportTableRow(cells, rowClass = '') {
|
|
const template = await getReportTableRowTemplate();
|
|
return renderHtmlTemplate(template, { rowClass, cells: cells.join('\n') });
|
|
}
|
|
|
|
let reportThumbnailImageTemplate = null;
|
|
let reportThumbnailPlaceholderTemplate = null;
|
|
let reportThumbnailAutoLoadTemplate = null;
|
|
let reportCivitaiContentTemplate = null;
|
|
let reportVersionLinkContentTemplate = null;
|
|
let reportVersionTextContentTemplate = null;
|
|
|
|
async function getReportThumbnailImageTemplate() {
|
|
if (!reportThumbnailImageTemplate) {
|
|
reportThumbnailImageTemplate = await loadHtmlTemplate('extensions/comfyui_sageutils/reports/partials/reportThumbnailImage.html');
|
|
}
|
|
return reportThumbnailImageTemplate;
|
|
}
|
|
|
|
async function getReportThumbnailPlaceholderTemplate() {
|
|
if (!reportThumbnailPlaceholderTemplate) {
|
|
reportThumbnailPlaceholderTemplate = await loadHtmlTemplate('extensions/comfyui_sageutils/reports/partials/reportThumbnailPlaceholder.html');
|
|
}
|
|
return reportThumbnailPlaceholderTemplate;
|
|
}
|
|
|
|
async function getReportThumbnailAutoLoadTemplate() {
|
|
if (!reportThumbnailAutoLoadTemplate) {
|
|
reportThumbnailAutoLoadTemplate = await loadHtmlTemplate('extensions/comfyui_sageutils/reports/partials/reportThumbnailAutoLoad.html');
|
|
}
|
|
return reportThumbnailAutoLoadTemplate;
|
|
}
|
|
|
|
async function getReportCivitaiContentTemplate() {
|
|
if (!reportCivitaiContentTemplate) {
|
|
reportCivitaiContentTemplate = await loadHtmlTemplate('extensions/comfyui_sageutils/reports/partials/reportCivitaiContent.html');
|
|
}
|
|
return reportCivitaiContentTemplate;
|
|
}
|
|
|
|
async function getReportVersionLinkContentTemplate() {
|
|
if (!reportVersionLinkContentTemplate) {
|
|
reportVersionLinkContentTemplate = await loadHtmlTemplate('extensions/comfyui_sageutils/reports/partials/reportVersionLinkContent.html');
|
|
}
|
|
return reportVersionLinkContentTemplate;
|
|
}
|
|
|
|
async function getReportVersionTextContentTemplate() {
|
|
if (!reportVersionTextContentTemplate) {
|
|
reportVersionTextContentTemplate = await loadHtmlTemplate('extensions/comfyui_sageutils/reports/partials/reportVersionTextContent.html');
|
|
}
|
|
return reportVersionTextContentTemplate;
|
|
}
|
|
|
|
async function renderReportThumbnailImage(imageUrl) {
|
|
const template = await getReportThumbnailImageTemplate();
|
|
return renderHtmlTemplate(template, { imageUrl });
|
|
}
|
|
|
|
async function renderReportThumbnailPlaceholder(placeholderText) {
|
|
const template = await getReportThumbnailPlaceholderTemplate();
|
|
return renderHtmlTemplate(template, { placeholderText });
|
|
}
|
|
|
|
async function renderReportThumbnailAutoLoad(hash, civitaiUrl) {
|
|
const template = await getReportThumbnailAutoLoadTemplate();
|
|
return renderHtmlTemplate(template, { hash, civitaiUrl });
|
|
}
|
|
|
|
async function renderReportCivitaiContent(civitaiUrl, modelId, updateMessage) {
|
|
const template = await getReportCivitaiContentTemplate();
|
|
return renderHtmlTemplate(template, { civitaiUrl, modelId, updateMessage });
|
|
}
|
|
|
|
async function renderReportVersionLinkContent(versionUrl, versionId) {
|
|
const template = await getReportVersionLinkContentTemplate();
|
|
return renderHtmlTemplate(template, { versionUrl, versionId });
|
|
}
|
|
|
|
async function renderReportVersionTextContent(versionId) {
|
|
const template = await getReportVersionTextContentTemplate();
|
|
return renderHtmlTemplate(template, { versionId });
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
|
|
function shouldAttemptCivitaiLoad(info) {
|
|
if (!info) return true;
|
|
const civitaiStatus = info.civitai;
|
|
let shouldAttempt = true;
|
|
|
|
if (civitaiStatus !== undefined && civitaiStatus !== null) {
|
|
if (typeof civitaiStatus === 'string') {
|
|
shouldAttempt = civitaiStatus.toLowerCase() === 'true';
|
|
} else if (typeof civitaiStatus === 'boolean') {
|
|
shouldAttempt = civitaiStatus;
|
|
}
|
|
}
|
|
|
|
const failedCount = info.civitai_failed_count || info.civitaiFailedCount || 0;
|
|
if (failedCount > 0) {
|
|
shouldAttempt = false;
|
|
}
|
|
|
|
return shouldAttempt;
|
|
}
|
|
|
|
function getCivitaiStatusReason(info) {
|
|
let noImageReason = 'No image available';
|
|
if (!info) return noImageReason;
|
|
|
|
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';
|
|
}
|
|
|
|
return noImageReason;
|
|
}
|
|
|
|
async function buildExampleImageContent(info, hash) {
|
|
if (info && info.images && Array.isArray(info.images) && info.images.length > 0) {
|
|
const firstImage = info.images[0];
|
|
if (firstImage && firstImage.url) {
|
|
return renderReportThumbnailImage(escapeHtml(firstImage.url));
|
|
}
|
|
}
|
|
|
|
if (!hash) {
|
|
return renderReportThumbnailPlaceholder('No image');
|
|
}
|
|
|
|
if (shouldAttemptCivitaiLoad(info)) {
|
|
const civitaiImageUrl = `https://civitai.com/api/v1/model-versions/by-hash/${encodeURIComponent(hash)}`;
|
|
return renderReportThumbnailAutoLoad(escapeHtml(hash), escapeHtml(civitaiImageUrl));
|
|
}
|
|
|
|
const noImageReason = getCivitaiStatusReason(info);
|
|
return renderReportThumbnailPlaceholder(escapeHtml(noImageReason));
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
const civitaiClass = 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;
|
|
|
|
const exampleImageContent = await buildExampleImageContent(info, hash);
|
|
|
|
// 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(' ')}"` : '';
|
|
|
|
const hasExistingImage = info && info.images && Array.isArray(info.images) && info.images.length > 0;
|
|
const imageCellClass = hasExistingImage || shouldAttemptCivitaiLoad(info) ? 'image-cell' : 'image-cell 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' }
|
|
];
|
|
|
|
for (const column of columnsToRender) {
|
|
switch (column.key) {
|
|
case 'name':
|
|
tableCells.push(await renderReportTableCell(`cell-center ${nameStyle}`, escapeHtml(modelName)));
|
|
break;
|
|
case 'basemodel':
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(baseModel)));
|
|
break;
|
|
case 'type':
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(modelType)));
|
|
break;
|
|
case 'folder': {
|
|
const folderType = getFolderTypeFromPath(filePath);
|
|
const folderDisplayName = getFolderDisplayName(folderType);
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(folderDisplayName)));
|
|
break;
|
|
}
|
|
case 'triggers':
|
|
tableCells.push(await renderReportTableCell('cell-center', triggerCellContent));
|
|
break;
|
|
case 'image':
|
|
tableCells.push(await renderReportTableCell(imageCellClass, exampleImageContent));
|
|
break;
|
|
case 'civitai': {
|
|
const updateMessage = shouldShowUpdateMessage ? '<br><br><i>Update available</i>' : '';
|
|
const civitaiContent = await renderReportCivitaiContent(escapeHtml(civitaiUrl), escapeHtml(modelId), updateMessage);
|
|
tableCells.push(await renderReportTableCell(`cell-center ${civitaiClass}`, civitaiContent));
|
|
break;
|
|
}
|
|
case 'versionid': {
|
|
const versionId = (info && info.id) || 'Unknown';
|
|
const versionContent = (versionId !== 'Unknown' && versionId !== '' && versionId != null && modelId !== 'Unknown' && modelId !== '' && modelId != null)
|
|
? await renderReportVersionLinkContent(escapeHtml(getModelUrl(modelId, versionId)), escapeHtml(String(versionId)))
|
|
: await renderReportVersionTextContent(escapeHtml(String(versionId)));
|
|
tableCells.push(await renderReportTableCell(`cell-center ${civitaiClass}`, versionContent));
|
|
break;
|
|
}
|
|
case 'hash':
|
|
tableCells.push(await renderReportTableCell('cell-center', `${escapeHtml(modelHash.substring(0, 12))}...`));
|
|
break;
|
|
case 'size':
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(fileSize), ` sorttable_customkey="${fileSizeBytes}"`));
|
|
break;
|
|
case 'lastused':
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(formattedLastUsed), ` sorttable_customkey="${lastUsedTimestamp}"`));
|
|
break;
|
|
case 'path':
|
|
tableCells.push(await renderReportTableCell('cell-left', escapeHtml(filePath)));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return renderReportTableRow(tableCells, groupClassAttribute);
|
|
}));
|
|
|
|
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);
|
|
const civitaiClass = 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;
|
|
|
|
const exampleImageContent = await buildExampleImageContent(info, hash);
|
|
|
|
// 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(' ')}"` : '';
|
|
|
|
const hasExistingImage = info && info.images && Array.isArray(info.images) && info.images.length > 0;
|
|
const imageCellClass = hasExistingImage || shouldAttemptCivitaiLoad(info) ? 'image-cell' : 'image-cell 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' }
|
|
];
|
|
|
|
for (const column of columnsToRender) {
|
|
switch (column.key) {
|
|
case 'name':
|
|
tableCells.push(await renderReportTableCell(`cell-center ${nameStyle}`, escapeHtml(modelName)));
|
|
break;
|
|
case 'basemodel':
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(baseModel)));
|
|
break;
|
|
case 'type':
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(modelType)));
|
|
break;
|
|
case 'folder': {
|
|
const folderType = getFolderTypeFromPath(filePath);
|
|
const folderDisplayName = getFolderDisplayName(folderType);
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(folderDisplayName)));
|
|
break;
|
|
}
|
|
case 'triggers':
|
|
tableCells.push(await renderReportTableCell('cell-center', triggerCellContent));
|
|
break;
|
|
case 'image':
|
|
tableCells.push(await renderReportTableCell(imageCellClass, exampleImageContent));
|
|
break;
|
|
case 'civitai': {
|
|
const updateMessage = shouldShowUpdateMessage ? '<br><br><i>Update available</i>' : '';
|
|
const civitaiContent = await renderReportCivitaiContent(escapeHtml(civitaiUrl), escapeHtml(modelId), updateMessage);
|
|
tableCells.push(await renderReportTableCell(`cell-center ${civitaiClass}`, civitaiContent));
|
|
break;
|
|
}
|
|
case 'versionid': {
|
|
const versionId = (info && info.id) || 'Unknown';
|
|
const versionContent = (versionId !== 'Unknown' && versionId !== '' && versionId != null && modelId !== 'Unknown' && modelId !== '' && modelId != null)
|
|
? await renderReportVersionLinkContent(escapeHtml(getModelUrl(modelId, versionId)), escapeHtml(String(versionId)))
|
|
: await renderReportVersionTextContent(escapeHtml(String(versionId)));
|
|
tableCells.push(await renderReportTableCell(`cell-center ${civitaiClass}`, versionContent));
|
|
break;
|
|
}
|
|
case 'hash':
|
|
tableCells.push(await renderReportTableCell('cell-center', `${escapeHtml(modelHash.substring(0, 12))}...`));
|
|
break;
|
|
case 'size':
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(fileSize), ` sorttable_customkey="${fileSizeBytes}"`));
|
|
break;
|
|
case 'lastused':
|
|
tableCells.push(await renderReportTableCell('cell-center', escapeHtml(formattedLastUsed), ` sorttable_customkey="${lastUsedTimestamp}"`));
|
|
break;
|
|
case 'path':
|
|
tableCells.push(await renderReportTableCell('cell-left', escapeHtml(filePath)));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return renderReportTableRow(tableCells, groupClassAttribute);
|
|
}));
|
|
|
|
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 cssHref = `${window.location.origin}/extensions/comfyui_sageutils/reports/reportStyles.css`;
|
|
const clientJs = generateClientSideJs(sortBy);
|
|
const htmlStart = await generateHtmlDocumentStart(currentDateTime, clientJs, cssHref);
|
|
|
|
let htmlContent = htmlStart + await 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 += await 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 += await 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 += await generateTableSection(folderType.displayName, folderModels.length, folderRows, visibleColumns);
|
|
}
|
|
}
|
|
|
|
htmlContent += await 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';
|