import { getModelApiClient } from '../../api/modelApiFactory.js'; import { downloadManager } from '../../managers/DownloadManager.js'; import { modalManager } from '../../managers/ModalManager.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']; const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png'; function buildCivitaiVersionUrl(modelId, versionId) { if (modelId == null || versionId == null) { return null; } const normalizedModelId = String(modelId).trim(); const normalizedVersionId = String(versionId).trim(); if (!normalizedModelId || !normalizedVersionId) { return null; } const encodedModelId = encodeURIComponent(normalizedModelId); const encodedVersionId = encodeURIComponent(normalizedVersionId); return `https://civitai.com/models/${encodedModelId}?modelVersionId=${encodedVersionId}`; } 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)}`; } const DISPLAY_FILTER_MODES = Object.freeze({ SAME_BASE: 'same_base', ANY: 'any', }); const FILTER_LABEL_KEY = 'modals.model.versions.filters.label'; const FILTER_STATE_KEYS = { [DISPLAY_FILTER_MODES.SAME_BASE]: 'modals.model.versions.filters.state.showSameBase', [DISPLAY_FILTER_MODES.ANY]: 'modals.model.versions.filters.state.showAll', }; const FILTER_TOOLTIP_KEYS = { [DISPLAY_FILTER_MODES.SAME_BASE]: 'modals.model.versions.filters.tooltip.showAllVersions', [DISPLAY_FILTER_MODES.ANY]: 'modals.model.versions.filters.tooltip.showSameBaseVersions', }; function normalizeBaseModelName(value) { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } return trimmed.toLowerCase(); } function getToggleLabelText() { return translate(FILTER_LABEL_KEY, {}, 'Base filter'); } function getToggleStateText(mode) { const key = FILTER_STATE_KEYS[mode] || FILTER_STATE_KEYS[DISPLAY_FILTER_MODES.ANY]; const fallback = mode === DISPLAY_FILTER_MODES.SAME_BASE ? 'Same base' : 'All versions'; return translate(key, {}, fallback); } function getToggleTooltipText(mode) { const key = FILTER_TOOLTIP_KEYS[mode] || FILTER_TOOLTIP_KEYS[DISPLAY_FILTER_MODES.ANY]; const fallback = mode === DISPLAY_FILTER_MODES.SAME_BASE ? 'Switch to showing all versions' : 'Switch to showing only versions with the current base model'; return translate(key, {}, fallback); } function getDefaultDisplayMode() { const strategy = state?.global?.settings?.update_flag_strategy; return strategy === DISPLAY_FILTER_MODES.SAME_BASE ? DISPLAY_FILTER_MODES.SAME_BASE : DISPLAY_FILTER_MODES.ANY; } function getCurrentVersionBaseModel(record, versionId) { if (!record || typeof versionId !== 'number' || !Array.isArray(record.versions)) { return { normalized: null, raw: null, }; } const currentVersion = record.versions.find(v => v.versionId === versionId); if (!currentVersion) { return { normalized: null, raw: null, }; } const baseModelRaw = currentVersion.baseModel ?? null; return { normalized: normalizeBaseModelName(baseModelRaw), raw: baseModelRaw, }; } 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 renderDeletePreview(version, versionName) { const previewUrl = version?.previewUrl; if (previewUrl && isVideoUrl(previewUrl)) { return ` `; } const imageUrl = previewUrl || PREVIEW_PLACEHOLDER_URL; return `${escapeHtml(versionName)}`; } function renderRow(version, options) { const { latestLibraryVersionId, currentVersionId, modelId: parentModelId } = 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( `` ); const linkTarget = buildCivitaiVersionUrl( version.modelId || parentModelId, version.versionId ); const civitaiTooltip = translate( 'modals.model.actions.viewOnCivitai', {}, 'View on Civitai' ); const rowAttributes = [ `class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}"`, `data-version-id="${escapeHtml(version.versionId)}"`, ]; if (linkTarget) { rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`); rowAttributes.push(`title="${escapeHtml(civitaiTooltip)}"`); } 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, toolbarState = {}) { 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.' ); const displayMode = toolbarState.displayMode || DISPLAY_FILTER_MODES.ANY; const toggleLabel = getToggleLabelText(); const toggleState = getToggleStateText(displayMode); const toggleTooltip = getToggleTooltipText(displayMode); const filterActive = toolbarState.isFilteringActive ? 'true' : 'false'; const screenReaderText = [toggleLabel, toggleState].filter(Boolean).join(': '); 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 renderFilteredEmptyState(baseModelLabel) { const message = translate( 'modals.model.versions.filters.empty', { baseModel: baseModelLabel }, 'No versions match the current base model filter.' ); return `

${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 displayMode = getDefaultDisplayMode(); 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); const { normalized: currentBaseModelNormalized, raw: currentBaseModelLabel } = getCurrentVersionBaseModel(record, normalizedCurrentVersionId); const isFilteringActive = displayMode === DISPLAY_FILTER_MODES.SAME_BASE && Boolean(currentBaseModelNormalized); const sortedVersions = [...record.versions].sort( (a, b) => Number(b.versionId) - Number(a.versionId) ); const filteredVersions = sortedVersions.filter(version => { if (!isFilteringActive) { return true; } return normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized; }); const dividerThresholdVersionId = (() => { if (!isFilteringActive) { return latestLibraryVersionId; } const baseLocalVersionIds = record.versions .filter( version => version.isInLibrary && normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized && typeof version.versionId === 'number' ) .map(version => version.versionId); if (!baseLocalVersionIds.length) { return null; } return Math.max(...baseLocalVersionIds); })(); let dividerInserted = false; const rowsMarkup = filteredVersions .map(version => { let markup = ''; if ( !dividerInserted && typeof dividerThresholdVersionId === 'number' && !(version.versionId > dividerThresholdVersionId) ) { dividerInserted = true; markup += ''; } markup += renderRow(version, { latestLibraryVersionId: dividerThresholdVersionId, currentVersionId: normalizedCurrentVersionId, modelId: record?.modelId ?? modelId, }); return markup; }) .join(''); const listContent = rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel); container.innerHTML = ` ${renderToolbar(record, { displayMode, isFilteringActive, })}
${listContent}
`; 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: false, }); 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; } } function handleToggleVersionDisplayMode() { displayMode = displayMode === DISPLAY_FILTER_MODES.SAME_BASE ? DISPLAY_FILTER_MODES.ANY : DISPLAY_FILTER_MODES.SAME_BASE; if (!controller.record) { return; } render(controller.record); } 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 performDeleteVersion({ triggerButton, confirmButton, closeModal, version, }) { if (!version?.filePath) { console.warn('Missing file path for deletion.'); return; } if (triggerButton) { triggerButton.disabled = true; } let confirmOriginalText = ''; if (confirmButton) { confirmOriginalText = confirmButton.textContent; confirmButton.disabled = true; } let deletionSucceeded = false; try { const client = ensureClient(); await client.deleteModel(version.filePath); deletionSucceeded = true; showToast( translate('modals.model.versions.toast.versionDeleted', {}, 'Version deleted'), {}, 'success' ); } catch (error) { console.error('Failed to delete version:', error); showToast(error?.message || 'Failed to delete version', {}, 'error'); } finally { if (triggerButton && document.body.contains(triggerButton)) { triggerButton.disabled = false; } if ( confirmButton && document.body.contains(confirmButton) && !deletionSucceeded ) { confirmButton.disabled = false; if (confirmOriginalText) { confirmButton.textContent = confirmOriginalText; } } } if (!deletionSucceeded) { return; } if (typeof closeModal === 'function') { closeModal(); } await refresh(); } function showDeleteVersionModal(version, triggerButton) { const modalRecord = modalManager?.getModal?.('deleteModal'); if (!modalRecord?.element) { return false; } const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete'); const cancelLabel = translate('common.actions.cancel', {}, 'Cancel'); const title = translate('modals.model.versions.actions.delete', {}, 'Delete'); const confirmMessage = translate( 'modals.model.versions.confirm.delete', {}, 'Delete this version from your library?' ); const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'); const metaMarkup = buildMetaMarkup(version); const previewMarkup = renderDeletePreview(version, versionName); const modalElement = modalRecord.element; const originalMarkup = modalElement.innerHTML; const content = ` `; const cleanupHandlers = []; modalManager.showModal( 'deleteModal', content, null, () => { cleanupHandlers.forEach(handler => { try { handler(); } catch (error) { console.error('Failed to cleanup delete modal handler:', error); } }); cleanupHandlers.length = 0; modalElement.innerHTML = originalMarkup; delete modalElement.dataset.versionId; } ); modalElement.dataset.versionId = String(version.versionId ?? ''); const cancelButton = modalElement.querySelector('.cancel-btn'); const confirmButton = modalElement.querySelector('.delete-btn'); const closeModal = () => modalManager.closeModal('deleteModal'); if (cancelButton) { const handleCancel = event => { event.preventDefault(); closeModal(); }; cancelButton.addEventListener('click', handleCancel); cleanupHandlers.push(() => { cancelButton.removeEventListener('click', handleCancel); }); } if (confirmButton) { const handleConfirm = async event => { event.preventDefault(); await performDeleteVersion({ triggerButton, confirmButton, closeModal, version, }); }; confirmButton.addEventListener('click', handleConfirm); cleanupHandlers.push(() => { confirmButton.removeEventListener('click', handleConfirm); }); } return true; } async function handleDeleteVersion(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 delete:', versionId); return; } if (showDeleteVersionModal(version, button)) { return; } const confirmText = translate( 'modals.model.versions.confirm.delete', {}, 'Delete this version from your library?' ); if (!window.confirm(confirmText)) { return; } await performDeleteVersion({ triggerButton: button, version, }); } 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; switch (action) { case 'toggle-model-ignore': event.preventDefault(); await handleToggleModelIgnore(toolbarAction); break; case 'toggle-version-display-mode': event.preventDefault(); handleToggleVersionDisplayMode(); break; default: break; } return; } const actionButton = event.target.closest('[data-version-action]'); if (actionButton) { 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; } const row = event.target.closest('.model-version-row.is-clickable'); if (!row) { return; } if (event.target.closest('button')) { return; } if (event.target.closest('.version-actions')) { return; } if (event.target.closest('a')) { return; } const targetUrl = row.dataset.civitaiUrl; if (!targetUrl) { return; } event.preventDefault(); window.open(targetUrl, '_blank', 'noopener,noreferrer'); }); return { load: options => loadVersions(options), refresh, }; }