diff --git a/locales/de.json b/locales/de.json
index 59dd60a1..11fcab53 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "CSV herunterladen",
"columnModelName": "Modellname",
"columnError": "Fehler"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/en.json b/locales/en.json
index 123cd8b0..f4608e55 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "Download CSV",
"columnModelName": "Model Name",
"columnError": "Error"
+ },
+ "downloadBatchSummary": {
+ "title": "Batch Download Summary",
+ "statSuccess": "Success",
+ "statFailed": "Failed",
+ "statTotal": "Total",
+ "successMessage": "All {count} models downloaded successfully",
+ "completedWithErrors": "Completed with errors",
+ "failed": "Download failed",
+ "failedItems": "Failed Items ({count})",
+ "columnName": "Model Name",
+ "columnError": "Error",
+ "close": "Close",
+ "copyReport": "Copy Report",
+ "retryFailed": "Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/es.json b/locales/es.json
index 2130e1ea..a69682bc 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "Descargar CSV",
"columnModelName": "Nombre del modelo",
"columnError": "Error"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/fr.json b/locales/fr.json
index cae78f7b..783d99bf 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "Télécharger CSV",
"columnModelName": "Nom du modèle",
"columnError": "Erreur"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/he.json b/locales/he.json
index dc8f978d..1f1e8698 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "הורד CSV",
"columnModelName": "שם המודל",
"columnError": "שגיאה"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/ja.json b/locales/ja.json
index 5dc90cad..d452eba8 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "CSVをダウンロード",
"columnModelName": "モデル名",
"columnError": "エラー"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/ko.json b/locales/ko.json
index 1a931b85..7ef3a8c8 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "CSV 다운로드",
"columnModelName": "모델 이름",
"columnError": "오류"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/ru.json b/locales/ru.json
index 7f8df383..2be5e0e4 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "Скачать CSV",
"columnModelName": "Имя модели",
"columnError": "Ошибка"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 3bda05cb..cac873e1 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "下载 CSV",
"columnModelName": "模型名称",
"columnError": "错误"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 3ff4652b..b4d42cea 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -1581,6 +1581,21 @@
"downloadCsv": "下載 CSV",
"columnModelName": "模型名稱",
"columnError": "錯誤"
+ },
+ "downloadBatchSummary": {
+ "title": "[TODO: Translate] Batch Download Summary",
+ "statSuccess": "[TODO: Translate] Success",
+ "statFailed": "[TODO: Translate] Failed",
+ "statTotal": "[TODO: Translate] Total",
+ "successMessage": "[TODO: Translate] All {count} models downloaded successfully",
+ "completedWithErrors": "[TODO: Translate] Completed with errors",
+ "failed": "[TODO: Translate] Download failed",
+ "failedItems": "[TODO: Translate] Failed Items ({count})",
+ "columnName": "[TODO: Translate] Model Name",
+ "columnError": "[TODO: Translate] Error",
+ "close": "[TODO: Translate] Close",
+ "copyReport": "[TODO: Translate] Copy Report",
+ "retryFailed": "[TODO: Translate] Retry Failed ({count})"
}
},
"modelTags": {
diff --git a/static/css/components/download-batch-summary.css b/static/css/components/download-batch-summary.css
new file mode 100644
index 00000000..bff608e9
--- /dev/null
+++ b/static/css/components/download-batch-summary.css
@@ -0,0 +1,67 @@
+/* Batch Download Summary Modal — component styles only.
+ Stat cards and failure table styles are shared with the metadata refresh
+ result modal (metadata-refresh-result.css) and are not redefined here. */
+
+.download-batch-summary-modal {
+ max-width: 700px;
+}
+
+.summary-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin: var(--space-2) 0;
+}
+
+.summary-header i {
+ font-size: 1.4em;
+}
+
+.summary-header.success i {
+ color: var(--color-success);
+}
+
+.summary-header.warning i {
+ color: var(--color-warning);
+}
+
+.summary-header.error i {
+ color: var(--color-error);
+}
+
+.summary-title {
+ font-weight: var(--weight-semibold);
+ color: var(--lora-text);
+}
+
+.summary-hint {
+ margin-left: auto;
+ font-size: var(--text-xs);
+ color: var(--text-secondary);
+}
+
+.btn-retry {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ background: var(--lora-accent, #4f46e5);
+ color: #fff;
+ border: none;
+ border-radius: var(--border-radius-sm);
+ padding: var(--space-2) var(--space-3);
+ cursor: pointer;
+ font-weight: var(--weight-semibold);
+}
+
+.btn-retry:hover {
+ background: var(--lora-accent-hover, #4338ca);
+}
+
+.failure-link {
+ color: var(--lora-accent, #4f46e5);
+ text-decoration: none;
+}
+
+.failure-link:hover {
+ text-decoration: underline;
+}
diff --git a/static/css/style.css b/static/css/style.css
index b8460bf1..cb098abf 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -41,6 +41,7 @@
@import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css';
@import 'components/metadata-refresh-result.css';
+@import 'components/download-batch-summary.css';
.initialization-notice {
display: flex;
diff --git a/static/js/components/DownloadBatchSummaryModal.js b/static/js/components/DownloadBatchSummaryModal.js
new file mode 100644
index 00000000..712b7457
--- /dev/null
+++ b/static/js/components/DownloadBatchSummaryModal.js
@@ -0,0 +1,340 @@
+import { translate } from '../utils/i18nHelpers.js';
+import { showToast, openHuggingFace } from '../utils/uiHelpers.js';
+
+/**
+ * Escape HTML entities in a string to prevent injection when interpolating into innerHTML.
+ * Safe for both text content and attribute values (quotes are escaped too).
+ * @param {string} str - The string to escape
+ * @returns {string} - The escaped string
+ */
+function _escapeHtml(str) {
+ if (!str) return '';
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
+}
+
+/**
+ * Resolve the display name of a failed download entry.
+ * Prefers the resolved name carried on the entry, then known item fields,
+ * then derives a name from the item URL as a last resort.
+ * @param {Object} entry - The failed entry ({ item, error, name? })
+ * @returns {string} - The best available display name
+ */
+function _resolveItemName(entry) {
+ if (entry?.name) {
+ return entry.name;
+ }
+ const item = entry?.item ?? entry;
+ const direct = item?.displayName || item?.name || item?.file_name || item?.filename || item?.selectedVersion?.name;
+ if (direct) {
+ return direct;
+ }
+ if (item?.url) {
+ try {
+ const segments = new URL(item.url).pathname.split('/').filter(Boolean);
+ if (segments.length > 0) {
+ return decodeURIComponent(segments[segments.length - 1]);
+ }
+ } catch (e) {
+ // Unparseable URL — fall through to 'Unknown'
+ }
+ }
+ return 'Unknown';
+}
+
+/**
+ * Resolve the URL to open for a failed item — always the original item URL.
+ * @param {Object} item - The failed item payload
+ * @returns {string|null} - A URL string, or null when nothing is available
+ */
+function _resolveItemUrl(item) {
+ return item?.url || null;
+}
+
+/**
+ * Format a raw failure error into a concise human-readable message.
+ * Unwraps JSON envelopes and extracts HTTP status/body details when present.
+ * @param {*} error - The raw error (usually a string)
+ * @returns {string} - The formatted error message
+ */
+function _formatError(error) {
+ if (!error) {
+ return 'Unknown error';
+ }
+ let base = typeof error === 'string' ? error : String(error);
+
+ // Unwrap JSON envelope: { "success": false, "error": "...", ... }
+ try {
+ const parsed = JSON.parse(base);
+ if (parsed && typeof parsed.error === 'string' && parsed.error) {
+ base = parsed.error;
+ }
+ } catch (e) {
+ // Not a JSON envelope — keep the raw string
+ }
+
+ // Extract HTTP status and JSON body details, e.g. "status=403 body={...}"
+ let result = base;
+ const statusMatch = base.match(/status=(\d{3})/);
+ const bodyMatch = base.match(/body=(\{.*\})/s);
+ if (bodyMatch) {
+ try {
+ const body = JSON.parse(bodyMatch[1]);
+ const detail = (typeof body?.message === 'string' && body.message)
+ || (typeof body?.error === 'string' && body.error)
+ || null;
+ if (detail) {
+ const status = statusMatch ? statusMatch[1] : null;
+ result = `${status ? `HTTP ${status} — ` : ''}${detail}`;
+ }
+ } catch (e) {
+ // Body is not valid JSON — keep the base string
+ }
+ }
+
+ // Truncate overly long messages
+ if (result.length > 220) {
+ result = result.slice(0, 220) + '…';
+ }
+ return result;
+}
+
+/**
+ * Build a plain-text report of the batch download results.
+ * @param {number} total - Total number of models attempted
+ * @param {number} completed - Number of models successfully downloaded
+ * @param {Array} failedItems - Array of failed items ({ item, error })
+ * @returns {string} - The report text
+ */
+function _buildReportText(total, completed, failedItems) {
+ const lines = [
+ '=== Batch Download Report ===',
+ `Date: ${new Date().toLocaleString()}`,
+ `Total: ${total}`,
+ `Successfully downloaded: ${completed}`,
+ `Failed: ${failedItems.length}`,
+ '',
+ ];
+ if (failedItems.length > 0) {
+ lines.push('--- Failed Items ---');
+ failedItems.forEach((entry, i) => {
+ const name = _resolveItemName(entry);
+ const error = _formatError(entry?.error);
+ lines.push(`${i + 1}. ${name} — ${error}`);
+ const itemUrl = _resolveItemUrl(entry?.item ?? entry);
+ if (itemUrl) {
+ lines.push(` URL: ${itemUrl}`);
+ }
+ });
+ lines.push('');
+ }
+ lines.push('====================');
+ return lines.join('\n');
+}
+
+/**
+ * Handle a successful clipboard write: confirm via toast and briefly swap the
+ * trigger button to a "Copied!" state.
+ * @param {HTMLElement|null} btn - The button that triggered the copy action
+ */
+function _onCopyReportSuccess(btn) {
+ showToast('toast.api.copiedToClipboard', {}, 'success');
+ if (btn) {
+ const origHTML = btn.innerHTML;
+ btn.innerHTML = ' Copied!';
+ setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
+ }
+}
+
+/**
+ * Fallback for environments without the async Clipboard API (e.g. insecure
+ * contexts over LAN http where `navigator.clipboard` is undefined): copy via a
+ * hidden textarea and `document.execCommand('copy')`.
+ * @param {string} text - The report text to copy
+ */
+function _copyReportWithExecCommand(text) {
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textarea);
+ showToast('toast.api.copiedToClipboard', {}, 'success');
+}
+
+/**
+ * Copy the batch download report to the clipboard.
+ * Uses the async Clipboard API when available, otherwise falls back to a hidden
+ * textarea + execCommand so the action still works in insecure contexts.
+ * @param {HTMLElement} btn - The button that triggered the copy action
+ * @param {number} total - Total number of models attempted
+ * @param {number} completed - Number of models successfully downloaded
+ * @param {Array} failedItems - Array of failed items
+ */
+function _copyReport(btn, total, completed, failedItems) {
+ const text = _buildReportText(total, completed, failedItems);
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+ navigator.clipboard.writeText(text)
+ .then(() => _onCopyReportSuccess(btn))
+ .catch(() => _copyReportWithExecCommand(text));
+ } else {
+ _copyReportWithExecCommand(text);
+ }
+}
+
+/**
+ * Show the batch download summary modal after a batch download completes.
+ * Mirrors the Metadata Fetch Summary modal lifecycle: the modal element is
+ * appended directly to document.body and removed on close; it is not
+ * registered with ModalManager.
+ * @param {Object} options - Summary options
+ * @param {number} options.total - Total number of models attempted
+ * @param {number} options.completed - Number of models successfully downloaded
+ * @param {Array} options.failedItems - Array of failed items ({ item, error })
+ * @param {Function} options.onRetry - Callback invoked with failedItems to retry the failed subset
+ */
+export function showDownloadBatchSummary({ total, completed, failedItems, onRetry }) {
+ const failures = failedItems || [];
+ const failedCount = failures.length;
+
+ // 3-state summary header semantics (mirrors BatchImportManager results header)
+ let headerState;
+ let headerIcon;
+ let headerText;
+ if (completed === 0) {
+ headerState = 'error';
+ headerIcon = 'fa-times-circle';
+ headerText = translate('modals.downloadBatchSummary.failed', {}, 'Download failed');
+ } else if (failedCount > 0) {
+ headerState = 'warning';
+ headerIcon = 'fa-exclamation-circle';
+ headerText = translate('modals.downloadBatchSummary.completedWithErrors', {}, 'Completed with errors');
+ } else {
+ headerState = 'success';
+ headerIcon = 'fa-check-circle';
+ headerText = translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully');
+ }
+
+ // Build failure table rows
+ const failureRows = failures.map((entry, i) => {
+ const item = entry?.item ?? entry;
+ const name = _resolveItemName(entry);
+ const itemUrl = _resolveItemUrl(item);
+ const rawError = entry?.error ? String(entry.error) : '';
+ const error = _formatError(entry?.error);
+ const nameCell = itemUrl
+ ? `
${_escapeHtml(name)} | `
+ : `${_escapeHtml(name)} | `;
+ return `
+ | ${i + 1} |
+ ${nameCell}
+ ${_escapeHtml(error)} |
+
`;
+ }).join('');
+
+ const modalHtml = `
+
+
+
+
+
${translate('modals.downloadBatchSummary.title', {}, 'Batch Download Summary')}
+
+
+
+
+
+
+ ${translate('modals.downloadBatchSummary.statSuccess', {}, 'Success')}
+ ${completed}
+
+
+
+
+ ${translate('modals.downloadBatchSummary.statFailed', {}, 'Failed')}
+ ${failedCount}
+
+
+
+
+ ${translate('modals.downloadBatchSummary.statTotal', {}, 'Total')}
+ ${total}
+
+
+
+
+ ${failedCount > 0 ? `
+
+
${translate('modals.downloadBatchSummary.failedItems', { count: failedCount }, 'Failed Items (' + failedCount + ')')}
+
+
+
+
+ | # |
+ ${translate('modals.downloadBatchSummary.columnName', {}, 'Model Name')} |
+ ${translate('modals.downloadBatchSummary.columnError', {}, 'Error')} |
+
+
+ ${failureRows}
+
+
+
+ ` : `
+
+ ${translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully')}
+
+ `}
+
+
+ ${failedCount > 0 ? `
+
+
+ ` : ''}
+
+
+
+
+ `;
+
+ const existing = document.getElementById('downloadBatchSummaryModal');
+ if (existing) existing.remove();
+
+ const container = document.createElement('div');
+ container.innerHTML = modalHtml;
+ const modal = container.firstElementChild;
+ document.body.appendChild(modal);
+
+ modal.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-action]');
+ const action = actionEl?.dataset.action;
+ if (!action) return;
+ e.preventDefault();
+
+ switch (action) {
+ case 'close-modal':
+ modal.remove();
+ break;
+ case 'retry-failed':
+ modal.remove();
+ if (typeof onRetry === 'function') {
+ onRetry(failures);
+ }
+ break;
+ case 'copy-report':
+ _copyReport(actionEl, total, completed, failures);
+ break;
+ case 'open-model': {
+ // Keep the modal open; just open the item's original URL in a new tab
+ const entry = failures[Number(actionEl.dataset.index)];
+ const item = entry?.item;
+ if (!item?.url) break;
+ openHuggingFace(item.url);
+ break;
+ }
+ }
+ });
+}
diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js
index 2ed83432..aeea0d11 100644
--- a/static/js/managers/DownloadManager.js
+++ b/static/js/managers/DownloadManager.js
@@ -8,6 +8,7 @@ import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
import { formatFileSize } from '../utils/formatters.js';
+import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
export class DownloadManager {
constructor() {
@@ -1548,6 +1549,10 @@ export class DownloadManager {
modalManager.closeModal('downloadModal');
+ return this.executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths });
+ }
+
+ async executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths }) {
const batchDownloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
@@ -1558,6 +1563,7 @@ export class DownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let cancelled = false;
+ const failedItems = [];
loadingManager.showCancelButton(async () => {
if (cancelled) return;
@@ -1658,6 +1664,7 @@ export class DownloadManager {
if (!response.success) {
failedDownloads++;
+ failedItems.push({ item, error: response.error || 'Unknown error', name });
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
@@ -1666,6 +1673,7 @@ export class DownloadManager {
if (!cancelled) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
+ failedItems.push({ item, error: err?.message || 'Unknown error', name });
}
}
}
@@ -1679,10 +1687,15 @@ export class DownloadManager {
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
- showToast('toast.loras.downloadPartialSuccess', {
- completed: completedDownloads,
+ showDownloadBatchSummary({
total: downloadItems.length,
- }, 'warning');
+ completed: completedDownloads,
+ failedItems,
+ onRetry: (failed) => this.executeBatchDownload(
+ failed.map((f) => f.item),
+ { modelRoot, targetFolder, useDefaultPaths }
+ ),
+ });
}
await resetAndReload(true);
diff --git a/tests/frontend/components/downloadBatchSummary.test.js b/tests/frontend/components/downloadBatchSummary.test.js
new file mode 100644
index 00000000..d79f49e0
--- /dev/null
+++ b/tests/frontend/components/downloadBatchSummary.test.js
@@ -0,0 +1,438 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const {
+ SUMMARY_MODULE,
+ I18N_HELPERS_MODULE,
+ UI_HELPERS_MODULE,
+} = vi.hoisted(() => ({
+ SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
+ I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+}));
+
+const showToastMock = vi.hoisted(() => vi.fn());
+const openHuggingFaceMock = vi.hoisted(() => vi.fn());
+
+vi.mock(I18N_HELPERS_MODULE, () => ({
+ translate: vi.fn((_key, _params, fallback) => fallback ?? ''),
+}));
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showToast: showToastMock,
+ openHuggingFace: openHuggingFaceMock,
+}));
+
+// A realistic failure payload from the backend: a JSON envelope whose `error`
+// field embeds an HTTP status and a nested JSON body (Civitai Early Access).
+const REAL_ERROR = '{"success": false, "error": "Failed to resolve authenticated Civitai redirect: status=403 body={\\"error\\":\\"Early Access\\",\\"deadline\\":\\"2026-08-12T08:18:36.063Z\\",\\"message\\":\\"This asset is in Early Access. You can use Buzz access it now!\\"}", "download_id": "1786065633067"}';
+
+// The human-readable error the component should derive from REAL_ERROR.
+const FORMATTED_REAL_ERROR = 'HTTP 403 — This asset is in Early Access. You can use Buzz access it now!';
+
+describe('DownloadBatchSummaryModal', () => {
+ let showDownloadBatchSummary;
+
+ beforeEach(async () => {
+ document.body.innerHTML = '';
+ showToastMock.mockClear();
+ openHuggingFaceMock.mockClear();
+ ({ showDownloadBatchSummary } = await import(SUMMARY_MODULE));
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ delete navigator.clipboard;
+ delete document.execCommand;
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('renders a warning summary with stat cards and a failure table on partial success', () => {
+ showDownloadBatchSummary({
+ total: 3,
+ completed: 2,
+ failedItems: [
+ { item: { displayName: 'LoraA' }, error: 'timeout' },
+ { item: { name: 'LoraB' }, error: '404' },
+ ],
+ onRetry: vi.fn(),
+ });
+
+ const modal = document.getElementById('downloadBatchSummaryModal');
+ expect(modal).not.toBeNull();
+ expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
+
+ // Success / Failed / Total stat cards.
+ const statValues = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent);
+ expect(statValues).toEqual(['2', '2', '3']);
+
+ const rows = modal.querySelectorAll('.failure-table tbody tr');
+ expect(rows).toHaveLength(2);
+ expect(rows[0].querySelector('.failure-name').textContent).toBe('LoraA');
+ expect(rows[0].querySelector('.failure-error').textContent).toBe('timeout');
+ expect(rows[1].querySelector('.failure-name').textContent).toBe('LoraB');
+ expect(rows[1].querySelector('.failure-error').textContent).toBe('404');
+
+ expect(modal.querySelector('[data-action="retry-failed"]').textContent).toContain('Retry Failed (2)');
+ expect(modal.querySelector('[data-action="copy-report"]')).not.toBeNull();
+ });
+
+ it('renders an error header when every download failed', () => {
+ showDownloadBatchSummary({
+ total: 2,
+ completed: 0,
+ failedItems: [
+ { item: { displayName: 'LoraA' }, error: 'timeout' },
+ { item: { displayName: 'LoraB' }, error: '404' },
+ ],
+ onRetry: vi.fn(),
+ });
+
+ const modal = document.getElementById('downloadBatchSummaryModal');
+ expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true);
+ expect(modal.querySelector('.summary-title').textContent).toBe('Download failed');
+ });
+
+ it('renders a success summary without a failure table or retry button', () => {
+ showDownloadBatchSummary({ total: 2, completed: 2, failedItems: [], onRetry: vi.fn() });
+
+ const modal = document.getElementById('downloadBatchSummaryModal');
+ expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
+ expect(modal.querySelector('.failure-table')).toBeNull();
+ expect(modal.querySelector('[data-action="retry-failed"]')).toBeNull();
+ expect(modal.querySelector('.refresh-success-message')).not.toBeNull();
+ });
+
+ it('escapes HTML in failed item names and errors', () => {
+ showDownloadBatchSummary({
+ total: 1,
+ completed: 0,
+ failedItems: [
+ { item: { name: '
', url: 'https://example.com/xss-model' }, error: '' },
+ ],
+ onRetry: vi.fn(),
+ });
+
+ const nameCell = document.querySelector('.failure-name');
+ const errorCell = document.querySelector('.failure-error');
+
+ // The URL resolves, so the name renders inside the failure link; the
+ // escaped entities must render back to the literal payload as text...
+ expect(nameCell.querySelector('a.failure-link')).not.toBeNull();
+ expect(nameCell.textContent).toContain('
');
+ expect(errorCell.textContent).toContain('');
+ // ...and never as live DOM nodes.
+ expect(document.querySelector('.failure-table img')).toBeNull();
+ expect(document.querySelector('.failure-table script')).toBeNull();
+ expect(nameCell.innerHTML).toContain('<img');
+ });
+
+ it('removes the modal and invokes onRetry with the original failed items', () => {
+ const onRetry = vi.fn();
+ const failedItems = [{ item: { displayName: 'LoraA' }, error: 'timeout' }];
+ showDownloadBatchSummary({ total: 3, completed: 2, failedItems, onRetry });
+
+ document.querySelector('[data-action="retry-failed"]').click();
+
+ expect(document.getElementById('downloadBatchSummaryModal')).toBeNull();
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ expect(onRetry).toHaveBeenCalledWith(failedItems);
+ // Same object references, not copies.
+ expect(onRetry.mock.calls[0][0][0]).toBe(failedItems[0]);
+ });
+
+ it('closes the modal via the close action without retrying', () => {
+ const onRetry = vi.fn();
+ showDownloadBatchSummary({
+ total: 2,
+ completed: 1,
+ failedItems: [{ item: { name: 'LoraA' }, error: 'timeout' }],
+ onRetry,
+ });
+
+ document.querySelector('.cancel-btn[data-action="close-modal"]').click();
+
+ expect(document.getElementById('downloadBatchSummaryModal')).toBeNull();
+ expect(onRetry).not.toHaveBeenCalled();
+ });
+
+ it('copies a plain-text batch report to the clipboard', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+
+ showDownloadBatchSummary({
+ total: 3,
+ completed: 2,
+ failedItems: [
+ { item: { displayName: 'LoraA', url: 'https://civitai.red/models/111/lora-a?modelVersionId=222' }, error: 'timeout' },
+ { item: { name: 'LoraB', url: 'https://example.com/lora-b' }, error: '404' },
+ ],
+ onRetry: vi.fn(),
+ });
+
+ document.querySelector('[data-action="copy-report"]').click();
+
+ // writeText is invoked synchronously by the click handler.
+ expect(writeText).toHaveBeenCalledTimes(1);
+ const text = writeText.mock.calls[0][0];
+ expect(text).toContain('Batch Download Report');
+ expect(text).toContain('Total: 3');
+ expect(text).toContain('LoraA — timeout');
+ expect(text).toContain('LoraB — 404');
+
+ // Each failed item with a URL gets an indented URL line right after it.
+ expect(text).toContain(' URL: https://civitai.red/models/111/lora-a?modelVersionId=222');
+ expect(text).toContain(' URL: https://example.com/lora-b');
+ // Exactly the two URLs from the failed items — nothing more, no undefined.
+ expect(text.match(/^\s+URL:/gm)).toHaveLength(2);
+ expect(text).not.toContain('URL: undefined');
+
+ // The toast fires after the mocked clipboard promise settles.
+ await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
+ expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success');
+ });
+
+ it('omits the URL line for failed items without a resolvable url', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+
+ showDownloadBatchSummary({
+ total: 2,
+ completed: 0,
+ failedItems: [
+ { item: { name: 'WithUrl', url: 'https://example.com/with-url' }, error: 'boom' },
+ { item: { name: 'NoUrl' }, error: 'boom' },
+ ],
+ onRetry: vi.fn(),
+ });
+
+ document.querySelector('[data-action="copy-report"]').click();
+
+ const text = writeText.mock.calls[0][0];
+ expect(text).toContain(' URL: https://example.com/with-url');
+ // Only the one URL line exists — the URL-less item contributes none.
+ expect(text.match(/^\s+URL:/gm)).toHaveLength(1);
+ expect(text).not.toContain(' URL: undefined');
+ expect(text).not.toContain(' URL: null');
+
+ await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
+ });
+
+ it('falls back to execCommand when navigator.clipboard is unavailable', async () => {
+ // afterEach deletes navigator.clipboard, but be explicit so this test is
+ // robust even if a previous test failed before its cleanup ran.
+ delete navigator.clipboard;
+ // jsdom does not implement document.execCommand, so install a mock for the
+ // fallback path (removed by the afterEach cleanup above).
+ const execCommandMock = vi.fn(() => true);
+ document.execCommand = execCommandMock;
+
+ showDownloadBatchSummary({
+ total: 3,
+ completed: 2,
+ failedItems: [
+ { item: { displayName: 'LoraA' }, error: 'timeout' },
+ { item: { name: 'LoraB' }, error: '404' },
+ ],
+ onRetry: vi.fn(),
+ });
+
+ document.querySelector('[data-action="copy-report"]').click();
+
+ // Without the async Clipboard API the fallback must run synchronously.
+ expect(execCommandMock).toHaveBeenCalledWith('copy');
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success');
+ });
+
+ it('keeps only a single modal instance across repeated calls', () => {
+ showDownloadBatchSummary({
+ total: 2,
+ completed: 1,
+ failedItems: [{ item: { name: 'A' }, error: 'e' }],
+ onRetry: vi.fn(),
+ });
+ showDownloadBatchSummary({ total: 3, completed: 3, failedItems: [], onRetry: vi.fn() });
+
+ expect(document.querySelectorAll('#downloadBatchSummaryModal')).toHaveLength(1);
+ const modal = document.getElementById('downloadBatchSummaryModal');
+ expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
+ });
+
+ it('resolves failure names from entry.name, item fields, URL paths, or Unknown', () => {
+ showDownloadBatchSummary({
+ total: 4,
+ completed: 0,
+ failedItems: [
+ { name: 'entryName', item: { displayName: 'ItemName' }, error: 'e1' },
+ { item: { selectedVersion: { name: 'v1.0' } }, error: 'e2' },
+ { item: { url: 'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960' }, error: 'e3' },
+ { item: {}, error: 'e4' },
+ ],
+ onRetry: vi.fn(),
+ });
+
+ const names = Array.from(document.querySelectorAll('.failure-name')).map(el => el.textContent);
+ expect(names).toEqual(['entryName', 'v1.0', 'midjourney-artful-nsfw', 'Unknown']);
+ });
+
+ it('formats the real JSON failure payload into a concise HTTP error and truncates long ones', () => {
+ showDownloadBatchSummary({
+ total: 2,
+ completed: 0,
+ failedItems: [
+ { item: { name: 'EarlyAccess' }, error: REAL_ERROR },
+ { item: { name: 'LongError' }, error: 'x'.repeat(300) },
+ ],
+ onRetry: vi.fn(),
+ });
+
+ const errorCells = document.querySelectorAll('.failure-error');
+ expect(errorCells[0].textContent).toBe(FORMATTED_REAL_ERROR);
+ expect(errorCells[1].textContent).toBe('x'.repeat(220) + '…');
+ });
+
+ it('keeps the raw error string in the error cell title for debugging', () => {
+ showDownloadBatchSummary({
+ total: 1,
+ completed: 0,
+ failedItems: [{ item: { name: 'EarlyAccess' }, error: REAL_ERROR }],
+ onRetry: vi.fn(),
+ });
+
+ const errorCell = document.querySelector('.failure-error');
+ expect(errorCell.getAttribute('title')).toBe(REAL_ERROR);
+ expect(errorCell.getAttribute('title')).not.toBe(FORMATTED_REAL_ERROR);
+ });
+
+ it('opens the original item url in a new tab when a failure link is clicked', () => {
+ showDownloadBatchSummary({
+ total: 1,
+ completed: 0,
+ failedItems: [{
+ item: {
+ url: 'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960',
+ modelId: '837884',
+ selectedVersion: { id: '3153960' },
+ },
+ error: 'rate limited',
+ }],
+ onRetry: vi.fn(),
+ });
+
+ document.querySelector('.failure-link').click();
+
+ expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
+ expect(openHuggingFaceMock).toHaveBeenCalledWith('https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960');
+ // The modal stays open so the user can keep inspecting the failures.
+ expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
+ });
+
+ it('opens the item url directly when selectedVersion is absent', () => {
+ showDownloadBatchSummary({
+ total: 1,
+ completed: 0,
+ failedItems: [{
+ item: {
+ modelId: '837884',
+ modelVersionId: '3153960',
+ url: 'https://civitai.red/models/837884/midjourney-artful-nsfw',
+ },
+ error: 'rate limited',
+ }],
+ onRetry: vi.fn(),
+ });
+
+ document.querySelector('.failure-link').click();
+
+ expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
+ expect(openHuggingFaceMock).toHaveBeenCalledWith('https://civitai.red/models/837884/midjourney-artful-nsfw');
+ expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
+ });
+
+ it('opens the original huggingface url directly when a huggingface failure link is clicked', () => {
+ showDownloadBatchSummary({
+ total: 1,
+ completed: 0,
+ failedItems: [{
+ item: {
+ url: 'https://huggingface.co/user/repo',
+ source: 'huggingface',
+ repo: 'user/repo',
+ filename: 'model.safetensors',
+ revision: 'main',
+ },
+ error: 'download failed',
+ }],
+ onRetry: vi.fn(),
+ });
+
+ document.querySelector('.failure-link').click();
+
+ expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
+ expect(openHuggingFaceMock).toHaveBeenCalledWith('https://huggingface.co/user/repo');
+ expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
+ });
+
+ it('opens an arbitrary URL via openHuggingFace for fallback items', () => {
+ showDownloadBatchSummary({
+ total: 1,
+ completed: 0,
+ failedItems: [{ item: { url: 'https://example.com/model' }, error: 'boom' }],
+ onRetry: vi.fn(),
+ });
+
+ document.querySelector('.failure-link').click();
+
+ expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
+ expect(openHuggingFaceMock).toHaveBeenCalledWith('https://example.com/model');
+ });
+
+ it('renders the failure name as plain text when no URL can be resolved', () => {
+ showDownloadBatchSummary({
+ total: 1,
+ completed: 0,
+ failedItems: [{ item: { modelId: null }, error: 'boom' }],
+ onRetry: vi.fn(),
+ });
+
+ expect(document.querySelector('a.failure-link')).toBeNull();
+ expect(document.querySelector('.failure-name').textContent).toBe('Unknown');
+
+ // Without a link there is nothing to open: clicking the cell is inert.
+ document.querySelector('.failure-name').click();
+ expect(openHuggingFaceMock).not.toHaveBeenCalled();
+ });
+
+ it('copies formatted errors (not raw JSON) into the report text', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+
+ showDownloadBatchSummary({
+ total: 1,
+ completed: 0,
+ failedItems: [{
+ item: {
+ name: 'EarlyAccess',
+ url: 'https://civitai.red/models/123/early-access?modelVersionId=456',
+ },
+ error: REAL_ERROR,
+ }],
+ onRetry: vi.fn(),
+ });
+
+ document.querySelector('[data-action="copy-report"]').click();
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ const text = writeText.mock.calls[0][0];
+ expect(text).toContain(FORMATTED_REAL_ERROR);
+ expect(text).toContain(' URL: https://civitai.red/models/123/early-access?modelVersionId=456');
+ expect(text).not.toContain('download_id');
+ expect(text).not.toContain('Failed to resolve authenticated Civitai redirect');
+
+ await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/tests/frontend/managers/downloadManager.batchSummary.test.js b/tests/frontend/managers/downloadManager.batchSummary.test.js
new file mode 100644
index 00000000..30cb057e
--- /dev/null
+++ b/tests/frontend/managers/downloadManager.batchSummary.test.js
@@ -0,0 +1,317 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const {
+ DOWNLOAD_MANAGER_MODULE,
+ MODAL_MANAGER_MODULE,
+ UI_HELPERS_MODULE,
+ STATE_MODULE,
+ LOADING_MANAGER_MODULE,
+ API_FACTORY_MODULE,
+ STORAGE_HELPERS_MODULE,
+ FOLDER_TREE_MANAGER_MODULE,
+ I18N_HELPERS_MODULE,
+ SUMMARY_MODULE,
+ mockApiClient,
+ mockLoadingManager,
+ showToastMock,
+ showDownloadBatchSummaryMock,
+ resetAndReloadMock,
+} = vi.hoisted(() => {
+ // Shared API client returned by the mocked getModelApiClient factory.
+ const mockApiClient = {
+ apiConfig: {
+ config: {
+ displayName: 'LoRA',
+ singularName: 'lora',
+ },
+ },
+ downloadModel: vi.fn(),
+ downloadHfModel: vi.fn(),
+ cancelDownload: vi.fn(),
+ };
+
+ // Shared loading manager served both via state.loadingManager and the
+ // LoadingManager constructor mock.
+ const mockLoadingManager = {
+ showSimpleLoading: vi.fn(),
+ hide: vi.fn(),
+ restoreProgressBar: vi.fn(),
+ showDownloadProgress: vi.fn(() => vi.fn()),
+ setStatus: vi.fn(),
+ showCancelButton: vi.fn(),
+ };
+
+ return {
+ DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
+ MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+ STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
+ LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
+ API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
+ STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
+ FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
+ I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+ SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
+ mockApiClient,
+ mockLoadingManager,
+ showToastMock: vi.fn(),
+ showDownloadBatchSummaryMock: vi.fn(),
+ resetAndReloadMock: vi.fn(),
+ };
+});
+
+vi.mock(MODAL_MANAGER_MODULE, () => ({
+ modalManager: {
+ showModal: vi.fn(),
+ closeModal: vi.fn(),
+ },
+}));
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showToast: showToastMock,
+}));
+
+vi.mock(STATE_MODULE, () => ({
+ state: {
+ global: {
+ settings: {},
+ },
+ loadingManager: mockLoadingManager,
+ },
+}));
+
+vi.mock(LOADING_MANAGER_MODULE, () => ({
+ LoadingManager: vi.fn(() => mockLoadingManager),
+}));
+
+vi.mock(API_FACTORY_MODULE, () => ({
+ getModelApiClient: vi.fn(() => mockApiClient),
+ resetAndReload: resetAndReloadMock,
+}));
+
+vi.mock(STORAGE_HELPERS_MODULE, () => ({
+ getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
+ setStorageItem: vi.fn(),
+}));
+
+vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
+ FolderTreeManager: vi.fn(() => ({
+ clearSelection: vi.fn(),
+ init: vi.fn(),
+ })),
+}));
+
+vi.mock(I18N_HELPERS_MODULE, () => ({
+ translate: vi.fn((_, __, fallback) => fallback ?? ''),
+}));
+
+vi.mock(SUMMARY_MODULE, () => ({
+ showDownloadBatchSummary: showDownloadBatchSummaryMock,
+}));
+
+/**
+ * Fake WebSocket used by executeBatchDownload. Resolves `onopen` on the
+ * microtask queue right after construction (which happens after the real
+ * code has assigned `onopen`), so the open promise resolves deterministically
+ * without real timers.
+ */
+class FakeWebSocket {
+ static instances = [];
+
+ constructor(url) {
+ this.url = url;
+ this.onopen = null;
+ this.onmessage = null;
+ this.onerror = null;
+ this.close = vi.fn();
+ FakeWebSocket.instances.push(this);
+ queueMicrotask(() => {
+ if (this.onopen) this.onopen();
+ });
+ }
+
+ static get lastInstance() {
+ return FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
+ }
+}
+
+describe('DownloadManager batch download summary flow', () => {
+ let DownloadManager;
+ let manager;
+
+ const options = { modelRoot: '/models/loras', targetFolder: '', useDefaultPaths: true };
+
+ const makeItem = (modelId, versionId, name) => ({
+ modelId,
+ displayName: name,
+ selectedVersion: { id: versionId, name, existsLocally: false },
+ });
+
+ const item0 = makeItem('111', 'v1', 'Model A');
+ const item1 = makeItem('222', 'v2', 'Model B');
+
+ beforeEach(async () => {
+ document.body.innerHTML = '';
+ FakeWebSocket.instances = [];
+
+ // Reset the shared mocks so mockResolvedValueOnce queues and call
+ // history never leak between tests.
+ mockApiClient.downloadModel.mockReset();
+ mockApiClient.downloadHfModel.mockReset();
+ mockApiClient.cancelDownload.mockReset();
+ showToastMock.mockClear();
+ showDownloadBatchSummaryMock.mockClear();
+ resetAndReloadMock.mockClear();
+ mockLoadingManager.hide.mockClear();
+ mockLoadingManager.setStatus.mockClear();
+ mockLoadingManager.showCancelButton.mockClear();
+ mockLoadingManager.showDownloadProgress.mockClear();
+
+ vi.stubGlobal('WebSocket', FakeWebSocket);
+ vi.resetModules();
+ ({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
+ manager = new DownloadManager();
+ // The constructor leaves apiClient null; executeBatchDownload reads it
+ // directly, so point it at the shared mocked client.
+ manager.apiClient = mockApiClient;
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+ });
+
+ it('shows the success toast when every item downloads successfully', async () => {
+ mockApiClient.downloadModel.mockResolvedValue({ success: true });
+
+ await manager.executeBatchDownload([item0, item1], options);
+
+ expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
+ // Each item is downloaded with its own modelId + versionId.
+ expect(mockApiClient.downloadModel.mock.calls[0][0]).toBe('111');
+ expect(mockApiClient.downloadModel.mock.calls[0][1]).toBe('v1');
+ expect(mockApiClient.downloadModel.mock.calls[1][0]).toBe('222');
+ expect(mockApiClient.downloadModel.mock.calls[1][1]).toBe('v2');
+
+ expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
+ expect(showToastMock).toHaveBeenCalledTimes(1);
+ expect(showToastMock).toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', { count: 2 }, 'success');
+ expect(resetAndReloadMock).toHaveBeenCalledWith(true);
+ });
+
+ it('shows a partial-failure summary when some items fail', async () => {
+ // The failing item has no displayName/filename, so the resolved entry
+ // name falls back to the selected version name.
+ const unnamedItem = { modelId: '333', selectedVersion: { id: 'v3', name: 'V3', existsLocally: false } };
+ mockApiClient.downloadModel
+ .mockResolvedValueOnce({ success: false, error: 'rate limited' })
+ .mockResolvedValueOnce({ success: true });
+
+ await manager.executeBatchDownload([unnamedItem, item1], options);
+
+ expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
+ const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
+ expect(summary.total).toBe(2);
+ expect(summary.completed).toBe(1);
+ expect(summary.failedItems).toHaveLength(1);
+ expect(summary.failedItems[0].item).toBe(unnamedItem);
+ expect(summary.failedItems[0].error).toBe('rate limited');
+ // The resolved display name is carried on the failed entry.
+ expect(summary.failedItems[0].name).toBe('V3');
+ expect(summary.onRetry).toEqual(expect.any(Function));
+
+ // No success toast and no downloadPartialSuccess toast for this path.
+ expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), 'success');
+ expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadPartialSuccess', expect.anything(), expect.anything());
+ });
+
+ it('shows an all-failed summary when every item fails', async () => {
+ mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'x' });
+
+ await manager.executeBatchDownload([item0, item1], options);
+
+ expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
+ const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
+ expect(summary.total).toBe(2);
+ expect(summary.completed).toBe(0);
+ expect(summary.failedItems).toHaveLength(2);
+ expect(summary.failedItems[0].item).toBe(item0);
+ expect(summary.failedItems[1].item).toBe(item1);
+ expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), expect.anything());
+ });
+
+ it('records the error message when downloadModel rejects', async () => {
+ // The item carries a filename but no displayName, so the resolved entry
+ // name comes from the filename.
+ const filenameItem = { modelId: '444', filename: 'model.safetensors', selectedVersion: { id: 'v4' } };
+ mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));
+
+ await manager.executeBatchDownload([filenameItem], options);
+
+ expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
+ const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
+ expect(summary.total).toBe(1);
+ expect(summary.completed).toBe(0);
+ expect(summary.failedItems).toHaveLength(1);
+ expect(summary.failedItems[0].item).toBe(filenameItem);
+ expect(summary.failedItems[0].error).toBe('network down');
+ expect(summary.failedItems[0].name).toBe('model.safetensors');
+ });
+
+ it('retries the failed subset through onRetry with unwrapped items', async () => {
+ mockApiClient.downloadModel
+ .mockResolvedValueOnce({ success: false, error: 'rate limited' })
+ .mockResolvedValueOnce({ success: true });
+
+ await manager.executeBatchDownload([item0, item1], options);
+
+ expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
+ const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
+ expect(summary.failedItems).toHaveLength(1);
+
+ // Retry the exact failed subset returned by the summary. The onRetry
+ // callback unwraps the { item, error } entries back into raw model items
+ // before re-running executeBatchDownload. Make the retried item fail
+ // again so a second summary is produced.
+ mockApiClient.downloadModel.mockResolvedValueOnce({ success: false, error: 'still rate limited' });
+ await summary.onRetry(summary.failedItems);
+
+ // downloadModel is called a third time — only for the failed item (item0),
+ // NOT for the item that already succeeded (item1).
+ expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(3);
+ const retryCall = mockApiClient.downloadModel.mock.calls[2];
+ expect(retryCall[0]).toBe(item0.modelId);
+ expect(retryCall[1]).toBe(item0.selectedVersion.id);
+
+ // A fresh summary is produced for the retry run (call count 1 -> 2).
+ expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(2);
+ const retrySummary = showDownloadBatchSummaryMock.mock.calls[1][0];
+ expect(retrySummary.total).toBe(1);
+ expect(retrySummary.completed).toBe(0);
+ expect(retrySummary.failedItems).toHaveLength(1);
+ expect(retrySummary.failedItems[0].item).toBe(item0);
+ expect(retrySummary.failedItems[0].error).toBe('still rate limited');
+ });
+
+ it('stops the batch without showing a summary when cancelled before downloads start', async () => {
+ const downloadPromise = manager.executeBatchDownload([item0, item1], options);
+
+ // showCancelButton captured the cancel callback synchronously. Invoking it
+ // sets `cancelled = true` before the download loop runs (the loop only
+ // starts after the WebSocket open promise resolves on the microtask queue).
+ const cancelCallback = mockLoadingManager.showCancelButton.mock.calls[0][0];
+ const cancelPromise = cancelCallback();
+
+ await Promise.all([downloadPromise, cancelPromise]);
+
+ expect(mockApiClient.downloadModel).not.toHaveBeenCalled();
+ expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
+ expect(showToastMock).toHaveBeenCalledWith(
+ 'toast.downloads.downloadStopped',
+ expect.anything(),
+ 'info',
+ expect.stringContaining('Download cancelled')
+ );
+ expect(resetAndReloadMock).toHaveBeenCalledWith(true);
+ });
+});