mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 08:21:27 -03:00
e914a0e19d
Stop resetting the whole listing after a successful download. The legacy flow reloaded page 1, scrolled to the top and hijacked the sidebar's active folder whenever a custom target folder was used, which made the Updates view lose its place (and sometimes render as an empty page). Downloads only flip the update flag for one model, so the listing is now reconciled in place through the virtual scroller: - Updates view: the model's cards are removed once its newest eligible version is installed (the flag is model-level). - Normal listing: the card stays; only update_available is cleared. - Model not in the current view (different folder/filter/window): no-op; the sidebar folder tree alone is refreshed. - Falling back to the legacy reload only when no virtual scroller is available (e.g. recipes page, duplicates mode, HF downloads).
1606 lines
55 KiB
JavaScript
1606 lines
55 KiB
JavaScript
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
|
import { modalManager } from '../../managers/ModalManager.js';
|
|
import { openCivitaiUrl, showToast } from '../../utils/uiHelpers.js';
|
|
import { translate } from '../../utils/i18nHelpers.js';
|
|
import { state } from '../../state/index.js';
|
|
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
|
import { formatFileSize } from './utils.js';
|
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
|
|
|
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
|
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
|
|
|
function buildCivitaiVersionUrl(modelId, versionId) {
|
|
return buildCivitaiModelUrl(
|
|
modelId,
|
|
versionId,
|
|
state?.global?.settings?.civitai_host
|
|
);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
if (value == null) {
|
|
return '';
|
|
}
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.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',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Format EA end time as smart relative time
|
|
* - < 1 day: "in Xh" (hours)
|
|
* - 1-7 days: "in Xd" (days)
|
|
* - > 7 days: "Jan 15" (short date)
|
|
*/
|
|
function formatEarlyAccessTime(endsAt) {
|
|
if (!endsAt) {
|
|
return null;
|
|
}
|
|
const endDate = new Date(endsAt);
|
|
if (Number.isNaN(endDate.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
const now = new Date();
|
|
const diffMs = endDate.getTime() - now.getTime();
|
|
const diffHours = diffMs / (1000 * 60 * 60);
|
|
const diffDays = diffHours / 24;
|
|
|
|
if (diffHours < 1) {
|
|
return translate('modals.model.versions.eaTime.endingSoon', {}, 'ending soon');
|
|
}
|
|
if (diffHours < 24) {
|
|
const hours = Math.ceil(diffHours);
|
|
return translate(
|
|
'modals.model.versions.eaTime.hours',
|
|
{ count: hours },
|
|
`in ${hours}h`
|
|
);
|
|
}
|
|
if (diffDays <= 7) {
|
|
const days = Math.ceil(diffDays);
|
|
return translate(
|
|
'modals.model.versions.eaTime.days',
|
|
{ count: days },
|
|
`in ${days}d`
|
|
);
|
|
}
|
|
// More than 7 days: show short date
|
|
return endDate.toLocaleDateString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
}
|
|
|
|
function isEarlyAccessActive(version) {
|
|
// Two-phase detection:
|
|
// 1. Use pre-computed isEarlyAccess flag if available (from backend)
|
|
// 2. Otherwise check exact end time if available
|
|
if (typeof version.isEarlyAccess === 'boolean') {
|
|
return version.isEarlyAccess;
|
|
}
|
|
if (!version.earlyAccessEndsAt) {
|
|
return false;
|
|
}
|
|
try {
|
|
return new Date(version.earlyAccessEndsAt) > new Date();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isPaidPermanent(version) {
|
|
return version && version.isPaid === true;
|
|
}
|
|
|
|
function isDownloadAllowed(version) {
|
|
if (!version.usageControl) {
|
|
return true;
|
|
}
|
|
return version.usageControl === 'Download';
|
|
}
|
|
|
|
function buildMetaMarkup(version, options = {}) {
|
|
const segments = [];
|
|
if (version.baseModel) {
|
|
segments.push(
|
|
`<span class="version-meta-primary">${escapeHtml(version.baseModel)}</span>`
|
|
);
|
|
}
|
|
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)));
|
|
}
|
|
|
|
// Add early access info if applicable
|
|
if (options.showEarlyAccess && isEarlyAccessActive(version)) {
|
|
const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt);
|
|
if (eaTime) {
|
|
segments.push(`<span class="version-meta-ea"><i class="fas fa-clock"></i> ${escapeHtml(eaTime)}</span>`);
|
|
}
|
|
}
|
|
|
|
if (!segments.length) {
|
|
return escapeHtml(
|
|
translate('modals.model.versions.labels.noDetails', {}, 'No additional details')
|
|
);
|
|
}
|
|
|
|
return segments
|
|
.map(segment => `<span class="version-meta-item">${segment}</span>`)
|
|
.join('<span class="version-meta-separator">•</span>');
|
|
}
|
|
|
|
function buildBadge(label, tone, options = {}) {
|
|
const attributes = [];
|
|
if (options.title) {
|
|
attributes.push(`title="${escapeHtml(options.title)}"`);
|
|
}
|
|
if (options.ariaLabel) {
|
|
attributes.push(`aria-label="${escapeHtml(options.ariaLabel)}"`);
|
|
}
|
|
const suffix = attributes.length ? ` ${attributes.join(' ')}` : '';
|
|
return `<span class="version-badge version-badge-${tone}"${suffix}>${escapeHtml(label)}</span>`;
|
|
}
|
|
|
|
function buildActionButton(label, variant, action, options = {}) {
|
|
const attributes = [
|
|
`class="version-action ${variant}"`,
|
|
];
|
|
if (action) {
|
|
attributes.push(`data-version-action="${escapeHtml(action)}"`);
|
|
}
|
|
if (!options.disabled && options.title) {
|
|
attributes.push(`title="${escapeHtml(options.title)}"`);
|
|
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
|
}
|
|
if (options.disabled) {
|
|
attributes.push('disabled');
|
|
}
|
|
if (options.extraAttributes) {
|
|
attributes.push(options.extraAttributes);
|
|
}
|
|
const buttonHtml = `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
|
if (options.disabled && options.title) {
|
|
return `<span class="version-action-disabled-wrapper" title="${escapeHtml(options.title)}" aria-label="${escapeHtml(options.title)}">${buttonHtml}</span>`;
|
|
}
|
|
return buttonHtml;
|
|
}
|
|
|
|
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?.version_grouping;
|
|
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 resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
|
if (!record) {
|
|
return false;
|
|
}
|
|
|
|
const strategy = state?.global?.settings?.version_grouping;
|
|
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
|
|
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
|
|
const hidePaid = state?.global?.settings?.hide_paid_updates;
|
|
|
|
if (!sameBaseMode) {
|
|
return Boolean(record?.hasUpdate);
|
|
}
|
|
|
|
const normalizedBase = normalizeBaseModelName(baseModel);
|
|
if (!normalizedBase || !Array.isArray(record.versions)) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedCurrentVersionId =
|
|
typeof currentVersionId === 'number'
|
|
? currentVersionId
|
|
: currentVersionId
|
|
? Number(currentVersionId)
|
|
: null;
|
|
|
|
let threshold = null;
|
|
for (const version of record.versions) {
|
|
if (!version.isInLibrary) {
|
|
continue;
|
|
}
|
|
const versionBase = normalizeBaseModelName(version.baseModel);
|
|
if (versionBase !== normalizedBase) {
|
|
continue;
|
|
}
|
|
if (threshold === null || version.versionId > threshold) {
|
|
threshold = version.versionId;
|
|
}
|
|
}
|
|
|
|
if (threshold === null) {
|
|
threshold = normalizedCurrentVersionId;
|
|
}
|
|
|
|
if (threshold === null) {
|
|
return false;
|
|
}
|
|
|
|
return record.versions.some(version => {
|
|
if (version.isInLibrary || version.shouldIgnore) {
|
|
return false;
|
|
}
|
|
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
|
return false;
|
|
}
|
|
if (hidePaid && isPaidPermanent(version)) {
|
|
return false;
|
|
}
|
|
if (!isDownloadAllowed(version)) {
|
|
return false;
|
|
}
|
|
const versionBase = normalizeBaseModelName(version.baseModel);
|
|
if (versionBase !== normalizedBase) {
|
|
return false;
|
|
}
|
|
return typeof version.versionId === 'number' && version.versionId > threshold;
|
|
});
|
|
}
|
|
|
|
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 `<div class="version-media version-media-placeholder">${escapeHtml(placeholderText)}</div>`;
|
|
}
|
|
|
|
if (isVideoUrl(version.previewUrl)) {
|
|
const autoplayOnHover = getAutoplaySetting();
|
|
return `
|
|
<div class="version-media">
|
|
<video
|
|
src="${escapeHtml(version.previewUrl)}"
|
|
${autoplayOnHover ? '' : 'controls'}
|
|
muted
|
|
loop
|
|
playsinline
|
|
preload="metadata"
|
|
data-autoplay-on-hover="${autoplayOnHover ? 'true' : 'false'}"
|
|
></video>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="version-media">
|
|
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderDeletePreview(version, versionName) {
|
|
const previewUrl = version?.previewUrl;
|
|
if (previewUrl && isVideoUrl(previewUrl)) {
|
|
return `
|
|
<video
|
|
src="${escapeHtml(previewUrl)}"
|
|
controls
|
|
muted
|
|
loop
|
|
playsinline
|
|
preload="metadata"
|
|
></video>
|
|
`;
|
|
}
|
|
|
|
const imageUrl = previewUrl || PREVIEW_PLACEHOLDER_URL;
|
|
return `<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(versionName)}" onerror="this.src='${PREVIEW_PLACEHOLDER_URL}'">`;
|
|
}
|
|
|
|
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 isEarlyAccess = isEarlyAccessActive(version);
|
|
const badges = [];
|
|
const openedBadgeLabel = translate('modals.model.versions.badges.current', {}, 'Opened Version');
|
|
const inLibraryBadgeLabel = translate('modals.model.versions.badges.inLibrary', {}, 'In Library');
|
|
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
|
|
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
|
|
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access');
|
|
const paidBadgeLabel = translate('modals.model.versions.badges.paid', {}, 'Paid');
|
|
const ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
|
|
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
|
|
|
if (isCurrent) {
|
|
badges.push(buildBadge(openedBadgeLabel, 'current', {
|
|
title: translate(
|
|
'modals.model.versions.badges.currentTooltip',
|
|
{},
|
|
'This is the version you opened this modal from'
|
|
),
|
|
}));
|
|
}
|
|
|
|
if (version.isInLibrary) {
|
|
badges.push(buildBadge(inLibraryBadgeLabel, 'success', {
|
|
title: translate(
|
|
'modals.model.versions.badges.inLibraryTooltip',
|
|
{},
|
|
'This version exists in your local library'
|
|
),
|
|
}));
|
|
}
|
|
|
|
if (!version.isInLibrary && version.hasBeenDownloaded) {
|
|
badges.push(buildBadge(downloadedBadgeLabel, 'info', {
|
|
title: translate(
|
|
'modals.model.versions.badges.downloadedTooltip',
|
|
{},
|
|
'This version was downloaded before, but is not currently in your library'
|
|
),
|
|
}));
|
|
}
|
|
|
|
if (!version.isInLibrary && isNewer && !version.shouldIgnore) {
|
|
badges.push(buildBadge(newerBadgeLabel, 'info', {
|
|
title: translate(
|
|
'modals.model.versions.badges.newerTooltip',
|
|
{},
|
|
'This version is newer than your latest local version'
|
|
),
|
|
}));
|
|
}
|
|
|
|
if (isEarlyAccess) {
|
|
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
|
|
title: translate(
|
|
'modals.model.versions.badges.earlyAccessTooltip',
|
|
{},
|
|
'This version currently requires Civitai early access'
|
|
),
|
|
}));
|
|
}
|
|
|
|
if (isPaidPermanent(version)) {
|
|
badges.push(buildBadge(paidBadgeLabel, 'paid', {
|
|
title: translate(
|
|
'modals.model.versions.badges.paidTooltip',
|
|
{},
|
|
'This version requires payment to download'
|
|
),
|
|
}));
|
|
}
|
|
|
|
if (!isDownloadAllowed(version)) {
|
|
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
|
|
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
|
|
title: translate(
|
|
'modals.model.versions.badges.onSiteOnlyTooltip',
|
|
{},
|
|
'This version is only available for on-site generation on Civitai'
|
|
),
|
|
}));
|
|
}
|
|
|
|
if (version.shouldIgnore) {
|
|
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
|
title: translate(
|
|
'modals.model.versions.badges.ignoredTooltip',
|
|
{},
|
|
'Update notifications are disabled for this version'
|
|
),
|
|
}));
|
|
}
|
|
|
|
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 = [];
|
|
const canDownload = isDownloadAllowed(version);
|
|
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
|
// The Download button always fetches the default (primary) file, keeping
|
|
// the single-file experience for users who don't care about variants.
|
|
// In-library versions hide it: their default file already exists locally,
|
|
// and multi-file versions use the "N files" badge below for the remaining
|
|
// variants instead (#1058). fileCount is null for records persisted before
|
|
// the field existed; default to the single-file behavior in that case.
|
|
const fileCount = typeof version.fileCount === 'number' ? version.fileCount : null;
|
|
const showDownload = !version.isInLibrary;
|
|
if (showDownload) {
|
|
let downloadTitle;
|
|
if (!canDownload) {
|
|
downloadTitle = translate(
|
|
'modals.model.versions.actions.downloadNotAllowedTooltip',
|
|
{},
|
|
'This version is only available for on-site generation on Civitai'
|
|
);
|
|
} else if (isPaidPermanent(version)) {
|
|
downloadTitle = translate(
|
|
'modals.model.versions.actions.downloadPaidTooltip',
|
|
{},
|
|
'Download this paid version from Civitai'
|
|
);
|
|
} else if (isEarlyAccess) {
|
|
downloadTitle = translate(
|
|
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
|
{},
|
|
'Download this early access version from Civitai'
|
|
);
|
|
} else {
|
|
downloadTitle = translate(
|
|
'modals.model.versions.actions.downloadTooltip',
|
|
{},
|
|
'Download this version'
|
|
);
|
|
}
|
|
actions.push(buildActionButton(
|
|
downloadLabel,
|
|
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
|
canDownload ? 'download' : '',
|
|
{
|
|
title: downloadTitle,
|
|
iconMarkup: downloadIcon,
|
|
disabled: !canDownload,
|
|
}
|
|
));
|
|
}
|
|
|
|
// Multi-file versions get an explicit entry into the download modal's
|
|
// file-selection step, mirroring the version step's file badge (#1058).
|
|
const fileSelectionBadge = fileCount !== null && fileCount > 1
|
|
? `<button type="button" class="file-select-badge" data-version-files title="${escapeHtml(translate('modals.model.versions.actions.downloadChooseFilesTooltip', {}, 'Choose which files to download'))}">
|
|
<i class="fas fa-th-list"></i> ${fileCount} ${escapeHtml(translate('modals.download.fileSelection.files', {}, 'files'))} <i class="fas fa-chevron-right badge-arrow"></i>
|
|
</button>`
|
|
: '';
|
|
if (version.isInLibrary && version.filePath) {
|
|
actions.push(buildActionButton(
|
|
deleteLabel,
|
|
'version-action-danger',
|
|
'delete',
|
|
{
|
|
title: translate(
|
|
'modals.model.versions.actions.deleteTooltip',
|
|
{},
|
|
'Delete this local version'
|
|
),
|
|
}
|
|
));
|
|
}
|
|
actions.push(buildActionButton(
|
|
ignoreLabel,
|
|
'version-action-ghost',
|
|
'toggle-ignore',
|
|
{
|
|
title: version.shouldIgnore
|
|
? translate(
|
|
'modals.model.versions.actions.unignoreTooltip',
|
|
{},
|
|
'Resume update notifications for this version'
|
|
)
|
|
: translate(
|
|
'modals.model.versions.actions.ignoreTooltip',
|
|
{},
|
|
'Ignore update notifications for this version'
|
|
),
|
|
extraAttributes: `data-ignore-state="${version.shouldIgnore ? 'ignored' : 'active'}"`,
|
|
}
|
|
));
|
|
|
|
const linkTarget = buildCivitaiVersionUrl(
|
|
version.modelId || parentModelId,
|
|
version.versionId
|
|
);
|
|
const civitaiTooltip = translate(
|
|
'modals.model.versions.actions.viewVersionOnCivitai',
|
|
{},
|
|
'View version on Civitai'
|
|
);
|
|
const civitaiLinkMarkup = linkTarget
|
|
? `
|
|
<a
|
|
class="version-civitai-link"
|
|
href="${escapeHtml(linkTarget)}"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title="${escapeHtml(civitaiTooltip)}"
|
|
aria-label="${escapeHtml(civitaiTooltip)}"
|
|
>
|
|
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
|
</a>
|
|
`
|
|
: '';
|
|
|
|
const rowAttributes = [
|
|
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
|
|
`data-version-id="${escapeHtml(version.versionId)}"`,
|
|
];
|
|
if (linkTarget) {
|
|
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
|
|
}
|
|
|
|
return `
|
|
<div ${rowAttributes.join(' ')}>
|
|
${renderMediaMarkup(version)}
|
|
<div class="version-details">
|
|
<div class="version-title">
|
|
<span class="versions-tab-version-name">${escapeHtml(versionName)}</span>
|
|
${civitaiLinkMarkup}
|
|
</div>
|
|
<div class="version-badges">${badges.join('')}</div>
|
|
<div class="version-meta">
|
|
${buildMetaMarkup(version, { showEarlyAccess: true })}
|
|
${fileSelectionBadge}
|
|
</div>
|
|
</div>
|
|
<div class="version-actions">
|
|
${actions.join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<header class="versions-toolbar">
|
|
<div class="versions-toolbar-info">
|
|
<div class="versions-toolbar-info-heading">
|
|
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
|
|
<button class="versions-filter-toggle" data-versions-action="toggle-version-display-mode" type="button" title="${escapeHtml(toggleTooltip)}" aria-label="${escapeHtml(toggleTooltip)}" data-filter-active="${filterActive}" aria-pressed="${filterActive}">
|
|
<i class="fas fa-th-list" aria-hidden="true"></i>
|
|
<span class="sr-only">${escapeHtml(screenReaderText)}</span>
|
|
</button>
|
|
</div>
|
|
<p>${escapeHtml(infoText)}</p>
|
|
</div>
|
|
<div class="versions-toolbar-actions">
|
|
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
|
|
${escapeHtml(ignoreText)}
|
|
</button>
|
|
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Show all local versions of this model on the main page'))}">
|
|
${escapeHtml(viewLocalText)}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
`;
|
|
}
|
|
|
|
function renderEmptyState(container) {
|
|
const message = translate('modals.model.versions.empty', {}, 'No version history available for this model yet.');
|
|
container.innerHTML = `
|
|
<div class="versions-empty">
|
|
<i class="fas fa-info-circle"></i>
|
|
<p>${escapeHtml(message)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderFilteredEmptyState(baseModelLabel) {
|
|
const message = translate(
|
|
'modals.model.versions.filters.empty',
|
|
{ baseModel: baseModelLabel },
|
|
'No versions match the current base model filter.'
|
|
);
|
|
return `
|
|
<div class="versions-empty versions-empty-filter">
|
|
<i class="fas fa-info-circle"></i>
|
|
<p>${escapeHtml(message)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderErrorState(container, message) {
|
|
const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.');
|
|
container.innerHTML = `
|
|
<div class="versions-error">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<p>${escapeHtml(message || fallback)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export function initVersionsTab({
|
|
modalId,
|
|
modelType,
|
|
modelId,
|
|
currentVersionId,
|
|
currentBaseModel,
|
|
modelName,
|
|
onUpdateStatusChange,
|
|
}) {
|
|
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,
|
|
};
|
|
|
|
const updateStatusChangeHandler =
|
|
typeof onUpdateStatusChange === 'function' ? onUpdateStatusChange : null;
|
|
let lastNotifiedUpdateState = 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 = `
|
|
<div class="versions-loading-state">
|
|
<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(loadingText)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 += '<div class="version-divider" role="presentation"></div>';
|
|
}
|
|
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,
|
|
})}
|
|
<div class="versions-list">
|
|
${listContent}
|
|
</div>
|
|
`;
|
|
|
|
setupMediaHoverInteractions(container);
|
|
|
|
if (updateStatusChangeHandler) {
|
|
const resolvedFlag = resolveUpdateAvailability(
|
|
record,
|
|
currentBaseModel,
|
|
normalizedCurrentVersionId
|
|
);
|
|
if (resolvedFlag !== lastNotifiedUpdateState) {
|
|
lastNotifiedUpdateState = resolvedFlag;
|
|
updateStatusChangeHandler(resolvedFlag, record);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
// HF group keys (e.g. "hf:user/repo") are not real CivitAI model IDs —
|
|
// skip the remote API call and show a helpful message instead.
|
|
const isHfGroupKey = typeof modelId === 'string' && modelId.startsWith('hf:');
|
|
if (isHfGroupKey) {
|
|
controller.isLoading = false;
|
|
controller.hasLoaded = true;
|
|
controller.record = null;
|
|
const hfMsg = translate(
|
|
'modals.model.versions.hfGroupInfo',
|
|
{},
|
|
'This is a HuggingFace model group. Open the library to see all versions in the grid.'
|
|
);
|
|
container.innerHTML = `
|
|
<div class="versions-empty-state">
|
|
<i class="fas fa-info-circle"></i>
|
|
<p>${escapeHtml(hfMsg)}</p>
|
|
</div>
|
|
`;
|
|
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);
|
|
}
|
|
|
|
function handleViewLocalVersions() {
|
|
if (!controller.record || !modelId) {
|
|
return;
|
|
}
|
|
// Determine base model filter based on current display mode
|
|
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
|
|
const isFilteringActive =
|
|
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
|
|
Boolean(baseModelInfo.normalized);
|
|
|
|
// Write filter params to sessionStorage (page-scoped)
|
|
setSessionItem('vlm_model_id', String(modelId));
|
|
setSessionItem('vlm_model_name', modelName || String(modelId));
|
|
setSessionItem('vlm_page_type', modelType);
|
|
if (isFilteringActive) {
|
|
// Use raw (non-normalized) base model for exact backend matching
|
|
setSessionItem('vlm_base_model', baseModelInfo.raw);
|
|
} else {
|
|
removeSessionItem('vlm_base_model');
|
|
}
|
|
|
|
// Close the modal and navigate via no-reload VLM flow
|
|
modalManager.closeModal(modalId);
|
|
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
|
|
window.pageControls.triggerVlmView(
|
|
modelId,
|
|
modelName || String(modelId),
|
|
isFilteringActive ? baseModelInfo.raw : undefined,
|
|
modelType
|
|
);
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div class="modal-content delete-modal-content version-delete-modal">
|
|
<h2>${escapeHtml(title)}</h2>
|
|
<p class="delete-message">${escapeHtml(confirmMessage)}</p>
|
|
<div class="delete-model-info">
|
|
<div class="delete-preview">
|
|
${previewMarkup}
|
|
</div>
|
|
<div class="delete-info">
|
|
<h3>${escapeHtml(versionName)}</h3>
|
|
${
|
|
version.baseModel
|
|
? `<p class="version-base-model">${escapeHtml(version.baseModel)}</p>`
|
|
: ''
|
|
}
|
|
${metaMarkup ? `<div class="version-meta">${metaMarkup}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="cancel-btn">${escapeHtml(cancelLabel)}</button>
|
|
<button class="delete-btn">${escapeHtml(deleteLabel)}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
function getCurrentInLibraryVersion() {
|
|
if (!normalizedCurrentVersionId || !controller.record?.versions) {
|
|
return null;
|
|
}
|
|
return controller.record.versions.find(
|
|
v => v.versionId === normalizedCurrentVersionId && v.isInLibrary && v.filePath
|
|
) || null;
|
|
}
|
|
|
|
function getDownloadPathTemplate() {
|
|
try {
|
|
const singularType = modelType.replace(/s$/, '');
|
|
const templates = state.global?.settings?.download_path_templates;
|
|
return (templates && templates[singularType]) || '';
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function shouldResolveTemplatePath(targetVersion, pathInfo) {
|
|
if (!getDownloadPathTemplate() || !pathInfo?.modelRoot) {
|
|
return false;
|
|
}
|
|
const currentVersion = getCurrentInLibraryVersion();
|
|
const currentBase = normalizeBaseModelName(currentVersion?.baseModel);
|
|
const targetBase = normalizeBaseModelName(targetVersion?.baseModel);
|
|
if (!currentBase || !targetBase || currentBase === targetBase) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function resolveDownloadPathFromCurrentVersion() {
|
|
const currentVersion = getCurrentInLibraryVersion();
|
|
if (!currentVersion) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const client = ensureClient();
|
|
const rootsData = await client.fetchModelRoots();
|
|
const roots = rootsData?.roots;
|
|
if (!Array.isArray(roots) || roots.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedFilePath = currentVersion.filePath.replace(/\\/g, '/');
|
|
let matchedRoot = null;
|
|
let relativePath = null;
|
|
|
|
for (const root of roots) {
|
|
const normalizedRoot = root.replace(/\\/g, '/');
|
|
if (normalizedFilePath.startsWith(normalizedRoot)) {
|
|
matchedRoot = root;
|
|
relativePath = normalizedFilePath.slice(normalizedRoot.length);
|
|
if (relativePath.startsWith('/')) {
|
|
relativePath = relativePath.slice(1);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matchedRoot || !relativePath) {
|
|
return null;
|
|
}
|
|
|
|
const lastSlash = relativePath.lastIndexOf('/');
|
|
const targetFolder = lastSlash > 0 ? relativePath.slice(0, lastSlash) : '';
|
|
|
|
return { modelRoot: matchedRoot, targetFolder };
|
|
} catch (error) {
|
|
console.debug('Failed to resolve download path from current version:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when the downloaded version is the newest version in the model's
|
|
* remote version set, i.e. the one whose install flips the backend
|
|
* update-available flag off. Unknown version sets fall back to "latest"
|
|
* so the post-download in-place reconciliation still runs by default.
|
|
* (#1078)
|
|
*/
|
|
function versionIsLatestAvailable(version) {
|
|
if (!controller.record || !Array.isArray(controller.record.versions)) {
|
|
return true;
|
|
}
|
|
const versions = controller.record.versions;
|
|
if (versions.length === 0) {
|
|
return true;
|
|
}
|
|
const target = Number(version?.versionId);
|
|
if (!Number.isFinite(target)) {
|
|
return true;
|
|
}
|
|
const maxId = versions.reduce(
|
|
(max, v) => Math.max(max, Number(v?.versionId) || 0),
|
|
0
|
|
);
|
|
return target >= maxId;
|
|
}
|
|
|
|
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 {
|
|
// The Download button only renders for versions not in the library
|
|
// and always fetches the default (primary) file. Multi-file
|
|
// variants are reached through the "N files" badge instead.
|
|
const pathInfo = await resolveDownloadPathFromCurrentVersion();
|
|
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
|
|
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
|
versionName: version.name || `#${version.versionId}`,
|
|
modelRoot: pathInfo?.modelRoot || '',
|
|
targetFolder: resolveTemplatePath ? '' : (pathInfo?.targetFolder || ''),
|
|
useDefaultPaths: resolveTemplatePath ? true : null,
|
|
useSaveDirAsRoot: resolveTemplatePath,
|
|
isLatestVersion: versionIsLatestAvailable(version),
|
|
});
|
|
|
|
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;
|
|
case 'view-local':
|
|
event.preventDefault();
|
|
handleViewLocalVersions();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const actionButton = event.target.closest('[data-version-action]');
|
|
if (actionButton) {
|
|
// Check if browser extension has already handled this action
|
|
if (actionButton.dataset.lmExtensionHandled === 'true') {
|
|
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;
|
|
}
|
|
|
|
// File-selection badge: enter the download modal's file step directly.
|
|
// Must run before the row-click navigation below (rows are clickable).
|
|
const filesBadge = event.target.closest('[data-version-files]');
|
|
if (filesBadge) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const row = filesBadge.closest('.model-version-row');
|
|
if (!row) {
|
|
return;
|
|
}
|
|
const versionId = Number(row.dataset.versionId);
|
|
await downloadManager.openFileSelectionForVersion(modelType, modelId, versionId);
|
|
return;
|
|
}
|
|
|
|
const row = event.target.closest('.model-version-row.is-clickable');
|
|
const civitaiLink = event.target.closest('.version-civitai-link');
|
|
if (civitaiLink) {
|
|
event.preventDefault();
|
|
openCivitaiUrl(civitaiLink.href);
|
|
return;
|
|
}
|
|
|
|
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();
|
|
openCivitaiUrl(targetUrl);
|
|
});
|
|
|
|
// Listen for extension-triggered refresh requests
|
|
container.addEventListener('lm:refreshVersions', async () => {
|
|
await refresh();
|
|
});
|
|
|
|
return {
|
|
load: options => loadVersions(options),
|
|
refresh,
|
|
};
|
|
}
|