mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): show a summary modal after rematch runs
Replace the post-run toast cascade and the standalone L4 results modal with a summary modal modeled on the batch download summary: 3-state header, stat cards (matched / needs review / unresolved / errors), an L4 review table with per-entry undo, and a copyable report. Wired into the global, bulk and single-recipe rematch entries; complete no-op runs keep the lightweight toast. Obsolete results-modal code, styles and i18n keys are removed.
This commit is contained in:
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "Rezepte lokalen Modellen neu zuordnen",
|
"label": "Rezepte lokalen Modellen neu zuordnen",
|
||||||
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
||||||
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||||
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
|
||||||
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
|
|
||||||
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
|
||||||
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
|
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
|
||||||
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "Neu zuordnen"
|
"confirmButton": "Neu zuordnen"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "Ergebnisse der neuen Zuordnung — Übereinstimmungen per Dateiname",
|
|
||||||
"message": "Diese Einträge wurden per Dateiname neu verbunden und verknüpfen möglicherweise eine andere Version eines Modells. Überprüfen Sie sie und machen Sie falsche Zuordnungen rückgängig.",
|
|
||||||
"undo": "Rückgängig",
|
"undo": "Rückgängig",
|
||||||
"undone": "Rückgängig gemacht",
|
"undone": "Rückgängig gemacht",
|
||||||
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
|
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Lokale Beispielbilder",
|
"title": "Lokale Beispielbilder",
|
||||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
||||||
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
||||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||||
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
|
||||||
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
|
||||||
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
|
|
||||||
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
|
||||||
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
||||||
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "Rematch recipes to local models",
|
"label": "Rematch recipes to local models",
|
||||||
"loading": "Rematching recipes to local models...",
|
"loading": "Rematching recipes to local models...",
|
||||||
"success": "Matched {entries} entries across {recipes} recipes",
|
"success": "Matched {entries} entries across {recipes} recipes",
|
||||||
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
|
||||||
"allFailed": "Rematch failed for {failures} of {total} recipes",
|
|
||||||
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
|
|
||||||
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
|
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
|
||||||
"error": "Recipe rematch failed: {message}"
|
"error": "Recipe rematch failed: {message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "Rematch"
|
"confirmButton": "Rematch"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "Rematch Results — Filename Matches",
|
|
||||||
"message": "These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
|
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"undone": "Undone",
|
"undone": "Undone",
|
||||||
"undoFailed": "Failed to undo rematch: {message}"
|
"undoFailed": "Failed to undo rematch: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "Rematch Summary",
|
||||||
|
"successMessage": "Matched {entries} entries",
|
||||||
|
"failed": "Rematch failed",
|
||||||
|
"completedWithWarnings": "Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "Matched entries",
|
||||||
|
"statReview": "Needs review",
|
||||||
|
"statUnresolved": "Unresolved",
|
||||||
|
"statErrors": "Errors",
|
||||||
|
"reviewSection": "Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "Recipe",
|
||||||
|
"columnEntry": "Entry",
|
||||||
|
"columnFile": "Matched file",
|
||||||
|
"columnUndo": "Undo",
|
||||||
|
"copyReport": "Copy Report",
|
||||||
|
"close": "Close",
|
||||||
|
"scope_global": "All recipes",
|
||||||
|
"scope_bulk": "Selected recipes",
|
||||||
|
"scope_single": "Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Local Example Images",
|
"title": "Local Example Images",
|
||||||
"message": "No local example images found for this model. View options:",
|
"message": "No local example images found for this model. View options:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No recipes selected",
|
"noRecipesSelected": "No recipes selected",
|
||||||
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
|
|
||||||
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
|
||||||
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
|
|
||||||
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
|
|
||||||
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
||||||
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
||||||
"reimporting": "Re-importing recipe from source...",
|
"reimporting": "Re-importing recipe from source...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "Reasociar recetas con modelos locales",
|
"label": "Reasociar recetas con modelos locales",
|
||||||
"loading": "Reasociando recetas con modelos locales...",
|
"loading": "Reasociando recetas con modelos locales...",
|
||||||
"success": "{entries} entradas asociadas en {recipes} recetas",
|
"success": "{entries} entradas asociadas en {recipes} recetas",
|
||||||
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
|
||||||
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
|
|
||||||
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
|
||||||
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
|
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
|
||||||
"error": "Falló la reasociación de recetas: {message}"
|
"error": "Falló la reasociación de recetas: {message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "Reasociar"
|
"confirmButton": "Reasociar"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "Resultados de la reasociación — Coincidencias por nombre de archivo",
|
|
||||||
"message": "Estas entradas se reconectaron por nombre de archivo y pueden enlazar una versión diferente de un modelo. Revísalas y deshaz las que sean incorrectas.",
|
|
||||||
"undo": "Deshacer",
|
"undo": "Deshacer",
|
||||||
"undone": "Deshecho",
|
"undone": "Deshecho",
|
||||||
"undoFailed": "No se pudo deshacer la reasociación: {message}"
|
"undoFailed": "No se pudo deshacer la reasociación: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Imágenes de ejemplo locales",
|
"title": "Imágenes de ejemplo locales",
|
||||||
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
||||||
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
||||||
"noRecipesSelected": "No se han seleccionado recetas",
|
"noRecipesSelected": "No se han seleccionado recetas",
|
||||||
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
|
|
||||||
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
|
||||||
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
|
|
||||||
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
|
||||||
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
||||||
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
||||||
"reimporting": "Reimportando receta desde origen...",
|
"reimporting": "Reimportando receta desde origen...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "Réassocier les Recipes aux modèles locaux",
|
"label": "Réassocier les Recipes aux modèles locaux",
|
||||||
"loading": "Réassociation des Recipes aux modèles locaux...",
|
"loading": "Réassociation des Recipes aux modèles locaux...",
|
||||||
"success": "{entries} entrées associées dans {recipes} Recipes",
|
"success": "{entries} entrées associées dans {recipes} Recipes",
|
||||||
"successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
|
||||||
"allFailed": "Échec de la réassociation de {failures} Recipes sur {total}",
|
|
||||||
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
|
|
||||||
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
|
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
|
||||||
"error": "Échec de la réassociation des Recipes : {message}"
|
"error": "Échec de la réassociation des Recipes : {message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "Réassocier"
|
"confirmButton": "Réassocier"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "Résultats de la réassociation — Correspondances par nom de fichier",
|
|
||||||
"message": "Ces entrées ont été reconnectées par nom de fichier et peuvent associer une version différente d'un modèle. Vérifiez-les et annulez celles qui sont incorrectes.",
|
|
||||||
"undo": "Annuler",
|
"undo": "Annuler",
|
||||||
"undone": "Annulé",
|
"undone": "Annulé",
|
||||||
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
|
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Images d'exemple locales",
|
"title": "Images d'exemple locales",
|
||||||
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
||||||
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
||||||
"noRecipesSelected": "Aucune Recipe sélectionnée",
|
"noRecipesSelected": "Aucune Recipe sélectionnée",
|
||||||
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
|
|
||||||
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
|
||||||
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
|
|
||||||
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
|
|
||||||
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
|
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
|
||||||
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
||||||
"reimporting": "Ré-import de la Recipe depuis la source...",
|
"reimporting": "Ré-import de la Recipe depuis la source...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
||||||
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||||
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||||
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
|
||||||
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
|
|
||||||
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
|
||||||
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
||||||
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "התאם מחדש"
|
"confirmButton": "התאם מחדש"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "תוצאות ההתאמה מחדש — התאמות לפי שם קובץ",
|
|
||||||
"message": "הפריטים האלה חוברו מחדש לפי שם קובץ וייתכן שהם מקושרים לגרסה אחרת של מודל. סקור אותם ובטל כל אחד שאינו נכון.",
|
|
||||||
"undo": "בטל",
|
"undo": "בטל",
|
||||||
"undone": "בוטל",
|
"undone": "בוטל",
|
||||||
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
|
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "תמונות דוגמה מקומיות",
|
"title": "תמונות דוגמה מקומיות",
|
||||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
||||||
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
||||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||||
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
|
||||||
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
|
||||||
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
|
||||||
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
|
||||||
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||||
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "レシピをローカルモデルに再マッチング",
|
"label": "レシピをローカルモデルに再マッチング",
|
||||||
"loading": "レシピをローカルモデルに再マッチングしています...",
|
"loading": "レシピをローカルモデルに再マッチングしています...",
|
||||||
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||||
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
|
||||||
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
|
||||||
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
|
||||||
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
||||||
"error": "レシピの再マッチングに失敗しました:{message}"
|
"error": "レシピの再マッチングに失敗しました:{message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "再マッチング"
|
"confirmButton": "再マッチング"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "再マッチング結果 — ファイル名によるマッチ",
|
|
||||||
"message": "これらのエントリはファイル名で再接続されたため、モデルの別バージョンに関連付けられている可能性があります。内容を確認し、誤っているものは元に戻してください。",
|
|
||||||
"undo": "元に戻す",
|
"undo": "元に戻す",
|
||||||
"undone": "元に戻しました",
|
"undone": "元に戻しました",
|
||||||
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
|
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "ローカル例画像",
|
"title": "ローカル例画像",
|
||||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
||||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||||
"noRecipesSelected": "レシピが選択されていません",
|
"noRecipesSelected": "レシピが選択されていません",
|
||||||
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
|
||||||
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
|
||||||
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
|
||||||
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
|
||||||
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||||
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||||
"reimporting": "ソースからレシピを再インポート中...",
|
"reimporting": "ソースからレシピを再インポート中...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "레시피를 로컬 모델에 다시 매칭",
|
"label": "레시피를 로컬 모델에 다시 매칭",
|
||||||
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||||
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||||
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
|
||||||
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
|
|
||||||
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
|
||||||
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
||||||
"error": "레시피 재매칭 실패: {message}"
|
"error": "레시피 재매칭 실패: {message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "재매칭"
|
"confirmButton": "재매칭"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "재매칭 결과 — 파일 이름 매칭",
|
|
||||||
"message": "이 항목들은 파일 이름으로 다시 연결되었으며 모델의 다른 버전에 연결되었을 수 있습니다. 검토 후 잘못된 항목은 실행 취소하세요.",
|
|
||||||
"undo": "실행 취소",
|
"undo": "실행 취소",
|
||||||
"undone": "실행 취소됨",
|
"undone": "실행 취소됨",
|
||||||
"undoFailed": "재매칭 실행 취소 실패: {message}"
|
"undoFailed": "재매칭 실행 취소 실패: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "로컬 예시 이미지",
|
"title": "로컬 예시 이미지",
|
||||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
||||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||||
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
|
||||||
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
|
||||||
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
|
||||||
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
|
||||||
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||||
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "Повторное сопоставление рецептов с локальными моделями",
|
"label": "Повторное сопоставление рецептов с локальными моделями",
|
||||||
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
||||||
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||||
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
|
||||||
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
|
|
||||||
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
|
||||||
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
||||||
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "Сопоставить"
|
"confirmButton": "Сопоставить"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "Результаты сопоставления — совпадения по имени файла",
|
|
||||||
"message": "Эти записи были переподключены по имени файла и могут ссылаться на другую версию модели. Проверьте их и отмените неверные.",
|
|
||||||
"undo": "Отменить",
|
"undo": "Отменить",
|
||||||
"undone": "Отменено",
|
"undone": "Отменено",
|
||||||
"undoFailed": "Не удалось отменить сопоставление: {message}"
|
"undoFailed": "Не удалось отменить сопоставление: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Локальные примеры изображений",
|
"title": "Локальные примеры изображений",
|
||||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
||||||
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
||||||
"noRecipesSelected": "Рецепты не выбраны",
|
"noRecipesSelected": "Рецепты не выбраны",
|
||||||
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
|
||||||
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
|
||||||
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
|
||||||
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
|
||||||
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||||
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||||
"reimporting": "Переимпорт рецепта из источника...",
|
"reimporting": "Переимпорт рецепта из источника...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "将配方重新匹配到本地模型",
|
"label": "将配方重新匹配到本地模型",
|
||||||
"loading": "正在将配方重新匹配到本地模型...",
|
"loading": "正在将配方重新匹配到本地模型...",
|
||||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||||
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
|
||||||
"allFailed": "{failures}/{total} 个配方重新匹配失败",
|
|
||||||
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
|
||||||
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
||||||
"error": "配方重新匹配失败:{message}"
|
"error": "配方重新匹配失败:{message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "重新匹配"
|
"confirmButton": "重新匹配"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "重新匹配结果——文件名匹配",
|
|
||||||
"message": "这些条目已按文件名重新关联,可能链接到了模型的其他版本。请检查它们,并撤销任何错误的关联。",
|
|
||||||
"undo": "撤销",
|
"undo": "撤销",
|
||||||
"undone": "已撤销",
|
"undone": "已撤销",
|
||||||
"undoFailed": "撤销重新匹配失败:{message}"
|
"undoFailed": "撤销重新匹配失败:{message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "本地示例图片",
|
"title": "本地示例图片",
|
||||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||||
"noRecipesSelected": "未选择任何配方",
|
"noRecipesSelected": "未选择任何配方",
|
||||||
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
|
||||||
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
|
||||||
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
|
|
||||||
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
|
||||||
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
||||||
"rematchFailed": "重新匹配所选配方失败:{message}",
|
"rematchFailed": "重新匹配所选配方失败:{message}",
|
||||||
"reimporting": "正在从源重新导入配方...",
|
"reimporting": "正在从源重新导入配方...",
|
||||||
|
|||||||
+21
-9
@@ -216,9 +216,6 @@
|
|||||||
"label": "將配方重新匹配到本地模型",
|
"label": "將配方重新匹配到本地模型",
|
||||||
"loading": "正在將配方重新匹配到本地模型...",
|
"loading": "正在將配方重新匹配到本地模型...",
|
||||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||||
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
|
||||||
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
|
|
||||||
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
|
||||||
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
||||||
"error": "配方重新匹配失敗:{message}"
|
"error": "配方重新匹配失敗:{message}"
|
||||||
},
|
},
|
||||||
@@ -1511,12 +1508,31 @@
|
|||||||
"confirmButton": "重新匹配"
|
"confirmButton": "重新匹配"
|
||||||
},
|
},
|
||||||
"rematchResults": {
|
"rematchResults": {
|
||||||
"title": "重新匹配結果——檔案名稱匹配",
|
|
||||||
"message": "這些條目已依檔案名稱重新關聯,可能連結到模型的不同版本。請檢閱這些條目,並撤銷任何錯誤的項目。",
|
|
||||||
"undo": "撤銷",
|
"undo": "撤銷",
|
||||||
"undone": "已撤銷",
|
"undone": "已撤銷",
|
||||||
"undoFailed": "撤銷重新匹配失敗:{message}"
|
"undoFailed": "撤銷重新匹配失敗:{message}"
|
||||||
},
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "[TODO: Translate] Rematch Summary",
|
||||||
|
"successMessage": "[TODO: Translate] Matched {entries} entries",
|
||||||
|
"failed": "[TODO: Translate] Rematch failed",
|
||||||
|
"completedWithWarnings": "[TODO: Translate] Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "[TODO: Translate] Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "[TODO: Translate] Matched entries",
|
||||||
|
"statReview": "[TODO: Translate] Needs review",
|
||||||
|
"statUnresolved": "[TODO: Translate] Unresolved",
|
||||||
|
"statErrors": "[TODO: Translate] Errors",
|
||||||
|
"reviewSection": "[TODO: Translate] Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "[TODO: Translate] Recipe",
|
||||||
|
"columnEntry": "[TODO: Translate] Entry",
|
||||||
|
"columnFile": "[TODO: Translate] Matched file",
|
||||||
|
"columnUndo": "[TODO: Translate] Undo",
|
||||||
|
"copyReport": "[TODO: Translate] Copy Report",
|
||||||
|
"close": "[TODO: Translate] Close",
|
||||||
|
"scope_global": "[TODO: Translate] All recipes",
|
||||||
|
"scope_bulk": "[TODO: Translate] Selected recipes",
|
||||||
|
"scope_single": "[TODO: Translate] Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "本機範例圖片",
|
"title": "本機範例圖片",
|
||||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||||
@@ -2239,10 +2255,6 @@
|
|||||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||||
"noRecipesSelected": "未選取任何配方",
|
"noRecipesSelected": "未選取任何配方",
|
||||||
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
|
||||||
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
|
||||||
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
|
|
||||||
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
|
||||||
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
||||||
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
||||||
"reimporting": "正在從來源重新匯入配方...",
|
"reimporting": "正在從來源重新匯入配方...",
|
||||||
|
|||||||
@@ -697,78 +697,40 @@ button:disabled,
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recipe Rematch L4 Results Modal */
|
/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js;
|
||||||
#rematchResultsModal .modal-body {
|
stat cards / failure table / summary header come from
|
||||||
padding: var(--space-3);
|
metadata-refresh-result.css and download-batch-summary.css). */
|
||||||
|
.rematch-summary-modal {
|
||||||
|
max-width: 700px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rematchResultsModal .confirmation-message {
|
.rematch-cancelled-note {
|
||||||
color: var(--text-color);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
font-size: 1em;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-preview {
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2) 0;
|
margin: 0 0 var(--space-2) 0;
|
||||||
border-bottom: 1px solid var(--border-color);
|
font-size: var(--text-sm);
|
||||||
font-size: 0.9em;
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-row:last-child {
|
.rematch-cancelled-note i {
|
||||||
border-bottom: none;
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-info {
|
/* Review section heading uses the accent (review, not failure) instead of
|
||||||
display: flex;
|
the failure-section error color. */
|
||||||
flex-direction: column;
|
.rematch-review-section h4 {
|
||||||
gap: 2px;
|
color: var(--lora-accent);
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-entry {
|
#rematchSummaryModal .rematch-undo-btn {
|
||||||
font-weight: 500;
|
padding: var(--space-1) var(--space-2);
|
||||||
color: var(--text-color);
|
font-size: var(--text-xs);
|
||||||
overflow-wrap: anywhere;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-file {
|
#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) {
|
||||||
color: var(--text-muted);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-recipe {
|
|
||||||
font-size: 0.85em;
|
|
||||||
opacity: 0.7;
|
|
||||||
color: var(--text-muted);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-row.undone .rematch-results-info {
|
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rematchResultsModal .rematch-results-undo {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { state } from '../../state/index.js';
|
|||||||
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||||
|
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||||
|
|
||||||
export class GlobalContextMenu extends BaseContextMenu {
|
export class GlobalContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -425,58 +426,37 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
||||||
const failures = p.errors || 0;
|
const failures = p.errors || 0;
|
||||||
const unresolved = p.unresolved_entries ?? 0;
|
const unresolved = p.unresolved_entries ?? 0;
|
||||||
if (entries > 0) {
|
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
|
||||||
const successKey = failures > 0
|
// Complete no-op (nothing matched, nothing
|
||||||
? 'globalContextMenu.rematchRecipes.successErrors'
|
// unresolved, no errors) keeps the lightweight
|
||||||
: 'globalContextMenu.rematchRecipes.success';
|
// toast; anything else opens the post-run summary
|
||||||
const successText = failures > 0
|
// modal.
|
||||||
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
|
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
|
||||||
: `Matched ${entries} entries across ${recipes} recipes.`;
|
if (isNoop) {
|
||||||
progressUI?.complete(translate(
|
|
||||||
successKey,
|
|
||||||
{ count: recipes, recipes, entries, failures },
|
|
||||||
successText
|
|
||||||
));
|
|
||||||
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
|
|
||||||
} else if (failures > 0) {
|
|
||||||
// Nothing matched and at least one recipe
|
|
||||||
// errored — "no rematch needed" would be
|
|
||||||
// actively misleading here.
|
|
||||||
progressUI?.complete(translate(
|
|
||||||
'globalContextMenu.rematchRecipes.allFailed',
|
|
||||||
{ total: p.total, recipes, entries, failures },
|
|
||||||
`Rematch failed for ${failures} of ${p.total} recipes.`
|
|
||||||
));
|
|
||||||
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
|
|
||||||
} else if (unresolved > 0) {
|
|
||||||
// Entries existed but have no local model —
|
|
||||||
// expected for models deleted from Civitai;
|
|
||||||
// informational, not an error.
|
|
||||||
const unresolvedRecipes = p.unresolved_recipes ?? 0;
|
|
||||||
progressUI?.complete(translate(
|
|
||||||
'globalContextMenu.rematchRecipes.noMatch',
|
|
||||||
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
|
|
||||||
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
|
|
||||||
));
|
|
||||||
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
|
|
||||||
} else {
|
|
||||||
// Everything was skipped (nothing to do).
|
|
||||||
progressUI?.complete(translate(
|
progressUI?.complete(translate(
|
||||||
'globalContextMenu.rematchRecipes.success',
|
'globalContextMenu.rematchRecipes.success',
|
||||||
{ count: recipes, recipes, entries, failures },
|
{ count: recipes, recipes, entries, failures },
|
||||||
`Matched ${entries} entries across ${recipes} recipes.`
|
`Matched ${entries} entries across ${recipes} recipes.`
|
||||||
));
|
));
|
||||||
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
|
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
|
||||||
|
} else {
|
||||||
|
progressUI?.complete();
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'global',
|
||||||
|
total: p.total || 0,
|
||||||
|
matchedRecipes: recipes,
|
||||||
|
matchedEntries: entries,
|
||||||
|
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||||
|
unresolvedEntries: unresolved,
|
||||||
|
skipped: p.skipped || 0,
|
||||||
|
errors: failures,
|
||||||
|
l4Matches,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Refresh recipes page if active
|
// Refresh recipes page if active
|
||||||
if (window.recipesPage) {
|
if (window.recipesPage) {
|
||||||
window.recipesPage.refresh();
|
window.recipesPage.refresh();
|
||||||
}
|
}
|
||||||
// Filename-level (L4) matches are imprecise —
|
|
||||||
// always surface them for review/undo.
|
|
||||||
if (Array.isArray(p.l4_matches) && p.l4_matches.length > 0) {
|
|
||||||
rematchModalManager.showResultsModal(p.l4_matches);
|
|
||||||
}
|
|
||||||
} else if (p.status === 'error') {
|
} else if (p.status === 'error') {
|
||||||
throw new Error(p.error || 'Rematch failed');
|
throw new Error(p.error || 'Rematch failed');
|
||||||
} else if (p.status === 'cancelled') {
|
} else if (p.status === 'cancelled') {
|
||||||
@@ -488,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
||||||
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
||||||
));
|
));
|
||||||
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
|
// A cancelled run still reports partial results
|
||||||
|
// via the summary modal (marked as cancelled).
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'global',
|
||||||
|
cancelled: true,
|
||||||
|
total: p.total || 0,
|
||||||
|
matchedRecipes: cancelledRecipes,
|
||||||
|
matchedEntries: cancelledEntries,
|
||||||
|
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||||
|
unresolvedEntries: p.unresolved_entries ?? 0,
|
||||||
|
skipped: p.skipped || 0,
|
||||||
|
errors: p.errors || 0,
|
||||||
|
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
|
||||||
|
});
|
||||||
|
if (window.recipesPage) {
|
||||||
|
window.recipesPage.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (progressResponse.status === 404) {
|
} else if (progressResponse.status === 404) {
|
||||||
// Progress might have finished quickly and been cleaned up
|
// Progress might have finished quickly and been cleaned up
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
|||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
import { moveManager } from '../../managers/MoveManager.js';
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||||
|
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
||||||
|
|
||||||
export class RecipeContextMenu extends BaseContextMenu {
|
export class RecipeContextMenu extends BaseContextMenu {
|
||||||
@@ -326,15 +327,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const matchedEntries = result.matched_entries || result.rematched || 0;
|
const matchedEntries = result.matched_entries || result.rematched || 0;
|
||||||
const failures = result.errors || 0;
|
const failures = result.errors || 0;
|
||||||
|
const unresolvedEntries = result.unresolved_entries || 0;
|
||||||
|
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||||
|
// Complete no-op (nothing matched, nothing unresolved, no
|
||||||
|
// errors) keeps the lightweight toast; anything else opens
|
||||||
|
// the post-run summary modal.
|
||||||
|
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||||
|
|
||||||
if (matchedEntries > 0) {
|
if (matchedEntries > 0) {
|
||||||
const toastKey = failures > 0
|
|
||||||
? 'toast.recipes.rematchCompleteErrors'
|
|
||||||
: 'toast.recipes.rematchComplete';
|
|
||||||
showToast(
|
|
||||||
toastKey,
|
|
||||||
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
|
|
||||||
failures > 0 ? 'warning' : 'success'
|
|
||||||
);
|
|
||||||
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
if (detailResponse.ok) {
|
if (detailResponse.ok) {
|
||||||
const updatedRecipe = await detailResponse.json();
|
const updatedRecipe = await detailResponse.json();
|
||||||
@@ -342,21 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Filename-level (L4) matches are imprecise — always
|
|
||||||
// surface them for review/undo.
|
|
||||||
if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) {
|
|
||||||
rematchModalManager.showResultsModal(result.l4_matches);
|
|
||||||
}
|
}
|
||||||
} else if (result.unresolved_entries > 0) {
|
|
||||||
// Entries existed but have no local model — expected for
|
if (isNoop) {
|
||||||
// models deleted from Civitai; informational, not an error.
|
|
||||||
showToast(
|
|
||||||
'toast.recipes.rematchUnmatched',
|
|
||||||
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
||||||
|
} else {
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'single',
|
||||||
|
total: 1,
|
||||||
|
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
|
||||||
|
matchedEntries,
|
||||||
|
unresolvedRecipes: result.unresolved_recipes || 0,
|
||||||
|
unresolvedEntries,
|
||||||
|
skipped: result.skipped || 0,
|
||||||
|
errors: failures,
|
||||||
|
l4Matches,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Rematch failed');
|
throw new Error(result.error || 'Rematch failed');
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML entities in a string to prevent injection when interpolating
|
||||||
|
* into innerHTML (same approach as DownloadBatchSummaryModal).
|
||||||
|
* @param {string} str - The string to escape
|
||||||
|
* @returns {string} - The escaped string
|
||||||
|
*/
|
||||||
|
function _escapeHtml(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(str);
|
||||||
|
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the 3-state summary header (mirrors the batch download/import
|
||||||
|
* summary semantics).
|
||||||
|
*
|
||||||
|
* - error: nothing matched and at least one recipe errored
|
||||||
|
* - warning: errors, unresolved entries, filename-level (L4) matches to
|
||||||
|
* review, or a cancelled run
|
||||||
|
* - success: otherwise
|
||||||
|
*/
|
||||||
|
function _resolveHeader({ matchedEntries, errors, unresolvedEntries, l4Count, cancelled }) {
|
||||||
|
if (matchedEntries === 0 && errors > 0) {
|
||||||
|
return {
|
||||||
|
state: 'error',
|
||||||
|
icon: 'fa-times-circle',
|
||||||
|
text: translate('modals.rematchSummary.failed', {}, 'Rematch failed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (errors > 0 || unresolvedEntries > 0 || l4Count > 0 || cancelled) {
|
||||||
|
return {
|
||||||
|
state: 'warning',
|
||||||
|
icon: 'fa-exclamation-circle',
|
||||||
|
text: translate('modals.rematchSummary.completedWithWarnings', {}, 'Rematch completed — review recommended'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: 'success',
|
||||||
|
icon: 'fa-check-circle',
|
||||||
|
text: translate('modals.rematchSummary.successMessage', { entries: matchedEntries }, `Matched ${matchedEntries} entries`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a plain-text report of the rematch run. `undoneIndexes` carries the
|
||||||
|
* L4 rows undone so far, so the report reflects the undo status at copy time.
|
||||||
|
*/
|
||||||
|
function _buildReportText({ scope, cancelled, total, matchedRecipes, matchedEntries, unresolvedRecipes, unresolvedEntries, skipped, errors, l4Matches, undoneIndexes }) {
|
||||||
|
const scopeFallbacks = {
|
||||||
|
global: 'All recipes',
|
||||||
|
bulk: 'Selected recipes',
|
||||||
|
single: 'Single recipe',
|
||||||
|
};
|
||||||
|
const scopeLabel = translate(
|
||||||
|
`modals.rematchSummary.scope_${scope}`,
|
||||||
|
{},
|
||||||
|
scopeFallbacks[scope] || scope
|
||||||
|
);
|
||||||
|
const lines = [
|
||||||
|
'=== Recipe Rematch Report ===',
|
||||||
|
`Date: ${new Date().toLocaleString()}`,
|
||||||
|
`Scope: ${scopeLabel}`,
|
||||||
|
`Cancelled: ${cancelled ? 'yes' : 'no'}`,
|
||||||
|
`Total recipes: ${total}`,
|
||||||
|
`Matched recipes: ${matchedRecipes}`,
|
||||||
|
`Matched entries: ${matchedEntries}`,
|
||||||
|
`Needs review (filename matches): ${l4Matches.length}`,
|
||||||
|
`Unresolved entries: ${unresolvedEntries} (in ${unresolvedRecipes} recipes)`,
|
||||||
|
`Skipped: ${skipped}`,
|
||||||
|
`Errors: ${errors}`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
if (l4Matches.length > 0) {
|
||||||
|
lines.push('--- Filename matches (L4) ---');
|
||||||
|
l4Matches.forEach((match, i) => {
|
||||||
|
const undone = undoneIndexes.has(i) ? ' [undone]' : '';
|
||||||
|
lines.push(`${i + 1}. [${match.recipe_id}] ${match.entry} -> ${match.file_name}${undone}`);
|
||||||
|
});
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push('====================');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a successful clipboard write: confirm via toast and briefly swap the
|
||||||
|
* trigger button to a "Copied!" state (mirrors the batch summary modal).
|
||||||
|
*/
|
||||||
|
function _onCopyReportSuccess(btn) {
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
if (btn) {
|
||||||
|
const origHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for environments without the async Clipboard API (e.g. insecure
|
||||||
|
* contexts over LAN http): copy via a hidden textarea and execCommand.
|
||||||
|
*/
|
||||||
|
function _copyReportWithExecCommand(text) {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _copyReport(btn, reportArgs) {
|
||||||
|
const text = _buildReportText(reportArgs);
|
||||||
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(() => _onCopyReportSuccess(btn))
|
||||||
|
.catch(() => _copyReportWithExecCommand(text));
|
||||||
|
} else {
|
||||||
|
_copyReportWithExecCommand(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo a single L4 match via the existing restore endpoints (moved from
|
||||||
|
* RematchModalManager). Checkpoint restore needs only recipe_id; lora
|
||||||
|
* restore additionally takes lora_index.
|
||||||
|
*/
|
||||||
|
async function _undoMatch(match) {
|
||||||
|
const isCheckpoint = match.type === 'checkpoint';
|
||||||
|
const body = isCheckpoint
|
||||||
|
? { recipe_id: match.recipe_id }
|
||||||
|
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
|
||||||
|
const response = await fetch(
|
||||||
|
isCheckpoint
|
||||||
|
? '/api/lm/recipe/checkpoint/restore'
|
||||||
|
: '/api/lm/recipe/lora/restore',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || 'Restore failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the post-run rematch summary modal. Mirrors the batch download
|
||||||
|
* summary lifecycle: the modal element is appended directly to
|
||||||
|
* document.body and removed on close; it is not registered with
|
||||||
|
* ModalManager.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {'global'|'bulk'|'single'} options.scope - Which entry point ran
|
||||||
|
* @param {boolean} options.cancelled - Whether the run was cancelled
|
||||||
|
* @param {number} options.total - Recipes scanned
|
||||||
|
* @param {number} options.matchedRecipes - Recipes updated
|
||||||
|
* @param {number} options.matchedEntries - Entries reconnected
|
||||||
|
* @param {number} options.unresolvedRecipes - Recipes with unresolved entries
|
||||||
|
* @param {number} options.unresolvedEntries - Candidate entries with no local match
|
||||||
|
* @param {number} options.skipped - Recipes left untouched
|
||||||
|
* @param {number} options.errors - Per-recipe errors
|
||||||
|
* @param {Array} options.l4Matches - Filename-level matches for review/undo
|
||||||
|
* ({ recipe_id, type, entry, file_name, lora_index? })
|
||||||
|
*/
|
||||||
|
export function showRematchSummary({
|
||||||
|
scope = 'global',
|
||||||
|
cancelled = false,
|
||||||
|
total = 0,
|
||||||
|
matchedRecipes = 0,
|
||||||
|
matchedEntries = 0,
|
||||||
|
unresolvedRecipes = 0,
|
||||||
|
unresolvedEntries = 0,
|
||||||
|
skipped = 0,
|
||||||
|
errors = 0,
|
||||||
|
l4Matches = [],
|
||||||
|
} = {}) {
|
||||||
|
const matches = Array.isArray(l4Matches) ? l4Matches : [];
|
||||||
|
const undoneIndexes = new Set();
|
||||||
|
const header = _resolveHeader({
|
||||||
|
matchedEntries,
|
||||||
|
errors,
|
||||||
|
unresolvedEntries,
|
||||||
|
l4Count: matches.length,
|
||||||
|
cancelled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchRows = matches.map((match, i) => `
|
||||||
|
<tr data-l4-index="${i}">
|
||||||
|
<td class="failure-index">${i + 1}</td>
|
||||||
|
<td class="failure-name" title="${_escapeHtml(match.recipe_id)}">${_escapeHtml(match.recipe_id)}</td>
|
||||||
|
<td class="failure-name" title="${_escapeHtml(match.entry)}">${_escapeHtml(match.entry)}</td>
|
||||||
|
<td class="failure-name" title="${_escapeHtml(match.file_name)}">${_escapeHtml(match.file_name)}</td>
|
||||||
|
<td class="rematch-undo-cell">
|
||||||
|
<button class="secondary-btn rematch-undo-btn" data-action="undo-match" data-index="${i}">
|
||||||
|
${translate('modals.rematchResults.undo', {}, 'Undo')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div id="rematchSummaryModal" class="modal" style="display: block;">
|
||||||
|
<div class="modal-content rematch-summary-modal">
|
||||||
|
<button class="close" data-action="close-modal">×</button>
|
||||||
|
|
||||||
|
<h2>${translate('modals.rematchSummary.title', {}, 'Rematch Summary')}</h2>
|
||||||
|
|
||||||
|
<div class="summary-header ${header.state}">
|
||||||
|
<i class="fas ${header.icon}"></i>
|
||||||
|
<span class="summary-title">${header.text}</span>
|
||||||
|
<span class="summary-hint">${matchedRecipes}/${total}</span>
|
||||||
|
</div>
|
||||||
|
${cancelled ? `
|
||||||
|
<p class="rematch-cancelled-note">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
${translate('modals.rematchSummary.cancelledNote', {}, 'Run cancelled before completion — counts are partial.')}
|
||||||
|
</p>` : ''}
|
||||||
|
|
||||||
|
<div class="refresh-summary-stats">
|
||||||
|
<div class="stat-card stat-card-success">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.rematchSummary.statMatched', {}, 'Matched entries')}</span>
|
||||||
|
<span class="stat-card-value">${matchedEntries}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-skipped">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.rematchSummary.statReview', {}, 'Needs review')}</span>
|
||||||
|
<span class="stat-card-value">${matches.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-total">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.rematchSummary.statUnresolved', {}, 'Unresolved')}</span>
|
||||||
|
<span class="stat-card-value">${unresolvedEntries}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-failure">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.rematchSummary.statErrors', {}, 'Errors')}</span>
|
||||||
|
<span class="stat-card-value">${errors}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${matches.length > 0 ? `
|
||||||
|
<div class="refresh-failures-section rematch-review-section">
|
||||||
|
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.rematchSummary.reviewSection', { count: matches.length }, `Filename matches to review (${matches.length})`)}</h4>
|
||||||
|
<div class="failure-table-wrapper">
|
||||||
|
<table class="failure-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>${translate('modals.rematchSummary.columnRecipe', {}, 'Recipe')}</th>
|
||||||
|
<th>${translate('modals.rematchSummary.columnEntry', {}, 'Entry')}</th>
|
||||||
|
<th>${translate('modals.rematchSummary.columnFile', {}, 'Matched file')}</th>
|
||||||
|
<th>${translate('modals.rematchSummary.columnUndo', {}, 'Undo')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${matchRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.rematchSummary.copyReport', {}, 'Copy Report')}</button>
|
||||||
|
<button class="cancel-btn" data-action="close-modal">${translate('modals.rematchSummary.close', {}, 'Close')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const existing = document.getElementById('rematchSummaryModal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = modalHtml;
|
||||||
|
const modal = container.firstElementChild;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const reportArgs = {
|
||||||
|
scope,
|
||||||
|
cancelled,
|
||||||
|
total,
|
||||||
|
matchedRecipes,
|
||||||
|
matchedEntries,
|
||||||
|
unresolvedRecipes,
|
||||||
|
unresolvedEntries,
|
||||||
|
skipped,
|
||||||
|
errors,
|
||||||
|
l4Matches: matches,
|
||||||
|
undoneIndexes,
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.addEventListener('click', async (e) => {
|
||||||
|
const actionEl = e.target.closest('[data-action]');
|
||||||
|
const action = actionEl?.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'close-modal':
|
||||||
|
modal.remove();
|
||||||
|
break;
|
||||||
|
case 'copy-report':
|
||||||
|
_copyReport(actionEl, reportArgs);
|
||||||
|
break;
|
||||||
|
case 'undo-match': {
|
||||||
|
const index = Number(actionEl.dataset.index);
|
||||||
|
const match = matches[index];
|
||||||
|
if (!match || actionEl.disabled) break;
|
||||||
|
const row = modal.querySelector(`tr[data-l4-index="${index}"]`);
|
||||||
|
try {
|
||||||
|
await _undoMatch(match);
|
||||||
|
undoneIndexes.add(index);
|
||||||
|
row?.classList.add('undone');
|
||||||
|
actionEl.disabled = true;
|
||||||
|
actionEl.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to undo rematch match:', error);
|
||||||
|
showToast(
|
||||||
|
'modals.rematchResults.undoFailed',
|
||||||
|
{ message: error.message },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { handleUndoDelete } from '../utils/undoHelpers.js';
|
|||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { rematchModalManager } from './RematchModalManager.js';
|
import { rematchModalManager } from './RematchModalManager.js';
|
||||||
|
import { showRematchSummary } from '../components/RematchSummaryModal.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
@@ -1025,47 +1026,32 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedEntries > 0) {
|
// Complete no-op (nothing matched, nothing unresolved, no
|
||||||
const hasFailures = failures > 0;
|
// errors) keeps the lightweight toast; anything else opens
|
||||||
const toastKey = hasFailures
|
// the post-run summary modal.
|
||||||
? 'toast.recipes.rematchCompleteErrors'
|
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||||
: 'toast.recipes.rematchComplete';
|
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||||
showToast(
|
if (isNoop) {
|
||||||
toastKey,
|
|
||||||
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
|
|
||||||
hasFailures ? 'warning' : 'success'
|
|
||||||
);
|
|
||||||
} else if (failures > 0) {
|
|
||||||
// Nothing matched and at least one recipe errored —
|
|
||||||
// "no rematch needed" would be actively misleading here.
|
|
||||||
showToast(
|
|
||||||
'toast.recipes.rematchAllFailed',
|
|
||||||
{ total, failures },
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
} else if (unresolvedEntries > 0) {
|
|
||||||
// Entries existed but have no local model — expected for
|
|
||||||
// models deleted from Civitai; informational, not an error.
|
|
||||||
showToast(
|
|
||||||
'toast.recipes.rematchUnmatched',
|
|
||||||
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showToast(
|
showToast(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
{ total },
|
{ total },
|
||||||
'info'
|
'info'
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'bulk',
|
||||||
|
total,
|
||||||
|
matchedRecipes,
|
||||||
|
matchedEntries,
|
||||||
|
unresolvedRecipes,
|
||||||
|
unresolvedEntries,
|
||||||
|
skipped,
|
||||||
|
errors: failures,
|
||||||
|
l4Matches,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.bulkMode) this.toggleBulkMode();
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
|
|
||||||
// Filename-level (L4) matches are imprecise — always surface
|
|
||||||
// them for review/undo.
|
|
||||||
if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) {
|
|
||||||
rematchModalManager.showResultsModal(result.l4_matches);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Bulk rematch failed');
|
throw new Error(result.error || 'Bulk rematch failed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,19 +360,6 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register rematchResultsModal
|
|
||||||
const rematchResultsModal = document.getElementById('rematchResultsModal');
|
|
||||||
if (rematchResultsModal) {
|
|
||||||
this.registerModal('rematchResultsModal', {
|
|
||||||
element: rematchResultsModal,
|
|
||||||
onClose: () => {
|
|
||||||
this.getModal('rematchResultsModal').element.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
},
|
|
||||||
closeOnOutsideClick: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', this.boundHandleEscape);
|
document.addEventListener('keydown', this.boundHandleEscape);
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the two recipe-rematch modals:
|
* Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE
|
||||||
*
|
* a global/bulk/single rematch run; collects the "relaxed matching" opt-in
|
||||||
* - rematchOptionsModal — shown BEFORE a global/bulk/single rematch run;
|
* and only then invokes the run callback. Post-run reporting lives in
|
||||||
* collects the "relaxed matching" opt-in and only then invokes the run
|
* static/js/components/RematchSummaryModal.js.
|
||||||
* callback.
|
|
||||||
* - rematchResultsModal — shown AFTER a run that produced L4 (filename
|
|
||||||
* level) matches; lists them for review with a per-row Undo that calls
|
|
||||||
* the existing restore endpoints.
|
|
||||||
*/
|
*/
|
||||||
export class RematchModalManager {
|
export class RematchModalManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._optionsConfirmCallback = null;
|
this._optionsConfirmCallback = null;
|
||||||
this._resultsMatches = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,99 +67,6 @@ export class RematchModalManager {
|
|||||||
this._optionsConfirmCallback = null;
|
this._optionsConfirmCallback = null;
|
||||||
modalManager.closeModal('rematchOptionsModal');
|
modalManager.closeModal('rematchOptionsModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the results modal listing L4 (filename-level) matches.
|
|
||||||
*
|
|
||||||
* @param {Array<{recipe_id: string, type: string, entry: string, file_name: string, lora_index?: number}>} l4Matches
|
|
||||||
*/
|
|
||||||
showResultsModal(l4Matches) {
|
|
||||||
if (!Array.isArray(l4Matches) || l4Matches.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const list = document.getElementById('rematchResultsList');
|
|
||||||
if (!list) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._resultsMatches = l4Matches;
|
|
||||||
list.innerHTML = '';
|
|
||||||
|
|
||||||
l4Matches.forEach((match, index) => {
|
|
||||||
const row = document.createElement('li');
|
|
||||||
row.className = 'rematch-results-row';
|
|
||||||
|
|
||||||
const info = document.createElement('div');
|
|
||||||
info.className = 'rematch-results-info';
|
|
||||||
|
|
||||||
const entryName = document.createElement('span');
|
|
||||||
entryName.className = 'rematch-results-entry';
|
|
||||||
entryName.textContent = match.entry || '';
|
|
||||||
|
|
||||||
const matchedFile = document.createElement('span');
|
|
||||||
matchedFile.className = 'rematch-results-file';
|
|
||||||
matchedFile.textContent = `→ ${match.file_name || ''}`;
|
|
||||||
|
|
||||||
const recipeRef = document.createElement('span');
|
|
||||||
recipeRef.className = 'rematch-results-recipe';
|
|
||||||
recipeRef.textContent = match.recipe_id || '';
|
|
||||||
|
|
||||||
info.appendChild(entryName);
|
|
||||||
info.appendChild(matchedFile);
|
|
||||||
info.appendChild(recipeRef);
|
|
||||||
|
|
||||||
const undoButton = document.createElement('button');
|
|
||||||
undoButton.className = 'secondary-btn rematch-results-undo';
|
|
||||||
undoButton.textContent = translate('modals.rematchResults.undo', {}, 'Undo');
|
|
||||||
undoButton.addEventListener('click', () => this.undoMatch(index, row, undoButton));
|
|
||||||
|
|
||||||
row.appendChild(info);
|
|
||||||
row.appendChild(undoButton);
|
|
||||||
list.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
modalManager.showModal('rematchResultsModal');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo a single L4 match via the existing restore endpoints. On success
|
|
||||||
* the row is struck through and its button disabled.
|
|
||||||
*/
|
|
||||||
async undoMatch(index, row, button) {
|
|
||||||
const match = this._resultsMatches[index];
|
|
||||||
if (!match || button.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const isCheckpoint = match.type === 'checkpoint';
|
|
||||||
const body = isCheckpoint
|
|
||||||
? { recipe_id: match.recipe_id }
|
|
||||||
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
|
|
||||||
const response = await fetch(
|
|
||||||
isCheckpoint
|
|
||||||
? '/api/lm/recipe/checkpoint/restore'
|
|
||||||
: '/api/lm/recipe/lora/restore',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || !result.success) {
|
|
||||||
throw new Error(result.error || 'Restore failed');
|
|
||||||
}
|
|
||||||
row.classList.add('undone');
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to undo rematch match:', error);
|
|
||||||
showToast(
|
|
||||||
'modals.rematchResults.undoFailed',
|
|
||||||
{ message: error.message },
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rematchModalManager = new RematchModalManager();
|
export const rematchModalManager = new RematchModalManager();
|
||||||
|
|||||||
@@ -157,22 +157,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recipe Rematch L4 Results Modal -->
|
|
||||||
<div id="rematchResultsModal" class="modal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>{{ t('modals.rematchResults.title') }}</h2>
|
|
||||||
<span class="close" onclick="modalManager.closeModal('rematchResultsModal')">×</span>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p class="confirmation-message">{{ t('modals.rematchResults.message') }}</p>
|
|
||||||
<div class="rematch-results-preview">
|
|
||||||
<ul class="rematch-results-list" id="rematchResultsList"></ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="secondary-btn" onclick="modalManager.closeModal('rematchResultsModal')">{{ t('common.actions.close') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key, params, fallback) => {
|
||||||
|
if (typeof fallback === 'string') {
|
||||||
|
// Apply {param} interpolation so counts remain assertable.
|
||||||
|
return Object.entries(params || {}).reduce(
|
||||||
|
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function getShowRematchSummary() {
|
||||||
|
const { showRematchSummary } = await import(
|
||||||
|
'../../../static/js/components/RematchSummaryModal.js'
|
||||||
|
);
|
||||||
|
return showRematchSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const L4_LORA = { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 };
|
||||||
|
const L4_CHECKPOINT = { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' };
|
||||||
|
|
||||||
|
describe('RematchSummaryModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete global.fetch;
|
||||||
|
delete navigator.clipboard;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a success header when everything matched cleanly', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({ scope: 'global', total: 10, matchedRecipes: 2, matchedEntries: 3 });
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(modal).not.toBeNull();
|
||||||
|
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
|
||||||
|
expect(modal.querySelector('.summary-title').textContent).toBe('Matched 3 entries');
|
||||||
|
expect(modal.querySelector('.failure-table')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an error header when nothing matched and errors occurred', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({ scope: 'global', total: 3, errors: 3 });
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
||||||
|
expect(modal.querySelector('.summary-title').textContent).toBe('Rematch failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a warning header for unresolved entries, L4 matches, or cancellations', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 1, unresolvedEntries: 1, unresolvedRecipes: 1 });
|
||||||
|
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA] });
|
||||||
|
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'global', total: 5, matchedEntries: 2, cancelled: true });
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(modal.querySelector('.rematch-cancelled-note')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the four stat cards in order', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'bulk',
|
||||||
|
total: 4,
|
||||||
|
matchedRecipes: 1,
|
||||||
|
matchedEntries: 2,
|
||||||
|
unresolvedEntries: 3,
|
||||||
|
errors: 1,
|
||||||
|
l4Matches: [L4_LORA],
|
||||||
|
});
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
const values = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent);
|
||||||
|
expect(values).toEqual(['2', '1', '3', '1']);
|
||||||
|
const labels = Array.from(modal.querySelectorAll('.stat-card-label')).map(el => el.textContent);
|
||||||
|
expect(labels).toEqual(['Matched entries', 'Needs review', 'Unresolved', 'Errors']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the L4 review table only when matches exist', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA, L4_CHECKPOINT] });
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
const rows = modal.querySelectorAll('.failure-table tbody tr');
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].textContent).toContain('r1');
|
||||||
|
expect(rows[0].textContent).toContain('old.safetensors');
|
||||||
|
expect(rows[0].textContent).toContain('new.safetensors');
|
||||||
|
expect(rows[1].textContent).toContain('cp-new.safetensors');
|
||||||
|
expect(modal.querySelectorAll('.rematch-undo-btn')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo posts to the lora restore endpoint, then strikes and disables the row', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
const row = modal.querySelector('tr[data-l4-index="0"]');
|
||||||
|
const button = row.querySelector('.rematch-undo-btn');
|
||||||
|
button.click();
|
||||||
|
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }),
|
||||||
|
});
|
||||||
|
expect(row.classList.contains('undone')).toBe(true);
|
||||||
|
expect(button.textContent).toBe('Undone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_CHECKPOINT] });
|
||||||
|
|
||||||
|
const button = document.querySelector('.rematch-undo-btn');
|
||||||
|
button.click();
|
||||||
|
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ recipe_id: 'r2' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the row actionable and toasts when undo fails', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: false, error: 'no snapshot' }) });
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
|
||||||
|
|
||||||
|
const row = document.querySelector('tr[data-l4-index="0"]');
|
||||||
|
const button = row.querySelector('.rematch-undo-btn');
|
||||||
|
button.click();
|
||||||
|
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(button.disabled).toBe(false);
|
||||||
|
expect(row.classList.contains('undone')).toBe(false);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'modals.rematchResults.undoFailed',
|
||||||
|
{ message: 'no snapshot' },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy report includes scope, counts and the L4 list with undo status', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
navigator.clipboard = { writeText };
|
||||||
|
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'bulk',
|
||||||
|
total: 2,
|
||||||
|
matchedRecipes: 1,
|
||||||
|
matchedEntries: 2,
|
||||||
|
unresolvedEntries: 1,
|
||||||
|
unresolvedRecipes: 1,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
l4Matches: [L4_LORA, L4_CHECKPOINT],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undo the first row before copying so the report carries its status.
|
||||||
|
const undoButton = document.querySelector('tr[data-l4-index="0"] .rematch-undo-btn');
|
||||||
|
undoButton.click();
|
||||||
|
await vi.waitFor(() => expect(undoButton.disabled).toBe(true));
|
||||||
|
|
||||||
|
document.querySelector('[data-action="copy-report"]').click();
|
||||||
|
await vi.waitFor(() => expect(writeText).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const report = writeText.mock.calls[0][0];
|
||||||
|
expect(report).toContain('Scope: Selected recipes');
|
||||||
|
expect(report).toContain('Total recipes: 2');
|
||||||
|
expect(report).toContain('Matched entries: 2');
|
||||||
|
expect(report).toContain('Needs review (filename matches): 2');
|
||||||
|
expect(report).toContain('Unresolved entries: 1 (in 1 recipes)');
|
||||||
|
expect(report).toContain('[r1] old.safetensors -> new.safetensors [undone]');
|
||||||
|
expect(report).toContain('[r2] cp-old -> cp-new.safetensors');
|
||||||
|
// The success toast fires in the writeText .then() microtask.
|
||||||
|
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close removes the modal from the DOM', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({ scope: 'single', total: 1, matchedEntries: 1 });
|
||||||
|
|
||||||
|
expect(document.getElementById('rematchSummaryModal')).not.toBeNull();
|
||||||
|
document.querySelector('[data-action="close-modal"].cancel-btn').click();
|
||||||
|
expect(document.getElementById('rematchSummaryModal')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML in L4 row fields', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'bulk',
|
||||||
|
total: 1,
|
||||||
|
matchedEntries: 1,
|
||||||
|
l4Matches: [{ recipe_id: 'r<x>', type: 'lora', entry: '<img src=x>', file_name: 'f.safetensors', lora_index: 0 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(modal.querySelector('.failure-table img')).toBeNull();
|
||||||
|
expect(modal.querySelector('.failure-table tbody tr').textContent).toContain('<img src=x>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2233,7 +2233,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
|
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs global recipe rematch with polling and toasts the rematched count', async () => {
|
it('runs global recipe rematch with polling and opens the summary modal', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2298,13 +2298,22 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
|
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes.');
|
// A non-noop run opens the summary modal instead of toasting; the
|
||||||
// Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined
|
// progress overlay completes without a message.
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'globalContextMenu.rematchRecipes.success',
|
'globalContextMenu.rematchRecipes.success',
|
||||||
{ count: 2, recipes: 2, entries: 5, failures: 0 },
|
expect.anything(),
|
||||||
'success'
|
expect.anything()
|
||||||
);
|
);
|
||||||
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(summaryModal).not.toBeNull();
|
||||||
|
// unresolved_entries > 0 forces the warning header
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('5');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('0');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
|
||||||
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
|
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
|
||||||
expect(rematchItem.classList.contains('disabled')).toBe(false);
|
expect(rematchItem.classList.contains('disabled')).toBe(false);
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
@@ -2313,7 +2322,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the warning toast variant when a global rematch completes with failures', async () => {
|
it('opens the summary modal with a warning header when a global rematch completes with failures', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2359,19 +2368,18 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
}
|
}
|
||||||
await runPromise;
|
await runPromise;
|
||||||
|
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.');
|
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'globalContextMenu.rematchRecipes.successErrors',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ count: 2, recipes: 2, entries: 5, failures: 2 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'warning'
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
|
||||||
);
|
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
|
|
||||||
delete window.recipesPage;
|
delete window.recipesPage;
|
||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an error when every recipe in a global rematch failed', async () => {
|
it('opens the summary modal with an error header when every recipe in a global rematch failed', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2417,19 +2425,18 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
}
|
}
|
||||||
await runPromise;
|
await runPromise;
|
||||||
|
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.');
|
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'globalContextMenu.rematchRecipes.allFailed',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ total: 3, recipes: 0, entries: 0, failures: 3 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
||||||
'error'
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('3');
|
||||||
);
|
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
|
|
||||||
delete window.recipesPage;
|
delete window.recipesPage;
|
||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an info message when a global rematch found no local matches', async () => {
|
it('opens the summary modal listing unresolved entries when a global rematch found no local matches', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2475,19 +2482,18 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
}
|
}
|
||||||
await runPromise;
|
await runPromise;
|
||||||
|
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.');
|
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'globalContextMenu.rematchRecipes.noMatch',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ entries: 2, recipes: 1, total: 3, failures: 0 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'info'
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
|
||||||
);
|
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
|
|
||||||
delete window.recipesPage;
|
delete window.recipesPage;
|
||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts the rematched count when a global rematch is cancelled', async () => {
|
it('opens the summary modal marked as cancelled when a global rematch is cancelled', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2533,11 +2539,11 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
await runPromise;
|
await runPromise;
|
||||||
|
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
|
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'globalContextMenu.rematchRecipes.cancelled',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ count: 1, recipes: 1, entries: 2 },
|
expect(summaryModal.querySelector('.rematch-cancelled-note')).not.toBeNull();
|
||||||
'info'
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
);
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
|
|
||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
|
|||||||
@@ -60,13 +60,6 @@ async function cancelRematchOptions() {
|
|||||||
rematchModalManager.cancelOptions();
|
rematchModalManager.cancelOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRematchModalManager() {
|
|
||||||
const { rematchModalManager } = await import(
|
|
||||||
'../../../static/js/managers/RematchModalManager.js'
|
|
||||||
);
|
|
||||||
return rematchModalManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RecipeContextMenu.rematchRecipe', () => {
|
describe('RecipeContextMenu.rematchRecipe', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -92,8 +85,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
|
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
|
||||||
// mirror would fire the skipped toast here.
|
// mirror would render 0 matched entries in the summary modal here.
|
||||||
it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => {
|
it('posts to the per-recipe rematch endpoint and opens the summary modal', async () => {
|
||||||
const menu = await createMenu();
|
const menu = await createMenu();
|
||||||
const card = document.getElementById('card');
|
const card = document.getElementById('card');
|
||||||
menu.showMenu(100, 100, card);
|
menu.showMenu(100, 100, card);
|
||||||
@@ -125,16 +118,22 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ relaxed: false }),
|
body: JSON.stringify({ relaxed: false }),
|
||||||
});
|
});
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
// Non-noop runs open the summary modal instead of toasting.
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchComplete',
|
'toast.recipes.rematchComplete',
|
||||||
{ rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 },
|
expect.anything(),
|
||||||
'success'
|
expect.anything()
|
||||||
);
|
);
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(summaryModal).not.toBeNull();
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('success')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
|
||||||
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
|
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
|
||||||
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
|
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
|
||||||
id: 'recipe-1',
|
id: 'recipe-1',
|
||||||
@@ -142,7 +141,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an info message when the entries had no local match', async () => {
|
it('opens the summary modal when the entries had no local match', async () => {
|
||||||
const menu = await createMenu();
|
const menu = await createMenu();
|
||||||
const card = document.getElementById('card');
|
const card = document.getElementById('card');
|
||||||
menu.showMenu(100, 100, card);
|
menu.showMenu(100, 100, card);
|
||||||
@@ -160,11 +159,11 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
await confirmRematchOptions();
|
await confirmRematchOptions();
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'toast.recipes.rematchUnmatched',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ entries: 2, recipes: 1, total: 1 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'info'
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
|
||||||
);
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -303,7 +302,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
expect(global.fetch).not.toHaveBeenCalled();
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the results modal when the result carries l4_matches', async () => {
|
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
|
||||||
const menu = await createMenu();
|
const menu = await createMenu();
|
||||||
const card = document.getElementById('card');
|
const card = document.getElementById('card');
|
||||||
menu.showMenu(100, 100, card);
|
menu.showMenu(100, 100, card);
|
||||||
@@ -321,11 +320,6 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
|
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const rematchModalManager = await getRematchModalManager();
|
|
||||||
const showResultsSpy = vi
|
|
||||||
.spyOn(rematchModalManager, 'showResultsModal')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
|
|
||||||
document
|
document
|
||||||
.querySelector('[data-action="rematch"]')
|
.querySelector('[data-action="rematch"]')
|
||||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
@@ -334,7 +328,15 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
await confirmRematchOptions();
|
await confirmRematchOptions();
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
|
|
||||||
expect(showResultsSpy).toHaveBeenCalledWith(l4Matches);
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
showResultsSpy.mockRestore();
|
expect(summaryModal).not.toBeNull();
|
||||||
|
// L4 matches to review force the warning header
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
|
||||||
|
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].textContent).toContain('old.safetensors');
|
||||||
|
expect(rows[0].textContent).toContain('new.safetensors');
|
||||||
|
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
stateStub.currentPageType = 'recipes';
|
stateStub.currentPageType = 'recipes';
|
||||||
stateStub.bulkMode = false;
|
stateStub.bulkMode = false;
|
||||||
stateStub.selectedModels.clear();
|
stateStub.selectedModels.clear();
|
||||||
|
document.body.innerHTML = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createBulkManager() {
|
async function createBulkManager() {
|
||||||
@@ -107,9 +108,9 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
|
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind
|
// Oracle R4-F1 pin: the summary modal must branch on `matched_entries` — a
|
||||||
// `repaired` mirror would fire the skipped toast with count 0 here.
|
// blind `repaired` mirror would render 0 matched entries here.
|
||||||
it('toasts the rematched count when the bulk rematch succeeds', async () => {
|
it('opens the summary modal when the bulk rematch succeeds', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
stateStub.selectedModels.add('/recipes/b.webp');
|
stateStub.selectedModels.add('/recipes/b.webp');
|
||||||
@@ -140,23 +141,31 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
],
|
],
|
||||||
{ relaxed: false }
|
{ relaxed: false }
|
||||||
);
|
);
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
// Non-noop runs open the summary modal instead of toasting.
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchComplete',
|
'toast.recipes.rematchComplete',
|
||||||
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
|
expect.anything(),
|
||||||
'success'
|
expect.anything()
|
||||||
);
|
);
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(summaryModal).not.toBeNull();
|
||||||
|
// unresolved_entries > 0 forces the warning header
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('4');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
|
||||||
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe);
|
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe);
|
||||||
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
|
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
|
||||||
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
||||||
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
|
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the errors toast variant when the bulk rematch has failures', async () => {
|
it('opens the summary modal with a warning header when the bulk rematch has failures', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
stateStub.selectedModels.add('/recipes/b.webp');
|
stateStub.selectedModels.add('/recipes/b.webp');
|
||||||
@@ -177,14 +186,14 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
await confirmRematchOptions();
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'toast.recipes.rematchCompleteErrors',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'warning'
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('3');
|
||||||
);
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an error when every selected recipe failed to rematch', async () => {
|
it('opens the summary modal with an error header when every selected recipe failed to rematch', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
stateStub.selectedModels.add('/recipes/b.webp');
|
stateStub.selectedModels.add('/recipes/b.webp');
|
||||||
@@ -205,11 +214,10 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
await confirmRematchOptions();
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'toast.recipes.rematchAllFailed',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ total: 2, failures: 2 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
||||||
'error'
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
|
||||||
);
|
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -217,7 +225,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an info message when entries had no local match', async () => {
|
it('opens the summary modal when entries had no local match', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
stateStub.selectedModels.add('/recipes/b.webp');
|
stateStub.selectedModels.add('/recipes/b.webp');
|
||||||
@@ -239,11 +247,11 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
await confirmRematchOptions();
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'toast.recipes.rematchUnmatched',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ entries: 2, recipes: 1, total: 3 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'info'
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
|
||||||
);
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -409,7 +417,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the L4 results modal when the bulk result carries l4_matches', async () => {
|
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
|
|
||||||
@@ -428,17 +436,18 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
l4_matches: l4Matches,
|
l4_matches: l4Matches,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { rematchModalManager } = await import(
|
|
||||||
'../../../static/js/managers/RematchModalManager.js'
|
|
||||||
);
|
|
||||||
const showResultsSpy = vi
|
|
||||||
.spyOn(rematchModalManager, 'showResultsModal')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
|
|
||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
await confirmRematchOptions();
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showResultsSpy).toHaveBeenCalledWith(l4Matches);
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
showResultsSpy.mockRestore();
|
expect(summaryModal).not.toBeNull();
|
||||||
|
// L4 matches to review force the warning header
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
|
||||||
|
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].textContent).toContain('old.safetensors');
|
||||||
|
expect(rows[0].textContent).toContain('new.safetensors');
|
||||||
|
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,111 +116,3 @@ describe('RematchModalManager options dialog', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RematchModalManager results modal', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
document.body.innerHTML = '<ul id="rematchResultsList"></ul>';
|
|
||||||
global.fetch = vi.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
document.body.innerHTML = '';
|
|
||||||
delete global.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing for an empty match list', async () => {
|
|
||||||
const manager = await getManager();
|
|
||||||
|
|
||||||
manager.showResultsModal([]);
|
|
||||||
|
|
||||||
expect(modalManagerMock.showModal).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders one row per L4 match with entry, file name and recipe', async () => {
|
|
||||||
const manager = await getManager();
|
|
||||||
|
|
||||||
manager.showResultsModal([
|
|
||||||
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 },
|
|
||||||
{ recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rows = document.querySelectorAll('#rematchResultsList .rematch-results-row');
|
|
||||||
expect(rows).toHaveLength(2);
|
|
||||||
expect(rows[0].textContent).toContain('old.safetensors');
|
|
||||||
expect(rows[0].textContent).toContain('new.safetensors');
|
|
||||||
expect(rows[0].textContent).toContain('r1');
|
|
||||||
expect(rows[1].textContent).toContain('cp-new.safetensors');
|
|
||||||
expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchResultsModal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('undo posts to the lora restore endpoint and disables the row', async () => {
|
|
||||||
const manager = await getManager();
|
|
||||||
global.fetch.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.showResultsModal([
|
|
||||||
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const row = document.querySelector('.rematch-results-row');
|
|
||||||
const button = row.querySelector('.rematch-results-undo');
|
|
||||||
button.click();
|
|
||||||
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }),
|
|
||||||
});
|
|
||||||
expect(row.classList.contains('undone')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => {
|
|
||||||
const manager = await getManager();
|
|
||||||
global.fetch.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.showResultsModal([
|
|
||||||
{ recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const button = document.querySelector('.rematch-results-undo');
|
|
||||||
button.click();
|
|
||||||
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ recipe_id: 'r2' }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the row actionable and toasts when undo fails', async () => {
|
|
||||||
const manager = await getManager();
|
|
||||||
global.fetch.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: false, error: 'no snapshot' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.showResultsModal([
|
|
||||||
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const row = document.querySelector('.rematch-results-row');
|
|
||||||
const button = row.querySelector('.rematch-results-undo');
|
|
||||||
button.click();
|
|
||||||
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
|
|
||||||
|
|
||||||
expect(button.disabled).toBe(false);
|
|
||||||
expect(row.classList.contains('undone')).toBe(false);
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
|
||||||
'modals.rematchResults.undoFailed',
|
|
||||||
{ message: 'no snapshot' },
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user