Compare commits

...
22 Commits
Author SHA1 Message Date
Will Miao c11f4b5c68 feat(ui): widen filter panel and preset name limit 2026-08-07 16:53:14 +08:00
Will Miao 86376284f4 fix(ui): clamp filter panel height to viewport 2026-08-07 16:53:04 +08:00
Will Miao 2b8a2fc7d8 feat(filters): remove preset count limit 2026-08-07 16:52:53 +08:00
Will Miao f26e1b41c8 fix(i18n): translate zh-TW api key placeholder 2026-08-07 16:25:40 +08:00
Will Miao c1671af99f feat(downloads): translate batch download summary strings 2026-08-07 16:23:55 +08:00
Will Miao ac7707d0f6 fix(cards): clear model-card min-width on the item element itself 2026-08-07 16:09:51 +08:00
Will Miao 381cd710a2 feat(recipes): translate recipes layout setting strings 2026-08-07 15:36:30 +08:00
Will Miao ad0d18cb79 chore: ignore .playwright-mcp working directory 2026-08-07 15:33:43 +08:00
Will Miao 7980ee77d0 perf(recipes): batch preview dimension reads via asyncio.gather 2026-08-07 15:31:13 +08:00
Will Miao 916b8bb327 fix(recipes): skip stale scroller re-enable on deferred layout switch 2026-08-07 14:03:13 +08:00
Will Miao 87e3d4dea9 feat(recipes): wire recipes layout switch event and rebuild 2026-08-07 12:49:30 +08:00
Will Miao 76a913f5e0 feat(recipes): complete MasonryScroller public API parity with VirtualScroller 2026-08-07 12:40:28 +08:00
Will Miao d8c192e647 feat(recipes): branch masonry scroller instantiation for recipes page 2026-08-07 12:38:17 +08:00
Will Miao c453437620 feat(recipes): add MasonryScroller with column-based virtual scrolling 2026-08-07 12:31:13 +08:00
Will Miao 720fa6d909 feat(recipes): expose preview width/height in recipe listing API 2026-08-07 11:59:26 +08:00
Will Miao b4f71089f4 feat(recipes): add recipes_layout setting (grid|masonry) with i18n 2026-08-07 11:49:12 +08:00
Will Miao 83e6657ead feat(recipes): add get_image_dimensions helper with LRU cache 2026-08-07 11:47:28 +08:00
Will Miao 7ea6df4111 feat(downloads): default to latest version when URL lacks modelVersionId
Auto-select the first (newest) version for URLs without an explicit
modelVersionId, matching the existing batch flow, so users can proceed
to location/download without manually picking a version.
2026-08-07 11:24:45 +08:00
Will Miao d9ab92602a feat(downloads): show failure summary modal for single downloads too 2026-08-07 10:49:14 +08:00
pixelpaws 5ffadaed31 Merge pull request #1054 from willmiao/feat/gemini-provider
feat(llm): add Gemini as a preset AI provider
2026-08-07 10:30:45 +08:00
Will Miao 24f5f7df5d feat(llm): add Gemini as a preset AI provider 2026-08-07 10:27:51 +08:00
Will Miao daf01fb1d6 feat(downloads): show batch download summary with failure details and retry 2026-08-07 10:23:17 +08:00
44 changed files with 4344 additions and 59 deletions
+1
View File
@@ -25,6 +25,7 @@ model_cache/
reasonix.toml
.reasonix/
.codegraph/
.playwright-mcp/
# Vue widgets development cache (but keep build output)
vue-widgets/node_modules/
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
"recipesLayout": "Rezepte-Layout",
"recipesLayoutHelp": "Wählen Sie, wie Rezeptkarten angeordnet werden: ein einheitliches Raster oder ein Masonry-Layout (Pinterest-Stil), das das Seitenverhältnis jedes Bildes beibehält.",
"recipesLayoutOptions": {
"grid": "Raster",
"masonry": "Masonry"
},
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
"cardInfoDisplay": "Karten-Info-Anzeige",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "Benutzerdefiniert (OpenAI-kompatibel)"
},
@@ -1581,6 +1588,21 @@
"downloadCsv": "CSV herunterladen",
"columnModelName": "Modellname",
"columnError": "Fehler"
},
"downloadBatchSummary": {
"title": "Zusammenfassung des Batch-Downloads",
"statSuccess": "Erfolgreich",
"statFailed": "Fehlgeschlagen",
"statTotal": "Gesamt",
"successMessage": "Alle {count} Modelle erfolgreich heruntergeladen",
"completedWithErrors": "Abgeschlossen, aber mit Fehlern",
"failed": "Download fehlgeschlagen",
"failedItems": "Fehlgeschlagene Elemente ({count})",
"columnName": "Modellname",
"columnError": "Fehler",
"close": "Schließen",
"copyReport": "Bericht kopieren",
"retryFailed": "Fehlgeschlagene erneut versuchen ({count})"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
"presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
"presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits",
"maxPresetsReached": "Maximal {max} Voreinstellungen erlaubt. Löschen Sie eine, um weitere hinzuzufügen.",
"presetNotFound": "Voreinstellung nicht gefunden",
"invalidPreset": "Ungültige Voreinstellungsdaten",
"deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
"recipesLayout": "Recipes Layout",
"recipesLayoutHelp": "Choose how recipe cards are arranged: a uniform grid or a masonry (Pinterest-style) layout that preserves each image's aspect ratio.",
"recipesLayoutOptions": {
"grid": "Grid",
"masonry": "Masonry"
},
"showFolderSidebar": "Show Folder Sidebar",
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
"cardInfoDisplay": "Card Info Display",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "Custom (OpenAI-compatible)"
},
@@ -1581,6 +1588,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": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "Preset name must be {max} characters or less",
"presetNameInvalidChars": "Preset name contains invalid characters",
"presetNameExists": "A preset with this name already exists",
"maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.",
"presetNotFound": "Preset not found",
"invalidPreset": "Invalid preset data",
"deletePresetFailed": "Failed to delete preset",
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
"recipesLayout": "Diseño de recetas",
"recipesLayoutHelp": "Elige cómo se organizan las tarjetas de recetas: una cuadrícula uniforme o un diseño masonry (estilo Pinterest) que conserva la proporción de aspecto de cada imagen.",
"recipesLayoutOptions": {
"grid": "Cuadrícula",
"masonry": "Masonry"
},
"showFolderSidebar": "Mostrar barra lateral de carpetas",
"showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.",
"cardInfoDisplay": "Visualización de información de tarjeta",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "Personalizado (compatible con OpenAI)"
},
@@ -1581,6 +1588,21 @@
"downloadCsv": "Descargar CSV",
"columnModelName": "Nombre del modelo",
"columnError": "Error"
},
"downloadBatchSummary": {
"title": "Resumen de descarga por lotes",
"statSuccess": "Correctos",
"statFailed": "Fallidos",
"statTotal": "Total",
"successMessage": "Todos los {count} modelos se descargaron correctamente",
"completedWithErrors": "Completado con errores",
"failed": "Descarga fallida",
"failedItems": "Elementos fallidos ({count})",
"columnName": "Nombre del modelo",
"columnError": "Error",
"close": "Cerrar",
"copyReport": "Copiar informe",
"retryFailed": "Reintentar fallidos ({count})"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
"presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
"presetNameExists": "Ya existe un preajuste con este nombre",
"maxPresetsReached": "Máximo {max} preajustes permitidos. Elimine uno para agregar más.",
"presetNotFound": "Preajuste no encontrado",
"invalidPreset": "Datos de preajuste inválidos",
"deletePresetFailed": "Error al eliminar el preajuste",
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
"recipesLayout": "Disposition des recettes",
"recipesLayoutHelp": "Choisissez comment les cartes de recettes sont organisées : une grille uniforme ou une disposition masonry (style Pinterest) qui préserve le rapport d'aspect de chaque image.",
"recipesLayoutOptions": {
"grid": "Grille",
"masonry": "Masonry"
},
"showFolderSidebar": "Afficher la barre latérale des dossiers",
"showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.",
"cardInfoDisplay": "Affichage des informations de carte",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "Personnalisé (compatible OpenAI)"
},
@@ -1581,6 +1588,21 @@
"downloadCsv": "Télécharger CSV",
"columnModelName": "Nom du modèle",
"columnError": "Erreur"
},
"downloadBatchSummary": {
"title": "Résumé du téléchargement groupé",
"statSuccess": "Réussis",
"statFailed": "Échoués",
"statTotal": "Total",
"successMessage": "Les {count} modèles ont été téléchargés avec succès",
"completedWithErrors": "Terminé avec des erreurs",
"failed": "Échec du téléchargement",
"failedItems": "Éléments échoués ({count})",
"columnName": "Nom du modèle",
"columnError": "Erreur",
"close": "Fermer",
"copyReport": "Copier le rapport",
"retryFailed": "Réessayer les échecs ({count})"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères",
"presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides",
"presetNameExists": "Un préréglage avec ce nom existe déjà",
"maxPresetsReached": "Maximum {max} préréglages autorisés. Supprimez-en un pour en ajouter plus.",
"presetNotFound": "Préréglage non trouvé",
"invalidPreset": "Données de préréglage invalides",
"deletePresetFailed": "Échec de la suppression du préréglage",
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
"recipesLayout": "פריסת מתכונים",
"recipesLayoutHelp": "בחר כיצד יסודרו כרטיסי המתכונים: רשת אחידה או פריסת Masonry (בסגנון Pinterest) השומרת על יחס הגובה-רוחב של כל תמונה.",
"recipesLayoutOptions": {
"grid": "רשת",
"masonry": "Masonry"
},
"showFolderSidebar": "הצג סרגל צד תיקיות",
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
"cardInfoDisplay": "תצוגת מידע בכרטיס",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "מותאם אישית (תואם OpenAI)"
},
@@ -1581,6 +1588,21 @@
"downloadCsv": "הורד CSV",
"columnModelName": "שם המודל",
"columnError": "שגיאה"
},
"downloadBatchSummary": {
"title": "סיכום הורדה בכמות",
"statSuccess": "הצליחו",
"statFailed": "נכשלו",
"statTotal": "סה\"כ",
"successMessage": "כל {count} הדגמים הורדו בהצלחה",
"completedWithErrors": "הושלם עם שגיאות",
"failed": "ההורדה נכשלה",
"failedItems": "פריטים שנכשלו ({count})",
"columnName": "שם הדגם",
"columnError": "שגיאה",
"close": "סגור",
"copyReport": "העתק דוח",
"retryFailed": "נסה שוב ({count})"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
"maxPresetsReached": "מותר מקסימום {max} קביעות מראש. מחק אחת כדי להוסיף עוד.",
"presetNotFound": "קביעה מראש לא נמצאה",
"invalidPreset": "נתוני קביעה מראש לא חוקיים",
"deletePresetFailed": "מחיקת קביעה מראש נכשלה",
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "71080p)、82K)、104K"
},
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
"recipesLayout": "レシピのレイアウト",
"recipesLayoutHelp": "レシピカードの配置方法を選択:均一なグリッド、または各画像のアスペクト比を保持するメイソンリー(Pinterest スタイル)レイアウト。",
"recipesLayoutOptions": {
"grid": "グリッド",
"masonry": "メイソンリー"
},
"showFolderSidebar": "フォルダサイドバーを表示",
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
"cardInfoDisplay": "カード情報表示",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "カスタム(OpenAI 互換)"
},
@@ -1581,6 +1588,21 @@
"downloadCsv": "CSVをダウンロード",
"columnModelName": "モデル名",
"columnError": "エラー"
},
"downloadBatchSummary": {
"title": "バッチダウンロードの概要",
"statSuccess": "成功",
"statFailed": "失敗",
"statTotal": "合計",
"successMessage": "{count} 個のモデルがすべて正常にダウンロードされました",
"completedWithErrors": "エラーありで完了",
"failed": "ダウンロードに失敗しました",
"failedItems": "失敗した項目({count}",
"columnName": "モデル名",
"columnError": "エラー",
"close": "閉じる",
"copyReport": "レポートをコピー",
"retryFailed": "失敗した項目を再試行({count}"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "プリセット名は{max}文字以内にしてください",
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
"presetNameExists": "同じ名前のプリセットが既に存在します",
"maxPresetsReached": "プリセットは最大{max}個までです。追加するには既存のものを削除してください。",
"presetNotFound": "プリセットが見つかりません",
"invalidPreset": "無効なプリセットデータです",
"deletePresetFailed": "プリセットの削除に失敗しました",
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
},
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
"recipesLayout": "레시피 레이아웃",
"recipesLayoutHelp": "레시피 카드의 배열 방식을 선택하세요: 균일한 그리드 또는 각 이미지의 종횡비를 유지하는 메이슨리(Pinterest 스타일) 레이아웃.",
"recipesLayoutOptions": {
"grid": "그리드",
"masonry": "메이슨리"
},
"showFolderSidebar": "폴더 사이드바 표시",
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
"cardInfoDisplay": "카드 정보 표시",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "사용자 정의 (OpenAI 호환)"
},
@@ -1581,6 +1588,21 @@
"downloadCsv": "CSV 다운로드",
"columnModelName": "모델 이름",
"columnError": "오류"
},
"downloadBatchSummary": {
"title": "일괄 다운로드 요약",
"statSuccess": "성공",
"statFailed": "실패",
"statTotal": "전체",
"successMessage": "{count}개 모델이 모두 성공적으로 다운로드되었습니다",
"completedWithErrors": "오류와 함께 완료됨",
"failed": "다운로드 실패",
"failedItems": "실패한 항목 ({count})",
"columnName": "모델 이름",
"columnError": "오류",
"close": "닫기",
"copyReport": "보고서 복사",
"retryFailed": "실패 항목 재시도 ({count})"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
"maxPresetsReached": "최대 {max}개의 프리셋만 허용됩니다. 더 추가하려면 기존 것을 삭제하세요.",
"presetNotFound": "프리셋을 찾을 수 없습니다",
"invalidPreset": "잘못된 프리셋 데이터입니다",
"deletePresetFailed": "프리셋 삭제에 실패했습니다",
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
"recipesLayout": "Макет рецептов",
"recipesLayoutHelp": "Выберите, как располагаются карточки рецептов: единая сетка или masonry-макет (в стиле Pinterest), сохраняющий пропорции каждого изображения.",
"recipesLayoutOptions": {
"grid": "Сетка",
"masonry": "Masonry"
},
"showFolderSidebar": "Показывать боковую панель папок",
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
"cardInfoDisplay": "Отображение информации карточки",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "Пользовательский (совместимый с OpenAI)"
},
@@ -1581,6 +1588,21 @@
"downloadCsv": "Скачать CSV",
"columnModelName": "Имя модели",
"columnError": "Ошибка"
},
"downloadBatchSummary": {
"title": "Сводка пакетной загрузки",
"statSuccess": "Успешно",
"statFailed": "Ошибки",
"statTotal": "Всего",
"successMessage": "Все {count} моделей успешно загружены",
"completedWithErrors": "Завершено с ошибками",
"failed": "Не удалось загрузить",
"failedItems": "Неудачные элементы ({count})",
"columnName": "Имя модели",
"columnError": "Ошибка",
"close": "Закрыть",
"copyReport": "Скопировать отчёт",
"retryFailed": "Повторить неудачные ({count})"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
"presetNameExists": "Пресет с таким именем уже существует",
"maxPresetsReached": "Допустимо максимум {max} пресетов. Удалите один, чтобы добавить больше.",
"presetNotFound": "Пресет не найден",
"invalidPreset": "Недопустимые данные пресета",
"deletePresetFailed": "Не удалось удалить пресет",
+22 -1
View File
@@ -449,6 +449,12 @@
"compact": "71080p),82K),104K"
},
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
"recipesLayout": "配方布局",
"recipesLayoutHelp": "选择配方卡片的排列方式:统一网格,或保留每张图片原始宽高比的瀑布流(Pinterest 风格)布局。",
"recipesLayoutOptions": {
"grid": "网格",
"masonry": "瀑布流"
},
"showFolderSidebar": "显示文件夹侧边栏",
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
"cardInfoDisplay": "卡片信息显示",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "自定义(OpenAI 兼容)"
},
@@ -1581,6 +1588,21 @@
"downloadCsv": "下载 CSV",
"columnModelName": "模型名称",
"columnError": "错误"
},
"downloadBatchSummary": {
"title": "批量下载摘要",
"statSuccess": "成功",
"statFailed": "失败",
"statTotal": "总数",
"successMessage": "全部 {count} 个模型下载成功",
"completedWithErrors": "已完成,但有错误",
"failed": "下载失败",
"failedItems": "失败项({count}",
"columnName": "模型名称",
"columnError": "错误",
"close": "关闭",
"copyReport": "复制报告",
"retryFailed": "重试失败项({count}"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "预设名称不能超过 {max} 个字符",
"presetNameInvalidChars": "预设名称包含无效字符",
"presetNameExists": "已存在同名预设",
"maxPresetsReached": "最多允许 {max} 个预设。删除一个以添加更多。",
"presetNotFound": "预设未找到",
"invalidPreset": "无效的预设数据",
"deletePresetFailed": "删除预设失败",
+23 -2
View File
@@ -449,6 +449,12 @@
"compact": "71080p)、82K)、104K"
},
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
"recipesLayout": "配方版面",
"recipesLayoutHelp": "選擇配方卡片的排列方式:統一網格,或保留每張圖片原始寬高比的瀑布流(Pinterest 風格)版面。",
"recipesLayoutOptions": {
"grid": "網格",
"masonry": "瀑布流"
},
"showFolderSidebar": "顯示資料夾側邊欄",
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
"cardInfoDisplay": "卡片資訊顯示",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "自訂(OpenAI 相容)"
},
@@ -686,7 +693,7 @@
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 金鑰",
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
"apiKeyPlaceholder": "[TODO: Translate] sk-...",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "未設定",
"apiKeyConfigured": "已設定",
"apiKeySet": "設定",
@@ -1581,6 +1588,21 @@
"downloadCsv": "下載 CSV",
"columnModelName": "模型名稱",
"columnError": "錯誤"
},
"downloadBatchSummary": {
"title": "批次下載摘要",
"statSuccess": "成功",
"statFailed": "失敗",
"statTotal": "總數",
"successMessage": "全部 {count} 個模型下載成功",
"completedWithErrors": "已完成,但有錯誤",
"failed": "下載失敗",
"failedItems": "失敗項目({count}",
"columnName": "模型名稱",
"columnError": "錯誤",
"close": "關閉",
"copyReport": "複製報告",
"retryFailed": "重試失敗項目({count}"
}
},
"modelTags": {
@@ -2037,7 +2059,6 @@
"presetNameTooLong": "預設名稱不能超過 {max} 個字元",
"presetNameInvalidChars": "預設名稱包含無效字元",
"presetNameExists": "已存在同名預設",
"maxPresetsReached": "最多允許 {max} 個預設。刪除一個以新增更多。",
"presetNotFound": "預設未找到",
"invalidPreset": "無效的預設資料",
"deletePresetFailed": "刪除預設失敗",
+39 -2
View File
@@ -10,7 +10,7 @@ import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
from aiohttp import web
@@ -44,6 +44,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
RecipeScannerGetter = Callable[[], Any]
CivitaiClientGetter = Callable[[], Any]
# Cap concurrent preview-dimension reads across requests. With a cold LRU
# cache one page can touch up to page_size image files; 16 balances SSD and
# HDD throughput without starving the event loop.
_DIMS_READ_SEMAPHORE = asyncio.Semaphore(16)
async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
"""Read preview dimensions off the event loop under the concurrency cap.
PIL I/O runs in a worker thread so it never blocks the event loop, and the
semaphore bounds how many files are opened at once even when many list
requests land together.
"""
async with _DIMS_READ_SEMAPHORE:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
@dataclass(frozen=True)
class RecipeHandlerSet:
@@ -246,7 +262,8 @@ class RecipeListingHandler:
recursive=recursive,
)
for item in result.get("items", []):
items = result.get("items", [])
for item in items:
file_path = item.get("file_path")
if file_path:
item["file_url"] = self.format_recipe_file_url(file_path)
@@ -255,6 +272,26 @@ class RecipeListingHandler:
item.setdefault("loras", [])
item.setdefault("base_model", "")
# Batch preview dimension reads with asyncio.gather. The previous
# loop awaited asyncio.to_thread once per item, so a page_size=100
# request submitted 100 sequential thread calls (50-300ms cold-page
# latency). gather runs them concurrently while the semaphore caps
# disk opens; dimensions stay omitted (not null) when a preview has
# no readable size (video, missing file).
to_read = [
(i, item.get("file_path"))
for i, item in enumerate(items)
if item.get("file_path")
]
if to_read:
dims_list = await asyncio.gather(
*(_read_preview_dims(path) for _, path in to_read)
)
for (idx, _), dims in zip(to_read, dims_list):
if dims:
item = items[idx]
item["width"], item["height"] = dims
return web.json_response(result)
except Exception as exc:
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
+5
View File
@@ -201,6 +201,11 @@ PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
"api_base": "https://openrouter.ai/api/v1",
"requires_key": True,
},
"google": {
"name": "Gemini",
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
"requires_key": True,
},
"opencode-go": {
"name": "OpenCode Go",
"api_base": "https://opencode.ai/zen/go/v1",
+1
View File
@@ -92,6 +92,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"mature_blur_level": "R",
"autoplay_on_hover": False,
"display_density": "default",
"recipes_layout": "grid",
"card_info_display": "always",
"include_trigger_words": False,
"compact_mode": False,
+37 -1
View File
@@ -1,9 +1,10 @@
import functools
import json
import logging
import os
import struct
from io import BytesIO
from typing import Any, Optional
from typing import Any, Optional, Tuple
import piexif
from PIL import Image, PngImagePlugin
@@ -17,6 +18,22 @@ except ImportError:
logger = logging.getLogger(__name__)
@functools.lru_cache(maxsize=2048)
def _get_image_dimensions_cached(path: str, _mtime_ns: int, _size: int) -> Optional[Tuple[int, int]]:
"""Return ``(width, height)`` for ``path``, or ``None`` on any failure.
The ``_mtime_ns`` and ``_size`` arguments are part of the cache key only;
they invalidate the entry when the file is replaced with a new image, so a
stale preview never serves outdated dimensions.
"""
try:
with Image.open(path) as img:
return img.size
except Exception:
return None
class ExifUtils:
"""Utility functions for working with EXIF data in images"""
@@ -422,6 +439,25 @@ class ExifUtils:
# Metadata is in the middle of the string
return user_comment[:recipe_marker_index] + user_comment[next_line_index:]
@staticmethod
def get_image_dimensions(image_path: str) -> Optional[Tuple[int, int]]:
"""Return ``(width, height)`` for an image, or ``None`` if unavailable.
Video containers (``.mp4``/``.webm``/``.avi``) and formats PIL cannot
read (``.avif``/``.jxl``) return ``None`` before PIL is invoked.
Missing or corrupt files return ``None``. Never raises.
"""
try:
ext = os.path.splitext(image_path)[1].lower()
if ext in ('.mp4', '.webm', '.avi', '.avif', '.jxl'):
return None
stat = os.stat(image_path)
return _get_image_dimensions_cached(
image_path, stat.st_mtime_ns, stat.st_size
)
except Exception:
return None
@staticmethod
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False):
"""
+4
View File
@@ -31,6 +31,10 @@
overflow: hidden;
}
.card-grid.masonry-layout .model-card {
aspect-ratio: auto;
}
.model-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
@@ -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;
}
+3 -2
View File
@@ -145,13 +145,14 @@
position: fixed;
right: 20px;
top: 50px; /* Position below header */
width: 366px;
width: 420px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: var(--shadow-md);
z-index: var(--z-overlay);
padding: 16px;
box-sizing: border-box; /* Include padding in max-height calculation */
transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: top right;
max-height: calc(100vh - 70px); /* Adjusted for header height */
@@ -563,7 +564,7 @@
align-items: center;
gap: 6px;
white-space: nowrap;
max-width: 120px; /* Prevent long names from breaking layout */
max-width: 180px; /* Prevent long names from breaking layout */
overflow: hidden;
text-overflow: ellipsis;
}
+1
View File
@@ -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;
@@ -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 = '<i class="fas fa-check"></i> 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
? `<td class="failure-name"><a href="#" class="failure-link" data-action="open-model" data-index="${i}" title="${_escapeHtml(itemUrl)}">${_escapeHtml(name)}</a></td>`
: `<td class="failure-name" title="${_escapeHtml(name)}">${_escapeHtml(name)}</td>`;
return `<tr>
<td class="failure-index">${i + 1}</td>
${nameCell}
<td class="failure-error" title="${_escapeHtml(rawError)}">${_escapeHtml(error)}</td>
</tr>`;
}).join('');
const modalHtml = `
<div id="downloadBatchSummaryModal" class="modal" style="display: block;">
<div class="modal-content download-batch-summary-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.downloadBatchSummary.title', {}, 'Batch Download Summary')}</h2>
<div class="summary-header ${headerState}">
<i class="fas ${headerIcon}"></i>
<span class="summary-title">${headerText}</span>
<span class="summary-hint">${completed}/${total}</span>
</div>
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statSuccess', {}, 'Success')}</span>
<span class="stat-card-value">${completed}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statFailed', {}, 'Failed')}</span>
<span class="stat-card-value">${failedCount}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statTotal', {}, 'Total')}</span>
<span class="stat-card-value">${total}</span>
</div>
</div>
</div>
${failedCount > 0 ? `
<div class="refresh-failures-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.downloadBatchSummary.failedItems', { count: failedCount }, 'Failed Items (' + failedCount + ')')}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.downloadBatchSummary.columnName', {}, 'Model Name')}</th>
<th>${translate('modals.downloadBatchSummary.columnError', {}, 'Error')}</th>
</tr>
</thead>
<tbody>${failureRows}</tbody>
</table>
</div>
</div>
` : `
<div class="refresh-success-message">
<i class="fas fa-check-circle"></i> ${translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully')}
</div>
`}
<div class="modal-actions">
${failedCount > 0 ? `
<button class="btn-retry" data-action="retry-failed"><i class="fas fa-redo"></i> ${translate('modals.downloadBatchSummary.retryFailed', { count: failedCount }, 'Retry Failed (' + failedCount + ')')}</button>
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.downloadBatchSummary.copyReport', {}, 'Copy Report')}</button>
` : ''}
<button class="cancel-btn" data-action="close-modal">${translate('modals.downloadBatchSummary.close', {}, 'Close')}</button>
</div>
</div>
</div>
`;
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;
}
}
});
}
+12 -3
View File
@@ -2,6 +2,7 @@
import { showToast } from '../utils/uiHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
import { recreateVirtualScroll } from '../utils/infiniteScroll.js';
export class DuplicatesManager {
constructor(recipeManager) {
@@ -71,7 +72,7 @@ export class DuplicatesManager {
this.updateSelectedCount();
}
exitDuplicateMode() {
async exitDuplicateMode() {
this.inDuplicateMode = false;
this.selectedForDeletion.clear();
@@ -94,8 +95,16 @@ export class DuplicatesManager {
recipeGrid.innerHTML = '';
}
// Re-enable virtual scrolling
state.virtualScroller.enable();
// Re-enable virtual scrolling, or apply a layout switch deferred
// while duplicates mode was active (enabling the old scroller first
// would let its pending rAF render repopulate the grid after the
// new scroller is created, leaving orphaned/overlapping cards).
if (state.pendingLayoutRecreate) {
state.pendingLayoutRecreate = false;
await recreateVirtualScroll('recipes');
} else if (state.virtualScroller) {
state.virtualScroller.enable();
}
}
renderDuplicateGroups() {
+128 -11
View File
@@ -6,8 +6,9 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
import { formatFileSize } from '../utils/formatters.js';
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
export class DownloadManager {
constructor() {
@@ -234,6 +235,9 @@ export class DownloadManager {
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
} else {
// No explicit version id in the URL → default to the latest version (Civitai returns newest first)
this.currentVersion = this.versions[0];
}
this.showVersionStep();
@@ -427,6 +431,9 @@ export class DownloadManager {
await this.retrieveVersionsForModel(this.modelId, this.source);
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
} else {
// No explicit version id → default to the latest version (Civitai returns newest first)
this.currentVersion = this.versions[0];
}
this.showVersionStep();
} catch (error) {
@@ -878,6 +885,26 @@ export class DownloadManager {
this.updateTargetPath();
}
/**
* Synthesize a clickable URL for a single-download failure entry.
* Single downloads have no pasted URL, so the modal link is derived from
* the model/version ids (CivitAI) or the HF repo/file (HuggingFace).
*/
_buildSingleItemUrl({ modelId, versionId, source, repo = null, filename = null }) {
if (source === 'huggingface' && repo) {
const base = `https://huggingface.co/${encodeURI(repo)}`;
return filename ? `${base}/blob/${encodeURI('main')}/${encodeURI(filename)}` : base;
}
if (modelId) {
return buildCivitaiUrl({
modelId,
versionId,
host: normalizeCivitaiPageHost(state?.global?.settings?.civitai_host),
});
}
return null;
}
async executeDownloadWithProgress({
modelId,
versionId,
@@ -896,6 +923,7 @@ export class DownloadManager {
}
const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false };
let ws = null;
let updateProgress = () => { };
let cancelled = false;
@@ -984,6 +1012,26 @@ export class DownloadManager {
return true;
}
if (!response?.success) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
modelId,
versionId,
source,
url: this._buildSingleItemUrl({ modelId, versionId, source }),
},
error: response?.error || 'Unknown error',
name: displayName,
}],
onRetry: () => this.executeDownloadWithProgress(retryParams),
});
return false;
}
showToast('toast.loras.downloadCompleted', {}, 'success');
if (closeModal) {
@@ -1018,7 +1066,21 @@ export class DownloadManager {
console.log('Download cancelled by user:', downloadId);
} else {
console.error('Failed to download model version:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
modelId,
versionId,
source,
url: this._buildSingleItemUrl({ modelId, versionId, source }),
},
error: error?.message || 'Unknown error',
name: displayName,
}],
onRetry: () => this.executeDownloadWithProgress(retryParams),
});
}
return false;
} finally {
@@ -1033,14 +1095,16 @@ export class DownloadManager {
}
}
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) {
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
modalManager.closeModal('downloadModal');
this.loadingManager.restoreProgressBar();
const totalFiles = this.hfSelectedFiles.length;
const filesToDownload = files || this.hfSelectedFiles;
const totalFiles = filesToDownload.length;
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
let cancelled = false;
let currentDownloadId = null;
const failedFiles = [];
this.loadingManager.showCancelButton(async () => {
if (cancelled) return;
@@ -1059,7 +1123,7 @@ export class DownloadManager {
for (let i = 0; i < totalFiles; i++) {
if (cancelled) break;
const filename = this.hfSelectedFiles[i];
const filename = filesToDownload[i];
updateProgress(0, completedDownloads, filename);
this.loadingManager.setStatus(`Downloading ${filename}...`);
@@ -1105,6 +1169,31 @@ export class DownloadManager {
if (response?.success) {
completedDownloads++;
updateProgress(100, completedDownloads, filename);
} else {
failedFiles.push({
item: {
source: 'huggingface',
repo: this.hfRepoId,
filename,
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
},
error: response?.error || 'Unknown error',
name: filename,
});
}
} catch (err) {
if (!cancelled) {
console.error(`Failed to download HF file ${filename}:`, err);
failedFiles.push({
item: {
source: 'huggingface',
repo: this.hfRepoId,
filename,
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
},
error: err?.message || 'Unknown error',
name: filename,
});
}
} finally {
ws.close();
@@ -1114,11 +1203,27 @@ export class DownloadManager {
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else {
showToast('toast.loras.downloadCompleted', {}, 'success');
await resetAndReload(true);
return true;
}
if (failedFiles.length === 0) {
showToast('toast.loras.downloadCompleted', {}, 'success');
await resetAndReload(true);
return true;
}
showDownloadBatchSummary({
total: totalFiles,
completed: completedDownloads,
failedItems: failedFiles,
onRetry: () => this._downloadHfSingle({
modelRoot,
targetFolder,
useDefaultPaths,
files: failedFiles.map((f) => f.item.filename),
}),
});
await resetAndReload(true);
return true;
return false;
} catch (error) {
if (!cancelled) {
console.error('Failed to download HF model:', error);
@@ -1548,6 +1653,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 +1667,7 @@ export class DownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let cancelled = false;
const failedItems = [];
loadingManager.showCancelButton(async () => {
if (cancelled) return;
@@ -1658,6 +1768,7 @@ export class DownloadManager {
if (!response.success) {
failedDownloads++;
failedItems.push({ item, error: response.error || 'Unknown error', name });
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
@@ -1666,6 +1777,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 +1791,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);
-21
View File
@@ -6,7 +6,6 @@ import { state } from '../state/index.js';
// Constants for preset management
const PRESETS_STORAGE_VERSION = 'v1';
const MAX_PRESET_NAME_LENGTH = 30;
const MAX_PRESETS_COUNT = 10;
// Marker for when wildcard patterns resolve to no matches
// This ensures we return empty results instead of all models
@@ -362,15 +361,6 @@ export class FilterPresetManager {
}
}
if (presets.length >= MAX_PRESETS_COUNT) {
showToast(
translate('toast.error.maxPresetsReached', { max: MAX_PRESETS_COUNT }, `Maximum ${MAX_PRESETS_COUNT} presets allowed. Delete one to add more.`),
{},
'error'
);
return false;
}
const preset = {
name: trimmedName,
filters: this.filterManager.cloneFilters(),
@@ -613,17 +603,6 @@ export class FilterPresetManager {
return;
}
// Check max presets limit before showing input
const presets = this.loadPresets();
if (presets.length >= MAX_PRESETS_COUNT) {
showToast(
translate('toast.error.maxPresetsReached', { max: MAX_PRESETS_COUNT }, `Maximum ${MAX_PRESETS_COUNT} presets allowed. Delete one to add more.`),
{},
'error'
);
return;
}
this.isInlineNamingActive = true;
const presetsContainer = document.getElementById('filterPresets');
+13
View File
@@ -1017,6 +1017,12 @@ export class SettingsManager {
displayDensitySelect.value = state.global.settings.display_density || 'default';
}
// Set recipes layout setting
const recipesLayoutSelect = document.getElementById('recipesLayout');
if (recipesLayoutSelect) {
recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid';
}
// Set card info display setting
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
if (cardInfoDisplaySelect) {
@@ -2288,6 +2294,13 @@ export class SettingsManager {
// Apply frontend settings immediately
this.applyFrontendSettings();
// Dispatch layout change event; the scroller instance is about to be rebuilt,
// so calculateLayout() must NOT run on the old instance here
if (settingKey === 'recipes_layout') {
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
return;
}
// Recalculate layout when display density changes
if (settingKey === 'display_density' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
+15 -1
View File
@@ -7,7 +7,7 @@ import { state, getCurrentPageState } from './state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshVirtualScroll, recreateVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
import { initSortDropdown } from './components/controls/SortDropdown.js';
@@ -272,6 +272,20 @@ class RecipeManager {
});
}
// Rebuild the scroller on layout switch; in duplicates mode defer until
// exitDuplicateMode re-enables the scroller (direct recreation would dispose
// the old instance while initializeVirtualScroll skips duplicates mode)
window.addEventListener('lm:recipes-layout-changed', () => {
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
state.pendingLayoutRecreate = true;
return;
}
if (typeof recreateVirtualScroll === 'function') {
recreateVirtualScroll('recipes');
}
});
// Initialize dropdown functionality for refresh button
this.initDropdowns();
}
+1
View File
@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
card_blur_amount: 8,
autoplay_on_hover: false,
display_density: 'default',
recipes_layout: 'grid',
card_info_display: 'always',
model_name_display: 'model_name',
lora_syntax_format: 'legacy',
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -483,10 +483,18 @@ export class VirtualScroller {
element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`;
// Remove max-width constraint from model-card to allow dynamic sizing
const modelCard = element.querySelector('.model-card');
// Remove max-width/min-width constraints from the model-card to allow
// dynamic sizing. The card is either the item element itself (e.g.
// RecipeCard returns the .model-card root) or a descendant (ModelCard).
// Without this, the CSS min-width of 200px forces compact-density cards
// wider than their allocated column, and the last column gets clipped
// by the grid's overflow-x: hidden.
const modelCard = element.classList.contains('model-card')
? element
: element.querySelector('.model-card');
if (modelCard) {
modelCard.style.maxWidth = 'none';
modelCard.style.minWidth = '0';
}
return element;
+24 -1
View File
@@ -1,5 +1,6 @@
import { state, getCurrentPageState } from '../state/index.js';
import { VirtualScroller } from './VirtualScroller.js';
import { MasonryScroller } from './MasonryScroller.js';
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { showToast } from './uiHelpers.js';
@@ -141,8 +142,14 @@ async function initializeVirtualScroll(pageType) {
throw new Error(`Required components not available for ${pageType} page`);
}
// Masonry applies to the recipes page only; the read pattern mirrors
// VirtualScroller.js (display_density).
const useMasonry = pageType === 'recipes'
&& (state.global.settings?.recipes_layout ?? 'grid') === 'masonry';
const ScrollerClass = useMasonry ? MasonryScroller : VirtualScroller;
// Initialize virtual scroller with renamed container elements
state.virtualScroller = new VirtualScroller({
state.virtualScroller = new ScrollerClass({
gridElement: grid,
containerElement: gridContainer,
scrollContainer: scrollContainer,
@@ -250,3 +257,19 @@ export async function refreshVirtualScroll(options = {}) {
}
}
}
// Rebuild the virtual scroller from scratch (used for layout switching).
// refreshVirtualScroll only resets the existing instance, so it cannot swap
// the scroller class. Keyboard navigation must be cleaned up first:
// setupKeyboardNavigation appends a new document keydown listener on every
// initializeVirtualScroll call and never removes the previous one.
export async function recreateVirtualScroll(pageType) {
cleanupKeyboardNavigation();
if (state.virtualScroller) {
state.virtualScroller.dispose();
state.virtualScroller = null;
}
await initializeVirtualScroll(pageType);
}
+2
View File
@@ -356,6 +356,8 @@ export function updatePanelPositions() {
if (filterPanel) {
filterPanel.style.top = `${topPosition}px`;
// Clamp panel height to the viewport below the header
filterPanel.style.maxHeight = `calc(100vh - ${topPosition + 10}px)`;
}
// Adjust panel horizontal position based on the search container
@@ -202,6 +202,7 @@
<option value="deepseek">{{ t('settings.aiProvider.providerOptions.deepseek') }}</option>
<option value="groq">{{ t('settings.aiProvider.providerOptions.groq') }}</option>
<option value="openrouter">{{ t('settings.aiProvider.providerOptions.openrouter') }}</option>
<option value="google">{{ t('settings.aiProvider.providerOptions.google') }}</option>
<option value="opencode-go">{{ t('settings.aiProvider.providerOptions.opencode-go') }}</option>
<option value="custom">{{ t('settings.aiProvider.providerOptions.custom') }}</option>
</select>
@@ -624,7 +625,24 @@
<span class="warning-text">{{ t('settings.layoutSettings.displayDensityWarning') }}</span>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="recipesLayout">
{{ t('settings.layoutSettings.recipesLayout') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
@@ -38,6 +38,7 @@ vi.mock('../../../static/js/state/index.js', () => {
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: captureScrollPositionMock,
restoreScrollPosition: restoreScrollPositionMock,
recreateVirtualScroll: vi.fn(),
}));
import {
@@ -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: '<img src=x onerror=alert(1)>', url: 'https://example.com/xss-model' }, error: '<script>bad()</script>' },
],
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('<img src=x onerror=alert(1)>');
expect(errorCell.textContent).toContain('<script>bad()</script>');
// ...and never as live DOM nodes.
expect(document.querySelector('.failure-table img')).toBeNull();
expect(document.querySelector('.failure-table script')).toBeNull();
expect(nameCell.innerHTML).toContain('&lt;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));
});
});
@@ -0,0 +1,87 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const recreateVirtualScrollMock = vi.fn();
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: class {},
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
recreateVirtualScroll: recreateVirtualScrollMock,
}));
const { DuplicatesManager } = await import('../../../static/js/components/DuplicatesManager.js');
const { state, getCurrentPageState, setCurrentPageType } = await import('../../../static/js/state/index.js');
function setupDom() {
document.body.innerHTML = `
<div id="duplicatesBanner" style="display: block;"></div>
<div id="recipeGrid"><div class="model-card">stale</div></div>
`;
document.body.classList.add('duplicate-mode');
}
describe('DuplicatesManager exitDuplicateMode', () => {
beforeEach(() => {
vi.clearAllMocks();
setCurrentPageType('recipes');
setupDom();
state.pendingLayoutRecreate = false;
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
});
afterEach(() => {
state.pendingLayoutRecreate = false;
state.virtualScroller = null;
});
it('skips enable() on the old scroller when a layout recreate was deferred', async () => {
state.pendingLayoutRecreate = true;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(state.virtualScroller.enable).not.toHaveBeenCalled();
expect(recreateVirtualScrollMock).toHaveBeenCalledWith('recipes');
expect(state.pendingLayoutRecreate).toBe(false);
});
it('re-enables the existing scroller when no layout recreate is pending', async () => {
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(state.virtualScroller.enable).toHaveBeenCalledTimes(1);
expect(recreateVirtualScrollMock).not.toHaveBeenCalled();
});
it('tolerates a missing scroller on the plain re-enable path', async () => {
state.virtualScroller = null;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await expect(manager.exitDuplicateMode()).resolves.toBeUndefined();
expect(recreateVirtualScrollMock).not.toHaveBeenCalled();
});
it('clears duplicates-mode state and the grid regardless of path', async () => {
state.pendingLayoutRecreate = true;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(manager.inDuplicateMode).toBe(false);
expect(getCurrentPageState().duplicatesMode).toBe(false);
expect(document.body.classList.contains('duplicate-mode')).toBe(false);
expect(document.getElementById('recipeGrid').innerHTML).toBe('');
expect(document.getElementById('duplicatesBanner').style.display).toBe('none');
});
});
+1
View File
@@ -92,6 +92,7 @@ vi.mock('../../../static/js/utils/eventManagementInit.js', () => ({
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
initializeInfiniteScroll: vi.fn(),
recreateVirtualScroll: vi.fn(),
}));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
@@ -0,0 +1,434 @@
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(),
getPageState: 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);
});
it('shows the batch summary for a single CivitAI download resolved as a failure', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'rate limited' });
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
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.modelId).toBe('111');
expect(summary.failedItems[0].item.versionId).toBe('v1');
expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
expect(summary.failedItems[0].error).toBe('rate limited');
expect(summary.failedItems[0].name).toBe('V1');
expect(summary.onRetry).toEqual(expect.any(Function));
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
});
it('shows the batch summary when a single download throws', async () => {
mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
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].error).toBe('network down');
expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
});
it('keeps the success toast and skips the summary for a successful single download', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: true });
const result = await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(result).toBe(true);
expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('retries a failed single download through onRetry with the same params', async () => {
mockApiClient.downloadModel
.mockResolvedValueOnce({ success: false, error: 'rate limited' })
.mockResolvedValueOnce({ success: true });
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.failedItems).toHaveLength(1);
await summary.onRetry();
expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
const retryCall = mockApiClient.downloadModel.mock.calls[1];
expect(retryCall[0]).toBe('111');
expect(retryCall[1]).toBe('v1');
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
});
it('shows a summary for HF partial failure and retries only the failed files', async () => {
manager.hfRepoId = 'user/repo';
manager.hfSelectedFiles = ['a.safetensors', 'b.safetensors'];
mockApiClient.downloadHfModel
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'denied' });
const result = await manager._downloadHfSingle({ modelRoot: '/m', useDefaultPaths: true });
expect(result).toBe(false);
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].name).toBe('b.safetensors');
expect(summary.failedItems[0].item.url).toEqual(
expect.stringContaining('huggingface.co/user/repo/blob/main/b.safetensors')
);
await summary.onRetry();
expect(mockApiClient.downloadHfModel).toHaveBeenCalledTimes(3);
expect(mockApiClient.downloadHfModel.mock.calls[2][0].filename).toBe('b.safetensors');
});
});
@@ -0,0 +1,264 @@
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',
},
},
fetchCivitaiVersions: vi.fn(),
downloadModel: vi.fn(),
downloadHfModel: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
};
// Shared loading manager served both via state.loadingManager and the
// LoadingManager constructor mock.
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
setStatus: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => 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,
}));
/** Minimal DOM used by validateAndFetchVersions / showVersionStep / batch preview. */
function setupDownloadDom() {
document.body.innerHTML = `
<div id="downloadModal">
<div class="download-step" id="urlStep"></div>
<div class="download-step" id="versionStep"></div>
<div class="download-step" id="fileSelectionStep"></div>
<div class="download-step" id="locationStep"></div>
<div id="batchPreviewStep"></div>
<textarea id="modelUrl"></textarea>
<div id="urlError"></div>
<div id="versionList"></div>
<div id="fileSelectionList"></div>
<div id="fileSelectionVersionName"></div>
<button id="nextFromVersion"></button>
<button id="nextFromBatchBtn"></button>
<div id="downloadModalTitle"></div>
<div id="batchPreviewList"></div>
<div id="modelRoot"></div>
<div id="folderPath"></div>
<div id="targetPathDisplay"></div>
<input id="useDefaultPath" />
<div id="manualPathSelection"></div>
</div>
`;
}
function makeVersion(id, name) {
return {
id,
name,
baseModel: 'SDXL',
createdAt: '2025-01-01T00:00:00Z',
availability: 'Public',
images: [{ url: 'https://image.civitai.com/preview.jpg' }],
files: [{ id: 1, type: 'Model', name: 'model.safetensors', sizeKB: 1000 }],
modelSizeKB: 1000,
existsLocally: false,
hasBeenDownloaded: false,
};
}
// Newest-first, matching the Civitai API response order. The default selection
// takes the first version, so the newest (largest id) must come first.
const versions = [makeVersion(250, 'v3'), makeVersion(100, 'v1'), makeVersion(30, 'v0')];
describe('DownloadManager latest-version default', () => {
let DownloadManager;
let manager;
beforeEach(async () => {
document.body.innerHTML = '';
setupDownloadDom();
mockApiClient.fetchCivitaiVersions.mockReset();
mockLoadingManager.showSimpleLoading.mockClear();
mockLoadingManager.hide.mockClear();
vi.resetModules();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
manager = new DownloadManager();
manager.apiClient = mockApiClient;
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('single URL without modelVersionId', () => {
it('auto-selects the latest version so no manual version click is needed', async () => {
document.getElementById('modelUrl').value = 'https://civitai.red/models/837884/midjourney-artful-nsfw';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.validateAndFetchVersions();
expect(manager.modelId).toBe('837884');
expect(manager.currentVersion.id).toBe(250);
// The version step is shown with the latest version pre-selected.
expect(document.getElementById('versionStep').style.display).toBe('block');
const selected = document.querySelector('.version-item.selected');
expect(selected.dataset.versionId).toBe('250');
expect(document.getElementById('nextFromVersion').disabled).toBe(false);
});
it('still honours an explicit modelVersionId from the URL', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=30';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.validateAndFetchVersions();
expect(manager.currentVersion.id).toBe(30);
});
});
describe('fetchVersionsForCurrentModel without modelVersionId', () => {
it('auto-selects the latest version', async () => {
manager.modelId = '837884';
manager.modelVersionId = null;
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.fetchVersionsForCurrentModel();
expect(manager.currentVersion.id).toBe(250);
});
it('still honours an explicit modelVersionId', async () => {
manager.modelId = '837884';
manager.modelVersionId = '100';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.fetchVersionsForCurrentModel();
expect(manager.currentVersion.id).toBe(100);
});
});
describe('multi-URL batch without modelVersionId', () => {
it('defaults each item to its latest version (first in API response)', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/111/foo\nhttps://civitai.red/models/222/bar';
const versionsA = [makeVersion(40, 'a2'), makeVersion(10, 'a1')];
const versionsB = [makeVersion(500, 'b1'), makeVersion(7, 'b2')];
mockApiClient.fetchCivitaiVersions.mockImplementation(async modelId =>
modelId === '111' ? versionsA : versionsB
);
await manager.validateAndFetchVersions();
expect(manager.isBatchMode).toBe(true);
expect(manager.batchModels).toHaveLength(2);
expect(manager.batchModels[0].selectedVersion.id).toBe(40);
expect(manager.batchModels[1].selectedVersion.id).toBe(500);
});
it('still honours an explicit modelVersionId per URL', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/111/foo?modelVersionId=10\nhttps://civitai.red/models/222/bar';
const versionsA = [makeVersion(40, 'a2'), makeVersion(10, 'a1')];
const versionsB = [makeVersion(500, 'b1'), makeVersion(7, 'b2')];
mockApiClient.fetchCivitaiVersions.mockImplementation(async modelId =>
modelId === '111' ? versionsA : versionsB
);
await manager.validateAndFetchVersions();
expect(manager.batchModels[0].selectedVersion.id).toBe(10);
expect(manager.batchModels[1].selectedVersion.id).toBe(500);
});
});
});
@@ -501,3 +501,33 @@ describe('SettingsManager library controls', () => {
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
});
});
describe('SettingsManager recipes layout switch', () => {
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
const manager = createManager();
const select = document.createElement('select');
select.id = 'recipesLayout';
const option = document.createElement('option');
option.value = 'masonry';
select.appendChild(option);
select.value = 'masonry';
document.body.appendChild(select);
const calculateLayout = vi.fn();
state.virtualScroller = { calculateLayout };
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
await manager.saveSelectSetting('recipesLayout', 'recipes_layout');
const layoutEvent = dispatchSpy.mock.calls
.map(([event]) => event)
.find(event => event.type === 'lm:recipes-layout-changed');
expect(layoutEvent).toBeInstanceOf(CustomEvent);
expect(calculateLayout).not.toHaveBeenCalled();
expect(showToast).not.toHaveBeenCalled();
dispatchSpy.mockRestore();
delete state.virtualScroller;
});
});
+1
View File
@@ -69,6 +69,7 @@ vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
refreshVirtualScroll: refreshVirtualScrollMock,
recreateVirtualScroll: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
+2 -1
View File
@@ -17,7 +17,8 @@ describe('state module', () => {
civitai_host: 'civitai.com',
language: 'en',
blur_mature_content: true,
mature_blur_level: 'R'
mature_blur_level: 'R',
recipes_layout: 'grid'
});
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
+198
View File
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
VirtualScrollerMock,
MasonryScrollerMock,
RecipeCardMock,
fetchRecipesPageMock,
getModelApiClientMock,
} = vi.hoisted(() => {
const makeScrollerClass = () => vi.fn(function (options) {
this.options = options;
this.initialize = vi.fn(async () => {});
this.dispose = vi.fn();
this.reset = vi.fn();
this.handlePageUpDown = vi.fn();
});
return {
VirtualScrollerMock: makeScrollerClass(),
MasonryScrollerMock: makeScrollerClass(),
RecipeCardMock: vi.fn(function () {
this.element = document.createElement('div');
}),
fetchRecipesPageMock: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
getModelApiClientMock: vi.fn(() => ({
fetchModelsPage: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
})),
};
});
vi.mock('../../../static/js/utils/VirtualScroller.js', () => ({
VirtualScroller: VirtualScrollerMock,
}));
vi.mock('../../../static/js/utils/MasonryScroller.js', () => ({
MasonryScroller: MasonryScrollerMock,
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: RecipeCardMock,
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipesPage: fetchRecipesPageMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: getModelApiClientMock,
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
createModelCard: vi.fn(() => document.createElement('div')),
setupModelCardEventDelegation: vi.fn(),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
import {
initializeInfiniteScroll,
recreateVirtualScroll,
} from '../../../static/js/utils/infiniteScroll.js';
import { state } from '../../../static/js/state/index.js';
function setupPageDom() {
const pageContent = document.createElement('div');
pageContent.className = 'page-content';
const container = document.createElement('div');
container.className = 'container';
pageContent.appendChild(container);
document.body.appendChild(pageContent);
const grid = document.createElement('div');
vi.spyOn(document, 'getElementById').mockImplementation((id) =>
id === 'recipeGrid' || id === 'modelGrid' ? grid : null);
return { grid };
}
describe('infiniteScroll scroller class branching', () => {
let originalSettings;
beforeEach(() => {
originalSettings = state.global.settings;
state.global.settings = { ...originalSettings };
if (state.pages.recipes) {
state.pages.recipes.duplicatesMode = false;
}
state.virtualScroller = null;
state.keyboardNavHandler = null;
setupPageDom();
});
afterEach(() => {
state.global.settings = originalSettings;
state.virtualScroller = null;
state.keyboardNavHandler = null;
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('constructs MasonryScroller for the recipes page when recipes_layout is masonry', async () => {
state.global.settings.recipes_layout = 'masonry';
await initializeInfiniteScroll('recipes');
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(VirtualScrollerMock).not.toHaveBeenCalled();
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
});
it('constructs VirtualScroller for the recipes page when recipes_layout is grid', async () => {
state.global.settings.recipes_layout = 'grid';
await initializeInfiniteScroll('recipes');
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
expect(state.virtualScroller).toBeInstanceOf(VirtualScrollerMock);
});
it('falls back to the grid branch when recipes_layout is missing', async () => {
delete state.global.settings.recipes_layout;
await expect(initializeInfiniteScroll('recipes')).resolves.toBeUndefined();
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
});
it('always constructs VirtualScroller for the loras page regardless of recipes_layout', async () => {
state.global.settings.recipes_layout = 'masonry';
await initializeInfiniteScroll('loras');
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
});
});
describe('recreateVirtualScroll', () => {
let originalSettings;
beforeEach(() => {
originalSettings = state.global.settings;
state.global.settings = { ...originalSettings };
if (state.pages.recipes) {
state.pages.recipes.duplicatesMode = false;
}
state.virtualScroller = null;
state.keyboardNavHandler = null;
setupPageDom();
});
afterEach(() => {
state.global.settings = originalSettings;
state.virtualScroller = null;
state.keyboardNavHandler = null;
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('cleans up keyboard navigation, disposes the old scroller, and rebuilds with the new layout', async () => {
state.global.settings.recipes_layout = 'grid';
await initializeInfiniteScroll('recipes');
const oldScroller = state.virtualScroller;
const oldKeyboardHandler = state.keyboardNavHandler;
expect(oldKeyboardHandler).toBeTypeOf('function');
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
state.global.settings.recipes_layout = 'masonry';
await recreateVirtualScroll('recipes');
// cleanupKeyboardNavigation removed the previous document keydown listener
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', oldKeyboardHandler);
// Old scroller disposed and replaced by a new masonry instance
expect(oldScroller.dispose).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
expect(state.virtualScroller).not.toBe(oldScroller);
// A fresh keyboard navigation listener was registered for the new instance
expect(state.keyboardNavHandler).toBeTypeOf('function');
expect(state.keyboardNavHandler).not.toBe(oldKeyboardHandler);
});
it('works when there is no existing virtual scroller', async () => {
state.global.settings.recipes_layout = 'masonry';
await expect(recreateVirtualScroll('recipes')).resolves.toBeUndefined();
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
});
});
@@ -0,0 +1,627 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MasonryScroller } from '../../../static/js/utils/MasonryScroller.js';
import { VirtualScroller } from '../../../static/js/utils/VirtualScroller.js';
import { getCurrentPageState, setCurrentPageType } from '../../../static/js/state/index.js';
// jsdom does not always provide requestAnimationFrame; polyfill when missing
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame !== 'function') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
const CONTAINER_WIDTH = 768; // yields 3 columns at default density: floor((768+12)/(240+12)) = 3
const COLUMN_GAP = 12;
const ROW_GAP = 20;
const PAD_TOP = 4;
const PAD_BOTTOM = 4;
const ITEM_WIDTH = (CONTAINER_WIDTH - 2 * COLUMN_GAP) / 3; // 248
const FALLBACK_HEIGHT = ITEM_WIDTH / (896 / 1152);
function createItemFn() {
const el = document.createElement('div');
const card = document.createElement('div');
card.className = 'model-card';
const preview = document.createElement('div');
preview.className = 'card-preview';
card.appendChild(preview);
el.appendChild(card);
return el;
}
function makeItems(dimensions) {
return dimensions.map((dims, i) => ({
file_path: `/recipes/item-${i}.png`,
...dims,
}));
}
/**
* Build a scroller attached to a stubbed container. clientWidth/clientHeight
* are 0 in jsdom, so they are defined explicitly for deterministic layout.
*/
function createScroller({ items = [], fetchItemsFn, overscan, viewportHeight = 600, createItemFn: customCreateItemFn } = {}) {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'clientWidth', { value: CONTAINER_WIDTH, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: viewportHeight, configurable: true });
const grid = document.createElement('div');
wrapper.appendChild(grid);
document.body.appendChild(wrapper);
const fetchMock = fetchItemsFn || vi.fn(async () => ({ items, totalItems: items.length, hasMore: false }));
const scroller = new MasonryScroller({
gridElement: grid,
containerElement: wrapper,
scrollContainer: wrapper,
createItemFn: customCreateItemFn || createItemFn,
fetchItemsFn: fetchMock,
overscan,
});
return { scroller, wrapper, grid, fetchMock };
}
describe('MasonryScroller', () => {
const liveScrollers = [];
beforeEach(() => {
setCurrentPageType('recipes');
getCurrentPageState().duplicatesMode = false;
});
afterEach(() => {
while (liveScrollers.length > 0) {
liveScrollers.pop().dispose();
}
getCurrentPageState().duplicatesMode = false;
});
function track(setup) {
liveScrollers.push(setup.scroller);
return setup;
}
it('adds virtual-scroll and masonry-layout classes and creates the spacer', () => {
const { scroller, grid } = track(createScroller());
expect(grid.classList.contains('virtual-scroll')).toBe(true);
expect(grid.classList.contains('masonry-layout')).toBe(true);
expect(scroller.spacerElement.className).toBe('virtual-scroll-spacer');
expect(grid.style.position).toBe('relative');
expect(grid.contains(scroller.spacerElement)).toBe(true);
});
it('computes density-based column count and item width', () => {
const { scroller } = track(createScroller());
expect(scroller.columnsCount).toBe(3);
expect(scroller.itemWidth).toBeCloseTo(ITEM_WIDTH);
});
it('places each item into the shortest column', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const cols = scroller.positions.map((p) => p.col);
expect(cols).toEqual([0, 1, 2, 2, 1, 2]);
// Tops follow the accumulated shortest-column heights
expect(scroller.positions[0].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[1].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[2].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[3].top).toBeCloseTo(PAD_TOP + 124 + ROW_GAP); // 148
expect(scroller.positions[4].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
expect(scroller.positions[5].top).toBeCloseTo(148 + 248 + ROW_GAP); // 416
// Left offsets are column index * (itemWidth + columnGap)
expect(scroller.positions[1].left).toBeCloseTo(ITEM_WIDTH + COLUMN_GAP);
expect(scroller.positions[2].left).toBeCloseTo(2 * (ITEM_WIDTH + COLUMN_GAP));
});
it('sets spacer height to the tallest column minus trailing gap plus padding', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
// Column heights after placement: [520, 540, 684] (each starts at padTop)
const expected = 684 - ROW_GAP + PAD_TOP + PAD_BOTTOM; // 672
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
});
it('falls back to the 896/1152 ratio for items without dimensions', () => {
const items = makeItems([{}, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
expect(scroller.positions[0].height).toBeCloseTo(FALLBACK_HEIGHT);
expect(scroller.positions[1].height).toBeCloseTo(ITEM_WIDTH);
});
it('visible range respects the overscan band', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller, wrapper } = track(createScroller({ items, viewportHeight: 300 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
// Without overscan, item 5 (top=416) is below the 300px viewport
scroller.overscan = 0;
let visible = scroller.getVisibleRange();
expect([...visible].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]);
// overscan=1 extends the band by one itemWidth (248px), pulling item 5 in
scroller.overscan = 1;
visible = scroller.getVisibleRange();
expect(visible.has(5)).toBe(true);
});
it('renders visible items with inline masonry styles and clears model-card max-width', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const rendered = grid.querySelectorAll('.virtual-scroll-item');
expect(rendered.length).toBe(3);
rendered.forEach((el, i) => {
expect(el.style.position).toBe('absolute');
expect(el.style.width).toBe(`${scroller.positions[i].width}px`);
expect(el.style.height).toBe(`${scroller.positions[i].height}px`);
expect(el.style.left).toBe(`${scroller.positions[i].left}px`);
expect(el.style.top).toBe(`${scroller.positions[i].top}px`);
expect(el.querySelector('.model-card').style.maxWidth).toBe('none');
expect(el.querySelector('.model-card').style.minWidth).toBe('0');
});
});
it('clears model-card max-width/min-width when the item element is the card root', () => {
// Production shape for recipe cards: RecipeCard returns the .model-card
// element itself, so the scroller must clear constraints on the element
// rather than a descendant (querySelector would find nothing).
const items = makeItems([{ width: 100, height: 200 }]);
const cardRoot = document.createElement('div');
cardRoot.className = 'model-card';
const { scroller, grid, wrapper } = track(createScroller({
items,
viewportHeight: 3000,
createItemFn: () => cardRoot.cloneNode(true),
}));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const rendered = grid.querySelectorAll('.virtual-scroll-item');
expect(rendered.length).toBe(1);
expect(rendered[0].style.maxWidth).toBe('none');
expect(rendered[0].style.minWidth).toBe('0');
});
it('triggers loadMoreItems when scrolled to the bottom', async () => {
const firstPage = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const fetchMock = vi.fn(async () => ({ items: makeItems([{ width: 100, height: 100 }]), totalItems: 7, hasMore: false }));
const { scroller, wrapper } = track(createScroller({ fetchItemsFn: fetchMock, viewportHeight: 600 }));
scroller.refreshWithData(firstPage, 100, true);
const pageState = getCurrentPageState();
const expectedPage = pageState.currentPage;
// contentBottom = 664; scrollBottom = 100 + 600 = 700 >= 664 - threshold
wrapper.scrollTop = 100;
scroller.handleScroll();
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expectedPage, scroller.pageSize);
});
});
it('does not trigger loadMoreItems when far from the bottom', async () => {
const fetchMock = vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false }));
const manyItems = makeItems(Array.from({ length: 30 }, () => ({ width: 100, height: 200 })));
const { scroller, wrapper } = track(createScroller({ fetchItemsFn: fetchMock, viewportHeight: 600 }));
scroller.refreshWithData(manyItems, 1000, true);
wrapper.scrollTop = 0;
scroller.handleScroll();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns false from calculateLayout in duplicates mode', () => {
const { scroller } = track(createScroller());
getCurrentPageState().duplicatesMode = true;
expect(scroller.calculateLayout()).toBe(false);
});
it('computes placement and spacer synchronously during initialize (before rAF)', async () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
await scroller.initialize();
// Assert immediately after initialize resolves, without waiting for rAF
expect(scroller.positions.length).toBe(items.length);
const expected = 684 - ROW_GAP + PAD_TOP + PAD_BOTTOM; // 672
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
expect(getCurrentPageState().currentPage).toBe(2);
});
it('shows the error placeholder and resets isLoading when the initial fetch fails', async () => {
const fetchMock = vi.fn(async () => {
throw new Error('network down');
});
const { scroller, grid } = track(createScroller({ fetchItemsFn: fetchMock }));
await expect(scroller.initialize()).resolves.toBeUndefined();
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
expect(placeholder).not.toBeNull();
expect(placeholder.textContent).toContain('Failed to load items');
expect(scroller.isLoading).toBe(false);
});
it('shows the recipes empty placeholder when no items are returned', async () => {
const { scroller, grid } = track(createScroller({ items: [] }));
await scroller.initialize();
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
expect(placeholder).not.toBeNull();
expect(placeholder.textContent).toContain('No recipes found');
});
it('dispose removes classes, spacer and event listeners', () => {
const { scroller, grid } = track(createScroller());
scroller.dispose();
expect(grid.classList.contains('virtual-scroll')).toBe(false);
expect(grid.classList.contains('masonry-layout')).toBe(false);
expect(grid.querySelector('.virtual-scroll-spacer')).toBeNull();
});
it('exposes every VirtualScroller prototype method (API parity)', () => {
const virtualMethods = Object.getOwnPropertyNames(VirtualScroller.prototype);
const masonryMethods = new Set(Object.getOwnPropertyNames(MasonryScroller.prototype));
const missing = virtualMethods.filter((name) => !masonryMethods.has(name));
expect(missing).toEqual([]);
});
it('exposes the VirtualScroller property surface after construction', () => {
const { scroller } = track(createScroller());
const expectedProperties = [
'items',
'renderedItems',
'totalItems',
'hasMore',
'isLoading',
'gridElement',
'containerElement',
'scrollContainer',
'columnsCount',
'itemWidth',
'disabled',
'spacerElement',
'pageSize',
];
for (const prop of expectedProperties) {
expect(scroller[prop]).not.toBeUndefined();
}
});
it('updateSingleItem re-places items and shows the updated indicator on rendered cards', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
// Item 2 (height 124) sits in column 2 with items 3 and 5 stacked below it
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const spacerBefore = scroller.spacerElement.style.height;
const colsBefore = scroller.positions.map((p) => p.col);
const result = scroller.updateSingleItem('/recipes/item-2.png', { width: 100, height: 150 });
expect(result).toBe(true);
// Item 2 height grew 124 -> 372, so the full synchronous re-placement
// re-flows every later item (item 3 moves from column 2 to column 1)
expect(scroller.positions[2].height).toBeCloseTo(ITEM_WIDTH * 1.5);
expect(scroller.positions.map((p) => p.col)).not.toEqual(colsBefore);
expect(scroller.spacerElement.style.height).not.toBe(spacerBefore);
// The re-placement equals a fresh full layout of the same items
const { scroller: reference } = track(createScroller({ items }));
reference.refreshWithData(scroller.items.slice(), items.length, false);
expect(scroller.positions.map((p) => p.col)).toEqual(reference.positions.map((p) => p.col));
for (let i = 0; i < items.length; i++) {
expect(scroller.positions[i].top).toBeCloseTo(reference.positions[i].top);
}
// The rendered card was recreated in place with the update indicator
const updatedCard = grid.querySelector('.virtual-scroll-item.updated');
expect(updatedCard).not.toBeNull();
const indicator = updatedCard.querySelector('.update-indicator');
expect(indicator).not.toBeNull();
expect(indicator.textContent).toBe('Updated');
expect(updatedCard.querySelector('.card-preview').contains(indicator)).toBe(true);
expect(updatedCard.style.height).toBe(`${scroller.positions[2].height}px`);
expect(updatedCard.style.top).toBe(`${scroller.positions[2].top}px`);
});
it('updateSingleItem returns false for an unknown file path without throwing', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
let result;
expect(() => {
result = scroller.updateSingleItem('/recipes/does-not-exist.png', { title: 'x' });
}).not.toThrow();
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('removeItemByFilePath re-places the remaining items and decrements the total', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 60, false);
const result = scroller.removeItemByFilePath('/recipes/item-2.png');
expect(result).toBe(true);
expect(scroller.items.length).toBe(5);
expect(scroller.totalItems).toBe(59);
expect(scroller.positions.length).toBe(5);
// Remaining heights: 496, 248, 248, 248, 248 -> shortest-column placement
expect(scroller.positions.map((p) => p.col)).toEqual([0, 1, 2, 1, 2]);
expect(scroller.positions[3].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
expect(scroller.positions[4].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
// Spacer reflects the tallest remaining column
const maxColumnHeight = Math.max(...scroller.columnHeights);
const expected = maxColumnHeight - ROW_GAP + PAD_TOP + PAD_BOTTOM;
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
});
it('removeItemByFilePath returns false for an unknown file path', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(scroller.removeItemByFilePath('/recipes/missing.png')).toBe(false);
warnSpy.mockRestore();
});
it('removeMultipleItemsByFilePath re-places items with no layout gaps', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 60, false);
const result = scroller.removeMultipleItemsByFilePath([
'/recipes/item-1.png',
'/recipes/item-3.png',
]);
expect(result).toBe(true);
expect(scroller.items.map((i) => i.file_path)).toEqual([
'/recipes/item-0.png',
'/recipes/item-2.png',
'/recipes/item-4.png',
'/recipes/item-5.png',
]);
expect(scroller.totalItems).toBe(58);
// The remaining items are laid out exactly as a fresh full placement:
// compare against a second scroller fed the same remaining items
const remaining = scroller.items.slice();
const { scroller: reference } = track(createScroller({ items: remaining }));
reference.refreshWithData(remaining, remaining.length, false);
expect(scroller.positions.map((p) => p.col)).toEqual(reference.positions.map((p) => p.col));
for (let i = 0; i < remaining.length; i++) {
expect(scroller.positions[i].top).toBeCloseTo(reference.positions[i].top);
expect(scroller.positions[i].left).toBeCloseTo(reference.positions[i].left);
}
});
it('removeMultipleItemsByFilePath returns false when nothing matches', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
expect(scroller.removeMultipleItemsByFilePath(['/recipes/missing.png'])).toBe(false);
expect(scroller.removeMultipleItemsByFilePath([])).toBe(false);
});
it('disable stops rendering and enable recreates the spacer after innerHTML is cleared', async () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(3);
scroller.disable();
expect(scroller.disabled).toBe(true);
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(0);
expect(scroller.spacerElement.style.display).toBe('none');
// Duplicates mode wipes the grid contents, destroying the spacer
grid.innerHTML = '';
expect(grid.contains(scroller.spacerElement)).toBe(false);
scroller.enable();
expect(scroller.disabled).toBe(false);
expect(grid.contains(scroller.spacerElement)).toBe(true);
expect(scroller.spacerElement.className).toBe('virtual-scroll-spacer');
// Full re-placement ran synchronously on re-enable
expect(scroller.positions.length).toBe(items.length);
// Rendering resumes after the scheduled rAF
await new Promise((resolve) => setTimeout(resolve, 20));
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(3);
});
it('getAdjacentItemByFilePath loads more pages when the target is beyond loaded items', async () => {
const page1 = [0, 1, 2].map((i) => ({
file_path: `/recipes/page1-${i}.png`,
width: 100,
height: 100,
}));
const page2 = [0, 1].map((i) => ({
file_path: `/recipes/page2-${i}.png`,
width: 100,
height: 100,
}));
const fetchMock = vi.fn(async () => ({ items: page2, totalItems: 5, hasMore: false }));
const { scroller } = track(createScroller({ fetchItemsFn: fetchMock }));
scroller.refreshWithData(page1, 5, true);
const pageState = getCurrentPageState();
const expectedPage = pageState.currentPage;
const result = await scroller.getAdjacentItemByFilePath('/recipes/page1-2.png', 'next');
expect(fetchMock).toHaveBeenCalledWith(expectedPage, scroller.pageSize);
expect(result).not.toBeNull();
expect(result.index).toBe(3);
expect(result.item.file_path).toBe('/recipes/page2-0.png');
});
it('getAdjacentItemByFilePath returns null at boundaries and for unknown paths', async () => {
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
await expect(scroller.getAdjacentItemByFilePath('/recipes/item-0.png', 'prev')).resolves.toBeNull();
await expect(scroller.getAdjacentItemByFilePath('/recipes/item-1.png', 'next')).resolves.toBeNull();
await expect(scroller.getAdjacentItemByFilePath('/recipes/missing.png', 'next')).resolves.toBeNull();
});
it('getNavigationState reports index, prev/next availability and totals', () => {
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 10, true);
expect(scroller.getNavigationState('/recipes/item-0.png')).toEqual({
index: 0,
hasPrev: false,
hasNext: true,
loadedItems: 2,
totalItems: 10,
});
const last = scroller.getNavigationState('/recipes/item-1.png');
expect(last.index).toBe(1);
expect(last.hasPrev).toBe(true);
// hasMore keeps forward navigation available past the loaded window
expect(last.hasNext).toBe(true);
expect(scroller.getNavigationState('/recipes/missing.png').index).toBe(-1);
expect(scroller.findIndexByFilePath('/recipes/item-1.png')).toBe(1);
expect(scroller.findIndexByFilePath('')).toBe(-1);
});
});
+159 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from dataclasses import dataclass
@@ -11,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional
from aiohttp import FormData, web
from aiohttp.test_utils import TestClient, TestServer
from PIL import Image
from py.config import config
from py.routes import base_recipe_routes
@@ -368,6 +370,163 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N
assert payload["items"][0]["loras"] == []
async def test_list_recipes_exposes_preview_dimensions(
monkeypatch, tmp_path: Path
) -> None:
"""(a) Image recipe items carry integer width/height from the on-disk file."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_path = harness.tmp_dir / "recipes" / "real.png"
recipe_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (64, 32), color="red").save(recipe_path)
harness.scanner.listing_items = [
{
"id": "recipe-1",
"file_path": str(recipe_path),
"title": "Image Recipe",
"loras": [],
}
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
item = payload["items"][0]
assert item["width"] == 64
assert item["height"] == 32
assert isinstance(item["width"], int)
assert isinstance(item["height"], int)
async def test_list_recipes_omits_dimensions_for_video_and_missing(
monkeypatch, tmp_path: Path
) -> None:
"""(b) Video/missing-image recipes omit width/height yet still return 200."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.listing_items = [
{
"id": "recipe-video",
"file_path": str(harness.tmp_dir / "recipes" / "preview.mp4"),
"title": "Video Recipe",
"loras": [],
},
{
"id": "recipe-missing",
"file_path": str(harness.tmp_dir / "recipes" / "gone.png"),
"title": "Missing Recipe",
"loras": [],
},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
for item in payload["items"]:
assert "width" not in item
assert "height" not in item
async def test_list_recipes_offloads_dimensions_to_thread(
monkeypatch, tmp_path: Path
) -> None:
"""(c) Dimension reads run through asyncio.to_thread for every item."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_path = harness.tmp_dir / "recipes" / "real.png"
recipe_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (16, 48), color="blue").save(recipe_path)
harness.scanner.listing_items = [
{
"id": "recipe-1",
"file_path": str(recipe_path),
"title": "Image Recipe",
"loras": [],
},
{
"id": "recipe-2",
"file_path": str(harness.tmp_dir / "recipes" / "gone.png"),
"title": "Missing Recipe",
"loras": [],
},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
real_to_thread = asyncio.to_thread
to_thread_calls: list[tuple] = []
async def counting_to_thread(fn, *args, **kwargs):
to_thread_calls.append((fn, args, kwargs))
return await real_to_thread(fn, *args, **kwargs)
monkeypatch.setattr(asyncio, "to_thread", counting_to_thread)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
assert len(to_thread_calls) >= len(harness.scanner.listing_items)
assert payload["items"][0]["width"] == 16
assert payload["items"][0]["height"] == 48
assert "width" not in payload["items"][1]
assert "height" not in payload["items"][1]
async def test_list_recipes_batches_dimensions_for_mixed_items(
monkeypatch, tmp_path: Path
) -> None:
"""(d) Mixed file_path presence: dims align per-item with their own files."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
wide_path = harness.tmp_dir / "recipes" / "wide.png"
tall_path = harness.tmp_dir / "recipes" / "tall.png"
wide_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (120, 40), color="green").save(wide_path)
Image.new("RGB", (30, 90), color="blue").save(tall_path)
harness.scanner.listing_items = [
{
"id": "recipe-wide",
"file_path": str(wide_path),
"title": "Wide",
"loras": [],
},
{"id": "recipe-none", "title": "No Preview", "loras": []},
{
"id": "recipe-tall",
"file_path": str(tall_path),
"title": "Tall",
"loras": [],
},
{"id": "recipe-none-2", "title": "No Preview 2", "loras": []},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
items = payload["items"]
# Items with a file_path carry integer dims read from their own file;
# the two images differ in both dimensions so a shifted pair would
# fail these assertions.
assert items[0]["width"] == 120
assert items[0]["height"] == 40
assert isinstance(items[0]["width"], int)
assert isinstance(items[0]["height"], int)
assert items[2]["width"] == 30
assert items[2]["height"] == 90
# Items without a file_path get the no-preview fallback and omit dims.
for item in (items[1], items[3]):
assert "width" not in item
assert "height" not in item
assert item["file_url"] == "/loras_static/images/no-preview.png"
async def test_list_recipes_passes_checkpoint_hash_filter(
monkeypatch, tmp_path: Path
) -> None:
@@ -909,8 +1068,6 @@ async def test_batch_import_start_missing_source(monkeypatch, tmp_path: Path) ->
async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None:
import asyncio
async with recipe_harness(monkeypatch, tmp_path) as harness:
original_analyze = harness.analysis.analyze_remote_image
+55
View File
@@ -288,3 +288,58 @@ class TestIsobmffBrotliExtraction:
# Direct extraction should return None because decompressed size exceeds limit
result = ExifUtils._extract_isobmff_brotli(str(path))
assert result is None
# --- get_image_dimensions tests ---
def test_get_image_dimensions_returns_actual_size(tmp_path):
"""(a) A valid image returns its real (width, height)."""
image_path = tmp_path / "preview.png"
Image.new("RGB", (64, 32), color="red").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (64, 32)
def test_get_image_dimensions_missing_path_returns_none(tmp_path):
"""(b) A nonexistent path returns None without raising."""
assert ExifUtils.get_image_dimensions(str(tmp_path / "missing.png")) is None
def test_get_image_dimensions_skips_video_extension_without_pil(tmp_path, monkeypatch):
"""(c) A .mp4 path returns None and never invokes PIL."""
video_path = tmp_path / "preview.mp4"
video_path.write_bytes(b"not really a video")
def fail_if_called(*args, **kwargs):
raise AssertionError("PIL Image.open must not be called for video paths")
monkeypatch.setattr("py.utils.exif_utils.Image.open", fail_if_called)
assert ExifUtils.get_image_dimensions(str(video_path)) is None
def test_get_image_dimensions_corrupt_file_returns_none(tmp_path):
"""(d) A corrupt file returns None without raising."""
image_path = tmp_path / "corrupt.png"
image_path.write_bytes(b"\x00\x01\x02\x03 not a real image")
assert ExifUtils.get_image_dimensions(str(image_path)) is None
def test_get_image_dimensions_cache_key_includes_mtime(tmp_path):
"""(e) Replacing a path with a different-size image returns the new size."""
image_path = tmp_path / "replaced.png"
Image.new("RGB", (64, 32), color="red").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (64, 32)
Image.new("RGB", (100, 50), color="blue").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (100, 50)
def test_get_image_dimensions_skips_unreadable_formats(tmp_path):
"""(f) .avif/.jxl paths return None without raising."""
for ext in (".avif", ".jxl"):
image_path = tmp_path / f"preview{ext}"
image_path.write_bytes(b"fake container data")
assert ExifUtils.get_image_dimensions(str(image_path)) is None