mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 19:21:27 -03:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56acefbd6c | ||
|
|
5ab06c4aae | ||
|
|
c11f4b5c68 | ||
|
|
86376284f4 | ||
|
|
2b8a2fc7d8 | ||
|
|
f26e1b41c8 | ||
|
|
c1671af99f | ||
|
|
ac7707d0f6 | ||
|
|
381cd710a2 | ||
|
|
ad0d18cb79 | ||
|
|
7980ee77d0 | ||
|
|
916b8bb327 | ||
|
|
87e3d4dea9 | ||
|
|
76a913f5e0 | ||
|
|
d8c192e647 | ||
|
|
c453437620 | ||
|
|
720fa6d909 | ||
|
|
b4f71089f4 | ||
|
|
83e6657ead | ||
|
|
7ea6df4111 | ||
|
|
d9ab92602a | ||
|
|
5ffadaed31 |
@@ -25,6 +25,7 @@ model_cache/
|
|||||||
reasonix.toml
|
reasonix.toml
|
||||||
.reasonix/
|
.reasonix/
|
||||||
.codegraph/
|
.codegraph/
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
vue-widgets/node_modules/
|
vue-widgets/node_modules/
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
|
|||||||
--cov-report=xml:coverage/backend/coverage.xml
|
--cov-report=xml:coverage/backend/coverage.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development (Standalone Web UI)
|
### Frontend Development (LoRA Manager Web UI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -154,9 +154,9 @@ npm run test:coverage # Generate coverage report
|
|||||||
|
|
||||||
## Frontend UI Architecture
|
## Frontend UI Architecture
|
||||||
|
|
||||||
### 1. Standalone Web UI
|
### 1. LoRA Manager Web UI
|
||||||
- Location: `./static/` and `./templates/`
|
- Location: `./static/` and `./templates/`
|
||||||
- Tech: Vanilla JS + CSS, served by standalone server
|
- Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
|
||||||
- Tests via npm in root directory
|
- Tests via npm in root directory
|
||||||
|
|
||||||
### 2. ComfyUI Custom Node Widgets
|
### 2. ComfyUI Custom Node Widgets
|
||||||
|
|||||||
+19
-14
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
|
"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",
|
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
|
||||||
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
|
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
|
||||||
"cardInfoDisplay": "Karten-Info-Anzeige",
|
"cardInfoDisplay": "Karten-Info-Anzeige",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "Fehler"
|
"columnError": "Fehler"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "Zusammenfassung des Batch-Downloads",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "Erfolgreich",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "Fehlgeschlagen",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "Gesamt",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "Alle {count} Modelle erfolgreich heruntergeladen",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "Abgeschlossen, aber mit Fehlern",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "Download fehlgeschlagen",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "Fehlgeschlagene Elemente ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "Modellname",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "Fehler",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "Schließen",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "Bericht kopieren",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "Fehlgeschlagene erneut versuchen ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
|
"presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
|
||||||
"presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
|
"presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
|
||||||
"presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits",
|
"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",
|
"presetNotFound": "Voreinstellung nicht gefunden",
|
||||||
"invalidPreset": "Ungültige Voreinstellungsdaten",
|
"invalidPreset": "Ungültige Voreinstellungsdaten",
|
||||||
"deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
|
"deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
|
||||||
|
|||||||
+6
-1
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
|
"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",
|
"showFolderSidebar": "Show Folder Sidebar",
|
||||||
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
|
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
|
||||||
"cardInfoDisplay": "Card Info Display",
|
"cardInfoDisplay": "Card Info Display",
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "Preset name must be {max} characters or less",
|
"presetNameTooLong": "Preset name must be {max} characters or less",
|
||||||
"presetNameInvalidChars": "Preset name contains invalid characters",
|
"presetNameInvalidChars": "Preset name contains invalid characters",
|
||||||
"presetNameExists": "A preset with this name already exists",
|
"presetNameExists": "A preset with this name already exists",
|
||||||
"maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.",
|
|
||||||
"presetNotFound": "Preset not found",
|
"presetNotFound": "Preset not found",
|
||||||
"invalidPreset": "Invalid preset data",
|
"invalidPreset": "Invalid preset data",
|
||||||
"deletePresetFailed": "Failed to delete preset",
|
"deletePresetFailed": "Failed to delete preset",
|
||||||
|
|||||||
+19
-14
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
|
"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",
|
"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.",
|
"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",
|
"cardInfoDisplay": "Visualización de información de tarjeta",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "Error"
|
"columnError": "Error"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "Resumen de descarga por lotes",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "Correctos",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "Fallidos",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "Total",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "Todos los {count} modelos se descargaron correctamente",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "Completado con errores",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "Descarga fallida",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "Elementos fallidos ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "Nombre del modelo",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "Error",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "Cerrar",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "Copiar informe",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "Reintentar fallidos ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
|
"presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
|
||||||
"presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
|
"presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
|
||||||
"presetNameExists": "Ya existe un preajuste con este nombre",
|
"presetNameExists": "Ya existe un preajuste con este nombre",
|
||||||
"maxPresetsReached": "Máximo {max} preajustes permitidos. Elimine uno para agregar más.",
|
|
||||||
"presetNotFound": "Preajuste no encontrado",
|
"presetNotFound": "Preajuste no encontrado",
|
||||||
"invalidPreset": "Datos de preajuste inválidos",
|
"invalidPreset": "Datos de preajuste inválidos",
|
||||||
"deletePresetFailed": "Error al eliminar el preajuste",
|
"deletePresetFailed": "Error al eliminar el preajuste",
|
||||||
|
|||||||
+19
-14
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"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.",
|
"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",
|
"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.",
|
"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",
|
"cardInfoDisplay": "Affichage des informations de carte",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "Erreur"
|
"columnError": "Erreur"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "Résumé du téléchargement groupé",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "Réussis",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "Échoués",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "Total",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "Les {count} modèles ont été téléchargés avec succès",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "Terminé avec des erreurs",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "Échec du téléchargement",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "Éléments échoués ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "Nom du modèle",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "Erreur",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "Fermer",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "Copier le rapport",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "Réessayer les échecs ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères",
|
"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",
|
"presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides",
|
||||||
"presetNameExists": "Un préréglage avec ce nom existe déjà",
|
"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é",
|
"presetNotFound": "Préréglage non trouvé",
|
||||||
"invalidPreset": "Données de préréglage invalides",
|
"invalidPreset": "Données de préréglage invalides",
|
||||||
"deletePresetFailed": "Échec de la suppression du préréglage",
|
"deletePresetFailed": "Échec de la suppression du préréglage",
|
||||||
|
|||||||
+19
-14
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
|
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
|
||||||
|
"recipesLayout": "פריסת מתכונים",
|
||||||
|
"recipesLayoutHelp": "בחר כיצד יסודרו כרטיסי המתכונים: רשת אחידה או פריסת Masonry (בסגנון Pinterest) השומרת על יחס הגובה-רוחב של כל תמונה.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "רשת",
|
||||||
|
"masonry": "Masonry"
|
||||||
|
},
|
||||||
"showFolderSidebar": "הצג סרגל צד תיקיות",
|
"showFolderSidebar": "הצג סרגל צד תיקיות",
|
||||||
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
|
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
|
||||||
"cardInfoDisplay": "תצוגת מידע בכרטיס",
|
"cardInfoDisplay": "תצוגת מידע בכרטיס",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "שגיאה"
|
"columnError": "שגיאה"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "סיכום הורדה בכמות",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "הצליחו",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "נכשלו",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "סה\"כ",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "כל {count} הדגמים הורדו בהצלחה",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "הושלם עם שגיאות",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "ההורדה נכשלה",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "פריטים שנכשלו ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "שם הדגם",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "שגיאה",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "סגור",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "העתק דוח",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "נסה שוב ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
|
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
|
||||||
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
|
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
|
||||||
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
|
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
|
||||||
"maxPresetsReached": "מותר מקסימום {max} קביעות מראש. מחק אחת כדי להוסיף עוד.",
|
|
||||||
"presetNotFound": "קביעה מראש לא נמצאה",
|
"presetNotFound": "קביעה מראש לא נמצאה",
|
||||||
"invalidPreset": "נתוני קביעה מראש לא חוקיים",
|
"invalidPreset": "נתוני קביעה מראש לא חוקיים",
|
||||||
"deletePresetFailed": "מחיקת קביעה מראש נכשלה",
|
"deletePresetFailed": "מחיקת קביעה מראש נכשלה",
|
||||||
|
|||||||
+19
-14
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
||||||
|
"recipesLayout": "レシピのレイアウト",
|
||||||
|
"recipesLayoutHelp": "レシピカードの配置方法を選択:均一なグリッド、または各画像のアスペクト比を保持するメイソンリー(Pinterest スタイル)レイアウト。",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "グリッド",
|
||||||
|
"masonry": "メイソンリー"
|
||||||
|
},
|
||||||
"showFolderSidebar": "フォルダサイドバーを表示",
|
"showFolderSidebar": "フォルダサイドバーを表示",
|
||||||
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
|
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
|
||||||
"cardInfoDisplay": "カード情報表示",
|
"cardInfoDisplay": "カード情報表示",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "エラー"
|
"columnError": "エラー"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "バッチダウンロードの概要",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "成功",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "失敗",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "合計",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "{count} 個のモデルがすべて正常にダウンロードされました",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "エラーありで完了",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "ダウンロードに失敗しました",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "失敗した項目({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "モデル名",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "エラー",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "閉じる",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "レポートをコピー",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "失敗した項目を再試行({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "プリセット名は{max}文字以内にしてください",
|
"presetNameTooLong": "プリセット名は{max}文字以内にしてください",
|
||||||
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
|
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
|
||||||
"presetNameExists": "同じ名前のプリセットが既に存在します",
|
"presetNameExists": "同じ名前のプリセットが既に存在します",
|
||||||
"maxPresetsReached": "プリセットは最大{max}個までです。追加するには既存のものを削除してください。",
|
|
||||||
"presetNotFound": "プリセットが見つかりません",
|
"presetNotFound": "プリセットが見つかりません",
|
||||||
"invalidPreset": "無効なプリセットデータです",
|
"invalidPreset": "無効なプリセットデータです",
|
||||||
"deletePresetFailed": "プリセットの削除に失敗しました",
|
"deletePresetFailed": "プリセットの削除に失敗しました",
|
||||||
|
|||||||
+19
-14
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
|
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
||||||
|
"recipesLayout": "레시피 레이아웃",
|
||||||
|
"recipesLayoutHelp": "레시피 카드의 배열 방식을 선택하세요: 균일한 그리드 또는 각 이미지의 종횡비를 유지하는 메이슨리(Pinterest 스타일) 레이아웃.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "그리드",
|
||||||
|
"masonry": "메이슨리"
|
||||||
|
},
|
||||||
"showFolderSidebar": "폴더 사이드바 표시",
|
"showFolderSidebar": "폴더 사이드바 표시",
|
||||||
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
|
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
|
||||||
"cardInfoDisplay": "카드 정보 표시",
|
"cardInfoDisplay": "카드 정보 표시",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "오류"
|
"columnError": "오류"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "일괄 다운로드 요약",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "성공",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "실패",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "전체",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "{count}개 모델이 모두 성공적으로 다운로드되었습니다",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "오류와 함께 완료됨",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "다운로드 실패",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "실패한 항목 ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "모델 이름",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "오류",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "닫기",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "보고서 복사",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "실패 항목 재시도 ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
|
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
|
||||||
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
|
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
|
||||||
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
|
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
|
||||||
"maxPresetsReached": "최대 {max}개의 프리셋만 허용됩니다. 더 추가하려면 기존 것을 삭제하세요.",
|
|
||||||
"presetNotFound": "프리셋을 찾을 수 없습니다",
|
"presetNotFound": "프리셋을 찾을 수 없습니다",
|
||||||
"invalidPreset": "잘못된 프리셋 데이터입니다",
|
"invalidPreset": "잘못된 프리셋 데이터입니다",
|
||||||
"deletePresetFailed": "프리셋 삭제에 실패했습니다",
|
"deletePresetFailed": "프리셋 삭제에 실패했습니다",
|
||||||
|
|||||||
+19
-14
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
||||||
|
"recipesLayout": "Макет рецептов",
|
||||||
|
"recipesLayoutHelp": "Выберите, как располагаются карточки рецептов: единая сетка или masonry-макет (в стиле Pinterest), сохраняющий пропорции каждого изображения.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "Сетка",
|
||||||
|
"masonry": "Masonry"
|
||||||
|
},
|
||||||
"showFolderSidebar": "Показывать боковую панель папок",
|
"showFolderSidebar": "Показывать боковую панель папок",
|
||||||
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
|
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
|
||||||
"cardInfoDisplay": "Отображение информации карточки",
|
"cardInfoDisplay": "Отображение информации карточки",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "Ошибка"
|
"columnError": "Ошибка"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "Сводка пакетной загрузки",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "Успешно",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "Ошибки",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "Всего",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "Все {count} моделей успешно загружены",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "Завершено с ошибками",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "Не удалось загрузить",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "Неудачные элементы ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "Имя модели",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "Ошибка",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "Закрыть",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "Скопировать отчёт",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "Повторить неудачные ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
|
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
|
||||||
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
|
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
|
||||||
"presetNameExists": "Пресет с таким именем уже существует",
|
"presetNameExists": "Пресет с таким именем уже существует",
|
||||||
"maxPresetsReached": "Допустимо максимум {max} пресетов. Удалите один, чтобы добавить больше.",
|
|
||||||
"presetNotFound": "Пресет не найден",
|
"presetNotFound": "Пресет не найден",
|
||||||
"invalidPreset": "Недопустимые данные пресета",
|
"invalidPreset": "Недопустимые данные пресета",
|
||||||
"deletePresetFailed": "Не удалось удалить пресет",
|
"deletePresetFailed": "Не удалось удалить пресет",
|
||||||
|
|||||||
+19
-14
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7(1080p),8(2K),10(4K)"
|
"compact": "7(1080p),8(2K),10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
||||||
|
"recipesLayout": "配方布局",
|
||||||
|
"recipesLayoutHelp": "选择配方卡片的排列方式:统一网格,或保留每张图片原始宽高比的瀑布流(Pinterest 风格)布局。",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "网格",
|
||||||
|
"masonry": "瀑布流"
|
||||||
|
},
|
||||||
"showFolderSidebar": "显示文件夹侧边栏",
|
"showFolderSidebar": "显示文件夹侧边栏",
|
||||||
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
|
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
|
||||||
"cardInfoDisplay": "卡片信息显示",
|
"cardInfoDisplay": "卡片信息显示",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "错误"
|
"columnError": "错误"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "批量下载摘要",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "成功",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "失败",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "总数",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "全部 {count} 个模型下载成功",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "已完成,但有错误",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "下载失败",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "失败项({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "模型名称",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "错误",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "关闭",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "复制报告",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "重试失败项({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "预设名称不能超过 {max} 个字符",
|
"presetNameTooLong": "预设名称不能超过 {max} 个字符",
|
||||||
"presetNameInvalidChars": "预设名称包含无效字符",
|
"presetNameInvalidChars": "预设名称包含无效字符",
|
||||||
"presetNameExists": "已存在同名预设",
|
"presetNameExists": "已存在同名预设",
|
||||||
"maxPresetsReached": "最多允许 {max} 个预设。删除一个以添加更多。",
|
|
||||||
"presetNotFound": "预设未找到",
|
"presetNotFound": "预设未找到",
|
||||||
"invalidPreset": "无效的预设数据",
|
"invalidPreset": "无效的预设数据",
|
||||||
"deletePresetFailed": "删除预设失败",
|
"deletePresetFailed": "删除预设失败",
|
||||||
|
|||||||
+20
-15
@@ -449,6 +449,12 @@
|
|||||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
||||||
|
"recipesLayout": "配方版面",
|
||||||
|
"recipesLayoutHelp": "選擇配方卡片的排列方式:統一網格,或保留每張圖片原始寬高比的瀑布流(Pinterest 風格)版面。",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "網格",
|
||||||
|
"masonry": "瀑布流"
|
||||||
|
},
|
||||||
"showFolderSidebar": "顯示資料夾側邊欄",
|
"showFolderSidebar": "顯示資料夾側邊欄",
|
||||||
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
|
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
|
||||||
"cardInfoDisplay": "卡片資訊顯示",
|
"cardInfoDisplay": "卡片資訊顯示",
|
||||||
@@ -687,7 +693,7 @@
|
|||||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||||
"apiKey": "API 金鑰",
|
"apiKey": "API 金鑰",
|
||||||
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
|
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
|
||||||
"apiKeyPlaceholder": "[TODO: Translate] sk-...",
|
"apiKeyPlaceholder": "sk-...",
|
||||||
"apiKeyNotSet": "未設定",
|
"apiKeyNotSet": "未設定",
|
||||||
"apiKeyConfigured": "已設定",
|
"apiKeyConfigured": "已設定",
|
||||||
"apiKeySet": "設定",
|
"apiKeySet": "設定",
|
||||||
@@ -1584,19 +1590,19 @@
|
|||||||
"columnError": "錯誤"
|
"columnError": "錯誤"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "批次下載摘要",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "成功",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "失敗",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "總數",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "全部 {count} 個模型下載成功",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "已完成,但有錯誤",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "下載失敗",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "失敗項目({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "模型名稱",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "錯誤",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "關閉",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "複製報告",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "重試失敗項目({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -2053,7 +2059,6 @@
|
|||||||
"presetNameTooLong": "預設名稱不能超過 {max} 個字元",
|
"presetNameTooLong": "預設名稱不能超過 {max} 個字元",
|
||||||
"presetNameInvalidChars": "預設名稱包含無效字元",
|
"presetNameInvalidChars": "預設名稱包含無效字元",
|
||||||
"presetNameExists": "已存在同名預設",
|
"presetNameExists": "已存在同名預設",
|
||||||
"maxPresetsReached": "最多允許 {max} 個預設。刪除一個以新增更多。",
|
|
||||||
"presetNotFound": "預設未找到",
|
"presetNotFound": "預設未找到",
|
||||||
"invalidPreset": "無效的預設資料",
|
"invalidPreset": "無效的預設資料",
|
||||||
"deletePresetFailed": "刪除預設失敗",
|
"deletePresetFailed": "刪除預設失敗",
|
||||||
|
|||||||
@@ -1488,8 +1488,73 @@ class ModelQueryHandler:
|
|||||||
search = request.query.get("search", "").strip()
|
search = request.query.get("search", "").strip()
|
||||||
limit = min(int(request.query.get("limit", "15")), 100)
|
limit = min(int(request.query.get("limit", "15")), 100)
|
||||||
offset = max(0, int(request.query.get("offset", "0")))
|
offset = max(0, int(request.query.get("offset", "0")))
|
||||||
|
|
||||||
|
folder = request.query.get("folder")
|
||||||
|
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||||
|
base_models = list(request.query.getall("base_model", []))
|
||||||
|
model_types = list(request.query.getall("model_type", []))
|
||||||
|
|
||||||
|
tag_filters: Dict[str, str] = {}
|
||||||
|
for tag in request.query.getall("tag_include", []):
|
||||||
|
if tag:
|
||||||
|
tag_filters[tag] = "include"
|
||||||
|
for tag in request.query.getall("tag_exclude", []):
|
||||||
|
if tag:
|
||||||
|
tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
auto_tag_filters: Dict[str, str] = {}
|
||||||
|
for tag in request.query.getall("auto_tag_include", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "include"
|
||||||
|
for tag in request.query.getall("auto_tag_exclude", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
tag_logic = request.query.get("tag_logic", "any").lower()
|
||||||
|
if tag_logic not in ("any", "all"):
|
||||||
|
tag_logic = "any"
|
||||||
|
|
||||||
|
credit_required = request.query.get("credit_required")
|
||||||
|
if credit_required is not None:
|
||||||
|
credit_required = credit_required.lower() not in ("false", "0", "")
|
||||||
|
|
||||||
|
allow_selling_generated_content = request.query.get(
|
||||||
|
"allow_selling_generated_content"
|
||||||
|
)
|
||||||
|
if allow_selling_generated_content is not None:
|
||||||
|
allow_selling_generated_content = (
|
||||||
|
allow_selling_generated_content.lower() not in ("false", "0", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# The presence of the recursive param (always sent by the loras
|
||||||
|
# widget when filter mode is on) signals that the filter pipeline
|
||||||
|
# must run even when no concrete filter is set, so global settings
|
||||||
|
# like show_only_sfw stay consistent with the list endpoint.
|
||||||
|
apply_filters = (
|
||||||
|
"recursive" in request.query
|
||||||
|
or folder is not None
|
||||||
|
or bool(base_models)
|
||||||
|
or bool(model_types)
|
||||||
|
or bool(tag_filters)
|
||||||
|
or bool(auto_tag_filters)
|
||||||
|
or credit_required is not None
|
||||||
|
or allow_selling_generated_content is not None
|
||||||
|
)
|
||||||
|
|
||||||
matching_paths = await self._service.search_relative_paths(
|
matching_paths = await self._service.search_relative_paths(
|
||||||
search, limit, offset
|
search,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
folder=folder,
|
||||||
|
recursive=recursive,
|
||||||
|
base_models=base_models,
|
||||||
|
model_types=model_types,
|
||||||
|
tags=tag_filters,
|
||||||
|
auto_tags=auto_tag_filters,
|
||||||
|
tag_logic=tag_logic,
|
||||||
|
credit_required=credit_required,
|
||||||
|
allow_selling_generated_content=allow_selling_generated_content,
|
||||||
|
apply_filters=apply_filters,
|
||||||
)
|
)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "relative_paths": matching_paths}
|
{"success": True, "relative_paths": matching_paths}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import asyncio
|
|||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -44,6 +44,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
|||||||
RecipeScannerGetter = Callable[[], Any]
|
RecipeScannerGetter = Callable[[], Any]
|
||||||
CivitaiClientGetter = 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)
|
@dataclass(frozen=True)
|
||||||
class RecipeHandlerSet:
|
class RecipeHandlerSet:
|
||||||
@@ -246,7 +262,8 @@ class RecipeListingHandler:
|
|||||||
recursive=recursive,
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in result.get("items", []):
|
items = result.get("items", [])
|
||||||
|
for item in items:
|
||||||
file_path = item.get("file_path")
|
file_path = item.get("file_path")
|
||||||
if file_path:
|
if file_path:
|
||||||
item["file_url"] = self.format_recipe_file_url(file_path)
|
item["file_url"] = self.format_recipe_file_url(file_path)
|
||||||
@@ -255,6 +272,26 @@ class RecipeListingHandler:
|
|||||||
item.setdefault("loras", [])
|
item.setdefault("loras", [])
|
||||||
item.setdefault("base_model", "")
|
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)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
|
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
|
||||||
|
|||||||
@@ -1259,19 +1259,87 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def search_relative_paths(
|
async def search_relative_paths(
|
||||||
self, search_term: str, limit: int = 15, offset: int = 0
|
self,
|
||||||
|
search_term: str,
|
||||||
|
limit: int = 15,
|
||||||
|
offset: int = 0,
|
||||||
|
*,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
folder_include: Optional[list] = None,
|
||||||
|
folder_exclude: Optional[list] = None,
|
||||||
|
base_models: Optional[list] = None,
|
||||||
|
model_types: Optional[list] = None,
|
||||||
|
tags: Optional[dict] = None,
|
||||||
|
auto_tags: Optional[dict] = None,
|
||||||
|
tag_logic: str = "any",
|
||||||
|
credit_required: Optional[bool] = None,
|
||||||
|
allow_selling_generated_content: Optional[bool] = None,
|
||||||
|
recursive: bool = True,
|
||||||
|
apply_filters: bool = False,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Search model relative file paths for autocomplete functionality"""
|
"""Search model relative file paths for autocomplete functionality.
|
||||||
|
|
||||||
|
Optional filter kwargs mirror the filters used by the list endpoint
|
||||||
|
(/api/lm/{prefix}/list). When no filter kwargs are provided the
|
||||||
|
behavior is identical to plain token-based path matching.
|
||||||
|
"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
||||||
|
|
||||||
|
data = cache.raw_data
|
||||||
|
has_filters = any(
|
||||||
|
[
|
||||||
|
apply_filters,
|
||||||
|
folder is not None,
|
||||||
|
folder_include,
|
||||||
|
folder_exclude,
|
||||||
|
base_models,
|
||||||
|
model_types,
|
||||||
|
tags,
|
||||||
|
auto_tags,
|
||||||
|
credit_required is not None,
|
||||||
|
allow_selling_generated_content is not None,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if has_filters:
|
||||||
|
# Auto-tags are not stored in the scanner cache — they are computed
|
||||||
|
# on the fly. Pre-compute them only when an auto-tag filter is
|
||||||
|
# active to avoid mutating cache entries unnecessarily.
|
||||||
|
if auto_tags:
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
if not item.get("auto_tags"):
|
||||||
|
item["auto_tags"] = extract_auto_tags(item)
|
||||||
|
|
||||||
|
criteria = FilterCriteria(
|
||||||
|
folder=folder,
|
||||||
|
folder_include=folder_include,
|
||||||
|
folder_exclude=folder_exclude,
|
||||||
|
base_models=base_models,
|
||||||
|
model_types=model_types,
|
||||||
|
tags=tags,
|
||||||
|
auto_tags=auto_tags,
|
||||||
|
search_options={"recursive": recursive},
|
||||||
|
tag_logic=tag_logic,
|
||||||
|
)
|
||||||
|
data = self.filter_set.apply(data, criteria)
|
||||||
|
if credit_required is not None:
|
||||||
|
data = await self._apply_credit_required_filter(
|
||||||
|
data, credit_required
|
||||||
|
)
|
||||||
|
if allow_selling_generated_content is not None:
|
||||||
|
data = await self._apply_allow_selling_filter(
|
||||||
|
data, allow_selling_generated_content
|
||||||
|
)
|
||||||
|
|
||||||
matching_paths = []
|
matching_paths = []
|
||||||
|
|
||||||
# Get model roots for path calculation
|
# Get model roots for path calculation
|
||||||
model_roots = self.scanner.get_model_roots()
|
model_roots = self.scanner.get_model_roots()
|
||||||
|
|
||||||
# Collect all matching paths first (needed for proper sorting and offset)
|
# Collect all matching paths first (needed for proper sorting and offset)
|
||||||
for model in cache.raw_data:
|
for model in data:
|
||||||
file_path = model.get("file_path", "")
|
file_path = model.get("file_path", "")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"mature_blur_level": "R",
|
"mature_blur_level": "R",
|
||||||
"autoplay_on_hover": False,
|
"autoplay_on_hover": False,
|
||||||
"display_density": "default",
|
"display_density": "default",
|
||||||
|
"recipes_layout": "grid",
|
||||||
"card_info_display": "always",
|
"card_info_display": "always",
|
||||||
"include_trigger_words": False,
|
"include_trigger_words": False,
|
||||||
"compact_mode": False,
|
"compact_mode": False,
|
||||||
|
|||||||
+37
-1
@@ -1,9 +1,10 @@
|
|||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
import piexif
|
import piexif
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
@@ -17,6 +18,22 @@ except ImportError:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class ExifUtils:
|
||||||
"""Utility functions for working with EXIF data in images"""
|
"""Utility functions for working with EXIF data in images"""
|
||||||
|
|
||||||
@@ -422,6 +439,25 @@ class ExifUtils:
|
|||||||
# Metadata is in the middle of the string
|
# Metadata is in the middle of the string
|
||||||
return user_comment[:recipe_marker_index] + user_comment[next_line_index:]
|
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
|
@staticmethod
|
||||||
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False):
|
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-grid.masonry-layout .model-card {
|
||||||
|
aspect-ratio: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.model-card:hover {
|
.model-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
|||||||
@@ -145,13 +145,14 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
top: 50px; /* Position below header */
|
top: 50px; /* Position below header */
|
||||||
width: 366px;
|
width: 420px;
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
box-sizing: border-box; /* Include padding in max-height calculation */
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
transform-origin: top right;
|
transform-origin: top right;
|
||||||
max-height: calc(100vh - 70px); /* Adjusted for header height */
|
max-height: calc(100vh - 70px); /* Adjusted for header height */
|
||||||
@@ -563,7 +564,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
max-width: 120px; /* Prevent long names from breaking layout */
|
max-width: 180px; /* Prevent long names from breaking layout */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { RecipeCard } from './RecipeCard.js';
|
import { RecipeCard } from './RecipeCard.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
|
import { recreateVirtualScroll } from '../utils/infiniteScroll.js';
|
||||||
|
|
||||||
export class DuplicatesManager {
|
export class DuplicatesManager {
|
||||||
constructor(recipeManager) {
|
constructor(recipeManager) {
|
||||||
@@ -71,7 +72,7 @@ export class DuplicatesManager {
|
|||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
exitDuplicateMode() {
|
async exitDuplicateMode() {
|
||||||
this.inDuplicateMode = false;
|
this.inDuplicateMode = false;
|
||||||
this.selectedForDeletion.clear();
|
this.selectedForDeletion.clear();
|
||||||
|
|
||||||
@@ -94,8 +95,16 @@ export class DuplicatesManager {
|
|||||||
recipeGrid.innerHTML = '';
|
recipeGrid.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-enable virtual scrolling
|
// Re-enable virtual scrolling, or apply a layout switch deferred
|
||||||
state.virtualScroller.enable();
|
// 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() {
|
renderDuplicateGroups() {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
|||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.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 { formatFileSize } from '../utils/formatters.js';
|
||||||
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
||||||
|
|
||||||
@@ -235,6 +235,9 @@ export class DownloadManager {
|
|||||||
|
|
||||||
if (this.modelVersionId) {
|
if (this.modelVersionId) {
|
||||||
this.currentVersion = this.versions.find(v => v.id.toString() === 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();
|
this.showVersionStep();
|
||||||
@@ -428,6 +431,9 @@ export class DownloadManager {
|
|||||||
await this.retrieveVersionsForModel(this.modelId, this.source);
|
await this.retrieveVersionsForModel(this.modelId, this.source);
|
||||||
if (this.modelVersionId) {
|
if (this.modelVersionId) {
|
||||||
this.currentVersion = this.versions.find(v => v.id.toString() === 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();
|
this.showVersionStep();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -879,6 +885,26 @@ export class DownloadManager {
|
|||||||
this.updateTargetPath();
|
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({
|
async executeDownloadWithProgress({
|
||||||
modelId,
|
modelId,
|
||||||
versionId,
|
versionId,
|
||||||
@@ -897,6 +923,7 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayName = versionName || `#${versionId}`;
|
const displayName = versionName || `#${versionId}`;
|
||||||
|
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false };
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let updateProgress = () => { };
|
let updateProgress = () => { };
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -985,6 +1012,26 @@ export class DownloadManager {
|
|||||||
return true;
|
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');
|
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||||
|
|
||||||
if (closeModal) {
|
if (closeModal) {
|
||||||
@@ -1019,7 +1066,21 @@ export class DownloadManager {
|
|||||||
console.log('Download cancelled by user:', downloadId);
|
console.log('Download cancelled by user:', downloadId);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to download model version:', error);
|
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;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1034,14 +1095,16 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) {
|
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
|
||||||
modalManager.closeModal('downloadModal');
|
modalManager.closeModal('downloadModal');
|
||||||
this.loadingManager.restoreProgressBar();
|
this.loadingManager.restoreProgressBar();
|
||||||
const totalFiles = this.hfSelectedFiles.length;
|
const filesToDownload = files || this.hfSelectedFiles;
|
||||||
|
const totalFiles = filesToDownload.length;
|
||||||
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let currentDownloadId = null;
|
let currentDownloadId = null;
|
||||||
|
const failedFiles = [];
|
||||||
|
|
||||||
this.loadingManager.showCancelButton(async () => {
|
this.loadingManager.showCancelButton(async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -1060,7 +1123,7 @@ export class DownloadManager {
|
|||||||
for (let i = 0; i < totalFiles; i++) {
|
for (let i = 0; i < totalFiles; i++) {
|
||||||
if (cancelled) break;
|
if (cancelled) break;
|
||||||
|
|
||||||
const filename = this.hfSelectedFiles[i];
|
const filename = filesToDownload[i];
|
||||||
updateProgress(0, completedDownloads, filename);
|
updateProgress(0, completedDownloads, filename);
|
||||||
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
||||||
|
|
||||||
@@ -1106,6 +1169,31 @@ export class DownloadManager {
|
|||||||
if (response?.success) {
|
if (response?.success) {
|
||||||
completedDownloads++;
|
completedDownloads++;
|
||||||
updateProgress(100, completedDownloads, filename);
|
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 {
|
} finally {
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -1115,11 +1203,27 @@ export class DownloadManager {
|
|||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
showToast('toast.downloads.downloadStopped', {}, 'info',
|
showToast('toast.downloads.downloadStopped', {}, 'info',
|
||||||
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
`Download cancelled. ${completedDownloads} item(s) completed.`);
|
||||||
} else {
|
await resetAndReload(true);
|
||||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
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);
|
await resetAndReload(true);
|
||||||
return true;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error('Failed to download HF model:', error);
|
console.error('Failed to download HF model:', error);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { state } from '../state/index.js';
|
|||||||
// Constants for preset management
|
// Constants for preset management
|
||||||
const PRESETS_STORAGE_VERSION = 'v1';
|
const PRESETS_STORAGE_VERSION = 'v1';
|
||||||
const MAX_PRESET_NAME_LENGTH = 30;
|
const MAX_PRESET_NAME_LENGTH = 30;
|
||||||
const MAX_PRESETS_COUNT = 10;
|
|
||||||
|
|
||||||
// Marker for when wildcard patterns resolve to no matches
|
// Marker for when wildcard patterns resolve to no matches
|
||||||
// This ensures we return empty results instead of all models
|
// 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 = {
|
const preset = {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
filters: this.filterManager.cloneFilters(),
|
filters: this.filterManager.cloneFilters(),
|
||||||
@@ -613,17 +603,6 @@ export class FilterPresetManager {
|
|||||||
return;
|
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;
|
this.isInlineNamingActive = true;
|
||||||
|
|
||||||
const presetsContainer = document.getElementById('filterPresets');
|
const presetsContainer = document.getElementById('filterPresets');
|
||||||
|
|||||||
@@ -1017,6 +1017,12 @@ export class SettingsManager {
|
|||||||
displayDensitySelect.value = state.global.settings.display_density || 'default';
|
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
|
// Set card info display setting
|
||||||
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
|
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
|
||||||
if (cardInfoDisplaySelect) {
|
if (cardInfoDisplaySelect) {
|
||||||
@@ -2288,6 +2294,13 @@ export class SettingsManager {
|
|||||||
// Apply frontend settings immediately
|
// Apply frontend settings immediately
|
||||||
this.applyFrontendSettings();
|
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
|
// Recalculate layout when display density changes
|
||||||
if (settingKey === 'display_density' && state.virtualScroller) {
|
if (settingKey === 'display_density' && state.virtualScroller) {
|
||||||
state.virtualScroller.calculateLayout();
|
state.virtualScroller.calculateLayout();
|
||||||
|
|||||||
+15
-1
@@ -7,7 +7,7 @@ import { state, getCurrentPageState } from './state/index.js';
|
|||||||
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.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 { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.js';
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
import { initSortDropdown } from './components/controls/SortDropdown.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
|
// Initialize dropdown functionality for refresh button
|
||||||
this.initDropdowns();
|
this.initDropdowns();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
card_blur_amount: 8,
|
card_blur_amount: 8,
|
||||||
autoplay_on_hover: false,
|
autoplay_on_hover: false,
|
||||||
display_density: 'default',
|
display_density: 'default',
|
||||||
|
recipes_layout: 'grid',
|
||||||
card_info_display: 'always',
|
card_info_display: 'always',
|
||||||
model_name_display: 'model_name',
|
model_name_display: 'model_name',
|
||||||
lora_syntax_format: 'legacy',
|
lora_syntax_format: 'legacy',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -483,10 +483,18 @@ export class VirtualScroller {
|
|||||||
element.style.width = `${this.itemWidth}px`;
|
element.style.width = `${this.itemWidth}px`;
|
||||||
element.style.height = `${this.itemHeight}px`;
|
element.style.height = `${this.itemHeight}px`;
|
||||||
|
|
||||||
// Remove max-width constraint from model-card to allow dynamic sizing
|
// Remove max-width/min-width constraints from the model-card to allow
|
||||||
const modelCard = element.querySelector('.model-card');
|
// 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) {
|
if (modelCard) {
|
||||||
modelCard.style.maxWidth = 'none';
|
modelCard.style.maxWidth = 'none';
|
||||||
|
modelCard.style.minWidth = '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { VirtualScroller } from './VirtualScroller.js';
|
import { VirtualScroller } from './VirtualScroller.js';
|
||||||
|
import { MasonryScroller } from './MasonryScroller.js';
|
||||||
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
|
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { showToast } from './uiHelpers.js';
|
import { showToast } from './uiHelpers.js';
|
||||||
@@ -141,8 +142,14 @@ async function initializeVirtualScroll(pageType) {
|
|||||||
throw new Error(`Required components not available for ${pageType} page`);
|
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
|
// Initialize virtual scroller with renamed container elements
|
||||||
state.virtualScroller = new VirtualScroller({
|
state.virtualScroller = new ScrollerClass({
|
||||||
gridElement: grid,
|
gridElement: grid,
|
||||||
containerElement: gridContainer,
|
containerElement: gridContainer,
|
||||||
scrollContainer: scrollContainer,
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -356,6 +356,8 @@ export function updatePanelPositions() {
|
|||||||
|
|
||||||
if (filterPanel) {
|
if (filterPanel) {
|
||||||
filterPanel.style.top = `${topPosition}px`;
|
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
|
// Adjust panel horizontal position based on the search container
|
||||||
|
|||||||
@@ -625,7 +625,24 @@
|
|||||||
<span class="warning-text">{{ t('settings.layoutSettings.displayDensityWarning') }}</span>
|
<span class="warning-text">{{ t('settings.layoutSettings.displayDensityWarning') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock('../../../static/js/state/index.js', () => {
|
|||||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
captureScrollPosition: captureScrollPositionMock,
|
captureScrollPosition: captureScrollPositionMock,
|
||||||
restoreScrollPosition: restoreScrollPositionMock,
|
restoreScrollPosition: restoreScrollPositionMock,
|
||||||
|
recreateVirtualScroll: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1666,4 +1666,374 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
// Entire phrase should be replaced with selected tag
|
// Entire phrase should be replaced with selected tag
|
||||||
expect(input.value).toBe('looking_to_the_side,');
|
expect(input.value).toBe('looking_to_the_side,');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows /af command for loras when active-filters autocomplete is off (default)', async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const commandNames = autoComplete.items.map((item) => item.command);
|
||||||
|
expect(commandNames).toContain('/af');
|
||||||
|
expect(commandNames).not.toContain('/noaf');
|
||||||
|
expect(commandNames).toContain('/activefilters');
|
||||||
|
expect(commandNames).not.toContain('/noactivefilters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not trigger preview for command items when selecting the loras command list', async () => {
|
||||||
|
// Regression: with showPreview enabled (the default for loras widgets), the
|
||||||
|
// auto-selected first command item was passed to showPreviewForItem() as a
|
||||||
|
// relative path, crashing on relativePath.split.
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: true, minChars: 1 });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
// Allow the async preview tooltip import to resolve
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const commandNames = autoComplete.items.map((item) => item.command);
|
||||||
|
expect(commandNames).toContain('/af');
|
||||||
|
expect(previewTooltipMock.show).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows /noaf command for loras when active-filters autocomplete is on', async () => {
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const commandNames = autoComplete.items.map((item) => item.command);
|
||||||
|
expect(commandNames).toContain('/noaf');
|
||||||
|
expect(commandNames).not.toContain('/af');
|
||||||
|
expect(commandNames).toContain('/noactivefilters');
|
||||||
|
expect(commandNames).not.toContain('/activefilters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles the active-filters setting when /activefilters alias is used', async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/activefilters';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
const commandResult = autoComplete._parseCommandInput('/activefilters');
|
||||||
|
expect(commandResult.command).toBeDefined();
|
||||||
|
expect(commandResult.command.type).toBe('toggle_setting');
|
||||||
|
expect(commandResult.command.value).toBe(true);
|
||||||
|
|
||||||
|
await autoComplete._handleToggleSettingCommand(commandResult.command);
|
||||||
|
|
||||||
|
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles the active-filters setting when /af is accepted', async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const afItem = autoComplete.items.find((item) => item.command === '/af');
|
||||||
|
expect(afItem).toBeDefined();
|
||||||
|
|
||||||
|
// Simulate the input being cleared after the command is accepted so the
|
||||||
|
// cleared-token input event does not re-trigger command parsing.
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('');
|
||||||
|
await autoComplete._handleToggleSettingCommand(afItem);
|
||||||
|
|
||||||
|
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends active filter params to loras autocomplete requests when enabled', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
||||||
|
baseModel: ['SD 1.5'],
|
||||||
|
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
|
||||||
|
autoTags: { I2V: 'include' },
|
||||||
|
modelTypes: ['standard'],
|
||||||
|
tagLogic: 'all',
|
||||||
|
license: { noCredit: 'include', allowSelling: 'exclude' },
|
||||||
|
}));
|
||||||
|
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
|
||||||
|
localStorage.setItem('lora_manager_loras_recursiveSearch', 'true');
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.value = 'example';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
|
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
|
||||||
|
expect(calledUrl).toContain('folder=MyLoras');
|
||||||
|
expect(calledUrl).toContain('recursive=true');
|
||||||
|
expect(calledUrl).toContain('tag_include=anime');
|
||||||
|
expect(calledUrl).toContain('tag_exclude=nsfw');
|
||||||
|
expect(calledUrl).toContain('tag_exclude=__no_tags__');
|
||||||
|
expect(calledUrl).toContain('auto_tag_include=I2V');
|
||||||
|
expect(calledUrl).toContain('tag_logic=all');
|
||||||
|
expect(calledUrl).toContain('credit_required=false');
|
||||||
|
expect(calledUrl).toContain('allow_selling_generated_content=false');
|
||||||
|
const parsed = new URL(calledUrl, 'https://example.com');
|
||||||
|
expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
|
||||||
|
expect(parsed.searchParams.get('model_type')).toBe('standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.value = 'example';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the filter-pipeline signal even when no filters are stored', async () => {
|
||||||
|
// Regression: with filter mode on but no folder/filters stored, the request
|
||||||
|
// carried no params, so the backend skipped the filter pipeline and global
|
||||||
|
// settings like show_only_sfw diverged from the list endpoint.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.removeItem('lora_manager_loras_filters');
|
||||||
|
localStorage.removeItem('lora_manager_loras_activeFolder');
|
||||||
|
localStorage.removeItem('lora_manager_loras_recursiveSearch');
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.value = 'example';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
|
expect(calledUrl).toContain('recursive=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits folder param when active folder is root and recursion is enabled', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
||||||
|
baseModel: ['SD 1.5'],
|
||||||
|
tags: { anime: 'include' },
|
||||||
|
}));
|
||||||
|
localStorage.setItem('lora_manager_loras_activeFolder', '');
|
||||||
|
localStorage.removeItem('lora_manager_loras_recursiveSearch');
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.value = 'example';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
|
expect(calledUrl).not.toContain('folder=');
|
||||||
|
expect(calledUrl).toContain('recursive=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
||||||
|
baseModel: ['SD 1.5'],
|
||||||
|
tags: { anime: 'include' },
|
||||||
|
}));
|
||||||
|
localStorage.setItem('lora_manager_loras_activeFolder', '');
|
||||||
|
localStorage.setItem('lora_manager_loras_recursiveSearch', 'false');
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.value = 'example';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
|
expect(calledUrl).toContain('folder=');
|
||||||
|
expect(calledUrl).toContain('recursive=false');
|
||||||
|
const parsed = new URL(calledUrl, 'https://example.com');
|
||||||
|
expect(parsed.searchParams.get('folder')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the active folder even when no filter-panel filters are set', async () => {
|
||||||
|
// Regression: folder was skipped when lora_manager_loras_filters was
|
||||||
|
// missing because the filters key gate returned early.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.removeItem('lora_manager_loras_filters');
|
||||||
|
localStorage.setItem('lora_manager_loras_activeFolder', 'Flux.1 D/style');
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: ['Flux.1 D/style/3D_Fairytales.safetensors'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('3D');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.value = '3D';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
|
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
|
||||||
|
expect(calledUrl).toContain('recursive=true');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,6 +92,7 @@ vi.mock('../../../static/js/utils/eventManagementInit.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
initializeInfiniteScroll: vi.fn(),
|
initializeInfiniteScroll: vi.fn(),
|
||||||
|
recreateVirtualScroll: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const {
|
|||||||
downloadModel: vi.fn(),
|
downloadModel: vi.fn(),
|
||||||
downloadHfModel: vi.fn(),
|
downloadHfModel: vi.fn(),
|
||||||
cancelDownload: vi.fn(),
|
cancelDownload: vi.fn(),
|
||||||
|
getPageState: vi.fn(() => ({})),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared loading manager served both via state.loadingManager and the
|
// Shared loading manager served both via state.loadingManager and the
|
||||||
@@ -314,4 +315,120 @@ describe('DownloadManager batch download summary flow', () => {
|
|||||||
);
|
);
|
||||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
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');
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
refreshVirtualScroll: refreshVirtualScrollMock,
|
refreshVirtualScroll: refreshVirtualScrollMock,
|
||||||
|
recreateVirtualScroll: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ describe('state module', () => {
|
|||||||
civitai_host: 'civitai.com',
|
civitai_host: 'civitai.com',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
blur_mature_content: true,
|
blur_mature_content: true,
|
||||||
mature_blur_level: 'R'
|
mature_blur_level: 'R',
|
||||||
|
recipes_layout: 'grid'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
|
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -11,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional
|
|||||||
|
|
||||||
from aiohttp import FormData, web
|
from aiohttp import FormData, web
|
||||||
from aiohttp.test_utils import TestClient, TestServer
|
from aiohttp.test_utils import TestClient, TestServer
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from py.config import config
|
from py.config import config
|
||||||
from py.routes import base_recipe_routes
|
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"] == []
|
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(
|
async def test_list_recipes_passes_checkpoint_hash_filter(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> 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:
|
async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None:
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
original_analyze = harness.analysis.analyze_remote_image
|
original_analyze = harness.analysis.analyze_remote_image
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ class FakeScanner:
|
|||||||
return list(self._roots)
|
return list(self._roots)
|
||||||
|
|
||||||
|
|
||||||
|
class StubSettings:
|
||||||
|
"""Settings stub that returns defaults, avoiding the real settings singleton."""
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_relative_paths_supports_multiple_tokens():
|
async def test_search_relative_paths_supports_multiple_tokens():
|
||||||
scanner = FakeScanner(
|
scanner = FakeScanner(
|
||||||
@@ -101,3 +108,274 @@ async def test_search_safe_does_not_match_all_files():
|
|||||||
matching = await service.search_relative_paths("safe")
|
matching = await service.search_relative_paths("safe")
|
||||||
|
|
||||||
assert len(matching) == 0
|
assert len(matching) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class SfwStubSettings(StubSettings):
|
||||||
|
"""Settings stub with the global SFW filter enabled."""
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
if key == "show_only_sfw":
|
||||||
|
return True
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_respects_global_sfw_setting():
|
||||||
|
"""Filtered search applies show_only_sfw like the list endpoint (parity)."""
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/sfw-model.safetensors", "preview_nsfw_level": 0},
|
||||||
|
{"file_path": "/models/nsfw-model.safetensors", "preview_nsfw_level": 4},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=SfwStubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths("model", apply_filters=True)
|
||||||
|
|
||||||
|
assert matching == ["sfw-model.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_sfw_only_applied_when_filter_mode_is_on():
|
||||||
|
"""Global settings (show_only_sfw) apply only when the filter pipeline runs."""
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/sfw-model.safetensors", "preview_nsfw_level": 0},
|
||||||
|
{"file_path": "/models/nsfw-model.safetensors", "preview_nsfw_level": 4},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=SfwStubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
default_matching = await service.search_relative_paths("model")
|
||||||
|
|
||||||
|
assert default_matching == [
|
||||||
|
"sfw-model.safetensors",
|
||||||
|
"nsfw-model.safetensors",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_folder_filter_recursive():
|
||||||
|
"""folder filter with recursive=True (default) matches subfolders."""
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/anime/model-a.safetensors", "folder": "anime"},
|
||||||
|
{
|
||||||
|
"file_path": "/models/anime/nsfw/model-b.safetensors",
|
||||||
|
"folder": "anime/nsfw",
|
||||||
|
},
|
||||||
|
{"file_path": "/models/realistic/model-c.safetensors", "folder": "realistic"},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths("model", folder="anime")
|
||||||
|
|
||||||
|
assert matching == [
|
||||||
|
f"anime{os.sep}model-a.safetensors",
|
||||||
|
f"anime{os.sep}nsfw{os.sep}model-b.safetensors",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_folder_filter_exact():
|
||||||
|
"""folder filter with recursive=False matches only the exact folder."""
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/anime/model-a.safetensors", "folder": "anime"},
|
||||||
|
{
|
||||||
|
"file_path": "/models/anime/nsfw/model-b.safetensors",
|
||||||
|
"folder": "anime/nsfw",
|
||||||
|
},
|
||||||
|
{"file_path": "/models/realistic/model-c.safetensors", "folder": "realistic"},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths(
|
||||||
|
"model", folder="anime", recursive=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matching == [f"anime{os.sep}model-a.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_base_model_filter():
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/model-a.safetensors", "base_model": "SD 1.5"},
|
||||||
|
{"file_path": "/models/model-b.safetensors", "base_model": "SDXL"},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths("model", base_models=["SD 1.5"])
|
||||||
|
|
||||||
|
assert matching == ["model-a.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_tag_include():
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/model-a.safetensors", "tags": ["anime"]},
|
||||||
|
{"file_path": "/models/model-b.safetensors", "tags": ["realistic"]},
|
||||||
|
{"file_path": "/models/model-c.safetensors", "tags": ["anime", "realistic"]},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths("model", tags={"anime": "include"})
|
||||||
|
|
||||||
|
assert set(matching) == {"model-a.safetensors", "model-c.safetensors"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_tag_exclude():
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/model-a.safetensors", "tags": ["anime"]},
|
||||||
|
{"file_path": "/models/model-b.safetensors", "tags": ["realistic"]},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths("model", tags={"anime": "exclude"})
|
||||||
|
|
||||||
|
assert matching == ["model-b.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_auto_tag_include():
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"file_path": "/models/model-i2v.safetensors",
|
||||||
|
"file_name": "model-i2v.safetensors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_path": "/models/model-t2v.safetensors",
|
||||||
|
"file_name": "model-t2v.safetensors",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths(
|
||||||
|
"model", auto_tags={"I2V": "include"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matching == ["model-i2v.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_tag_logic_all():
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/model-a.safetensors", "tags": ["anime", "style"]},
|
||||||
|
{"file_path": "/models/model-b.safetensors", "tags": ["anime"]},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths(
|
||||||
|
"model", tags={"anime": "include", "style": "include"}, tag_logic="all"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matching == ["model-a.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_credit_required_filter():
|
||||||
|
# license_flags bit0: 1 = no credit required, 0 = credit required
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/model-a.safetensors", "license_flags": 127},
|
||||||
|
{"file_path": "/models/model-b.safetensors", "license_flags": 0},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths("model", credit_required=True)
|
||||||
|
assert matching == ["model-b.safetensors"]
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths("model", credit_required=False)
|
||||||
|
assert matching == ["model-a.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_allow_selling_filter():
|
||||||
|
# license_flags bit1: 1 = commercial image use allowed, 0 = not allowed
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/model-a.safetensors", "license_flags": 2},
|
||||||
|
{"file_path": "/models/model-b.safetensors", "license_flags": 1},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths(
|
||||||
|
"model", allow_selling_generated_content=True
|
||||||
|
)
|
||||||
|
assert matching == ["model-a.safetensors"]
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths(
|
||||||
|
"model", allow_selling_generated_content=False
|
||||||
|
)
|
||||||
|
assert matching == ["model-b.safetensors"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_relative_paths_no_filters_regression():
|
||||||
|
"""No filter kwargs -> behavior is byte-identical to plain token matching."""
|
||||||
|
scanner = FakeScanner(
|
||||||
|
[
|
||||||
|
{"file_path": "/models/flux/detail-model.safetensors"},
|
||||||
|
{"file_path": "/models/flux/only-flux.safetensors"},
|
||||||
|
],
|
||||||
|
["/models"],
|
||||||
|
)
|
||||||
|
service = DummyService(
|
||||||
|
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
|
||||||
|
)
|
||||||
|
|
||||||
|
matching = await service.search_relative_paths("flux")
|
||||||
|
|
||||||
|
assert matching == [
|
||||||
|
f"flux{os.sep}only-flux.safetensors",
|
||||||
|
f"flux{os.sep}detail-model.safetensors",
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -288,3 +288,58 @@ class TestIsobmffBrotliExtraction:
|
|||||||
# Direct extraction should return None because decompressed size exceeds limit
|
# Direct extraction should return None because decompressed size exceeds limit
|
||||||
result = ExifUtils._extract_isobmff_brotli(str(path))
|
result = ExifUtils._extract_isobmff_brotli(str(path))
|
||||||
assert result is None
|
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
|
||||||
|
|||||||
+202
-16
@@ -14,6 +14,7 @@ import {
|
|||||||
getAutocompleteAppendCommaPreference,
|
getAutocompleteAppendCommaPreference,
|
||||||
getAutocompleteAutoFormatPreference,
|
getAutocompleteAutoFormatPreference,
|
||||||
getAutocompleteAcceptKeyPreference,
|
getAutocompleteAcceptKeyPreference,
|
||||||
|
getLoraActiveFiltersAutocompletePreference,
|
||||||
getPromptTagAutocompletePreference,
|
getPromptTagAutocompletePreference,
|
||||||
getTagSpaceReplacementPreference,
|
getTagSpaceReplacementPreference,
|
||||||
} from "./settings.js";
|
} from "./settings.js";
|
||||||
@@ -48,6 +49,47 @@ const TAG_COMMANDS = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Command definitions for LoRA active-filters search
|
||||||
|
// Aliases (/activefilters, /noactivefilters) mirror /emb ↔ /embedding
|
||||||
|
const LORAS_COMMANDS = {
|
||||||
|
'/af': {
|
||||||
|
type: 'toggle_setting',
|
||||||
|
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||||||
|
value: true,
|
||||||
|
label: 'Active Filters: ON',
|
||||||
|
feedbackSummary: 'Active Filters Search: ON',
|
||||||
|
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
|
||||||
|
condition: () => !getLoraActiveFiltersAutocompletePreference()
|
||||||
|
},
|
||||||
|
'/noaf': {
|
||||||
|
type: 'toggle_setting',
|
||||||
|
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||||||
|
value: false,
|
||||||
|
label: 'Active Filters: OFF',
|
||||||
|
feedbackSummary: 'Active Filters Search: OFF',
|
||||||
|
feedbackDetail: 'LoRA autocomplete searches the full library again.',
|
||||||
|
condition: () => getLoraActiveFiltersAutocompletePreference()
|
||||||
|
},
|
||||||
|
'/activefilters': {
|
||||||
|
type: 'toggle_setting',
|
||||||
|
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||||||
|
value: true,
|
||||||
|
label: 'Active Filters: ON',
|
||||||
|
feedbackSummary: 'Active Filters Search: ON',
|
||||||
|
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
|
||||||
|
condition: () => !getLoraActiveFiltersAutocompletePreference()
|
||||||
|
},
|
||||||
|
'/noactivefilters': {
|
||||||
|
type: 'toggle_setting',
|
||||||
|
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||||||
|
value: false,
|
||||||
|
label: 'Active Filters: OFF',
|
||||||
|
feedbackSummary: 'Active Filters Search: OFF',
|
||||||
|
feedbackDetail: 'LoRA autocomplete searches the full library again.',
|
||||||
|
condition: () => getLoraActiveFiltersAutocompletePreference()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Category display information
|
// Category display information
|
||||||
const CATEGORY_INFO = {
|
const CATEGORY_INFO = {
|
||||||
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
||||||
@@ -719,6 +761,36 @@ class AutoComplete {
|
|||||||
searchTerm = (match[1] || '').trim();
|
searchTerm = (match[1] || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For loras model type, check if we're in command mode (/af, /noaf)
|
||||||
|
if (this.modelType === 'loras') {
|
||||||
|
const commandResult = this._parseCommandInput(rawSearchTerm);
|
||||||
|
|
||||||
|
if (commandResult.showCommands) {
|
||||||
|
// Show command list dropdown
|
||||||
|
this.showingCommands = true;
|
||||||
|
this.activeCommand = null;
|
||||||
|
this.searchType = 'commands';
|
||||||
|
this._showCommandList(commandResult.commandFilter);
|
||||||
|
return;
|
||||||
|
} else if (commandResult.command?.type === 'toggle_setting') {
|
||||||
|
// Handle toggle setting command (/af, /noaf)
|
||||||
|
this._handleToggleSettingCommand(commandResult.command);
|
||||||
|
return;
|
||||||
|
} else if (commandResult.command) {
|
||||||
|
// Command is active, use filtered search
|
||||||
|
this.showingCommands = false;
|
||||||
|
this.activeCommand = null;
|
||||||
|
this.searchType = null;
|
||||||
|
searchTerm = commandResult.searchTerm || rawSearchTerm;
|
||||||
|
} else {
|
||||||
|
// No command - regular lora search
|
||||||
|
this.showingCommands = false;
|
||||||
|
this.activeCommand = null;
|
||||||
|
this.searchType = null;
|
||||||
|
searchTerm = rawSearchTerm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For prompt model type, check if we're searching embeddings, commands, or tags
|
// For prompt model type, check if we're searching embeddings, commands, or tags
|
||||||
if (this.modelType === 'prompt') {
|
if (this.modelType === 'prompt') {
|
||||||
const match = rawSearchTerm.match(/^emb:(.*)$/i);
|
const match = rawSearchTerm.match(/^emb:(.*)$/i);
|
||||||
@@ -1095,7 +1167,11 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isSelectableInfoItem(item) {
|
_isSelectableInfoItem(item) {
|
||||||
return isWildcardInfoItem(item);
|
if (isWildcardInfoItem(item)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Command items are not model paths — never show preview for them
|
||||||
|
return item && typeof item === 'object' && 'command' in item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1159,6 +1235,11 @@ class AutoComplete {
|
|||||||
return (match?.[1] || '').trim();
|
return (match?.[1] || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.modelType === 'loras') {
|
||||||
|
const commandResult = this._parseCommandInput(rawSearchTerm);
|
||||||
|
return commandResult.searchTerm ?? rawSearchTerm;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.modelType === 'prompt') {
|
if (this.modelType === 'prompt') {
|
||||||
const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
|
const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
|
||||||
if (embeddingMatch) {
|
if (embeddingMatch) {
|
||||||
@@ -1245,6 +1326,91 @@ class AutoComplete {
|
|||||||
return this._getPreferredSelectedIndex(searchTerm);
|
return this._getPreferredSelectedIndex(searchTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a URL-encoded query string from the LoRA Manager page's active
|
||||||
|
* filters in localStorage, or null when not applicable.
|
||||||
|
*/
|
||||||
|
_getActiveLoraFilters() {
|
||||||
|
if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const folder = localStorage.getItem('lora_manager_loras_activeFolder');
|
||||||
|
const recursiveRaw = localStorage.getItem('lora_manager_loras_recursiveSearch');
|
||||||
|
const recursive = recursiveRaw === null ? true : recursiveRaw.toLowerCase() === 'true';
|
||||||
|
|
||||||
|
if (folder && folder !== 'null') {
|
||||||
|
params.append('folder', folder);
|
||||||
|
} else if (!recursive) {
|
||||||
|
// Root folder with recursion disabled mirrors the page list,
|
||||||
|
// which matches only root-level files via folder=''.
|
||||||
|
params.append('folder', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = localStorage.getItem('lora_manager_loras_filters');
|
||||||
|
if (raw) {
|
||||||
|
const filters = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (Array.isArray(filters.baseModel)) {
|
||||||
|
filters.baseModel.forEach((m) => m && params.append('base_model', m));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.tags && typeof filters.tags === 'object') {
|
||||||
|
Object.entries(filters.tags).forEach(([tag, state]) => {
|
||||||
|
if (state === 'include') {
|
||||||
|
params.append('tag_include', tag);
|
||||||
|
} else if (state === 'exclude') {
|
||||||
|
params.append('tag_exclude', tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.autoTags && typeof filters.autoTags === 'object') {
|
||||||
|
Object.entries(filters.autoTags).forEach(([tag, state]) => {
|
||||||
|
if (state === 'include') {
|
||||||
|
params.append('auto_tag_include', tag);
|
||||||
|
} else if (state === 'exclude') {
|
||||||
|
params.append('auto_tag_exclude', tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(filters.modelTypes)) {
|
||||||
|
filters.modelTypes.forEach((t) => t && params.append('model_type', t));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.tagLogic) {
|
||||||
|
params.append('tag_logic', filters.tagLogic);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.license) {
|
||||||
|
if (filters.license.noCredit === 'include') {
|
||||||
|
params.append('credit_required', 'false');
|
||||||
|
} else if (filters.license.noCredit === 'exclude') {
|
||||||
|
params.append('credit_required', 'true');
|
||||||
|
}
|
||||||
|
if (filters.license.allowSelling === 'include') {
|
||||||
|
params.append('allow_selling_generated_content', 'true');
|
||||||
|
} else if (filters.license.allowSelling === 'exclude') {
|
||||||
|
params.append('allow_selling_generated_content', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always send recursive in filter mode — its presence also signals
|
||||||
|
// the backend to run the filter pipeline (e.g. show_only_sfw) even
|
||||||
|
// when no concrete filter is set, matching the list endpoint.
|
||||||
|
params.append('recursive', String(recursive));
|
||||||
|
|
||||||
|
return params.toString();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Lora Manager] Failed to read active filters for autocomplete:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async search(term = '', endpoint = null) {
|
async search(term = '', endpoint = null) {
|
||||||
try {
|
try {
|
||||||
this.currentSearchTerm = term;
|
this.currentSearchTerm = term;
|
||||||
@@ -1262,6 +1428,10 @@ class AutoComplete {
|
|||||||
endpoint = `/lm/${this.modelType}/relative-paths`;
|
endpoint = `/lm/${this.modelType}/relative-paths`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active-filter query params for loras (null when setting off or
|
||||||
|
// model type is not loras, so appending is safe for all types)
|
||||||
|
const activeFiltersQuery = this._getActiveLoraFilters();
|
||||||
|
|
||||||
// Generate multiple query variations for better matching, but avoid
|
// Generate multiple query variations for better matching, but avoid
|
||||||
// sending duplicate-equivalent requests that normalize to the same
|
// sending duplicate-equivalent requests that normalize to the same
|
||||||
// backend search term.
|
// backend search term.
|
||||||
@@ -1281,9 +1451,10 @@ class AutoComplete {
|
|||||||
const url = endpoint.includes('?')
|
const url = endpoint.includes('?')
|
||||||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
|
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
|
||||||
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
|
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
|
||||||
|
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.fetchApi(url);
|
const response = await api.fetchApi(finalUrl);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
items: data.success ? (data.relative_paths || data.words || []) : [],
|
items: data.success ? (data.relative_paths || data.words || []) : [],
|
||||||
@@ -1358,6 +1529,15 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the command map for the current model type.
|
||||||
|
* Lora model types get the active-filters toggle commands, all others
|
||||||
|
* keep the prompt tag commands.
|
||||||
|
*/
|
||||||
|
_getCommands() {
|
||||||
|
return this.modelType === 'loras' ? LORAS_COMMANDS : TAG_COMMANDS;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse command input to detect command mode
|
* Parse command input to detect command mode
|
||||||
* @param {string} rawInput - Raw input text
|
* @param {string} rawInput - Raw input text
|
||||||
@@ -1379,8 +1559,8 @@ class AutoComplete {
|
|||||||
const partialCommand = trimmed.toLowerCase();
|
const partialCommand = trimmed.toLowerCase();
|
||||||
|
|
||||||
// Check for exact command match
|
// Check for exact command match
|
||||||
if (TAG_COMMANDS[partialCommand]) {
|
if (this._getCommands()[partialCommand]) {
|
||||||
const cmd = TAG_COMMANDS[partialCommand];
|
const cmd = this._getCommands()[partialCommand];
|
||||||
// Filter out toggle commands that don't meet their condition
|
// Filter out toggle commands that don't meet their condition
|
||||||
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
||||||
return { showCommands: false, command: null, searchTerm: '' };
|
return { showCommands: false, command: null, searchTerm: '' };
|
||||||
@@ -1405,8 +1585,8 @@ class AutoComplete {
|
|||||||
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
|
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
|
||||||
const searchPart = trimmed.slice(spaceIndex + 1).trim();
|
const searchPart = trimmed.slice(spaceIndex + 1).trim();
|
||||||
|
|
||||||
if (TAG_COMMANDS[commandPart]) {
|
if (this._getCommands()[commandPart]) {
|
||||||
const cmd = TAG_COMMANDS[commandPart];
|
const cmd = this._getCommands()[commandPart];
|
||||||
// Filter out toggle commands that don't meet their condition
|
// Filter out toggle commands that don't meet their condition
|
||||||
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
||||||
return { showCommands: false, command: null, searchTerm: trimmed };
|
return { showCommands: false, command: null, searchTerm: trimmed };
|
||||||
@@ -1437,7 +1617,7 @@ class AutoComplete {
|
|||||||
|
|
||||||
const commands = [];
|
const commands = [];
|
||||||
|
|
||||||
for (const [cmd, info] of Object.entries(TAG_COMMANDS)) {
|
for (const [cmd, info] of Object.entries(this._getCommands())) {
|
||||||
// Filter out toggle commands that don't meet their condition
|
// Filter out toggle commands that don't meet their condition
|
||||||
if (info.type === 'toggle_setting' && info.condition) {
|
if (info.type === 'toggle_setting' && info.condition) {
|
||||||
if (!info.condition()) continue;
|
if (!info.condition()) continue;
|
||||||
@@ -1902,7 +2082,8 @@ class AutoComplete {
|
|||||||
|
|
||||||
showPreviewForItem(relativePath, itemElement) {
|
showPreviewForItem(relativePath, itemElement) {
|
||||||
if (!this.options.showPreview || !this.previewTooltip) return;
|
if (!this.options.showPreview || !this.previewTooltip) return;
|
||||||
|
if (typeof relativePath !== 'string' || !relativePath) return;
|
||||||
|
|
||||||
// Extract filename without extension for preview
|
// Extract filename without extension for preview
|
||||||
const fileName = relativePath.split(/[/\\]/).pop();
|
const fileName = relativePath.split(/[/\\]/).pop();
|
||||||
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||||
@@ -1984,14 +2165,18 @@ class AutoComplete {
|
|||||||
const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
|
const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
|
||||||
const offset = this.items.length;
|
const offset = this.items.length;
|
||||||
|
|
||||||
|
// Active-filter query params for loras (null when setting off)
|
||||||
|
const activeFiltersQuery = this._getActiveLoraFilters();
|
||||||
|
|
||||||
// Execute all queries in parallel with offset
|
// Execute all queries in parallel with offset
|
||||||
const searchPromises = queriesToExecute.map(async (query) => {
|
const searchPromises = queriesToExecute.map(async (query) => {
|
||||||
const url = endpoint.includes('?')
|
const url = endpoint.includes('?')
|
||||||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
|
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
|
||||||
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`;
|
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`;
|
||||||
|
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.fetchApi(url);
|
const response = await api.fetchApi(finalUrl);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.success ? (data.relative_paths || data.words || []) : [];
|
return data.success ? (data.relative_paths || data.words || []) : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2692,14 +2877,14 @@ class AutoComplete {
|
|||||||
const settingManager = app?.extensionManager?.setting;
|
const settingManager = app?.extensionManager?.setting;
|
||||||
if (settingManager && typeof settingManager.set === 'function') {
|
if (settingManager && typeof settingManager.set === 'function') {
|
||||||
await settingManager.set(settingId, value);
|
await settingManager.set(settingId, value);
|
||||||
this._showToggleFeedback(value);
|
this._showToggleFeedback(command, value);
|
||||||
this._clearCurrentToken();
|
this._clearCurrentToken();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: use legacy settings API
|
// Fallback: use legacy settings API
|
||||||
const setting = app.ui.settings.settingsById?.[settingId];
|
const setting = app.ui.settings.settingsById?.[settingId];
|
||||||
if (setting) {
|
if (setting) {
|
||||||
app.ui.settings.setSettingValue(settingId, value);
|
app.ui.settings.setSettingValue(settingId, value);
|
||||||
this._showToggleFeedback(value);
|
this._showToggleFeedback(command, value);
|
||||||
this._clearCurrentToken();
|
this._clearCurrentToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2718,15 +2903,16 @@ class AutoComplete {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Show visual feedback for toggle action using toast
|
* Show visual feedback for toggle action using toast
|
||||||
|
* @param {Object} command - The toggle command that was executed
|
||||||
* @param {boolean} enabled - New autocomplete state
|
* @param {boolean} enabled - New autocomplete state
|
||||||
*/
|
*/
|
||||||
_showToggleFeedback(enabled) {
|
_showToggleFeedback(command, enabled) {
|
||||||
showToast({
|
showToast({
|
||||||
severity: enabled ? 'success' : 'secondary',
|
severity: enabled ? 'success' : 'secondary',
|
||||||
summary: enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled',
|
summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
|
||||||
detail: enabled
|
detail: command.feedbackDetail || (enabled
|
||||||
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
||||||
: 'Tag autocomplete is now OFF. Use /ac to re-enable.',
|
: 'Tag autocomplete is now OFF. Use /ac to re-enable.'),
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const NEW_TAB_ZOOM_LEVEL = 0.8;
|
|||||||
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
|
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
|
||||||
const STRENGTH_STEP_DEFAULT = 0.05;
|
const STRENGTH_STEP_DEFAULT = 0.05;
|
||||||
|
|
||||||
|
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID = "loramanager.lora_active_filters_autocomplete";
|
||||||
|
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT = false;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -360,6 +363,32 @@ const getStrengthStepPreference = (() => {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const getLoraActiveFiltersAutocompletePreference = (() => {
|
||||||
|
let settingsUnavailableLogged = false;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const settingManager = app?.extensionManager?.setting;
|
||||||
|
if (!settingManager || typeof settingManager.get !== "function") {
|
||||||
|
if (!settingsUnavailableLogged) {
|
||||||
|
console.warn("LoRA Manager: settings API unavailable, using default lora active filters autocomplete setting.");
|
||||||
|
settingsUnavailableLogged = true;
|
||||||
|
}
|
||||||
|
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = settingManager.get(LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID);
|
||||||
|
return value ?? LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
|
||||||
|
} catch (error) {
|
||||||
|
if (!settingsUnavailableLogged) {
|
||||||
|
console.warn("LoRA Manager: unable to read lora active filters autocomplete setting, using default.", error);
|
||||||
|
settingsUnavailableLogged = true;
|
||||||
|
}
|
||||||
|
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Register Extension with All Settings
|
// Register Extension with All Settings
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -396,6 +425,14 @@ app.registerExtension({
|
|||||||
tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.",
|
tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.",
|
||||||
category: ["LoRA Manager", "Autocomplete", "Prompt"],
|
category: ["LoRA Manager", "Autocomplete", "Prompt"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
|
||||||
|
name: "Search LoRA autocomplete within active filters",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT,
|
||||||
|
tooltip: "When enabled, LoRA autocomplete suggestions respect the active filters (folder/base model/tags) set in the LoRA Manager page. Commands /af and /noaf toggle this mode.",
|
||||||
|
category: ["LoRA Manager", "Autocomplete", "LoRA Active Filters"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID,
|
id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID,
|
||||||
name: "Append comma after autocomplete",
|
name: "Append comma after autocomplete",
|
||||||
@@ -549,4 +586,5 @@ export {
|
|||||||
getUsageStatisticsPreference,
|
getUsageStatisticsPreference,
|
||||||
getNewTabTemplatePreference,
|
getNewTabTemplatePreference,
|
||||||
getStrengthStepPreference,
|
getStrengthStepPreference,
|
||||||
|
getLoraActiveFiltersAutocompletePreference,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user