import { getModelApiClient } from '../../api/modelApiFactory.js'; import { downloadManager } from '../../managers/DownloadManager.js'; import { showToast } from '../../utils/uiHelpers.js'; import { translate } from '../../utils/i18nHelpers.js'; import { state } from '../../state/index.js'; import { formatFileSize } from './utils.js'; const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv']; function escapeHtml(value) { if (value == null) { return ''; } return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function extractExtension(value) { if (value == null) { return ''; } const targets = []; const stringValue = String(value); if (stringValue) { targets.push(stringValue); stringValue.split(/[?&=]/).forEach(fragment => { if (fragment) { targets.push(fragment); } }); } for (const target of targets) { let candidate = target; try { candidate = decodeURIComponent(candidate); } catch (error) { // ignoring malformed sequences, fallback to raw value } const lastDot = candidate.lastIndexOf('.'); if (lastDot === -1) { continue; } const extension = candidate.slice(lastDot).toLowerCase(); if (extension.includes('/') || extension.includes('\\')) { continue; } return extension; } return ''; } function isVideoUrl(url) { if (!url || typeof url !== 'string') { return false; } const candidates = new Set(); const addCandidate = value => { if (value == null) { return; } const stringValue = String(value); if (!stringValue) { return; } candidates.add(stringValue); }; addCandidate(url); try { const parsed = new URL(url, window.location.origin); addCandidate(parsed.pathname); parsed.searchParams.forEach(value => addCandidate(value)); } catch (error) { // ignore parse errors and rely on fallbacks below } for (const candidate of candidates) { const extension = extractExtension(candidate); if (extension && VIDEO_EXTENSIONS.includes(extension)) { return true; } } return false; } function formatDateLabel(value) { if (!value) { return null; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return null; } return parsed.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } function buildMetaMarkup(version) { const segments = []; if (version.baseModel) { segments.push( `${escapeHtml(version.baseModel)}` ); } const releaseLabel = formatDateLabel(version.releasedAt); if (releaseLabel) { segments.push(escapeHtml(releaseLabel)); } if (typeof version.sizeBytes === 'number' && version.sizeBytes > 0) { segments.push(escapeHtml(formatFileSize(version.sizeBytes))); } if (!segments.length) { return escapeHtml( translate('modals.model.versions.labels.noDetails', {}, 'No additional details') ); } return segments .map(segment => `${segment}`) .join(''); } function buildBadge(label, tone) { return `${escapeHtml(label)}`; } function getAutoplaySetting() { try { return Boolean(state?.global?.settings?.autoplay_on_hover); } catch (error) { return false; } } function renderMediaMarkup(version) { if (!version.previewUrl) { const placeholderText = translate('modals.model.versions.media.placeholder', {}, 'No preview'); return `
${escapeHtml(placeholderText)}
`; } if (isVideoUrl(version.previewUrl)) { const autoplayOnHover = getAutoplaySetting(); return `
`; } return `
${escapeHtml(version.name || 'preview')}
`; } function renderRow(version, options) { const { latestLibraryVersionId, currentVersionId } = options; const isCurrent = currentVersionId && version.versionId === currentVersionId; const isNewer = typeof latestLibraryVersionId === 'number' && version.versionId > latestLibraryVersionId; const badges = []; if (isCurrent) { badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current')); } if (version.isInLibrary) { badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success')); } else if (isNewer && !version.shouldIgnore) { badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info')); } if (version.shouldIgnore) { badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted')); } const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download'); const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete'); const ignoreLabel = translate( version.shouldIgnore ? 'modals.model.versions.actions.unignore' : 'modals.model.versions.actions.ignore', {}, version.shouldIgnore ? 'Unignore' : 'Ignore' ); const actions = []; if (!version.isInLibrary) { actions.push( `` ); } else if (version.filePath) { actions.push( `` ); } actions.push( `` ); return `
${renderMediaMarkup(version)}
${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}
${badges.join('')}
${buildMetaMarkup(version)}
${actions.join('')}
`; } function setupMediaHoverInteractions(container) { const autoplayOnHover = getAutoplaySetting(); if (!autoplayOnHover) { return; } container.querySelectorAll('.version-media video').forEach(video => { if (video.dataset.autoplayOnHover !== 'true') { return; } const play = () => { try { video.currentTime = 0; const promise = video.play(); if (promise && typeof promise.catch === 'function') { promise.catch(() => {}); } } catch (error) { console.debug('Failed to autoplay preview video:', error); } }; const stop = () => { video.pause(); video.currentTime = 0; }; video.addEventListener('mouseenter', play); video.addEventListener('focus', play); video.addEventListener('mouseleave', stop); video.addEventListener('blur', stop); }); } function getLatestLibraryVersionId(record) { if (!record || !Array.isArray(record.inLibraryVersionIds) || !record.inLibraryVersionIds.length) { return null; } return Math.max(...record.inLibraryVersionIds); } function renderToolbar(record) { const ignoreText = record.shouldIgnore ? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model') : translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model'); const viewLocalText = translate('modals.model.versions.actions.viewLocalVersions', {}, 'View all local versions'); const infoText = translate( 'modals.model.versions.copy', { count: record.versions.length }, 'Track and manage every version of this model in one place.' ); return `

${translate('modals.model.versions.heading', {}, 'Model versions')}

${escapeHtml(infoText)}

`; } function renderEmptyState(container) { const message = translate('modals.model.versions.empty', {}, 'No version history available for this model yet.'); container.innerHTML = `

${escapeHtml(message)}

`; } function renderErrorState(container, message) { const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.'); container.innerHTML = `

${escapeHtml(message || fallback)}

`; } export function initVersionsTab({ modalId, modelType, modelId, currentVersionId, }) { const pane = document.querySelector(`#${modalId} #versions-tab`); const container = pane ? pane.querySelector('.model-versions-tab') : null; const normalizedCurrentVersionId = typeof currentVersionId === 'number' ? currentVersionId : currentVersionId ? Number(currentVersionId) : null; if (!container) { return { async load() {}, async refresh() {}, }; } let controller = { isLoading: false, hasLoaded: false, record: null, }; let apiClient; function ensureClient() { if (apiClient) { return apiClient; } try { apiClient = getModelApiClient(modelType); } catch (error) { apiClient = getModelApiClient(); } return apiClient; } function showLoading() { const loadingText = translate('modals.model.loading.versions', {}, 'Loading versions...'); container.innerHTML = `
${escapeHtml(loadingText)}
`; } function render(record) { controller.record = record; controller.hasLoaded = true; if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { renderEmptyState(container); return; } const latestLibraryVersionId = getLatestLibraryVersionId(record); let dividerInserted = false; const sortedVersions = [...record.versions].sort( (a, b) => Number(b.versionId) - Number(a.versionId) ); const rowsMarkup = sortedVersions .map(version => { const isNewer = typeof latestLibraryVersionId === 'number' && version.versionId > latestLibraryVersionId; let markup = ''; if ( !dividerInserted && typeof latestLibraryVersionId === 'number' && !isNewer ) { dividerInserted = true; markup += ''; } markup += renderRow(version, { latestLibraryVersionId, currentVersionId: normalizedCurrentVersionId, }); return markup; }) .join(''); container.innerHTML = ` ${renderToolbar(record)}
${rowsMarkup}
`; setupMediaHoverInteractions(container); } async function loadVersions({ forceRefresh = false, eager = false } = {}) { if (controller.isLoading) { return; } if (!modelId) { renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.')); return; } if (controller.hasLoaded && !forceRefresh) { return; } controller.isLoading = true; if (!eager) { showLoading(); } try { const client = ensureClient(); const response = await client.fetchModelUpdateVersions(modelId, { refresh: true, }); if (!response?.success) { throw new Error(response?.error || 'Request failed'); } render(response.record); } catch (error) { console.error('Failed to load model versions:', error); renderErrorState(container, error?.message); } finally { controller.isLoading = false; } } async function refresh() { await loadVersions({ forceRefresh: true }); } async function handleToggleModelIgnore(button) { if (!controller.record) { return; } const client = ensureClient(); const nextValue = !controller.record.shouldIgnore; button.disabled = true; try { const response = await client.setModelUpdateIgnore(modelId, nextValue); if (!response?.success) { throw new Error(response?.error || 'Request failed'); } render(response.record); const toastKey = nextValue ? 'modals.model.versions.toast.modelIgnored' : 'modals.model.versions.toast.modelResumed'; const toastMessage = translate( toastKey, {}, nextValue ? 'Updates ignored for this model' : 'Update tracking resumed' ); showToast(toastMessage, {}, 'success'); } catch (error) { console.error('Failed to update model ignore state:', error); showToast(error?.message || 'Failed to update ignore preference', {}, 'error'); } finally { button.disabled = false; } } async function handleToggleVersionIgnore(button, versionId) { if (!controller.record) { return; } const client = ensureClient(); const targetVersion = controller.record.versions.find(v => v.versionId === versionId); const nextValue = targetVersion ? !targetVersion.shouldIgnore : true; button.disabled = true; try { const response = await client.setVersionUpdateIgnore( modelId, versionId, nextValue ); if (!response?.success) { throw new Error(response?.error || 'Request failed'); } render(response.record); const updatedVersion = response.record.versions.find(v => v.versionId === versionId); const toastKey = updatedVersion?.shouldIgnore ? 'modals.model.versions.toast.versionIgnored' : 'modals.model.versions.toast.versionUnignored'; const toastMessage = translate( toastKey, {}, updatedVersion?.shouldIgnore ? 'Updates ignored for this version' : 'Version re-enabled' ); showToast(toastMessage, {}, 'success'); } catch (error) { console.error('Failed to toggle version ignore state:', error); showToast(error?.message || 'Failed to update version preference', {}, 'error'); } finally { button.disabled = false; } } async function handleDeleteVersion(button, versionId) { if (!controller.record) { return; } const version = controller.record.versions.find(item => item.versionId === versionId); if (!version?.filePath) { return; } const confirmText = translate( 'modals.model.versions.confirm.delete', {}, 'Delete this version from your library?' ); if (!window.confirm(confirmText)) { return; } button.disabled = true; try { const client = ensureClient(); await client.deleteModel(version.filePath); showToast( translate('modals.model.versions.toast.versionDeleted', {}, 'Version deleted'), {}, 'success' ); await refresh(); } catch (error) { console.error('Failed to delete version:', error); showToast(error?.message || 'Failed to delete version', {}, 'error'); } finally { button.disabled = false; } } async function handleDownloadVersion(button, versionId) { if (!controller.record) { return; } const version = controller.record.versions.find(item => item.versionId === versionId); if (!version) { console.warn('Target version missing from record for download:', versionId); return; } button.disabled = true; try { const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, { versionName: version.name || `#${version.versionId}`, }); if (success) { await refresh(); } } catch (error) { console.error('Failed to start direct download for version:', error); } finally { if (document.body.contains(button)) { button.disabled = false; } } } container.addEventListener('click', async event => { const toolbarAction = event.target.closest('[data-versions-action]'); if (toolbarAction) { const action = toolbarAction.dataset.versionsAction; if (action === 'toggle-model-ignore') { event.preventDefault(); await handleToggleModelIgnore(toolbarAction); } return; } const actionButton = event.target.closest('[data-version-action]'); if (!actionButton) { return; } const row = actionButton.closest('.model-version-row'); if (!row) { return; } const versionId = Number(row.dataset.versionId); const action = actionButton.dataset.versionAction; switch (action) { case 'download': event.preventDefault(); await handleDownloadVersion(actionButton, versionId); break; case 'delete': event.preventDefault(); await handleDeleteVersion(actionButton, versionId); break; case 'toggle-ignore': event.preventDefault(); await handleToggleVersionIgnore(actionButton, versionId); break; default: break; } }); return { load: options => loadVersions(options), refresh, }; }