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}
+ @@ -140,6 +142,67 @@ class RecipeCard { return `${missingCount} of ${totalCount} LoRAs missing`; } + async toggleFavorite(card) { + // Find the latest star icon in case the card was re-rendered + const getStarIcon = (c) => c.querySelector('.fa-star'); + let starIcon = getStarIcon(card); + + const isFavorite = this.recipe.favorite || false; + const newFavoriteState = !isFavorite; + + // Update early to provide instant feedback and avoid race conditions with re-renders + this.recipe.favorite = newFavoriteState; + + // Function to update icon state + const updateIconUI = (icon, state) => { + if (!icon) return; + if (state) { + icon.classList.remove('far'); + icon.classList.add('fas', 'favorite-active'); + icon.title = 'Remove from Favorites'; + } else { + icon.classList.remove('fas', 'favorite-active'); + icon.classList.add('far'); + icon.title = 'Add to Favorites'; + } + }; + + // Update current icon immediately + updateIconUI(starIcon, newFavoriteState); + + try { + await updateRecipeMetadata(this.recipe.file_path, { + favorite: newFavoriteState + }); + + // Status already updated, just show toast + if (newFavoriteState) { + showToast('modelCard.favorites.added', {}, 'success'); + } else { + showToast('modelCard.favorites.removed', {}, 'success'); + } + + // Re-find star icon after API call as VirtualScroller might have replaced the element + // During updateRecipeMetadata, VirtualScroller.updateSingleItem might have re-rendered the card + // We need to find the NEW element in the DOM to ensure we don't have a stale reference + // Though typically VirtualScroller handles the re-render with the NEW this.recipe.favorite + // we will check the DOM just to be sure if this instance's internal card is still what's in DOM + } catch (error) { + console.error('Failed to update favorite status:', error); + // Revert local state on error + this.recipe.favorite = isFavorite; + + // Re-find star icon in case of re-render during fault + const currentCard = card.ownerDocument.evaluate( + `.//*[@data-filepath="${this.recipe.file_path}"]`, + card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null + ).singleNodeValue || card; + + updateIconUI(getStarIcon(currentCard), isFavorite); + showToast('modelCard.favorites.updateFailed', {}, 'error'); + } + } + attachEventListeners(card, isDuplicatesMode, shouldBlur) { // Add blur toggle functionality if content should be blurred if (shouldBlur) { @@ -164,9 +227,19 @@ class RecipeCard { // Recipe card click event - only attach if not in duplicates mode if (!isDuplicatesMode) { card.addEventListener('click', () => { + if (state.bulkMode) { + bulkManager.toggleCardSelection(card); + return; + } this.clickHandler(this.recipe); }); + // Favorite button click event - prevent propagation to card + card.querySelector('.fa-star')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleFavorite(card); + }); + // Share button click event - prevent propagation to card card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { e.stopPropagation(); diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index f4ed13bb..8c21643a 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -77,7 +77,9 @@ export class SidebarManager { this.pageControls = pageControls; this.pageType = pageControls.pageType; this.lastPageControls = pageControls; - this.apiClient = getModelApiClient(); + this.apiClient = pageControls?.getSidebarApiClient?.() + || pageControls?.sidebarApiClient + || getModelApiClient(); // Set initial sidebar state immediately (hidden by default) this.setInitialSidebarState(); @@ -205,6 +207,10 @@ export class SidebarManager { } initializeDragAndDrop() { + if (this.apiClient?.apiConfig?.config?.supportsMove === false) { + return; + } + if (!this.dragHandlersInitialized) { document.addEventListener('dragstart', this.handleCardDragStart); document.addEventListener('dragend', this.handleCardDragEnd); @@ -416,7 +422,14 @@ export class SidebarManager { } if (!this.apiClient) { - this.apiClient = getModelApiClient(); + this.apiClient = this.pageControls?.getSidebarApiClient?.() + || this.pageControls?.sidebarApiClient + || getModelApiClient(); + } + + if (this.apiClient?.apiConfig?.config?.supportsMove === false) { + showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error'); + return false; } const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : ''; @@ -470,7 +483,9 @@ export class SidebarManager { } async init() { - this.apiClient = getModelApiClient(); + this.apiClient = this.pageControls?.getSidebarApiClient?.() + || this.pageControls?.sidebarApiClient + || getModelApiClient(); // Set initial sidebar state immediately (hidden by default) this.setInitialSidebarState(); diff --git a/static/js/core.js b/static/js/core.js index 11c09a1a..b78c07bf 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -60,14 +60,12 @@ export class AppCore { initTheme(); initBackToTop(); - // Initialize the bulk manager and context menu only if not on recipes page - if (state.currentPageType !== 'recipes') { - bulkManager.initialize(); + // Initialize the bulk manager and context menu + bulkManager.initialize(); - // Initialize bulk context menu - const bulkContextMenu = new BulkContextMenu(); - bulkManager.setBulkContextMenu(bulkContextMenu); - } + // Initialize bulk context menu + const bulkContextMenu = new BulkContextMenu(); + bulkManager.setBulkContextMenu(bulkContextMenu); // Initialize the example images manager exampleImagesManager.initialize(); @@ -121,4 +119,4 @@ export class AppCore { } // Create and export a singleton instance -export const appCore = new AppCore(); \ No newline at end of file +export const appCore = new AppCore(); diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 10eda62a..8b32adca 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -3,6 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { modalManager } from './ModalManager.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; +import { RecipeSidebarApiClient } from '../api/recipeApi.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; import { BASE_MODEL_CATEGORIES } from '../utils/constants.js'; import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js'; @@ -62,9 +63,22 @@ export class BulkManager { autoOrganize: true, deleteAll: true, setContentRating: true + }, + recipes: { + addTags: false, + sendToWorkflow: false, + copyAll: false, + refreshAll: false, + checkUpdates: false, + moveAll: true, + autoOrganize: false, + deleteAll: true, + setContentRating: false } }; + this.recipeApiClient = null; + window.addEventListener('lm:priority-tags-updated', () => { const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container'); if (!container) { @@ -87,9 +101,6 @@ export class BulkManager { } initialize() { - // Do not initialize on recipes page - if (state.currentPageType === 'recipes') return; - // Register with event manager for coordinated event handling this.registerEventHandlers(); @@ -97,6 +108,23 @@ export class BulkManager { eventManager.setState('bulkMode', state.bulkMode || false); } + getActiveApiClient() { + if (state.currentPageType === 'recipes') { + if (!this.recipeApiClient) { + this.recipeApiClient = new RecipeSidebarApiClient(); + } + return this.recipeApiClient; + } + return getModelApiClient(); + } + + getCurrentDisplayConfig() { + if (state.currentPageType === 'recipes') { + return { displayName: 'Recipe' }; + } + return MODEL_CONFIG[state.currentPageType] || { displayName: 'Model' }; + } + setBulkContextMenu(bulkContextMenu) { this.bulkContextMenu = bulkContextMenu; } @@ -240,7 +268,9 @@ export class BulkManager { // Update event manager state eventManager.setState('bulkMode', state.bulkMode); - this.bulkBtn.classList.toggle('active', state.bulkMode); + if (this.bulkBtn) { + this.bulkBtn.classList.toggle('active', state.bulkMode); + } updateCardsForBulkMode(state.bulkMode); @@ -504,13 +534,13 @@ export class BulkManager { modalManager.closeModal('bulkDeleteModal'); try { - const apiClient = getModelApiClient(); + const apiClient = this.getActiveApiClient(); const filePaths = Array.from(state.selectedModels); const result = await apiClient.bulkDeleteModels(filePaths); if (result.success) { - const currentConfig = MODEL_CONFIG[state.currentPageType]; + const currentConfig = this.getCurrentDisplayConfig(); showToast('toast.models.deletedSuccessfully', { count: result.deleted_count, type: currentConfig.displayName.toLowerCase() @@ -570,7 +600,7 @@ export class BulkManager { this.applySelectionState(); const newlySelected = state.selectedModels.size - oldCount; - const currentConfig = MODEL_CONFIG[state.currentPageType]; + const currentConfig = this.getCurrentDisplayConfig(); showToast('toast.models.selectedAdditional', { count: newlySelected, type: currentConfig.displayName.toLowerCase() @@ -622,8 +652,7 @@ export class BulkManager { return; } - const currentType = state.currentPageType; - const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA]; + const currentConfig = this.getCurrentDisplayConfig(); const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase(); const { ids: modelIds, missingCount } = this.collectSelectedModelIds(); @@ -969,7 +998,7 @@ export class BulkManager { modalManager.closeModal('bulkAddTagsModal'); if (successCount > 0) { - const currentConfig = MODEL_CONFIG[state.currentPageType]; + const currentConfig = this.getCurrentDisplayConfig(); const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully'; showToast(toastKey, { count: successCount, diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 1b23a827..88f62839 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -3,6 +3,7 @@ import { state, getCurrentPageState } from '../state/index.js'; import { modalManager } from './ModalManager.js'; import { bulkManager } from './BulkManager.js'; import { getModelApiClient } from '../api/modelApiFactory.js'; +import { RecipeSidebarApiClient } from '../api/recipeApi.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { sidebarManager } from '../components/SidebarManager.js'; @@ -12,11 +13,22 @@ class MoveManager { this.bulkFilePaths = null; this.folderTreeManager = new FolderTreeManager(); this.initialized = false; + this.recipeApiClient = null; // Bind methods this.updateTargetPath = this.updateTargetPath.bind(this); } + _getApiClient(modelType = null) { + if (state.currentPageType === 'recipes') { + if (!this.recipeApiClient) { + this.recipeApiClient = new RecipeSidebarApiClient(); + } + return this.recipeApiClient; + } + return getModelApiClient(modelType); + } + initializeEventListeners() { if (this.initialized) return; @@ -36,7 +48,7 @@ class MoveManager { this.currentFilePath = null; this.bulkFilePaths = null; - const apiClient = getModelApiClient(); + const apiClient = this._getApiClient(modelType); const currentPageType = state.currentPageType; const modelConfig = apiClient.apiConfig.config; @@ -121,7 +133,7 @@ class MoveManager { async initializeFolderTree() { try { - const apiClient = getModelApiClient(); + const apiClient = this._getApiClient(); // Fetch unified folder tree const treeData = await apiClient.fetchUnifiedFolderTree(); @@ -141,7 +153,7 @@ class MoveManager { updateTargetPath() { const pathDisplay = document.getElementById('moveTargetPathDisplay'); const modelRoot = document.getElementById('moveModelRoot').value; - const apiClient = getModelApiClient(); + const apiClient = this._getApiClient(); const config = apiClient.apiConfig.config; let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`; @@ -158,7 +170,7 @@ class MoveManager { async moveModel() { const selectedRoot = document.getElementById('moveModelRoot').value; - const apiClient = getModelApiClient(); + const apiClient = this._getApiClient(); const config = apiClient.apiConfig.config; if (!selectedRoot) { diff --git a/static/js/recipes.js b/static/js/recipes.js index 293bb8d9..94f158f7 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -2,31 +2,60 @@ import { appCore } from './core.js'; import { ImportManager } from './managers/ImportManager.js'; import { RecipeModal } from './components/RecipeModal.js'; -import { getCurrentPageState } from './state/index.js'; +import { state, getCurrentPageState } from './state/index.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { DuplicatesManager } from './components/DuplicatesManager.js'; import { refreshVirtualScroll } from './utils/infiniteScroll.js'; -import { refreshRecipes } from './api/recipeApi.js'; +import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js'; +import { sidebarManager } from './components/SidebarManager.js'; + +class RecipePageControls { + constructor() { + this.pageType = 'recipes'; + this.pageState = getCurrentPageState(); + this.sidebarApiClient = new RecipeSidebarApiClient(); + } + + async resetAndReload() { + refreshVirtualScroll(); + } + + async refreshModels(fullRebuild = false) { + if (fullRebuild) { + await refreshRecipes(); + return; + } + + refreshVirtualScroll(); + } + + getSidebarApiClient() { + return this.sidebarApiClient; + } +} class RecipeManager { constructor() { // Get page state this.pageState = getCurrentPageState(); - + + // Page controls for shared sidebar behaviors + this.pageControls = new RecipePageControls(); + // Initialize ImportManager this.importManager = new ImportManager(); - + // Initialize RecipeModal this.recipeModal = new RecipeModal(); - + // Initialize DuplicatesManager this.duplicatesManager = new DuplicatesManager(this); - + // Add state tracking for infinite scroll this.pageState.isLoading = false; this.pageState.hasMore = true; - + // Custom filter state - move to pageState for compatibility with virtual scrolling this.pageState.customFilter = { active: false, @@ -35,27 +64,40 @@ class RecipeManager { recipeId: null }; } - + async initialize() { // Initialize event listeners this.initEventListeners(); - + // Set default search options if not already defined this._initSearchOptions(); - + // Initialize context menu new RecipeContextMenu(); - + // Check for custom filter parameters in session storage this._checkCustomFilter(); - + // Expose necessary functions to the page this._exposeGlobalFunctions(); - + + // Initialize sidebar navigation + await this._initSidebar(); + // Initialize common page features appCore.initializePageFeatures(); } - + + async _initSidebar() { + try { + sidebarManager.setHostPageControls(this.pageControls); + const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false; + await sidebarManager.setSidebarEnabled(shouldShowSidebar); + } catch (error) { + console.error('Failed to initialize recipe sidebar:', error); + } + } + _initSearchOptions() { // Ensure recipes search options are properly initialized if (!this.pageState.searchOptions) { @@ -63,25 +105,27 @@ class RecipeManager { title: true, // Recipe title tags: true, // Recipe tags loraName: true, // LoRA file name - loraModel: true // LoRA model name + loraModel: true, // LoRA model name + prompt: true, // Prompt search + recursive: true }; } } - + _exposeGlobalFunctions() { // Only expose what's needed for the page window.recipeManager = this; window.importManager = this.importManager; } - + _checkCustomFilter() { // Check for Lora filter const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName'); const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash'); - + // Check for specific recipe ID const viewRecipeId = getSessionItem('viewRecipeId'); - + // Set custom filter if any parameter is present if (filterLoraName || filterLoraHash || viewRecipeId) { this.pageState.customFilter = { @@ -90,35 +134,35 @@ class RecipeManager { loraHash: filterLoraHash, recipeId: viewRecipeId }; - + // Show custom filter indicator this._showCustomFilterIndicator(); } } - + _showCustomFilterIndicator() { const indicator = document.getElementById('customFilterIndicator'); const textElement = document.getElementById('customFilterText'); - + if (!indicator || !textElement) return; - + // Update text based on filter type let filterText = ''; - + if (this.pageState.customFilter.recipeId) { filterText = 'Viewing specific recipe'; } else if (this.pageState.customFilter.loraName) { // Format with Lora name const loraName = this.pageState.customFilter.loraName; - const displayName = loraName.length > 25 ? - loraName.substring(0, 22) + '...' : + const displayName = loraName.length > 25 ? + loraName.substring(0, 22) + '...' : loraName; - + filterText = `Recipes using: ${displayName}`; } else { filterText = 'Filtered recipes'; } - + // Update indicator text and show it textElement.innerHTML = filterText; // Add title attribute to show the lora name as a tooltip @@ -126,14 +170,14 @@ class RecipeManager { textElement.setAttribute('title', this.pageState.customFilter.loraName); } indicator.classList.remove('hidden'); - + // Add pulse animation const filterElement = indicator.querySelector('.filter-active'); if (filterElement) { filterElement.classList.add('animate'); setTimeout(() => filterElement.classList.remove('animate'), 600); } - + // Add click handler for clear filter button const clearFilterBtn = indicator.querySelector('.clear-filter'); if (clearFilterBtn) { @@ -143,7 +187,7 @@ class RecipeManager { }); } } - + _clearCustomFilter() { // Reset custom filter this.pageState.customFilter = { @@ -152,33 +196,48 @@ class RecipeManager { loraHash: null, recipeId: null }; - + // Hide indicator const indicator = document.getElementById('customFilterIndicator'); if (indicator) { indicator.classList.add('hidden'); } - + // Clear any session storage items removeSessionItem('lora_to_recipe_filterLoraName'); removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('viewRecipeId'); - + // Reset and refresh the virtual scroller refreshVirtualScroll(); } - + initEventListeners() { // Sort select const sortSelect = document.getElementById('sortSelect'); if (sortSelect) { + sortSelect.value = this.pageState.sortBy || 'date:desc'; sortSelect.addEventListener('change', () => { this.pageState.sortBy = sortSelect.value; refreshVirtualScroll(); }); } + + const bulkButton = document.querySelector('[data-action="bulk"]'); + if (bulkButton) { + bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode()); + } + + const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); + if (favoriteFilterBtn) { + favoriteFilterBtn.addEventListener('click', () => { + this.pageState.showFavoritesOnly = !this.pageState.showFavoritesOnly; + favoriteFilterBtn.classList.toggle('active', this.pageState.showFavoritesOnly); + refreshVirtualScroll(); + }); + } } - + // This method is kept for compatibility but now uses virtual scrolling async loadRecipes(resetPage = true) { // Skip loading if in duplicates mode @@ -186,32 +245,32 @@ class RecipeManager { if (pageState.duplicatesMode) { return; } - + if (resetPage) { refreshVirtualScroll(); } } - + /** * Refreshes the recipe list by first rebuilding the cache and then loading recipes */ async refreshRecipes() { return refreshRecipes(); } - + showRecipeDetails(recipe) { this.recipeModal.showRecipeDetails(recipe); } - + // Duplicate detection and management methods async findDuplicateRecipes() { return await this.duplicatesManager.findDuplicates(); } - + selectLatestDuplicates() { this.duplicatesManager.selectLatestDuplicates(); } - + deleteSelectedDuplicates() { this.duplicatesManager.deleteSelectedDuplicates(); } @@ -219,14 +278,14 @@ class RecipeManager { confirmDeleteDuplicates() { this.duplicatesManager.confirmDeleteDuplicates(); } - + exitDuplicateMode() { // Clear the grid first to prevent showing old content temporarily const recipeGrid = document.getElementById('recipeGrid'); if (recipeGrid) { recipeGrid.innerHTML = ''; } - + this.duplicatesManager.exitDuplicateMode(); } } @@ -235,11 +294,11 @@ class RecipeManager { document.addEventListener('DOMContentLoaded', async () => { // Initialize core application await appCore.initialize(); - + // Initialize recipe manager const recipeManager = new RecipeManager(); await recipeManager.initialize(); }); // Export for use in other modules -export { RecipeManager }; \ No newline at end of file +export { RecipeManager }; diff --git a/static/js/state/index.js b/static/js/state/index.js index 4ee59b55..1dfbf568 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -58,7 +58,7 @@ export const state = { loadingManager: null, observer: null, }, - + // Page-specific states pages: { [MODEL_TYPES.LORA]: { @@ -69,20 +69,20 @@ export const state = { activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`), activeLetterFilter: null, previewVersions: loraPreviewVersions, - searchManager: null, - searchOptions: { - filename: true, - modelname: true, - tags: false, - creator: false, - recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true), - }, - filters: { - baseModel: [], - tags: {}, - license: {}, - modelTypes: [] - }, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + creator: false, + recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [] + }, bulkMode: false, selectedLoras: new Set(), loraMetadataCache: new Map(), @@ -90,33 +90,35 @@ export const state = { showUpdateAvailableOnly: false, duplicatesMode: false, }, - + recipes: { currentPage: 1, isLoading: false, hasMore: true, - sortBy: 'date', - searchManager: null, - searchOptions: { - title: true, - tags: true, - loraName: true, - loraModel: true - }, - filters: { - baseModel: [], - tags: {}, - license: {}, - modelTypes: [], - search: '' - }, + sortBy: 'date:desc', + activeFolder: getStorageItem('recipes_activeFolder'), + searchManager: null, + searchOptions: { + title: true, + tags: true, + loraName: true, + loraModel: true, + recursive: getStorageItem('recipes_recursiveSearch', true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [], + search: '' + }, pageSize: 20, showFavoritesOnly: false, duplicatesMode: false, bulkMode: false, selectedModels: new Set(), }, - + [MODEL_TYPES.CHECKPOINT]: { currentPage: 1, isLoading: false, @@ -124,19 +126,19 @@ export const state = { sortBy: 'name', activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`), previewVersions: checkpointPreviewVersions, - searchManager: null, - searchOptions: { - filename: true, - modelname: true, - creator: false, - recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true), - }, - filters: { - baseModel: [], - tags: {}, - license: {}, - modelTypes: [] - }, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + creator: false, + recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [] + }, modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model' bulkMode: false, selectedModels: new Set(), @@ -145,7 +147,7 @@ export const state = { showUpdateAvailableOnly: false, duplicatesMode: false, }, - + [MODEL_TYPES.EMBEDDING]: { currentPage: 1, isLoading: false, @@ -154,20 +156,20 @@ export const state = { activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`), activeLetterFilter: null, previewVersions: embeddingPreviewVersions, - searchManager: null, - searchOptions: { - filename: true, - modelname: true, - tags: false, - creator: false, - recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true), - }, - filters: { - baseModel: [], - tags: {}, - license: {}, - modelTypes: [] - }, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + creator: false, + recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [] + }, bulkMode: false, selectedModels: new Set(), metadataCache: new Map(), @@ -176,45 +178,45 @@ export const state = { duplicatesMode: false, } }, - + // Current active page - use MODEL_TYPES constants currentPageType: MODEL_TYPES.LORA, - + // Backward compatibility - proxy properties get currentPage() { return this.pages[this.currentPageType].currentPage; }, set currentPage(value) { this.pages[this.currentPageType].currentPage = value; }, - + get isLoading() { return this.pages[this.currentPageType].isLoading; }, set isLoading(value) { this.pages[this.currentPageType].isLoading = value; }, - + get hasMore() { return this.pages[this.currentPageType].hasMore; }, set hasMore(value) { this.pages[this.currentPageType].hasMore = value; }, - + get sortBy() { return this.pages[this.currentPageType].sortBy; }, set sortBy(value) { this.pages[this.currentPageType].sortBy = value; }, - + get activeFolder() { return this.pages[this.currentPageType].activeFolder; }, set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; }, - + get loadingManager() { return this.global.loadingManager; }, set loadingManager(value) { this.global.loadingManager = value; }, - + get observer() { return this.global.observer; }, set observer(value) { this.global.observer = value; }, - + get previewVersions() { return this.pages.loras.previewVersions; }, set previewVersions(value) { this.pages.loras.previewVersions = value; }, - + get searchManager() { return this.pages[this.currentPageType].searchManager; }, set searchManager(value) { this.pages[this.currentPageType].searchManager = value; }, - + get searchOptions() { return this.pages[this.currentPageType].searchOptions; }, set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; }, - + get filters() { return this.pages[this.currentPageType].filters; }, set filters(value) { this.pages[this.currentPageType].filters = value; }, - - get bulkMode() { + + get bulkMode() { const currentType = this.currentPageType; if (currentType === MODEL_TYPES.LORA) { return this.pages.loras.bulkMode; @@ -222,7 +224,7 @@ export const state = { return this.pages[currentType].bulkMode; } }, - set bulkMode(value) { + set bulkMode(value) { const currentType = this.currentPageType; if (currentType === MODEL_TYPES.LORA) { this.pages.loras.bulkMode = value; @@ -230,11 +232,11 @@ export const state = { this.pages[currentType].bulkMode = value; } }, - + get selectedLoras() { return this.pages.loras.selectedLoras; }, set selectedLoras(value) { this.pages.loras.selectedLoras = value; }, - - get selectedModels() { + + get selectedModels() { const currentType = this.currentPageType; if (currentType === MODEL_TYPES.LORA) { return this.pages.loras.selectedLoras; @@ -242,7 +244,7 @@ export const state = { return this.pages[currentType].selectedModels; } }, - set selectedModels(value) { + set selectedModels(value) { const currentType = this.currentPageType; if (currentType === MODEL_TYPES.LORA) { this.pages.loras.selectedLoras = value; @@ -250,10 +252,10 @@ export const state = { this.pages[currentType].selectedModels = value; } }, - + get loraMetadataCache() { return this.pages.loras.loraMetadataCache; }, set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; }, - + get settings() { return this.global.settings; }, set settings(value) { this.global.settings = value; } }; diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index 9fd37bfe..59630f57 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -12,13 +12,13 @@ export class VirtualScroller { this.scrollContainer = options.scrollContainer || this.containerElement; this.batchSize = options.batchSize || 50; this.pageSize = options.pageSize || 100; - this.itemAspectRatio = 896/1152; // Aspect ratio of cards + this.itemAspectRatio = 896 / 1152; // Aspect ratio of cards this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px) - + // Add container padding properties this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS - + // Add data windowing enable/disable flag this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false; @@ -73,15 +73,15 @@ export class VirtualScroller { this.spacerElement.style.width = '100%'; this.spacerElement.style.height = '0px'; // Will be updated as items are loaded this.spacerElement.style.pointerEvents = 'none'; - + // The grid will be used for the actual visible items this.gridElement.style.position = 'relative'; this.gridElement.style.minHeight = '0'; - + // Apply padding directly to ensure consistency this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`; this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`; - + // Place the spacer inside the grid container this.gridElement.appendChild(this.spacerElement); } @@ -97,16 +97,16 @@ export class VirtualScroller { const containerStyle = getComputedStyle(this.containerElement); const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0; const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0; - + // Calculate available content width (excluding padding) const availableContentWidth = containerWidth - paddingLeft - paddingRight; - + // Get display density setting const displayDensity = state.global.settings?.display_density || 'default'; - + // Set exact column counts and grid widths to match CSS container widths let maxColumns, maxGridWidth; - + // Match exact column counts and CSS container width values based on density if (window.innerWidth >= 3000) { // 4K if (displayDensity === 'default') { @@ -137,17 +137,17 @@ export class VirtualScroller { } maxGridWidth = 1400; // Match exact CSS container width for 1080p } - + // Calculate baseCardWidth based on desired column count and available space // Formula: (maxGridWidth - (columns-1)*gap) / columns const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns; - + // Use the smaller of available content width or max grid width const actualGridWidth = Math.min(availableContentWidth, maxGridWidth); - + // Set exact column count based on screen size and mode this.columnsCount = maxColumns; - + // When available width is smaller than maxGridWidth, recalculate columns if (availableContentWidth < maxGridWidth) { // Calculate how many columns can fit in the available space @@ -155,30 +155,30 @@ export class VirtualScroller { (availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap) )); } - + // Calculate actual item width this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount; - + // Calculate height based on aspect ratio this.itemHeight = this.itemWidth / this.itemAspectRatio; - + // Calculate the left offset to center the grid within the content area this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); // Update grid element max-width to match available width this.gridElement.style.maxWidth = `${actualGridWidth}px`; - + // Add or remove density classes for style adjustments this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density'); this.gridElement.classList.add(`${displayDensity}-density`); - + // Update spacer height this.updateSpacerHeight(); - + // Re-render with new layout this.clearRenderedItems(); this.scheduleRender(); - + return true; } @@ -186,20 +186,20 @@ export class VirtualScroller { // Debounced scroll handler this.scrollHandler = this.debounce(() => this.handleScroll(), 10); this.scrollContainer.addEventListener('scroll', this.scrollHandler); - + // Window resize handler for layout recalculation this.resizeHandler = this.debounce(() => { this.calculateLayout(); }, 150); - + window.addEventListener('resize', this.resizeHandler); - + // Use ResizeObserver for more accurate container size detection if (typeof ResizeObserver !== 'undefined') { this.resizeObserver = new ResizeObserver(this.debounce(() => { this.calculateLayout(); }, 150)); - + this.resizeObserver.observe(this.containerElement); } } @@ -217,35 +217,35 @@ export class VirtualScroller { async loadInitialBatch() { const pageState = getCurrentPageState(); if (this.isLoading) return; - + this.isLoading = true; this.setLoadingTimeout(); // Add loading timeout safety - + try { const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize); - + // Initialize the data window with the first batch of items this.items = items || []; this.totalItems = totalItems || 0; this.hasMore = hasMore; this.dataWindow = { start: 0, end: this.items.length }; this.absoluteWindowStart = 0; - + // Update the spacer height based on the total number of items this.updateSpacerHeight(); - + // Check if there are no items and show placeholder if needed if (this.items.length === 0) { this.showNoItemsPlaceholder(); } else { this.removeNoItemsPlaceholder(); } - + // Reset page state to sync with our virtual scroller pageState.currentPage = 2; // Next page to load would be 2 pageState.hasMore = this.hasMore; pageState.isLoading = false; - + return { items, totalItems, hasMore }; } catch (err) { console.error('Failed to load initial batch:', err); @@ -260,36 +260,36 @@ export class VirtualScroller { async loadMoreItems() { const pageState = getCurrentPageState(); if (this.isLoading || !this.hasMore) return; - + this.isLoading = true; pageState.isLoading = true; this.setLoadingTimeout(); // Add loading timeout safety - + try { console.log('Loading more items, page:', pageState.currentPage); const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize); - + if (items && items.length > 0) { this.items = [...this.items, ...items]; this.hasMore = hasMore; pageState.hasMore = hasMore; - + // Update page for next request pageState.currentPage++; - + // Update the spacer height this.updateSpacerHeight(); - + // Render the newly loaded items if they're in view this.scheduleRender(); - + console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`); } else { this.hasMore = false; pageState.hasMore = false; console.log('No more items to load'); } - + return items; } catch (err) { console.error('Failed to load more items:', err); @@ -305,7 +305,7 @@ export class VirtualScroller { setLoadingTimeout() { // Clear any existing timeout first this.clearLoadingTimeout(); - + // Set a new timeout to prevent loading state from getting stuck this.loadingTimeout = setTimeout(() => { if (this.isLoading) { @@ -326,15 +326,15 @@ export class VirtualScroller { updateSpacerHeight() { if (this.columnsCount === 0) return; - + // Calculate total rows needed based on total items and columns const totalRows = Math.ceil(this.totalItems / this.columnsCount); // Add row gaps to the total height calculation const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap; - + // Include container padding in the total height const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom; - + // Update spacer height to represent all items this.spacerElement.style.height = `${spacerHeight}px`; } @@ -342,28 +342,28 @@ export class VirtualScroller { getVisibleRange() { const scrollTop = this.scrollContainer.scrollTop; const viewportHeight = this.scrollContainer.clientHeight; - + // Calculate the visible row range, accounting for row gaps const rowHeight = this.itemHeight + this.rowGap; const startRow = Math.floor(scrollTop / rowHeight); const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight); - + // Add overscan for smoother scrolling const overscanRows = this.overscan; const firstRow = Math.max(0, startRow - overscanRows); const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows); - + // Calculate item indices const firstIndex = firstRow * this.columnsCount; const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount); - + return { start: firstIndex, end: lastIndex }; } // Update the scheduleRender method to check for disabled state scheduleRender() { if (this.disabled || this.renderScheduled) return; - + this.renderScheduled = true; requestAnimationFrame(() => { this.renderItems(); @@ -374,25 +374,25 @@ export class VirtualScroller { // Update the renderItems method to check for disabled state renderItems() { if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return; - + const { start, end } = this.getVisibleRange(); - + // Check if render range has significantly changed - const isSameRange = - start >= this.lastRenderRange.start && + const isSameRange = + start >= this.lastRenderRange.start && end <= this.lastRenderRange.end && Math.abs(start - this.lastRenderRange.start) < 10; - + if (isSameRange) return; - + this.lastRenderRange = { start, end }; - + // Determine which items need to be added and removed const currentIndices = new Set(); for (let i = start; i < end && i < this.items.length; i++) { currentIndices.add(i); } - + // Remove items that are no longer visible for (const [index, element] of this.renderedItems.entries()) { if (!currentIndices.has(index)) { @@ -400,10 +400,10 @@ export class VirtualScroller { this.renderedItems.delete(index); } } - + // Use DocumentFragment for batch DOM operations const fragment = document.createDocumentFragment(); - + // Add new visible items to the fragment for (let i = start; i < end && i < this.items.length; i++) { if (!this.renderedItems.has(i)) { @@ -413,17 +413,17 @@ export class VirtualScroller { this.renderedItems.set(i, element); } } - + // Add the fragment to the grid (single DOM operation) if (fragment.childNodes.length > 0) { this.gridElement.appendChild(fragment); } - + // If we're close to the end and have more items to load, fetch them if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) { this.loadMoreItems(); } - + // Check if we need to slide the data window this.slideDataWindow(); } @@ -439,14 +439,14 @@ export class VirtualScroller { this.totalItems = totalItems || 0; this.hasMore = hasMore; this.updateSpacerHeight(); - + // Check if there are no items and show placeholder if needed if (this.items.length === 0) { this.showNoItemsPlaceholder(); } else { this.removeNoItemsPlaceholder(); } - + // Clear all rendered items and redraw this.clearRenderedItems(); this.scheduleRender(); @@ -455,29 +455,29 @@ export class VirtualScroller { createItemElement(item, index) { // Create the DOM element const element = this.createItemFn(item); - + // Add virtual scroll item class element.classList.add('virtual-scroll-item'); - + // Calculate the position const row = Math.floor(index / this.columnsCount); const col = index % this.columnsCount; - + // Calculate precise positions with row gap included // Add the top padding to account for container padding const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap)); - + // Position correctly with leftOffset (no need to add padding as absolute // positioning is already relative to the padding edge of the container) const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap)); - + // Position the element with absolute positioning element.style.position = 'absolute'; element.style.left = `${leftPos}px`; element.style.top = `${topPos}px`; element.style.width = `${this.itemWidth}px`; element.style.height = `${this.itemHeight}px`; - + return element; } @@ -486,17 +486,17 @@ export class VirtualScroller { const scrollTop = this.scrollContainer.scrollTop; this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; this.lastScrollTop = scrollTop; - + // Handle large jumps in scroll position - check if we need to fetch a new window const { scrollHeight } = this.scrollContainer; const scrollRatio = scrollTop / scrollHeight; - + // Only perform data windowing if the feature is enabled if (this.enableDataWindowing && this.totalItems > this.windowSize) { const estimatedIndex = Math.floor(scrollRatio * this.totalItems); const currentWindowStart = this.absoluteWindowStart; const currentWindowEnd = currentWindowStart + this.items.length; - + // If the estimated position is outside our current window by a significant amount if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) { // Fetch a new data window centered on the estimated position @@ -504,14 +504,14 @@ export class VirtualScroller { return; // Skip normal rendering until new data is loaded } } - + // Render visible items this.scheduleRender(); - + // If we're near the bottom and have more items, load them const { clientHeight } = this.scrollContainer; const scrollBottom = scrollTop + clientHeight; - + // Fix the threshold calculation - use percentage of remaining height instead // We'll trigger loading when within 20% of the bottom of rendered content const remainingScroll = scrollHeight - scrollBottom; @@ -521,9 +521,9 @@ export class VirtualScroller { // Or when within 2 rows of content from the bottom, whichever is larger (this.itemHeight + this.rowGap) * 2 ); - + const shouldLoadMore = remainingScroll <= scrollThreshold; - + if (shouldLoadMore && this.hasMore && !this.isLoading) { this.loadMoreItems(); } @@ -533,40 +533,40 @@ export class VirtualScroller { async fetchDataWindow(targetIndex) { // Skip if data windowing is disabled or already fetching if (!this.enableDataWindowing || this.fetchingWindow) return; - + this.fetchingWindow = true; - + try { // Calculate which page we need to fetch based on target index const targetPage = Math.floor(targetIndex / this.pageSize) + 1; console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`); - + const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize); - + if (items && items.length > 0) { // Calculate new absolute window start this.absoluteWindowStart = (targetPage - 1) * this.pageSize; - + // Replace the entire data window with new items this.items = items; - this.dataWindow = { + this.dataWindow = { start: 0, end: items.length }; - + this.totalItems = totalItems || 0; this.hasMore = hasMore; - + // Update the current page for future fetches const pageState = getCurrentPageState(); pageState.currentPage = targetPage + 1; pageState.hasMore = hasMore; - + // Update the spacer height and clear current rendered items this.updateSpacerHeight(); this.clearRenderedItems(); this.scheduleRender(); - + console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`); } } catch (err) { @@ -581,37 +581,37 @@ export class VirtualScroller { async slideDataWindow() { // Skip if data windowing is disabled if (!this.enableDataWindowing) return; - + const { start, end } = this.getVisibleRange(); const windowStart = this.dataWindow.start; const windowEnd = this.dataWindow.end; const absoluteIndex = this.absoluteWindowStart + windowStart; - + // Calculate the midpoint of the visible range const visibleMidpoint = Math.floor((start + end) / 2); const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint; - + // Check if we're too close to the window edges const closeToStart = start - windowStart < this.windowPadding; const closeToEnd = windowEnd - end < this.windowPadding; - + // If we're close to either edge and have total items > window size if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) { // Calculate a new target index centered around the current viewport const halfWindow = Math.floor(this.windowSize / 2); const targetIndex = Math.max(0, absoluteMidpoint - halfWindow); - + // Don't fetch a new window if we're already showing items near the beginning if (targetIndex === 0 && this.absoluteWindowStart === 0) { return; } - + // Don't fetch if we're showing the end of the list and are near the end - if (this.absoluteWindowStart + this.items.length >= this.totalItems && + if (this.absoluteWindowStart + this.items.length >= this.totalItems && this.totalItems - end < halfWindow) { return; } - + // Fetch the new data window await this.fetchDataWindow(targetIndex); } @@ -620,18 +620,18 @@ export class VirtualScroller { reset() { // Remove all rendered items this.clearRenderedItems(); - + // Reset state this.items = []; this.totalItems = 0; this.hasMore = true; - + // Reset spacer height this.spacerElement.style.height = '0px'; - + // Remove any placeholder this.removeNoItemsPlaceholder(); - + // Schedule a re-render this.scheduleRender(); } @@ -640,21 +640,21 @@ export class VirtualScroller { // Remove event listeners this.scrollContainer.removeEventListener('scroll', this.scrollHandler); window.removeEventListener('resize', this.resizeHandler); - + // Clean up the resize observer if present if (this.resizeObserver) { this.resizeObserver.disconnect(); } - + // Remove rendered elements this.clearRenderedItems(); - + // Remove spacer this.spacerElement.remove(); - + // Remove virtual scroll class this.gridElement.classList.remove('virtual-scroll'); - + // Clear any pending timeout this.clearLoadingTimeout(); } @@ -663,19 +663,19 @@ export class VirtualScroller { showNoItemsPlaceholder(message) { // Remove any existing placeholder first this.removeNoItemsPlaceholder(); - + // Create placeholder message const placeholder = document.createElement('div'); placeholder.className = 'placeholder-message'; - + // Determine appropriate message based on page type let placeholderText = ''; - + if (message) { placeholderText = message; } else { const pageType = state.currentPageType; - + if (pageType === 'recipes') { placeholderText = `

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 @@
{% set current_path = request.path %} {% if current_path.startswith('/loras/recipes') %} - {% set current_page = 'recipes' %} + {% set current_page = 'recipes' %} {% elif current_path.startswith('/checkpoints') %} - {% set current_page = 'checkpoints' %} + {% set current_page = 'checkpoints' %} {% elif current_path.startswith('/embeddings') %} - {% set current_page = 'embeddings' %} + {% set current_page = 'embeddings' %} {% elif current_path.startswith('/statistics') %} - {% set current_page = 'statistics' %} + {% set current_page = 'statistics' %} {% else %} - {% set current_page = 'loras' %} + {% set current_page = 'loras' %} {% endif %} {% set search_disabled = current_page == 'statistics' %} - {% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~ current_page %} + {% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~ + current_page %} {% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %} - +
- + - -
- +
@@ -97,6 +105,7 @@
{{ t('header.search.filters.tags') }}
{{ t('header.search.filters.loraName') }}
{{ t('header.search.filters.loraModel') }}
+
{{ t('header.search.filters.prompt') }}
{% elif request.path == '/checkpoints' %}
{{ t('header.search.filters.filename') }}
{{ t('header.search.filters.modelname') }}
@@ -165,4 +174,4 @@ {{ t('header.filter.clearAll') }}
-
+ \ No newline at end of file diff --git a/templates/recipes.html b/templates/recipes.html index 202791a2..2d7392d2 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -15,17 +15,26 @@ {% endblock %} @@ -34,53 +43,92 @@ {% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %} {% block content %} - -
-
-
- -
-
- -
- -
- -
- -