diff --git a/locales/de.json b/locales/de.json index 873d2284..03e74c70 100644 --- a/locales/de.json +++ b/locales/de.json @@ -188,7 +188,8 @@ "creator": "Ersteller", "title": "Rezept-Titel", "loraName": "LoRA-Dateiname", - "loraModel": "LoRA-Modellname" + "loraModel": "LoRA-Modellname", + "prompt": "Prompt" } }, "filter": { @@ -588,10 +589,26 @@ "selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus" } }, + "sort": { + "title": "Rezepte sortieren nach...", + "name": "Name", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Datum", + "dateDesc": "Neueste", + "dateAsc": "Älteste", + "lorasCount": "LoRA-Anzahl", + "lorasCountDesc": "Meiste", + "lorasCountAsc": "Wenigste" + }, "refresh": { "title": "Rezeptliste aktualisieren" }, - "filteredByLora": "Gefiltert nach LoRA" + "filteredByLora": "Gefiltert nach LoRA", + "favorites": { + "title": "Nur Favoriten anzeigen", + "action": "Favoriten" + } }, "duplicates": { "found": "{count} Duplikat-Gruppen gefunden", @@ -638,7 +655,8 @@ "recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar", "collapseAllDisabled": "Im Listenmodus nicht verfügbar", "dragDrop": { - "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden." + "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}", "bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben", "exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!", - "exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}" + "exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index df25d8e9..13bd170f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -32,7 +32,7 @@ "korean": "한국어", "french": "Français", "spanish": "Español", - "Hebrew": "עברית" + "Hebrew": "עברית" }, "fileSize": { "zero": "0 Bytes", @@ -188,7 +188,8 @@ "creator": "Creator", "title": "Recipe Title", "loraName": "LoRA Filename", - "loraModel": "LoRA Model Name" + "loraModel": "LoRA Model Name", + "prompt": "Prompt" } }, "filter": { @@ -336,7 +337,7 @@ "templateOptions": { "flatStructure": "Flat Structure", "byBaseModel": "By Base Model", - "byAuthor": "By Author", + "byAuthor": "By Author", "byFirstTag": "By First Tag", "baseModelFirstTag": "Base Model + First Tag", "baseModelAuthor": "Base Model + Author", @@ -347,7 +348,7 @@ "customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})", "modelTypes": { "lora": "LoRA", - "checkpoint": "Checkpoint", + "checkpoint": "Checkpoint", "embedding": "Embedding" }, "baseModelPathMappings": "Base Model Path Mappings", @@ -420,11 +421,11 @@ "proxyHost": "Proxy Host", "proxyHostPlaceholder": "proxy.example.com", "proxyHostHelp": "The hostname or IP address of your proxy server", - "proxyPort": "Proxy Port", + "proxyPort": "Proxy Port", "proxyPortPlaceholder": "8080", "proxyPortHelp": "The port number of your proxy server", "proxyUsername": "Username (Optional)", - "proxyUsernamePlaceholder": "username", + "proxyUsernamePlaceholder": "username", "proxyUsernameHelp": "Username for proxy authentication (if required)", "proxyPassword": "Password (Optional)", "proxyPasswordPlaceholder": "password", @@ -588,10 +589,26 @@ "selectLoraRoot": "Please select a LoRA root directory" } }, + "sort": { + "title": "Sort recipes by...", + "name": "Name", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Date", + "dateDesc": "Newest", + "dateAsc": "Oldest", + "lorasCount": "LoRA Count", + "lorasCountDesc": "Most", + "lorasCountAsc": "Least" + }, "refresh": { "title": "Refresh recipe list" }, - "filteredByLora": "Filtered by LoRA" + "filteredByLora": "Filtered by LoRA", + "favorites": { + "title": "Show Favorites Only", + "action": "Favorites" + } }, "duplicates": { "found": "Found {count} duplicate groups", @@ -638,7 +655,8 @@ "recursiveUnavailable": "Recursive search is available in tree view only", "collapseAllDisabled": "Not available in list view", "dragDrop": { - "unableToResolveRoot": "Unable to determine destination path for move." + "unableToResolveRoot": "Unable to determine destination path for move.", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "Failed moves:\n{failures}", "bulkMoveSuccess": "Successfully moved {successCount} {type}s", "exampleImagesDownloadSuccess": "Successfully downloaded example images!", - "exampleImagesDownloadFailed": "Failed to download example images: {message}" + "exampleImagesDownloadFailed": "Failed to download example images: {message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/es.json b/locales/es.json index ff0e9e16..f5da4abf 100644 --- a/locales/es.json +++ b/locales/es.json @@ -188,7 +188,8 @@ "creator": "Creador", "title": "Título de la receta", "loraName": "Nombre de archivo LoRA", - "loraModel": "Nombre del modelo LoRA" + "loraModel": "Nombre del modelo LoRA", + "prompt": "Prompt" } }, "filter": { @@ -588,10 +589,26 @@ "selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA" } }, + "sort": { + "title": "Ordenar recetas por...", + "name": "Nombre", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Fecha", + "dateDesc": "Más reciente", + "dateAsc": "Más antiguo", + "lorasCount": "Cant. de LoRAs", + "lorasCountDesc": "Más", + "lorasCountAsc": "Menos" + }, "refresh": { "title": "Actualizar lista de recetas" }, - "filteredByLora": "Filtrado por LoRA" + "filteredByLora": "Filtrado por LoRA", + "favorites": { + "title": "Mostrar solo favoritos", + "action": "Favoritos" + } }, "duplicates": { "found": "Se encontraron {count} grupos de duplicados", @@ -638,7 +655,8 @@ "recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol", "collapseAllDisabled": "No disponible en vista de lista", "dragDrop": { - "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento." + "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "Movimientos fallidos:\n{failures}", "bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s", "exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!", - "exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}" + "exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/fr.json b/locales/fr.json index d7c82004..54cac2d3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -188,7 +188,8 @@ "creator": "Créateur", "title": "Titre de la recipe", "loraName": "Nom de fichier LoRA", - "loraModel": "Nom du modèle LoRA" + "loraModel": "Nom du modèle LoRA", + "prompt": "Prompt" } }, "filter": { @@ -588,10 +589,26 @@ "selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA" } }, + "sort": { + "title": "Trier les recettes par...", + "name": "Nom", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "Date", + "dateDesc": "Plus récent", + "dateAsc": "Plus ancien", + "lorasCount": "Nombre de LoRAs", + "lorasCountDesc": "Plus", + "lorasCountAsc": "Moins" + }, "refresh": { "title": "Actualiser la liste des recipes" }, - "filteredByLora": "Filtré par LoRA" + "filteredByLora": "Filtré par LoRA", + "favorites": { + "title": "Afficher uniquement les favoris", + "action": "Favoris" + } }, "duplicates": { "found": "Trouvé {count} groupes de doublons", @@ -638,7 +655,8 @@ "recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente", "collapseAllDisabled": "Non disponible en vue liste", "dragDrop": { - "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement." + "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "Échecs de déplacement :\n{failures}", "bulkMoveSuccess": "{successCount} {type}s déplacés avec succès", "exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !", - "exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}" + "exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/he.json b/locales/he.json index 4afa4aa4..41402dfc 100644 --- a/locales/he.json +++ b/locales/he.json @@ -188,7 +188,8 @@ "creator": "יוצר", "title": "כותרת מתכון", "loraName": "שם קובץ LoRA", - "loraModel": "שם מודל LoRA" + "loraModel": "שם מודל LoRA", + "prompt": "הנחיה" } }, "filter": { @@ -228,6 +229,7 @@ "videoSettings": "הגדרות וידאו", "layoutSettings": "הגדרות פריסה", "folderSettings": "הגדרות תיקייה", + "priorityTags": "תגיות עדיפות", "downloadPathTemplates": "תבניות נתיב הורדה", "exampleImages": "תמונות דוגמה", "updateFlags": "תגי עדכון", @@ -235,8 +237,7 @@ "misc": "שונות", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", "storageLocation": "מיקום ההגדרות", - "proxySettings": "הגדרות פרוקסי", - "priorityTags": "תגיות עדיפות" + "proxySettings": "הגדרות פרוקסי" }, "storage": { "locationLabel": "מצב נייד", @@ -309,6 +310,26 @@ "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", "noDefault": "אין ברירת מחדל" }, + "priorityTags": { + "title": "תגיות עדיפות", + "description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "saveSuccess": "תגיות העדיפות עודכנו.", + "saveError": "עדכון תגיות העדיפות נכשל.", + "loadingSuggestions": "טוען הצעות...", + "validation": { + "missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.", + "missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.", + "duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.", + "unknown": "תצורת תגיות העדיפות שגויה." + } + }, "downloadPathTemplates": { "title": "תבניות נתיב הורדה", "help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.", @@ -320,8 +341,8 @@ "byFirstTag": "לפי תגית ראשונה", "baseModelFirstTag": "מודל בסיס + תגית ראשונה", "baseModelAuthor": "מודל בסיס + יוצר", - "baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה", "authorFirstTag": "יוצר + תגית ראשונה", + "baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה", "customTemplate": "תבנית מותאמת אישית" }, "customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})", @@ -409,26 +430,6 @@ "proxyPassword": "סיסמה (אופציונלי)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)" - }, - "priorityTags": { - "title": "תגיות עדיפות", - "description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "saveSuccess": "תגיות העדיפות עודכנו.", - "saveError": "עדכון תגיות העדיפות נכשל.", - "loadingSuggestions": "טוען הצעות...", - "validation": { - "missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.", - "missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.", - "duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.", - "unknown": "תצורת תגיות העדיפות שגויה." - } } }, "loras": { @@ -588,10 +589,26 @@ "selectLoraRoot": "אנא בחר ספריית שורש של LoRA" } }, + "sort": { + "title": "מיון מתכונים לפי...", + "name": "שם", + "nameAsc": "א - ת", + "nameDesc": "ת - א", + "date": "תאריך", + "dateDesc": "הכי חדש", + "dateAsc": "הכי ישן", + "lorasCount": "מספר LoRAs", + "lorasCountDesc": "הכי הרבה", + "lorasCountAsc": "הכי פחות" + }, "refresh": { "title": "רענן רשימת מתכונים" }, - "filteredByLora": "מסונן לפי LoRA" + "filteredByLora": "מסונן לפי LoRA", + "favorites": { + "title": "הצג מועדפים בלבד", + "action": "מועדפים" + } }, "duplicates": { "found": "נמצאו {count} קבוצות כפולות", @@ -638,7 +655,8 @@ "recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", "collapseAllDisabled": "לא זמין בתצוגת רשימה", "dragDrop": { - "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה." + "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "העברות שנכשלו:\n{failures}", "bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s", "exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!", - "exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}" + "exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/ja.json b/locales/ja.json index 7b83ec8f..8fcd070d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -188,7 +188,8 @@ "creator": "作成者", "title": "レシピタイトル", "loraName": "LoRAファイル名", - "loraModel": "LoRAモデル名" + "loraModel": "LoRAモデル名", + "prompt": "プロンプト" } }, "filter": { @@ -588,10 +589,26 @@ "selectLoraRoot": "LoRAルートディレクトリを選択してください" } }, + "sort": { + "title": "レシピの並び替え...", + "name": "名前", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "日付", + "dateDesc": "新しい順", + "dateAsc": "古い順", + "lorasCount": "LoRA数", + "lorasCountDesc": "多い順", + "lorasCountAsc": "少ない順" + }, "refresh": { "title": "レシピリストを更新" }, - "filteredByLora": "LoRAでフィルタ済み" + "filteredByLora": "LoRAでフィルタ済み", + "favorites": { + "title": "お気に入りのみ表示", + "action": "お気に入り" + } }, "duplicates": { "found": "{count} 個の重複グループが見つかりました", @@ -638,7 +655,8 @@ "recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", "collapseAllDisabled": "リストビューでは利用できません", "dragDrop": { - "unableToResolveRoot": "移動先のパスを特定できません。" + "unableToResolveRoot": "移動先のパスを特定できません。", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "失敗した移動:\n{failures}", "bulkMoveSuccess": "{successCount} {type}が正常に移動されました", "exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!", - "exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}" + "exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/ko.json b/locales/ko.json index 9750f070..9cdd36fb 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -188,7 +188,8 @@ "creator": "제작자", "title": "레시피 제목", "loraName": "LoRA 파일명", - "loraModel": "LoRA 모델명" + "loraModel": "LoRA 모델명", + "prompt": "프롬프트" } }, "filter": { @@ -588,10 +589,26 @@ "selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요" } }, + "sort": { + "title": "레시피 정렬...", + "name": "이름", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "날짜", + "dateDesc": "최신순", + "dateAsc": "오래된순", + "lorasCount": "LoRA 수", + "lorasCountDesc": "많은순", + "lorasCountAsc": "적은순" + }, "refresh": { "title": "레시피 목록 새로고침" }, - "filteredByLora": "LoRA로 필터링됨" + "filteredByLora": "LoRA로 필터링됨", + "favorites": { + "title": "즐겨찾기만 표시", + "action": "즐겨찾기" + } }, "duplicates": { "found": "{count}개의 중복 그룹 발견", @@ -638,7 +655,8 @@ "recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "dragDrop": { - "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다." + "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "실패한 이동:\n{failures}", "bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다", "exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!", - "exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}" + "exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json index 9c22651a..58e7ef0e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -188,7 +188,8 @@ "creator": "Автор", "title": "Название рецепта", "loraName": "Имя файла LoRA", - "loraModel": "Название модели LoRA" + "loraModel": "Название модели LoRA", + "prompt": "Запрос" } }, "filter": { @@ -588,10 +589,26 @@ "selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA" } }, + "sort": { + "title": "Сортировка рецептов...", + "name": "Имя", + "nameAsc": "А - Я", + "nameDesc": "Я - А", + "date": "Дата", + "dateDesc": "Сначала новые", + "dateAsc": "Сначала старые", + "lorasCount": "Кол-во LoRA", + "lorasCountDesc": "Больше всего", + "lorasCountAsc": "Меньше всего" + }, "refresh": { "title": "Обновить список рецептов" }, - "filteredByLora": "Фильтр по LoRA" + "filteredByLora": "Фильтр по LoRA", + "favorites": { + "title": "Только избранные", + "action": "Избранное" + } }, "duplicates": { "found": "Найдено {count} групп дубликатов", @@ -638,7 +655,8 @@ "recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", "collapseAllDisabled": "Недоступно в виде списка", "dragDrop": { - "unableToResolveRoot": "Не удалось определить путь назначения для перемещения." + "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "Неудачные перемещения:\n{failures}", "bulkMoveSuccess": "Успешно перемещено {successCount} {type}s", "exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!", - "exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}" + "exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/locales/zh-CN.json b/locales/zh-CN.json index df02db1d..11f5c226 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -188,7 +188,8 @@ "creator": "创作者", "title": "配方标题", "loraName": "LoRA 文件名", - "loraModel": "LoRA 模型名称" + "loraModel": "LoRA 模型名称", + "prompt": "提示词" } }, "filter": { @@ -588,10 +589,26 @@ "selectLoraRoot": "请选择 LoRA 根目录" } }, + "sort": { + "title": "配方排序...", + "name": "名称", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "时间", + "dateDesc": "最新", + "dateAsc": "最早", + "lorasCount": "LoRA 数量", + "lorasCountDesc": "最多", + "lorasCountAsc": "最少" + }, "refresh": { "title": "刷新配方列表" }, - "filteredByLora": "按 LoRA 筛选" + "filteredByLora": "按 LoRA 筛选", + "favorites": { + "title": "仅显示收藏", + "action": "收藏" + } }, "duplicates": { "found": "发现 {count} 个重复组", @@ -638,7 +655,8 @@ "recursiveUnavailable": "仅在树形视图中可使用递归搜索", "collapseAllDisabled": "列表视图下不可用", "dragDrop": { - "unableToResolveRoot": "无法确定移动的目标路径。" + "unableToResolveRoot": "无法确定移动的目标路径。", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "移动失败:\n{failures}", "bulkMoveSuccess": "成功移动 {successCount} 个 {type}", "exampleImagesDownloadSuccess": "示例图片下载成功!", - "exampleImagesDownloadFailed": "示例图片下载失败:{message}" + "exampleImagesDownloadFailed": "示例图片下载失败:{message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "浏览器插件教程" } } -} +} \ No newline at end of file diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 0d5a8dae..de95602f 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -188,7 +188,8 @@ "creator": "創作者", "title": "配方標題", "loraName": "LoRA 檔案名稱", - "loraModel": "LoRA 模型名稱" + "loraModel": "LoRA 模型名稱", + "prompt": "提示詞" } }, "filter": { @@ -588,10 +589,26 @@ "selectLoraRoot": "請選擇 LoRA 根目錄" } }, + "sort": { + "title": "配方排序...", + "name": "名稱", + "nameAsc": "A - Z", + "nameDesc": "Z - A", + "date": "時間", + "dateDesc": "最新", + "dateAsc": "最舊", + "lorasCount": "LoRA 數量", + "lorasCountDesc": "最多", + "lorasCountAsc": "最少" + }, "refresh": { "title": "重新整理配方列表" }, - "filteredByLora": "已依 LoRA 篩選" + "filteredByLora": "已依 LoRA 篩選", + "favorites": { + "title": "僅顯示收藏", + "action": "收藏" + } }, "duplicates": { "found": "發現 {count} 組重複項", @@ -638,7 +655,8 @@ "recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", "collapseAllDisabled": "列表檢視下不可用", "dragDrop": { - "unableToResolveRoot": "無法確定移動的目標路徑。" + "unableToResolveRoot": "無法確定移動的目標路徑。", + "moveUnsupported": "Move is not supported for this item." } }, "statistics": { @@ -1460,7 +1478,8 @@ "bulkMoveFailures": "移動失敗:\n{failures}", "bulkMoveSuccess": "已成功移動 {successCount} 個 {type}", "exampleImagesDownloadSuccess": "範例圖片下載成功!", - "exampleImagesDownloadFailed": "下載範例圖片失敗:{message}" + "exampleImagesDownloadFailed": "下載範例圖片失敗:{message}", + "moveFailed": "Failed to move item: {message}" } }, "banners": { @@ -1478,4 +1497,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} +} \ No newline at end of file diff --git a/py/recipes/merger.py b/py/recipes/merger.py new file mode 100644 index 00000000..1ddd3268 --- /dev/null +++ b/py/recipes/merger.py @@ -0,0 +1,50 @@ +from typing import Any, Dict, Optional +import logging + +logger = logging.getLogger(__name__) + +class GenParamsMerger: + """Utility to merge generation parameters from multiple sources with priority.""" + + BLACKLISTED_KEYS = {"id", "url", "userId", "username", "createdAt", "updatedAt", "hash"} + + @staticmethod + def merge( + request_params: Optional[Dict[str, Any]] = None, + civitai_meta: Optional[Dict[str, Any]] = None, + embedded_metadata: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Merge generation parameters from three sources. + + Priority: request_params > civitai_meta > embedded_metadata + + Args: + request_params: Params provided directly in the import request + civitai_meta: Params from Civitai Image API 'meta' field + embedded_metadata: Params extracted from image EXIF/embedded metadata + + Returns: + Merged parameters dictionary + """ + result = {} + + # 1. Start with embedded metadata (lowest priority) + if embedded_metadata: + # If it's a full recipe metadata, we use its gen_params + if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict): + result.update(embedded_metadata["gen_params"]) + else: + # Otherwise assume the dict itself contains gen_params + result.update(embedded_metadata) + + # 2. Layer Civitai meta (medium priority) + if civitai_meta: + result.update(civitai_meta) + + # 3. Layer request params (highest priority) + if request_params: + result.update(request_params) + + # Filter out blacklisted keys + return {k: v for k, v in result.items() if k not in GenParamsMerger.BLACKLISTED_KEYS} diff --git a/py/recipes/parsers/comfy.py b/py/recipes/parsers/comfy.py index f81a15ad..e1d7251e 100644 --- a/py/recipes/parsers/comfy.py +++ b/py/recipes/parsers/comfy.py @@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser): # Find all LoraLoader nodes lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'} - if not lora_nodes: - return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []} - # Process each LoraLoader node for node_id, node in lora_nodes.items(): if 'inputs' not in node or 'lora_name' not in node['inputs']: diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 35469eaf..c18155dc 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -24,6 +24,8 @@ from ...services.recipes import ( ) from ...services.metadata_service import get_default_metadata_provider from ...utils.civitai_utils import rewrite_preview_url +from ...utils.exif_utils import ExifUtils +from ...recipes.merger import GenParamsMerger Logger = logging.Logger EnsureDependenciesCallable = Callable[[], Awaitable[None]] @@ -56,16 +58,22 @@ class RecipeHandlerSet: "delete_recipe": self.management.delete_recipe, "get_top_tags": self.query.get_top_tags, "get_base_models": self.query.get_base_models, + "get_roots": self.query.get_roots, + "get_folders": self.query.get_folders, + "get_folder_tree": self.query.get_folder_tree, + "get_unified_folder_tree": self.query.get_unified_folder_tree, "share_recipe": self.sharing.share_recipe, "download_shared_recipe": self.sharing.download_shared_recipe, "get_recipe_syntax": self.query.get_recipe_syntax, "update_recipe": self.management.update_recipe, "reconnect_lora": self.management.reconnect_lora, "find_duplicates": self.query.find_duplicates, + "move_recipes_bulk": self.management.move_recipes_bulk, "bulk_delete": self.management.bulk_delete, "save_recipe_from_widget": self.management.save_recipe_from_widget, "get_recipes_for_lora": self.query.get_recipes_for_lora, "scan_recipes": self.query.scan_recipes, + "move_recipe": self.management.move_recipe, } @@ -149,12 +157,15 @@ class RecipeListingHandler: page_size = int(request.query.get("page_size", "20")) sort_by = request.query.get("sort_by", "date") search = request.query.get("search") + folder = request.query.get("folder") + recursive = request.query.get("recursive", "true").lower() == "true" search_options = { "title": request.query.get("search_title", "true").lower() == "true", "tags": request.query.get("search_tags", "true").lower() == "true", "lora_name": request.query.get("search_lora_name", "true").lower() == "true", "lora_model": request.query.get("search_lora_model", "true").lower() == "true", + "prompt": request.query.get("search_prompt", "true").lower() == "true", } filters: Dict[str, Any] = {} @@ -162,6 +173,9 @@ class RecipeListingHandler: if base_models: filters["base_model"] = base_models.split(",") + if request.query.get("favorite", "false").lower() == "true": + filters["favorite"] = True + tag_filters: Dict[str, str] = {} legacy_tags = request.query.get("tags") if legacy_tags: @@ -193,6 +207,8 @@ class RecipeListingHandler: filters=filters, search_options=search_options, lora_hash=lora_hash, + folder=folder, + recursive=recursive, ) for item in result.get("items", []): @@ -299,6 +315,58 @@ class RecipeQueryHandler: self._logger.error("Error retrieving base models: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def get_roots(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else [] + return web.json_response({"success": True, "roots": roots}) + except Exception as exc: + self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def get_folders(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + folders = await recipe_scanner.get_folders() + return web.json_response({"success": True, "folders": folders}) + except Exception as exc: + self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def get_folder_tree(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + folder_tree = await recipe_scanner.get_folder_tree() + return web.json_response({"success": True, "tree": folder_tree}) + except Exception as exc: + self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def get_unified_folder_tree(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + folder_tree = await recipe_scanner.get_folder_tree() + return web.json_response({"success": True, "tree": folder_tree}) + except Exception as exc: + self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def get_recipes_for_lora(self, request: web.Request) -> web.Response: try: await self._ensure_dependencies_ready() @@ -486,7 +554,41 @@ class RecipeManagementHandler: metadata["base_model"] = base_model_from_metadata tags = self._parse_tags(params.get("tags")) - image_bytes, extension = await self._download_remote_media(image_url) + image_bytes, extension, civitai_meta = await self._download_remote_media(image_url) + + # Extract embedded metadata from the downloaded image + embedded_metadata = None + try: + with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img: + temp_img.write(image_bytes) + temp_img_path = temp_img.name + + try: + raw_embedded = ExifUtils.extract_image_metadata(temp_img_path) + if raw_embedded: + # Try to parse it using standard parsers if it looks like a recipe + parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded) + if parser: + parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner) + embedded_metadata = parsed_embedded + else: + # Fallback to raw string if no parser matches (might be simple params) + embedded_metadata = {"gen_params": {"raw_metadata": raw_embedded}} + finally: + if os.path.exists(temp_img_path): + os.unlink(temp_img_path) + except Exception as exc: + self._logger.warning("Failed to extract embedded metadata during import: %s", exc) + + # Merge gen_params from all sources + merged_gen_params = GenParamsMerger.merge( + request_params=gen_params, + civitai_meta=civitai_meta, + embedded_metadata=embedded_metadata + ) + + if merged_gen_params: + metadata["gen_params"] = merged_gen_params result = await self._persistence_service.save_recipe( recipe_scanner=recipe_scanner, @@ -545,6 +647,64 @@ class RecipeManagementHandler: self._logger.error("Error updating recipe: %s", exc, exc_info=True) return web.json_response({"error": str(exc)}, status=500) + async def move_recipe(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + data = await request.json() + recipe_id = data.get("recipe_id") + target_path = data.get("target_path") + if not recipe_id or not target_path: + return web.json_response( + {"success": False, "error": "recipe_id and target_path are required"}, status=400 + ) + + result = await self._persistence_service.move_recipe( + recipe_scanner=recipe_scanner, + recipe_id=str(recipe_id), + target_path=str(target_path), + ) + return web.json_response(result.payload, status=result.status) + except RecipeValidationError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=400) + except RecipeNotFoundError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=404) + except Exception as exc: + self._logger.error("Error moving recipe: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def move_recipes_bulk(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + data = await request.json() + recipe_ids = data.get("recipe_ids") or [] + target_path = data.get("target_path") + if not recipe_ids or not target_path: + return web.json_response( + {"success": False, "error": "recipe_ids and target_path are required"}, status=400 + ) + + result = await self._persistence_service.move_recipes_bulk( + recipe_scanner=recipe_scanner, + recipe_ids=recipe_ids, + target_path=str(target_path), + ) + return web.json_response(result.payload, status=result.status) + except RecipeValidationError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=400) + except RecipeNotFoundError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=404) + except Exception as exc: + self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def reconnect_lora(self, request: web.Request) -> web.Response: try: await self._ensure_dependencies_ready() @@ -776,7 +936,7 @@ class RecipeManagementHandler: extension = ".webp" # Default to webp if unknown with open(temp_path, "rb") as file_obj: - return file_obj.read(), extension + return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None except RecipeDownloadError: raise except RecipeValidationError: diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 18bf4cba..b91693c4 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -27,10 +27,16 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"), + RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"), + RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"), + RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"), + RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), + RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), + RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), diff --git a/py/services/recipe_cache.py b/py/services/recipe_cache.py index ac28b3aa..279c9e37 100644 --- a/py/services/recipe_cache.py +++ b/py/services/recipe_cache.py @@ -7,12 +7,18 @@ from natsort import natsorted @dataclass class RecipeCache: """Cache structure for Recipe data""" + raw_data: List[Dict] sorted_by_name: List[Dict] sorted_by_date: List[Dict] + folders: List[str] | None = None + folder_tree: Dict | None = None def __post_init__(self): self._lock = asyncio.Lock() + # Normalize optional metadata containers + self.folders = self.folders or [] + self.folder_tree = self.folder_tree or {} async def resort(self, name_only: bool = False): """Resort all cached data views""" diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index c7ac2842..c8f1c078 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -1,7 +1,9 @@ -import os -import logging +from __future__ import annotations + import asyncio import json +import logging +import os import time from typing import Any, Dict, Iterable, List, Optional, Set, Tuple from ..config import config @@ -117,7 +119,9 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=[], sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) # Mark as initializing to prevent concurrent initializations @@ -218,6 +222,7 @@ class RecipeScanner: # Update cache with the collected data self._cache.raw_data = recipes + self._update_folder_metadata(self._cache) # Create a simplified resort function that doesn't use await if hasattr(self._cache, "resort"): @@ -336,6 +341,9 @@ class RecipeScanner: if not self._cache: return + # Keep folder metadata up to date alongside sort order + self._update_folder_metadata() + async def _resort_wrapper() -> None: try: await self._cache.resort(name_only=name_only) @@ -346,6 +354,75 @@ class RecipeScanner: self._resort_tasks.add(task) task.add_done_callback(lambda finished: self._resort_tasks.discard(finished)) + def _calculate_folder(self, recipe_path: str) -> str: + """Calculate a normalized folder path relative to ``recipes_dir``.""" + + recipes_dir = self.recipes_dir + if not recipes_dir: + return "" + + try: + recipe_dir = os.path.dirname(os.path.normpath(recipe_path)) + relative_dir = os.path.relpath(recipe_dir, recipes_dir) + if relative_dir in (".", ""): + return "" + return relative_dir.replace(os.path.sep, "/") + except Exception: + return "" + + def _build_folder_tree(self, folders: list[str]) -> dict: + """Build a nested folder tree structure from relative folder paths.""" + + tree: dict[str, dict] = {} + for folder in folders: + if not folder: + continue + + parts = folder.split("/") + current_level = tree + + for part in parts: + if part not in current_level: + current_level[part] = {} + current_level = current_level[part] + + return tree + + def _update_folder_metadata(self, cache: RecipeCache | None = None) -> None: + """Ensure folder lists and tree metadata are synchronized with cache contents.""" + + cache = cache or self._cache + if cache is None: + return + + folders: set[str] = set() + for item in cache.raw_data: + folder_value = item.get("folder", "") + if folder_value is None: + folder_value = "" + if folder_value == ".": + folder_value = "" + normalized = str(folder_value).replace("\\", "/") + item["folder"] = normalized + folders.add(normalized) + + cache.folders = sorted(folders, key=lambda entry: entry.lower()) + cache.folder_tree = self._build_folder_tree(cache.folders) + + async def get_folders(self) -> list[str]: + """Return a sorted list of recipe folders relative to the recipes root.""" + + cache = await self.get_cached_data() + self._update_folder_metadata(cache) + return cache.folders + + async def get_folder_tree(self) -> dict: + """Return a hierarchical tree of recipe folders for sidebar navigation.""" + + cache = await self.get_cached_data() + self._update_folder_metadata(cache) + return cache.folder_tree + @property def recipes_dir(self) -> str: """Get path to recipes directory""" @@ -362,11 +439,14 @@ class RecipeScanner: """Get cached recipe data, refresh if needed""" # If cache is already initialized and no refresh is needed, return it immediately if self._cache is not None and not force_refresh: + self._update_folder_metadata() return self._cache # If another initialization is already in progress, wait for it to complete if self._is_initializing and not force_refresh: - return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) + return self._cache or RecipeCache( + raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={} + ) # If force refresh is requested, initialize the cache directly if force_refresh: @@ -384,11 +464,14 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=raw_data, sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) - + # Resort cache await self._cache.resort() + self._update_folder_metadata(self._cache) return self._cache @@ -398,7 +481,9 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=[], sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) return self._cache finally: @@ -409,7 +494,9 @@ class RecipeScanner: logger.error(f"Unexpected error in get_cached_data: {e}") # Return the cache (may be empty or partially initialized) - return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) + return self._cache or RecipeCache( + raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={} + ) async def refresh_cache(self, force: bool = False) -> RecipeCache: """Public helper to refresh or return the recipe cache.""" @@ -424,6 +511,7 @@ class RecipeScanner: cache = await self.get_cached_data() await cache.add_recipe(recipe_data, resort=False) + self._update_folder_metadata(cache) self._schedule_resort() async def remove_recipe(self, recipe_id: str) -> bool: @@ -437,6 +525,7 @@ class RecipeScanner: if removed is None: return False + self._update_folder_metadata(cache) self._schedule_resort() return True @@ -521,6 +610,9 @@ class RecipeScanner: if path_updated: self._write_recipe_file(recipe_path, recipe_data) + + # Track folder placement relative to recipes directory + recipe_data['folder'] = recipe_data.get('folder') or self._calculate_folder(recipe_path) # Ensure loras array exists if 'loras' not in recipe_data: @@ -914,7 +1006,7 @@ class RecipeScanner: return await self._lora_scanner.get_model_info_by_name(name) - async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True): + async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True, folder: str | None = None, recursive: bool = True): """Get paginated and filtered recipe data Args: @@ -926,11 +1018,20 @@ class RecipeScanner: search_options: Dictionary of search options to apply lora_hash: Optional SHA256 hash of a LoRA to filter recipes by bypass_filters: If True, ignore other filters when a lora_hash is provided + folder: Optional folder filter relative to recipes directory + recursive: Whether to include recipes in subfolders of the selected folder """ cache = await self.get_cached_data() # Get base dataset - filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name + sort_field = sort_by.split(':')[0] if ':' in sort_by else sort_by + + if sort_field == 'date': + filtered_data = list(cache.sorted_by_date) + elif sort_field == 'name': + filtered_data = list(cache.sorted_by_name) + else: + filtered_data = list(cache.raw_data) # Apply SFW filtering if enabled from .settings_manager import get_settings_manager @@ -961,6 +1062,22 @@ class RecipeScanner: # Skip further filtering if we're only filtering by LoRA hash with bypass enabled if not (lora_hash and bypass_filters): + # Apply folder filter before other criteria + normalized_folder = (folder or "").strip("/") + if normalized_folder: + def matches_folder(item_folder: str) -> bool: + item_path = (item_folder or "").strip("/") + if not item_path: + return False + if recursive: + return item_path == normalized_folder or item_path.startswith(f"{normalized_folder}/") + return item_path == normalized_folder + + filtered_data = [ + item for item in filtered_data + if matches_folder(item.get('folder', '')) + ] + # Apply search filter if search: # Default search options if none provided @@ -997,6 +1114,14 @@ class RecipeScanner: if fuzzy_match(str(lora.get('modelName', '')), search): return True + # Search in prompt and negative_prompt if enabled + if search_options.get('prompt', True) and 'gen_params' in item: + gen_params = item['gen_params'] + if fuzzy_match(str(gen_params.get('prompt', '')), search): + return True + if fuzzy_match(str(gen_params.get('negative_prompt', '')), search): + return True + # No match found return False @@ -1012,6 +1137,13 @@ class RecipeScanner: if item.get('base_model', '') in filters['base_model'] ] + # Filter by favorite + if 'favorite' in filters and filters['favorite']: + filtered_data = [ + item for item in filtered_data + if item.get('favorite') is True + ] + # Filter by tags if 'tags' in filters and filters['tags']: tag_spec = filters['tags'] @@ -1041,6 +1173,20 @@ class RecipeScanner: if not any(tag in exclude_tags for tag in (item.get('tags', []) or [])) ] + + # Apply sorting if not already handled by pre-sorted cache + if ':' in sort_by or sort_field == 'loras_count': + field, order = (sort_by.split(':') + ['desc'])[:2] + reverse = order.lower() == 'desc' + + if field == 'name': + filtered_data = natsorted(filtered_data, key=lambda x: x.get('title', '').lower(), reverse=reverse) + elif field == 'date': + # Use modified if available, falling back to created_date + filtered_data.sort(key=lambda x: (x.get('modified', x.get('created_date', 0)), x.get('file_path', '')), reverse=reverse) + elif field == 'loras_count': + filtered_data.sort(key=lambda x: len(x.get('loras', [])), reverse=reverse) + # Calculate pagination total_items = len(filtered_data) start_idx = (page - 1) * page_size @@ -1136,6 +1282,30 @@ class RecipeScanner: from datetime import datetime return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') + async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]: + """Locate the recipe JSON file, accounting for folder placement.""" + + recipes_dir = self.recipes_dir + if not recipes_dir: + return None + + cache = await self.get_cached_data() + folder = "" + for item in cache.raw_data: + if str(item.get("id")) == str(recipe_id): + folder = item.get("folder") or "" + break + + candidate = os.path.normpath(os.path.join(recipes_dir, folder, f"{recipe_id}.recipe.json")) + if os.path.exists(candidate): + return candidate + + for root, _, files in os.walk(recipes_dir): + if f"{recipe_id}.recipe.json" in files: + return os.path.join(root, f"{recipe_id}.recipe.json") + + return None + async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool: """Update recipe metadata (like title and tags) in both file system and cache @@ -1146,13 +1316,9 @@ class RecipeScanner: Returns: bool: True if successful, False otherwise """ - import os - import json - # First, find the recipe JSON file path - recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json") - - if not os.path.exists(recipe_json_path): + recipe_json_path = await self.get_recipe_json_path(recipe_id) + if not recipe_json_path or not os.path.exists(recipe_json_path): return False try: @@ -1201,8 +1367,8 @@ class RecipeScanner: if target_name is None: raise ValueError("target_name must be provided") - recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json") - if not os.path.exists(recipe_json_path): + recipe_json_path = await self.get_recipe_json_path(recipe_id) + if not recipe_json_path or not os.path.exists(recipe_json_path): raise RecipeNotFoundError("Recipe not found") async with self._mutation_lock: diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index 535f0853..e1c7ae15 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -5,6 +5,7 @@ import base64 import json import os import re +import shutil import time import uuid from dataclasses import dataclass @@ -154,12 +155,8 @@ class RecipePersistenceService: async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult: """Delete an existing recipe.""" - recipes_dir = recipe_scanner.recipes_dir - if not recipes_dir or not os.path.exists(recipes_dir): - raise RecipeNotFoundError("Recipes directory not found") - - recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json") - if not os.path.exists(recipe_json_path): + recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id) + if not recipe_json_path or not os.path.exists(recipe_json_path): raise RecipeNotFoundError("Recipe not found") with open(recipe_json_path, "r", encoding="utf-8") as file_obj: @@ -176,9 +173,9 @@ class RecipePersistenceService: async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult: """Update persisted metadata for a recipe.""" - if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level")): + if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")): raise RecipeValidationError( - "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)" + "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)" ) success = await recipe_scanner.update_recipe_metadata(recipe_id, updates) @@ -187,6 +184,163 @@ class RecipePersistenceService: return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates}) + def _normalize_target_path(self, recipe_scanner, target_path: str) -> tuple[str, str]: + """Normalize and validate the target path for recipe moves.""" + + if not target_path: + raise RecipeValidationError("Target path is required") + + recipes_root = recipe_scanner.recipes_dir + if not recipes_root: + raise RecipeNotFoundError("Recipes directory not found") + + normalized_target = os.path.normpath(target_path) + recipes_root = os.path.normpath(recipes_root) + if not os.path.isabs(normalized_target): + normalized_target = os.path.normpath(os.path.join(recipes_root, normalized_target)) + + try: + common_root = os.path.commonpath([normalized_target, recipes_root]) + except ValueError as exc: + raise RecipeValidationError("Invalid target path") from exc + + if common_root != recipes_root: + raise RecipeValidationError("Target path must be inside the recipes directory") + + return normalized_target, recipes_root + + async def _move_recipe_files( + self, + *, + recipe_scanner, + recipe_id: str, + normalized_target: str, + recipes_root: str, + ) -> dict[str, Any]: + """Move the recipe's JSON and preview image into the normalized target.""" + + recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id) + if not recipe_json_path or not os.path.exists(recipe_json_path): + raise RecipeNotFoundError("Recipe not found") + + recipe_data = await recipe_scanner.get_recipe_by_id(recipe_id) + if not recipe_data: + raise RecipeNotFoundError("Recipe not found") + + current_json_dir = os.path.dirname(recipe_json_path) + normalized_image_path = os.path.normpath(recipe_data.get("file_path") or "") if recipe_data.get("file_path") else None + + os.makedirs(normalized_target, exist_ok=True) + + if os.path.normpath(current_json_dir) == normalized_target: + return { + "success": True, + "message": "Recipe is already in the target folder", + "recipe_id": recipe_id, + "original_file_path": recipe_data.get("file_path"), + "new_file_path": recipe_data.get("file_path"), + } + + new_json_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(recipe_json_path))) + shutil.move(recipe_json_path, new_json_path) + + new_image_path = normalized_image_path + if normalized_image_path: + target_image_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(normalized_image_path))) + if os.path.exists(normalized_image_path) and normalized_image_path != target_image_path: + shutil.move(normalized_image_path, target_image_path) + new_image_path = target_image_path + + relative_folder = os.path.relpath(normalized_target, recipes_root) + if relative_folder in (".", ""): + relative_folder = "" + updates = {"file_path": new_image_path or recipe_data.get("file_path"), "folder": relative_folder.replace(os.path.sep, "/")} + + updated = await recipe_scanner.update_recipe_metadata(recipe_id, updates) + if not updated: + raise RecipeNotFoundError("Recipe not found after move") + + return { + "success": True, + "recipe_id": recipe_id, + "original_file_path": recipe_data.get("file_path"), + "new_file_path": updates["file_path"], + "json_path": new_json_path, + "folder": updates["folder"], + } + + async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult: + """Move a recipe's assets into a new folder under the recipes root.""" + + normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path) + result = await self._move_recipe_files( + recipe_scanner=recipe_scanner, + recipe_id=recipe_id, + normalized_target=normalized_target, + recipes_root=recipes_root, + ) + return PersistenceResult(result) + + async def move_recipes_bulk( + self, + *, + recipe_scanner, + recipe_ids: Iterable[str], + target_path: str, + ) -> PersistenceResult: + """Move multiple recipes to a new folder.""" + + recipe_ids = list(recipe_ids) + if not recipe_ids: + raise RecipeValidationError("No recipe IDs provided") + + normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path) + + results: list[dict[str, Any]] = [] + success_count = 0 + failure_count = 0 + + for recipe_id in recipe_ids: + try: + move_result = await self._move_recipe_files( + recipe_scanner=recipe_scanner, + recipe_id=str(recipe_id), + normalized_target=normalized_target, + recipes_root=recipes_root, + ) + results.append( + { + "recipe_id": recipe_id, + "original_file_path": move_result.get("original_file_path"), + "new_file_path": move_result.get("new_file_path"), + "success": True, + "message": move_result.get("message", ""), + "folder": move_result.get("folder", ""), + } + ) + success_count += 1 + except Exception as exc: # pragma: no cover - per-item error handling + results.append( + { + "recipe_id": recipe_id, + "original_file_path": None, + "new_file_path": None, + "success": False, + "message": str(exc), + } + ) + failure_count += 1 + + return PersistenceResult( + { + "success": True, + "message": f"Moved {success_count} of {len(recipe_ids)} recipes", + "results": results, + "success_count": success_count, + "failure_count": failure_count, + } + ) + async def reconnect_lora( self, *, @@ -197,8 +351,8 @@ class RecipePersistenceService: ) -> PersistenceResult: """Reconnect a LoRA entry within an existing recipe.""" - recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json") - if not os.path.exists(recipe_path): + recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id) + if not recipe_path or not os.path.exists(recipe_path): raise RecipeNotFoundError("Recipe not found") target_lora = await recipe_scanner.get_local_lora(target_name) @@ -243,16 +397,12 @@ class RecipePersistenceService: if not recipe_ids: raise RecipeValidationError("No recipe IDs provided") - recipes_dir = recipe_scanner.recipes_dir - if not recipes_dir or not os.path.exists(recipes_dir): - raise RecipeNotFoundError("Recipes directory not found") - deleted_recipes: list[str] = [] failed_recipes: list[dict[str, Any]] = [] for recipe_id in recipe_ids: - recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json") - if not os.path.exists(recipe_json_path): + recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id) + if not recipe_json_path or not os.path.exists(recipe_json_path): failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"}) continue diff --git a/scripts/sync_translation_keys.py b/scripts/sync_translation_keys.py index 8a0edb66..5ae1b71d 100644 --- a/scripts/sync_translation_keys.py +++ b/scripts/sync_translation_keys.py @@ -34,7 +34,7 @@ class TranslationKeySynchronizer: self.locales_dir = locales_dir self.verbose = verbose self.reference_locale = 'en' - self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko'] + self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko', 'he'] def log(self, message: str, level: str = 'INFO'): """Log a message if verbose mode is enabled.""" diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index ece9938f..7f700a21 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -2,6 +2,35 @@ import { RecipeCard } from '../components/RecipeCard.js'; import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; +const RECIPE_ENDPOINTS = { + list: '/api/lm/recipes', + detail: '/api/lm/recipe', + scan: '/api/lm/recipes/scan', + update: '/api/lm/recipe', + roots: '/api/lm/recipes/roots', + folders: '/api/lm/recipes/folders', + folderTree: '/api/lm/recipes/folder-tree', + unifiedFolderTree: '/api/lm/recipes/unified-folder-tree', + move: '/api/lm/recipe/move', + moveBulk: '/api/lm/recipes/move-bulk', + bulkDelete: '/api/lm/recipes/bulk-delete', +}; + +const RECIPE_SIDEBAR_CONFIG = { + config: { + displayName: 'Recipe', + supportsMove: true, + }, + endpoints: RECIPE_ENDPOINTS, +}; + +export function extractRecipeId(filePath) { + if (!filePath) return null; + const basename = filePath.split('/').pop().split('\\').pop(); + const dotIndex = basename.lastIndexOf('.'); + return dotIndex > 0 ? basename.substring(0, dotIndex) : basename; +} + /** * Fetch recipes with pagination for virtual scrolling * @param {number} page - Page number to fetch @@ -10,25 +39,36 @@ import { showToast } from '../utils/uiHelpers.js'; */ export async function fetchRecipesPage(page = 1, pageSize = 100) { const pageState = getCurrentPageState(); - + try { const params = new URLSearchParams({ page: page, page_size: pageSize || pageState.pageSize || 20, sort_by: pageState.sortBy }); - + + if (pageState.showFavoritesOnly) { + params.append('favorite', 'true'); + } + + if (pageState.activeFolder) { + params.append('folder', pageState.activeFolder); + params.append('recursive', pageState.searchOptions?.recursive !== false); + } else if (pageState.searchOptions?.recursive !== undefined) { + params.append('recursive', pageState.searchOptions.recursive); + } + // If we have a specific recipe ID to load if (pageState.customFilter?.active && pageState.customFilter?.recipeId) { // Special case: load specific recipe - const response = await fetch(`/api/lm/recipe/${pageState.customFilter.recipeId}`); - + const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`); + if (!response.ok) { throw new Error(`Failed to load recipe: ${response.statusText}`); } - + const recipe = await response.json(); - + // Return in expected format return { items: [recipe], @@ -38,33 +78,34 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { hasMore: false }; } - + // Add custom filter for Lora if present if (pageState.customFilter?.active && pageState.customFilter?.loraHash) { params.append('lora_hash', pageState.customFilter.loraHash); params.append('bypass_filters', 'true'); } else { // Normal filtering logic - + // Add search filter if present if (pageState.filters?.search) { params.append('search', pageState.filters.search); - + // Add search option parameters if (pageState.searchOptions) { params.append('search_title', pageState.searchOptions.title.toString()); params.append('search_tags', pageState.searchOptions.tags.toString()); params.append('search_lora_name', pageState.searchOptions.loraName.toString()); params.append('search_lora_model', pageState.searchOptions.loraModel.toString()); + params.append('search_prompt', (pageState.searchOptions.prompt || false).toString()); params.append('fuzzy', 'true'); } } - + // Add base model filters if (pageState.filters?.baseModel && pageState.filters.baseModel.length) { params.append('base_models', pageState.filters.baseModel.join(',')); } - + // Add tag filters if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) { Object.entries(pageState.filters.tags).forEach(([tag, state]) => { @@ -78,14 +119,14 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { } // Fetch recipes - const response = await fetch(`/api/lm/recipes?${params.toString()}`); - + const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`); + if (!response.ok) { throw new Error(`Failed to load recipes: ${response.statusText}`); } - + const data = await response.json(); - + return { items: data.items, totalItems: data.total, @@ -111,29 +152,29 @@ export async function resetAndReloadWithVirtualScroll(options = {}) { updateFolders = false, fetchPageFunction } = options; - + const pageState = getCurrentPageState(); - + try { pageState.isLoading = true; - + // Reset page counter pageState.currentPage = 1; - + // Fetch the first page const result = await fetchPageFunction(1, pageState.pageSize || 50); - + // Update the virtual scroller state.virtualScroller.refreshWithData( result.items, result.totalItems, result.hasMore ); - + // Update state pageState.hasMore = result.hasMore; pageState.currentPage = 2; // Next page will be 2 - + return result; } catch (error) { console.error(`Error reloading ${modelType}s:`, error); @@ -156,32 +197,32 @@ export async function loadMoreWithVirtualScroll(options = {}) { updateFolders = false, fetchPageFunction } = options; - + const pageState = getCurrentPageState(); - + try { // Start loading state pageState.isLoading = true; - + // Reset to first page if requested if (resetPage) { pageState.currentPage = 1; } - + // Fetch the first page of data const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); - + // Update virtual scroller with the new data state.virtualScroller.refreshWithData( result.items, result.totalItems, result.hasMore ); - + // Update state pageState.hasMore = result.hasMore; pageState.currentPage = 2; // Next page to load would be 2 - + return result; } catch (error) { console.error(`Error loading ${modelType}s:`, error); @@ -211,18 +252,18 @@ export async function resetAndReload(updateFolders = false) { export async function refreshRecipes() { try { state.loadingManager.showSimpleLoading('Refreshing recipes...'); - + // Call the API endpoint to rebuild the recipe cache - const response = await fetch('/api/lm/recipes/scan'); - + const response = await fetch(RECIPE_ENDPOINTS.scan); + if (!response.ok) { const data = await response.json(); throw new Error(data.error || 'Failed to refresh recipe cache'); } - + // After successful cache rebuild, reload the recipes await resetAndReload(); - + showToast('toast.recipes.refreshComplete', {}, 'success'); } catch (error) { console.error('Error refreshing recipes:', error); @@ -240,7 +281,7 @@ export async function refreshRecipes() { */ export async function loadMoreRecipes(resetPage = false) { const pageState = getCurrentPageState(); - + // Use virtual scroller if available if (state.virtualScroller) { return loadMoreWithVirtualScroll({ @@ -277,10 +318,12 @@ export async function updateRecipeMetadata(filePath, updates) { state.loadingManager.showSimpleLoading('Saving metadata...'); // Extract recipeId from filePath (basename without extension) - const basename = filePath.split('/').pop().split('\\').pop(); - const recipeId = basename.substring(0, basename.lastIndexOf('.')); - - const response = await fetch(`/api/lm/recipe/${recipeId}/update`, { + const recipeId = extractRecipeId(filePath); + if (!recipeId) { + throw new Error('Unable to determine recipe ID'); + } + + const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -296,7 +339,7 @@ export async function updateRecipeMetadata(filePath, updates) { } state.virtualScroller.updateSingleItem(filePath, updates); - + return data; } catch (error) { console.error('Error updating recipe:', error); @@ -306,3 +349,187 @@ export async function updateRecipeMetadata(filePath, updates) { state.loadingManager.hide(); } } + +export class RecipeSidebarApiClient { + constructor() { + this.apiConfig = RECIPE_SIDEBAR_CONFIG; + } + + async fetchUnifiedFolderTree() { + const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree); + if (!response.ok) { + throw new Error('Failed to fetch recipe folder tree'); + } + return response.json(); + } + + async fetchModelRoots() { + const response = await fetch(this.apiConfig.endpoints.roots); + if (!response.ok) { + throw new Error('Failed to fetch recipe roots'); + } + return response.json(); + } + + async fetchModelFolders() { + const response = await fetch(this.apiConfig.endpoints.folders); + if (!response.ok) { + throw new Error('Failed to fetch recipe folders'); + } + return response.json(); + } + + async moveBulkModels(filePaths, targetPath) { + if (!this.apiConfig.config.supportsMove) { + showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning'); + return []; + } + + const recipeIds = filePaths + .map((path) => extractRecipeId(path)) + .filter((id) => !!id); + + if (recipeIds.length === 0) { + showToast('toast.models.noModelsSelected', {}, 'warning'); + return []; + } + + const response = await fetch(this.apiConfig.endpoints.moveBulk, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_ids: recipeIds, + target_path: targetPath, + }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}s`); + } + + if (result.failure_count > 0) { + showToast( + 'toast.api.bulkMovePartial', + { + successCount: result.success_count, + type: this.apiConfig.config.displayName, + failureCount: result.failure_count, + }, + 'warning' + ); + + const failedFiles = (result.results || []) + .filter((item) => !item.success) + .map((item) => item.message || 'Unknown error'); + + if (failedFiles.length > 0) { + const failureMessage = + failedFiles.length <= 3 + ? failedFiles.join('\n') + : `${failedFiles.slice(0, 3).join('\n')}\n(and ${failedFiles.length - 3} more)`; + showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000); + } + } else { + showToast( + 'toast.api.bulkMoveSuccess', + { + successCount: result.success_count, + type: this.apiConfig.config.displayName, + }, + 'success' + ); + } + + return result.results || []; + } + + async moveSingleModel(filePath, targetPath) { + if (!this.apiConfig.config.supportsMove) { + showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning'); + return null; + } + + const recipeId = extractRecipeId(filePath); + if (!recipeId) { + showToast('toast.api.moveFailed', { message: 'Recipe ID missing' }, 'error'); + return null; + } + + const response = await fetch(this.apiConfig.endpoints.move, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_id: recipeId, + target_path: targetPath, + }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}`); + } + + if (result.message) { + showToast('toast.api.moveInfo', { message: result.message }, 'info'); + } else { + showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success'); + } + + return { + original_file_path: result.original_file_path || filePath, + new_file_path: result.new_file_path || filePath, + folder: result.folder || '', + message: result.message, + }; + } + + async bulkDeleteModels(filePaths) { + if (!filePaths || filePaths.length === 0) { + throw new Error('No file paths provided'); + } + + const recipeIds = filePaths + .map((path) => extractRecipeId(path)) + .filter((id) => !!id); + + if (recipeIds.length === 0) { + throw new Error('No recipe IDs could be derived from file paths'); + } + + try { + state.loadingManager?.showSimpleLoading('Deleting recipes...'); + + const response = await fetch(this.apiConfig.endpoints.bulkDelete, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_ids: recipeIds, + }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || 'Failed to delete recipes'); + } + + return { + success: true, + deleted_count: result.total_deleted, + failed_count: result.total_failed || 0, + errors: result.failed || [], + }; + } finally { + state.loadingManager?.hide(); + } + } +} diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index f9cb9719..6dcb709b 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -4,6 +4,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { state } from '../../state/index.js'; +import { moveManager } from '../../managers/MoveManager.js'; export class RecipeContextMenu extends BaseContextMenu { constructor() { @@ -77,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu { // Share recipe this.currentCard.querySelector('.fa-share-alt')?.click(); break; + case 'move': + moveManager.showMoveModal(this.currentCard.dataset.filepath); + break; case 'delete': // Delete recipe this.currentCard.querySelector('.fa-trash')?.click(); diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 2c42ddf8..f89a2425 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -1,9 +1,11 @@ // Recipe Card Component import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; +import { updateRecipeMetadata } from '../api/recipeApi.js'; import { configureModelCardVideo } from './shared/ModelCard.js'; import { modalManager } from '../managers/ModalManager.js'; import { getCurrentPageState } from '../state/index.js'; import { state } from '../state/index.js'; +import { bulkManager } from '../managers/BulkManager.js'; import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js'; class RecipeCard { @@ -43,8 +45,11 @@ class RecipeCard { (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : '/loras_static/images/no-preview.png'); + const isDuplicatesMode = getCurrentPageState().duplicatesMode; + const autoplayOnHover = state?.global?.settings?.autoplay_on_hover === true; + const isFavorite = this.recipe.favorite === true; + // Video preview logic - const autoplayOnHover = state.settings.autoplay_on_hover || false; const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm'); const videoAttrs = [ 'controls', @@ -59,10 +64,6 @@ class RecipeCard { videoAttrs.push('data-autoplay="true"'); } - // Check if in duplicates mode - const pageState = getCurrentPageState(); - const isDuplicatesMode = pageState.duplicatesMode; - // NSFW blur logic - similar to LoraCard const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0; const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; @@ -95,6 +96,7 @@ class RecipeCard { ` : ''} ${baseModelDisplay}
No recipes found
@@ -698,10 +698,10 @@ export class VirtualScroller { `; } } - + placeholder.innerHTML = placeholderText; placeholder.id = 'virtualScrollPlaceholder'; - + // Append placeholder to the grid this.gridElement.appendChild(placeholder); } @@ -716,7 +716,7 @@ export class VirtualScroller { // Utility method for debouncing debounce(func, wait) { let timeout; - return function(...args) { + return function (...args) { const context = this; clearTimeout(timeout); timeout = setTimeout(() => func.apply(context, args), wait); @@ -727,55 +727,55 @@ export class VirtualScroller { disable() { // Detach scroll event listener this.scrollContainer.removeEventListener('scroll', this.scrollHandler); - + // Clear all rendered items from the DOM this.clearRenderedItems(); - + // Hide the spacer element if (this.spacerElement) { this.spacerElement.style.display = 'none'; } - + // Flag as disabled this.disabled = true; - + console.log('Virtual scroller disabled'); } // Add enable method to resume rendering and events enable() { if (!this.disabled) return; - + // Reattach scroll event listener this.scrollContainer.addEventListener('scroll', this.scrollHandler); - + // Check if spacer element exists in the DOM, if not, recreate it if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) { console.log('Spacer element not found in DOM, recreating it'); - + // Create a new spacer element this.spacerElement = document.createElement('div'); this.spacerElement.className = 'virtual-scroll-spacer'; this.spacerElement.style.width = '100%'; this.spacerElement.style.height = '0px'; this.spacerElement.style.pointerEvents = 'none'; - + // Append it to the grid this.gridElement.appendChild(this.spacerElement); - + // Update the spacer height this.updateSpacerHeight(); } else { // Show the spacer element if it exists this.spacerElement.style.display = 'block'; } - + // Flag as enabled this.disabled = false; - + // Re-render items this.scheduleRender(); - + console.log('Virtual scroller enabled'); } @@ -783,31 +783,30 @@ export class VirtualScroller { deepMerge(target, source) { if (!source || !target) return target; + // Initialize result with a copy of target const result = { ...target }; - // Only iterate over keys that exist in target - Object.keys(target).forEach(key => { - // Check if source has this key - if (source.hasOwnProperty(key)) { - const targetValue = target[key]; - const sourceValue = source[key]; + if (!source) return result; - // If both values are non-null objects and not arrays, merge recursively - if ( - targetValue !== null && - typeof targetValue === 'object' && - !Array.isArray(targetValue) && - sourceValue !== null && - typeof sourceValue === 'object' && - !Array.isArray(sourceValue) - ) { - result[key] = this.deepMerge(targetValue, sourceValue); - } else { - // For primitive types, arrays, or null, use the value from source - result[key] = sourceValue; - } + // Iterate over all keys in the source object + Object.keys(source).forEach(key => { + const targetValue = target[key]; + const sourceValue = source[key]; + + // If both values are non-null objects and not arrays, merge recursively + if ( + targetValue !== null && + typeof targetValue === 'object' && + !Array.isArray(targetValue) && + sourceValue !== null && + typeof sourceValue === 'object' && + !Array.isArray(sourceValue) + ) { + result[key] = this.deepMerge(targetValue || {}, sourceValue); + } else { + // Otherwise update with source value (includes primitives, arrays, and new keys) + result[key] = sourceValue; } - // If source does not have this key, keep the original value from target }); return result; @@ -828,43 +827,43 @@ export class VirtualScroller { // Update the item data using deep merge this.items[index] = this.deepMerge(this.items[index], updatedItem); - + // If the item is currently rendered, update its DOM representation if (this.renderedItems.has(index)) { const element = this.renderedItems.get(index); - + // Remove the old element element.remove(); this.renderedItems.delete(index); - + // Create and render the updated element const updatedElement = this.createItemElement(this.items[index], index); - + // Add update indicator visual effects updatedElement.classList.add('updated'); - + // Add temporary update tag const updateIndicator = document.createElement('div'); updateIndicator.className = 'update-indicator'; updateIndicator.textContent = 'Updated'; updatedElement.querySelector('.card-preview').appendChild(updateIndicator); - + // Automatically remove the updated class after animation completes setTimeout(() => { updatedElement.classList.remove('updated'); }, 1500); - + // Automatically remove the indicator after animation completes setTimeout(() => { if (updateIndicator && updateIndicator.parentNode) { updateIndicator.remove(); } }, 2000); - + this.renderedItems.set(index, updatedElement); this.gridElement.appendChild(updatedElement); } - + return true; } @@ -882,26 +881,26 @@ export class VirtualScroller { // Remove the item from the data array this.items.splice(index, 1); - + // Decrement total count this.totalItems = Math.max(0, this.totalItems - 1); - + // Remove the item from rendered items if it exists if (this.renderedItems.has(index)) { this.renderedItems.get(index).remove(); this.renderedItems.delete(index); } - + // Shift all rendered items with higher indices down by 1 const indicesToUpdate = []; - + // Collect all indices that need to be updated for (const [idx, element] of this.renderedItems.entries()) { if (idx > index) { indicesToUpdate.push(idx); } } - + // Update the elements and map entries for (const idx of indicesToUpdate) { const element = this.renderedItems.get(idx); @@ -909,14 +908,14 @@ export class VirtualScroller { // The item is now at the previous index this.renderedItems.set(idx - 1, element); } - + // Update the spacer height to reflect the new total this.updateSpacerHeight(); - + // Re-render to ensure proper layout this.clearRenderedItems(); this.scheduleRender(); - + console.log(`Removed item with file path ${filePath} from virtual scroller data`); return true; } @@ -929,28 +928,28 @@ export class VirtualScroller { return; // Ignore rapid repeated triggers } this.lastPageNavTime = now; - + const scrollContainer = this.scrollContainer; const viewportHeight = scrollContainer.clientHeight; - + // Calculate scroll distance (one viewport minus 10% overlap for context) const scrollDistance = viewportHeight * 0.9; - + // Determine the new scroll position const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance); - + // Remove any existing transition indicators this.removeExistingTransitionIndicator(); - + // Scroll to the new position with smooth animation scrollContainer.scrollTo({ top: newScrollTop, behavior: 'smooth' }); - + // Page transition indicator removed // this.showTransitionIndicator(); - + // Force render after scrolling setTimeout(() => this.renderItems(), 100); setTimeout(() => this.renderItems(), 300); @@ -966,25 +965,25 @@ export class VirtualScroller { scrollToTop() { this.removeExistingTransitionIndicator(); - + // Page transition indicator removed // this.showTransitionIndicator(); - + this.scrollContainer.scrollTo({ top: 0, behavior: 'smooth' }); - + // Force render after scrolling setTimeout(() => this.renderItems(), 100); } scrollToBottom() { this.removeExistingTransitionIndicator(); - + // Page transition indicator removed // this.showTransitionIndicator(); - + // Start loading all remaining pages to ensure content is available this.loadRemainingPages().then(() => { // After loading all content, scroll to the very bottom @@ -995,27 +994,27 @@ export class VirtualScroller { }); }); } - + // New method to load all remaining pages async loadRemainingPages() { // If we're already at the end or loading, don't proceed if (!this.hasMore || this.isLoading) return; - + console.log('Loading all remaining pages for End key navigation...'); - + // Keep loading pages until we reach the end while (this.hasMore && !this.isLoading) { await this.loadMoreItems(); - + // Force render after each page load this.renderItems(); - + // Small delay to prevent overwhelming the browser await new Promise(resolve => setTimeout(resolve, 50)); } - + console.log('Finished loading all pages'); - + // Final render to ensure all content is displayed this.renderItems(); } diff --git a/templates/components/header.html b/templates/components/header.html index e2942c39..95c7c01e 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -8,52 +8,60 @@