From 2939813e1a15ec5b94b195c8e1388873da89c010 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 16 Jun 2026 22:38:50 +0800 Subject: [PATCH] feat(metadata-fetch): add result summary modal with i18n, fix contrast and counting bugs (#38) --- locales/de.json | 19 +- locales/en.json | 21 +- locales/es.json | 19 +- locales/fr.json | 19 +- locales/he.json | 19 +- locales/ja.json | 19 +- locales/ko.json | 19 +- locales/ru.json | 19 +- locales/zh-CN.json | 19 +- locales/zh-TW.json | 19 +- .../bulk_metadata_refresh_use_case.py | 51 +++- .../components/metadata-refresh-result.css | 196 +++++++++++++ static/css/style.css | 1 + static/js/api/baseModelApi.js | 260 ++++++++++++++++-- 14 files changed, 664 insertions(+), 36 deletions(-) create mode 100644 static/css/components/metadata-refresh-result.css diff --git a/locales/de.json b/locales/de.json index c1131fd0..6587ae98 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1398,6 +1398,21 @@ "versionDeleted": "Version gelöscht" } } + }, + "metadataFetchSummary": { + "title": "Metadaten abrufen — Zusammenfassung", + "statSuccess": "Erfolgreich", + "statFailed": "Fehlgeschlagen", + "statSkipped": "Übersprungen", + "statTotal": "Gesamt geprüft", + "statDuration": "Dauer", + "successMessage": "Alle {count} {type}s erfolgreich aktualisiert!", + "failedItems": "Fehlgeschlagene Elemente ({count})", + "close": "Schließen", + "copyReport": "Bericht kopieren", + "downloadCsv": "CSV herunterladen", + "columnModelName": "Modellname", + "columnError": "Fehler" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben", "exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!", "exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "In die Zwischenablage kopiert", + "downloadStarted": "Download gestartet" } }, "doctor": { diff --git a/locales/en.json b/locales/en.json index 99e0e2d8..376cafc0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1398,6 +1398,21 @@ "versionDeleted": "Version deleted" } } + }, + "metadataFetchSummary": { + "title": "Metadata Fetch Summary", + "statSuccess": "Success", + "statFailed": "Failed", + "statSkipped": "Skipped", + "statTotal": "Total Scanned", + "statDuration": "Duration", + "successMessage": "All {count} {type}s updated successfully!", + "failedItems": "Failed Items ({count})", + "close": "Close", + "copyReport": "Copy Report", + "downloadCsv": "Download CSV", + "columnModelName": "Model Name", + "columnError": "Error" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "Successfully moved {successCount} {type}s", "exampleImagesDownloadSuccess": "Successfully downloaded example images!", "exampleImagesDownloadFailed": "Failed to download example images: {message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "Copied to clipboard", + "downloadStarted": "Download started" } }, "doctor": { @@ -2052,4 +2069,4 @@ "retry": "Retry" } } -} \ No newline at end of file +} diff --git a/locales/es.json b/locales/es.json index 014ea17d..20ad876f 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1398,6 +1398,21 @@ "versionDeleted": "Versión eliminada" } } + }, + "metadataFetchSummary": { + "title": "Resumen de obtención de metadatos", + "statSuccess": "Éxito", + "statFailed": "Fallido", + "statSkipped": "Omitido", + "statTotal": "Total escaneado", + "statDuration": "Duración", + "successMessage": "¡Todos los {count} {type}s actualizados correctamente!", + "failedItems": "Elementos fallidos ({count})", + "close": "Cerrar", + "copyReport": "Copiar informe", + "downloadCsv": "Descargar CSV", + "columnModelName": "Nombre del modelo", + "columnError": "Error" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s", "exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!", "exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "Copiado al portapapeles", + "downloadStarted": "Descarga iniciada" } }, "doctor": { diff --git a/locales/fr.json b/locales/fr.json index cca906fb..5314d690 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1398,6 +1398,21 @@ "versionDeleted": "Version supprimée" } } + }, + "metadataFetchSummary": { + "title": "Récapitulatif de la récupération des métadonnées", + "statSuccess": "Réussi", + "statFailed": "Échoué", + "statSkipped": "Ignoré", + "statTotal": "Total scanné", + "statDuration": "Durée", + "successMessage": "Tous les {count} {type}s mis à jour avec succès !", + "failedItems": "Éléments échoués ({count})", + "close": "Fermer", + "copyReport": "Copier le rapport", + "downloadCsv": "Télécharger CSV", + "columnModelName": "Nom du modèle", + "columnError": "Erreur" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "{successCount} {type}s déplacés avec succès", "exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !", "exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "Copié dans le presse-papiers", + "downloadStarted": "Téléchargement démarré" } }, "doctor": { diff --git a/locales/he.json b/locales/he.json index 2e80873b..974e5a5e 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1398,6 +1398,21 @@ "versionDeleted": "הגרסה נמחקה" } } + }, + "metadataFetchSummary": { + "title": "סיכום שליפת מטא-דאטה", + "statSuccess": "הצלחה", + "statFailed": "נכשל", + "statSkipped": "דולג", + "statTotal": "סה\"כ נסרק", + "statDuration": "משך", + "successMessage": "כל {count} {type}s עודכנו בהצלחה!", + "failedItems": "פריטים נכשלים ({count})", + "close": "סגור", + "copyReport": "העתק דוח", + "downloadCsv": "הורד CSV", + "columnModelName": "שם המודל", + "columnError": "שגיאה" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s", "exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!", "exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "הועתק ללוח", + "downloadStarted": "ההורדה החלה" } }, "doctor": { diff --git a/locales/ja.json b/locales/ja.json index 87231cf4..1327f481 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1398,6 +1398,21 @@ "versionDeleted": "バージョンを削除しました" } } + }, + "metadataFetchSummary": { + "title": "メタデータ取得サマリー", + "statSuccess": "成功", + "statFailed": "失敗", + "statSkipped": "スキップ", + "statTotal": "スキャン合計", + "statDuration": "所要時間", + "successMessage": "すべての{count}件の{type}を正常に更新しました", + "failedItems": "失敗したアイテム ({count})", + "close": "閉じる", + "copyReport": "レポートをコピー", + "downloadCsv": "CSVをダウンロード", + "columnModelName": "モデル名", + "columnError": "エラー" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "{successCount} {type}が正常に移動されました", "exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!", "exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "クリップボードにコピーしました", + "downloadStarted": "ダウンロードを開始しました" } }, "doctor": { diff --git a/locales/ko.json b/locales/ko.json index d8cf62df..158e2216 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1398,6 +1398,21 @@ "versionDeleted": "버전이 삭제되었습니다" } } + }, + "metadataFetchSummary": { + "title": "메타데이터 가져오기 요약", + "statSuccess": "성공", + "statFailed": "실패", + "statSkipped": "건너뜀", + "statTotal": "총 스캔", + "statDuration": "소요 시간", + "successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다", + "failedItems": "실패한 항목 ({count})", + "close": "닫기", + "copyReport": "보고서 복사", + "downloadCsv": "CSV 다운로드", + "columnModelName": "모델 이름", + "columnError": "오류" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다", "exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!", "exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "클립보드에 복사됨", + "downloadStarted": "다운로드 시작됨" } }, "doctor": { diff --git a/locales/ru.json b/locales/ru.json index f732048c..608ebca9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1398,6 +1398,21 @@ "versionDeleted": "Версия удалена" } } + }, + "metadataFetchSummary": { + "title": "Сводка получения метаданных", + "statSuccess": "Успешно", + "statFailed": "Ошибка", + "statSkipped": "Пропущено", + "statTotal": "Всего проверено", + "statDuration": "Длительность", + "successMessage": "Все {count} {type}s успешно обновлены", + "failedItems": "Ошибочные элементы ({count})", + "close": "Закрыть", + "copyReport": "Копировать отчет", + "downloadCsv": "Скачать CSV", + "columnModelName": "Имя модели", + "columnError": "Ошибка" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "Успешно перемещено {successCount} {type}s", "exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!", "exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "Скопировано в буфер обмена", + "downloadStarted": "Загрузка начата" } }, "doctor": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 40d54e95..98959980 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1398,6 +1398,21 @@ "versionDeleted": "版本已删除" } } + }, + "metadataFetchSummary": { + "title": "元数据获取摘要", + "statSuccess": "成功", + "statFailed": "失败", + "statSkipped": "已跳过", + "statTotal": "总计扫描", + "statDuration": "耗时", + "successMessage": "全部 {count} 个 {type} 更新成功!", + "failedItems": "失败项目 ({count})", + "close": "关闭", + "copyReport": "复制报告", + "downloadCsv": "下载 CSV", + "columnModelName": "模型名称", + "columnError": "错误" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "成功移动 {successCount} 个 {type}", "exampleImagesDownloadSuccess": "示例图片下载成功!", "exampleImagesDownloadFailed": "示例图片下载失败:{message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "已复制到剪贴板", + "downloadStarted": "下载已开始" } }, "doctor": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index fcbe22b8..6fec6f57 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1398,6 +1398,21 @@ "versionDeleted": "已刪除此版本" } } + }, + "metadataFetchSummary": { + "title": "元資料獲取摘要", + "statSuccess": "成功", + "statFailed": "失敗", + "statSkipped": "已跳過", + "statTotal": "總計掃描", + "statDuration": "耗時", + "successMessage": "全部 {count} 個 {type} 更新成功!", + "failedItems": "失敗項目 ({count})", + "close": "關閉", + "copyReport": "複製報告", + "downloadCsv": "下載 CSV", + "columnModelName": "模型名稱", + "columnError": "錯誤" } }, "modelTags": { @@ -1957,7 +1972,9 @@ "bulkMoveSuccess": "已成功移動 {successCount} 個 {type}", "exampleImagesDownloadSuccess": "範例圖片下載成功!", "exampleImagesDownloadFailed": "下載範例圖片失敗:{message}", - "moveFailed": "Failed to move item: {message}" + "moveFailed": "Failed to move item: {message}", + "copiedToClipboard": "已複製到剪貼簿", + "downloadStarted": "下載已開始" } }, "doctor": { diff --git a/py/services/use_cases/bulk_metadata_refresh_use_case.py b/py/services/use_cases/bulk_metadata_refresh_use_case.py index 21e960d7..2947750c 100644 --- a/py/services/use_cases/bulk_metadata_refresh_use_case.py +++ b/py/services/use_cases/bulk_metadata_refresh_use_case.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from typing import Any, Dict, List, Optional, Protocol, Sequence from ..metadata_sync_service import MetadataSyncService @@ -62,16 +63,35 @@ class BulkMetadataRefreshUseCase: ] total_to_process = len(to_process) + initial_skipped = total_models - total_to_process # models excluded from fetch queue processed = 0 success = 0 + skipped_count = initial_skipped + handled_count = initial_skipped needs_resort = False + start_time = time.monotonic() + failures: List[Dict[str, str]] = [] self._service.scanner.reset_cancellation() async def emit(status: str, **extra: Any) -> None: if progress_callback is None: return - payload = {"status": status, "total": total_to_process, "processed": processed, "success": success} + payload = { + "status": status, + "total": total_models, + "processed": processed, + "success": success, + "failure_count": len(failures), + "skipped_count": skipped_count, + "handled": handled_count, + "elapsed_seconds": int(time.monotonic() - start_time), + } + # Only include full failure details in terminal emits (completed, + # cancelled, rate_limited) to avoid serializing the list on every + # per-model progress update. + if failures and status in ("completed", "cancelled", "rate_limited"): + payload["failures"] = failures payload.update(extra) await progress_callback.on_progress(payload) @@ -84,7 +104,7 @@ class BulkMetadataRefreshUseCase: if self._service.scanner.is_cancelled(): self._logger.info("Bulk metadata refresh cancelled by user") await emit("cancelled", processed=processed, success=success) - return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models} + return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)} try: original_name = model.get("model_name") @@ -104,17 +124,23 @@ class BulkMetadataRefreshUseCase: model["hash_status"] = "completed" else: self._logger.error(f"Failed to calculate hash for {file_path}") + failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"}) processed += 1 + handled_count += 1 continue else: self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}") + skipped_count += 1 processed += 1 + handled_count += 1 continue # Skip models without valid hash if not model.get("sha256"): self._logger.warning(f"Skipping model without hash: {file_path}") + skipped_count += 1 processed += 1 + handled_count += 1 continue await MetadataManager.hydrate_model_data(model) @@ -130,7 +156,16 @@ class BulkMetadataRefreshUseCase: else: consecutive_rate_limits = 0 + if not result: + current_name = model.get("model_name", file_path or "Unknown") + failures.append({"name": current_name, "error": error_msg or "Unknown error"}) + self._logger.warning("Failed to fetch metadata for %s: %s", current_name, error_msg) + if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD: + # The current model was attempted and failed due to rate limiting; + # count it before aborting so the summary is consistent. + processed += 1 + handled_count += 1 self._logger.warning( "Bulk metadata refresh aborted: %d consecutive rate limits detected. " "Processed %d/%d models.", @@ -140,8 +175,6 @@ class BulkMetadataRefreshUseCase: ) await emit( "rate_limited", - processed=processed, - success=success, ) return { "success": False, @@ -149,6 +182,10 @@ class BulkMetadataRefreshUseCase: "processed": processed, "updated": success, "total": total_models, + "failures": failures, + "failure_count": len(failures), + "skipped_count": skipped_count, + "elapsed_seconds": int(time.monotonic() - start_time), } if result: @@ -156,6 +193,7 @@ class BulkMetadataRefreshUseCase: if original_name != model.get("model_name"): needs_resort = True processed += 1 + handled_count += 1 await emit( "processing", processed=processed, @@ -164,6 +202,9 @@ class BulkMetadataRefreshUseCase: ) except Exception as exc: # pragma: no cover - logging path processed += 1 + handled_count += 1 + current_name = model.get("model_name", model.get("file_path", "Unknown")) + failures.append({"name": current_name, "error": str(exc)}) self._logger.error( "Error fetching CivitAI data for %s: %s", model.get("file_path"), @@ -180,7 +221,7 @@ class BulkMetadataRefreshUseCase: f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})" ) - return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models} + return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)} @staticmethod def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool: diff --git a/static/css/components/metadata-refresh-result.css b/static/css/components/metadata-refresh-result.css new file mode 100644 index 00000000..97834358 --- /dev/null +++ b/static/css/components/metadata-refresh-result.css @@ -0,0 +1,196 @@ +/* Metadata Refresh Result Modal — component styles only */ + +.metadata-refresh-result-modal { + max-width: 700px; +} + +.refresh-summary-stats { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.stat-card { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--border-radius-sm); + background: var(--surface-subtle); + border-left: 4px solid transparent; + font-size: var(--text-sm); + flex: 1; + min-width: 130px; +} + +.stat-card > i { + font-size: 1.25em; + flex-shrink: 0; +} + +.stat-card-body { + display: flex; + flex-direction: column; + min-width: 0; +} + +.stat-card-label { + font-size: var(--text-xs); + color: var(--text-secondary); + line-height: var(--leading-tight); +} + +.stat-card-value { + font-weight: var(--weight-bold); + font-size: var(--text-lg); + color: var(--lora-text); + line-height: var(--leading-tight); +} + +.stat-card-success { + border-left-color: var(--color-success); +} + +.stat-card-success > i { + color: var(--color-success); +} + +.stat-card-failure { + border-left-color: var(--color-error); +} + +.stat-card-failure > i { + color: var(--color-error); +} + +.stat-card-skipped { + border-left-color: var(--color-warning); +} + +.stat-card-skipped > i { + color: var(--color-warning); +} + +.stat-card-total { + border-left-color: var(--color-info); +} + +.stat-card-total > i { + color: var(--color-info); +} + +.stat-card-time { + border-left-color: var(--color-accent); +} + +.stat-card-time > i { + color: var(--color-accent); +} + +.refresh-failures-section { + margin-bottom: var(--space-3); +} + +.refresh-failures-section h4 { + margin: 0 0 var(--space-2) 0; + font-size: var(--text-base); + color: var(--color-error); + display: flex; + align-items: center; + gap: var(--space-1); +} + +.refresh-failures-section h4 i { + font-size: 0.9em; +} + +.failure-table-wrapper { + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); +} + +.failure-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} + +.failure-table th { + position: sticky; + top: 0; + background: var(--lora-surface); + border-bottom: 2px solid var(--lora-border); + padding: var(--space-1) var(--space-2); + text-align: left; + font-weight: var(--weight-semibold); + color: var(--text-secondary); + z-index: 1; +} + +.failure-table td { + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--lora-border); + vertical-align: top; +} + +.failure-table tr:last-child td { + border-bottom: none; +} + +.failure-table tr:hover td { + background: var(--surface-subtle); +} + +.failure-index { + width: 30px; + text-align: center; + color: var(--text-secondary); +} + +.failure-name { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +.failure-error { + color: var(--color-error); + font-size: var(--text-xs); +} + +.refresh-success-message { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3); + margin-bottom: var(--space-3); + background: var(--surface-subtle); + border-left: 4px solid var(--color-success); + color: var(--lora-text); + border-radius: var(--border-radius-sm); + font-weight: var(--weight-medium); +} + +.refresh-success-message i { + font-size: 1.2em; + flex-shrink: 0; + color: var(--color-success); +} + +[data-theme="dark"] .failure-table th { + background: var(--lora-surface); +} + +[data-theme="dark"] .failure-table td { + border-bottom-color: var(--lora-border); +} + +[data-theme="dark"] .failure-table tr:hover td { + background: var(--surface-subtle); +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 2e7b8e3f..5208d90b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -40,6 +40,7 @@ @import 'components/statistics.css'; /* Add statistics component */ @import 'components/sidebar.css'; /* Add sidebar component */ @import 'components/media-viewer.css'; +@import 'components/metadata-refresh-result.css'; .initialization-notice { display: flex; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 7e7563d7..ba264d55 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -547,6 +547,14 @@ export class BaseModelApiClient { const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); + // Wait for WebSocket connection to establish + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + + // Now that we're connected, set up the message/error handlers + // for the actual operation (separate from connection errors) const operationComplete = new Promise((resolve, reject) => { ws.onmessage = (event) => { const data = JSON.parse(event.data); @@ -556,25 +564,39 @@ export class BaseModelApiClient { loading.setStatus('Starting metadata fetch...'); break; - case 'processing': - const percent = ((data.processed / data.total) * 100).toFixed(1); + case 'processing': { + const handled = data.handled || data.processed; + const percent = ((handled / data.total) * 100).toFixed(1); loading.setProgress(percent); - loading.setStatus( - `Processing (${data.processed}/${data.total}) ${data.current_name}` - ); + let statusText = `Processing (${handled}/${data.total}) ${data.current_name || ''}`; + if (data.failure_count > 0) { + statusText += ` | ❌ ${data.failure_count} failed`; + } + if (data.skipped_count > 0) { + statusText += ` | ⏭️ ${data.skipped_count} skipped`; + } + loading.setStatus(statusText); break; + } - case 'completed': + case 'completed': { loading.setProgress(100); - loading.setStatus( - `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s` - ); + let summaryText = `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`; + if (data.failure_count > 0) { + summaryText += ` | ❌ ${data.failure_count} failed`; + } + if (data.skipped_count > 0) { + summaryText += ` | ⏭️ ${data.skipped_count} skipped`; + } + summaryText += ` (⏱ ${data.elapsed_seconds || '?'}s)`; + loading.setStatus(summaryText); resolve(data); break; + } case 'cancelled': loading.setStatus('Operation cancelled by user'); - resolve(data); // Consider it complete but marked as cancelled + resolve(data); break; case 'error': @@ -588,12 +610,6 @@ export class BaseModelApiClient { }; }); - // Wait for WebSocket connection to establish - await new Promise((resolve, reject) => { - ws.onopen = resolve; - ws.onerror = reject; - }); - const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -608,10 +624,10 @@ export class BaseModelApiClient { const finalData = await operationComplete; resetAndReload(false); - if (finalData && finalData.status === 'cancelled') { - showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info'); - } else { - showToast('toast.api.metadataUpdateComplete', {}, 'success'); + + // Show result summary with failure details + if (finalData) { + this._showMetadataRefreshResult(finalData); } } catch (error) { console.error('Error fetching metadata:', error); @@ -627,6 +643,210 @@ export class BaseModelApiClient { }); } + _showMetadataRefreshResult(data) { + const { success, total } = data; + + if (data.status === 'cancelled') { + showToast('toast.api.operationCancelledPartial', { success, total }, 'info'); + return; + } + + this._showFailureDetailsModal(data); + } + + _showFailureDetailsModal(data) { + const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data; + + // Build failure list HTML + const failureRows = failures.map((f, i) => + ` + ${i + 1} + ${this._escapeHtml(f.name)} + ${this._escapeHtml(f.error || 'Unknown')} + ` + ).join(''); + + const modalHtml = ` + + `; + + const existing = document.getElementById('metadataRefreshResultModal'); + 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 action = e.target.closest('[data-action]')?.dataset.action; + if (!action) return; + e.preventDefault(); + + switch (action) { + case 'close-modal': + modal.remove(); + break; + case 'copy-report': + BaseModelApiClient._copyRefreshReport(e.target.closest('[data-action]'), data); + break; + case 'download-csv': + BaseModelApiClient._downloadRefreshReport(data); + break; + } + }); + } + + _escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + static _copyRefreshReport(btn, data) { + const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data; + const lines = [ + '=== Metadata Refresh Report ===', + `Date: ${new Date().toLocaleString()}`, + `Duration: ${elapsed_seconds}s`, + `Total scanned: ${total || processed}`, + `Successfully updated: ${success}`, + `Failed: ${failure_count}`, + `Skipped: ${skipped_count}`, + '', + ]; + if (failure_count > 0) { + lines.push('--- Failed Items ---'); + failures.forEach((f, i) => { + lines.push(`${i + 1}. ${f.name || 'Unknown'} — ${f.error || 'Unknown error'}`); + }); + lines.push(''); + } + lines.push('===================='); + + const text = lines.join('\n'); + navigator.clipboard.writeText(text).then(() => { + showToast('toast.api.copiedToClipboard', {}, 'success'); + if (btn) { + const origHTML = btn.innerHTML; + btn.innerHTML = ' Copied!'; + setTimeout(() => { btn.innerHTML = origHTML; }, 2000); + } + }).catch(() => { + // Fallback + 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'); + }); + } + + static _downloadRefreshReport(data) { + const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data; + + // CSV header + let csv = 'Model Name,Error\n'; + failures.forEach(f => { + const name = (f.name || 'Unknown').replace(/"/g, '""'); + const error = (f.error || 'Unknown').replace(/"/g, '""'); + csv += `"${name}","${error}"\n`; + }); + + // Add summary as trailing comments + csv += `\n# Summary: ${success} success, ${failure_count} failed, ${skipped_count} skipped, ${elapsed_seconds}s\n`; + csv += `# Total scanned: ${total || processed}\n`; + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `metadata-refresh-failures-${Date.now()}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + showToast('toast.api.downloadStarted', {}, 'success'); + } + async refreshBulkModelMetadata(filePaths) { if (!filePaths || filePaths.length === 0) { throw new Error('No file paths provided');