mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -03:00
Merge branch 'sort-by-usage-count' into main
This commit is contained in:
84
GEMINI.md
Normal file
84
GEMINI.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# ComfyUI LoRA Manager
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
ComfyUI LoRA Manager is a comprehensive extension for ComfyUI that streamlines the organization, downloading, and application of LoRA models. It functions as both a custom node within ComfyUI and a standalone application.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
* **Model Management:** Browse, organize, and download LoRA models (and Checkpoints/Embeddings) from Civitai and CivArchive.
|
||||||
|
* **Visualization:** Preview images, videos, and trigger words.
|
||||||
|
* **Workflow Integration:** "One-click" integration into ComfyUI workflows, preserving generation parameters.
|
||||||
|
* **Recipe System:** Save and share LoRA combinations as "recipes".
|
||||||
|
* **Architecture:** Hybrid Python backend (API, file management) and JavaScript/HTML frontend (Web UI).
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
* `py/`: Core Python backend source code.
|
||||||
|
* `lora_manager.py`: Main entry point for the ComfyUI node.
|
||||||
|
* `routes/`: API route definitions (using `aiohttp` in standalone, or ComfyUI's server).
|
||||||
|
* `services/`: Business logic (downloading, metadata, scanning).
|
||||||
|
* `nodes/`: ComfyUI custom node implementations.
|
||||||
|
* `static/`: Frontend static assets (CSS, JS, Images).
|
||||||
|
* `templates/`: HTML templates (Jinja2).
|
||||||
|
* `locales/`: Internationalization JSON files.
|
||||||
|
* `web/comfyui/`: JavaScript extensions specifically for the ComfyUI interface.
|
||||||
|
* `standalone.py`: Entry point for running the manager as a standalone web app.
|
||||||
|
* `tests/`: Backend tests.
|
||||||
|
* `requirements.txt`: Python runtime dependencies.
|
||||||
|
* `package.json`: Frontend development dependencies and test scripts.
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
* Python 3.8+
|
||||||
|
* Node.js (only for running frontend tests)
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
1. Install Python dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running in Standalone Mode
|
||||||
|
You can run the manager independently of ComfyUI for development or management purposes.
|
||||||
|
```bash
|
||||||
|
python standalone.py --port 8188
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running in ComfyUI
|
||||||
|
Ensure the folder is located in `ComfyUI/custom_nodes/`. ComfyUI will automatically load it upon startup.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Backend Tests (Pytest)
|
||||||
|
1. Install development dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
2. Run tests:
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
* Coverage reports are generated in `coverage/backend/`.
|
||||||
|
|
||||||
|
### Frontend Tests (Vitest)
|
||||||
|
1. Install Node dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
2. Run tests:
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
3. Run coverage:
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
* **Python Style:** Follow PEP 8. Use snake_case for files/functions and PascalCase for classes.
|
||||||
|
* **Frontend:** Standard ES modules. UI components often end in `_widget.js`.
|
||||||
|
* **Configuration:** User settings are stored in `settings.json`. Developers should reference `settings.json.example`.
|
||||||
|
* **Localization:** Update `locales/<lang>.json` and run `scripts/sync_translation_keys.py` when changing UI text.
|
||||||
|
* **Documentation:** Architecture details are in `docs/architecture/` and `IFLOW.md`.
|
||||||
@@ -4,7 +4,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||||
from .py.nodes.prompt import PromptLoraManager
|
from .py.nodes.prompt import PromptLoraManager
|
||||||
from .py.nodes.lora_stacker import LoraStacker
|
from .py.nodes.lora_stacker import LoraStacker
|
||||||
from .py.nodes.save_image import SaveImage
|
from .py.nodes.save_image import SaveImageLM
|
||||||
from .py.nodes.debug_metadata import DebugMetadata
|
from .py.nodes.debug_metadata import DebugMetadata
|
||||||
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect
|
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect
|
||||||
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
||||||
@@ -24,7 +24,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
|
|||||||
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
|
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
|
||||||
TriggerWordToggle = importlib.import_module("py.nodes.trigger_word_toggle").TriggerWordToggle
|
TriggerWordToggle = importlib.import_module("py.nodes.trigger_word_toggle").TriggerWordToggle
|
||||||
LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker
|
LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker
|
||||||
SaveImage = importlib.import_module("py.nodes.save_image").SaveImage
|
SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM
|
||||||
DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata
|
DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata
|
||||||
WanVideoLoraSelect = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelect
|
WanVideoLoraSelect = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelect
|
||||||
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
|
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
|
||||||
@@ -36,7 +36,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
||||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||||
LoraStacker.NAME: LoraStacker,
|
LoraStacker.NAME: LoraStacker,
|
||||||
SaveImage.NAME: SaveImage,
|
SaveImageLM.NAME: SaveImageLM,
|
||||||
DebugMetadata.NAME: DebugMetadata,
|
DebugMetadata.NAME: DebugMetadata,
|
||||||
WanVideoLoraSelect.NAME: WanVideoLoraSelect,
|
WanVideoLoraSelect.NAME: WanVideoLoraSelect,
|
||||||
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText
|
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Recipe-Daten reparieren",
|
||||||
|
"loading": "Recipe-Daten werden repariert...",
|
||||||
|
"success": "{count} Rezepte erfolgreich repariert.",
|
||||||
|
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "Ersteller",
|
"creator": "Ersteller",
|
||||||
"title": "Rezept-Titel",
|
"title": "Rezept-Titel",
|
||||||
"loraName": "LoRA-Dateiname",
|
"loraName": "LoRA-Dateiname",
|
||||||
"loraModel": "LoRA-Modellname"
|
"loraModel": "LoRA-Modellname",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
|
"noTags": "Keine Tags",
|
||||||
"clearAll": "Alle Filter löschen"
|
"clearAll": "Alle Filter löschen"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "Vorschau ersetzen",
|
"replacePreview": "Vorschau ersetzen",
|
||||||
"setContentRating": "Inhaltsbewertung festlegen",
|
"setContentRating": "Inhaltsbewertung festlegen",
|
||||||
"moveToFolder": "In Ordner verschieben",
|
"moveToFolder": "In Ordner verschieben",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "Modell ausschließen",
|
"excludeModel": "Modell ausschließen",
|
||||||
"deleteModel": "Modell löschen",
|
"deleteModel": "Modell löschen",
|
||||||
"shareRecipe": "Rezept teilen",
|
"shareRecipe": "Rezept teilen",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
"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": {
|
"refresh": {
|
||||||
"title": "Rezeptliste aktualisieren"
|
"title": "Rezeptliste aktualisieren"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Gefiltert nach LoRA"
|
"filteredByLora": "Gefiltert nach LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Nur Favoriten anzeigen",
|
||||||
|
"action": "Favoriten"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} Duplikat-Gruppen gefunden",
|
"found": "{count} Duplikat-Gruppen gefunden",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||||
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||||
"dragDrop": {
|
"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": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
"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": {
|
"banners": {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"korean": "한국어",
|
"korean": "한국어",
|
||||||
"french": "Français",
|
"french": "Français",
|
||||||
"spanish": "Español",
|
"spanish": "Español",
|
||||||
"Hebrew": "עברית"
|
"Hebrew": "עברית"
|
||||||
},
|
},
|
||||||
"fileSize": {
|
"fileSize": {
|
||||||
"zero": "0 Bytes",
|
"zero": "0 Bytes",
|
||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Repair recipes data",
|
||||||
|
"loading": "Repairing recipe data...",
|
||||||
|
"success": "Successfully repaired {count} recipes.",
|
||||||
|
"error": "Recipe repair failed: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "Creator",
|
"creator": "Creator",
|
||||||
"title": "Recipe Title",
|
"title": "Recipe Title",
|
||||||
"loraName": "LoRA Filename",
|
"loraName": "LoRA Filename",
|
||||||
"loraModel": "LoRA Model Name"
|
"loraModel": "LoRA Model Name",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "License",
|
"license": "License",
|
||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
|
"noTags": "No tags",
|
||||||
"clearAll": "Clear All Filters"
|
"clearAll": "Clear All Filters"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -336,7 +344,7 @@
|
|||||||
"templateOptions": {
|
"templateOptions": {
|
||||||
"flatStructure": "Flat Structure",
|
"flatStructure": "Flat Structure",
|
||||||
"byBaseModel": "By Base Model",
|
"byBaseModel": "By Base Model",
|
||||||
"byAuthor": "By Author",
|
"byAuthor": "By Author",
|
||||||
"byFirstTag": "By First Tag",
|
"byFirstTag": "By First Tag",
|
||||||
"baseModelFirstTag": "Base Model + First Tag",
|
"baseModelFirstTag": "Base Model + First Tag",
|
||||||
"baseModelAuthor": "Base Model + Author",
|
"baseModelAuthor": "Base Model + Author",
|
||||||
@@ -347,7 +355,7 @@
|
|||||||
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
|
||||||
"modelTypes": {
|
"modelTypes": {
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"checkpoint": "Checkpoint",
|
"checkpoint": "Checkpoint",
|
||||||
"embedding": "Embedding"
|
"embedding": "Embedding"
|
||||||
},
|
},
|
||||||
"baseModelPathMappings": "Base Model Path Mappings",
|
"baseModelPathMappings": "Base Model Path Mappings",
|
||||||
@@ -420,11 +428,11 @@
|
|||||||
"proxyHost": "Proxy Host",
|
"proxyHost": "Proxy Host",
|
||||||
"proxyHostPlaceholder": "proxy.example.com",
|
"proxyHostPlaceholder": "proxy.example.com",
|
||||||
"proxyHostHelp": "The hostname or IP address of your proxy server",
|
"proxyHostHelp": "The hostname or IP address of your proxy server",
|
||||||
"proxyPort": "Proxy Port",
|
"proxyPort": "Proxy Port",
|
||||||
"proxyPortPlaceholder": "8080",
|
"proxyPortPlaceholder": "8080",
|
||||||
"proxyPortHelp": "The port number of your proxy server",
|
"proxyPortHelp": "The port number of your proxy server",
|
||||||
"proxyUsername": "Username (Optional)",
|
"proxyUsername": "Username (Optional)",
|
||||||
"proxyUsernamePlaceholder": "username",
|
"proxyUsernamePlaceholder": "username",
|
||||||
"proxyUsernameHelp": "Username for proxy authentication (if required)",
|
"proxyUsernameHelp": "Username for proxy authentication (if required)",
|
||||||
"proxyPassword": "Password (Optional)",
|
"proxyPassword": "Password (Optional)",
|
||||||
"proxyPasswordPlaceholder": "password",
|
"proxyPasswordPlaceholder": "password",
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "Replace Preview",
|
"replacePreview": "Replace Preview",
|
||||||
"setContentRating": "Set Content Rating",
|
"setContentRating": "Set Content Rating",
|
||||||
"moveToFolder": "Move to Folder",
|
"moveToFolder": "Move to Folder",
|
||||||
|
"repairMetadata": "Repair metadata",
|
||||||
"excludeModel": "Exclude Model",
|
"excludeModel": "Exclude Model",
|
||||||
"deleteModel": "Delete Model",
|
"deleteModel": "Delete Model",
|
||||||
"shareRecipe": "Share Recipe",
|
"shareRecipe": "Share Recipe",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "Please select a LoRA root directory"
|
"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": {
|
"refresh": {
|
||||||
"title": "Refresh recipe list"
|
"title": "Refresh recipe list"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtered by LoRA"
|
"filteredByLora": "Filtered by LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Show Favorites Only",
|
||||||
|
"action": "Favorites"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Found {count} duplicate groups",
|
"found": "Found {count} duplicate groups",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "No missing LoRAs to download",
|
"noMissingLoras": "No missing LoRAs to download",
|
||||||
"getInfoFailed": "Failed to get information for missing LoRAs",
|
"getInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
"prepareError": "Error preparing LoRAs for download: {message}"
|
"prepareError": "Error preparing LoRAs for download: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "Repairing recipe metadata...",
|
||||||
|
"success": "Recipe metadata repaired successfully",
|
||||||
|
"skipped": "Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "Failed to repair recipe: {message}",
|
||||||
|
"missingId": "Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||||
"collapseAllDisabled": "Not available in list view",
|
"collapseAllDisabled": "Not available in list view",
|
||||||
"dragDrop": {
|
"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": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "Failed moves:\n{failures}",
|
"bulkMoveFailures": "Failed moves:\n{failures}",
|
||||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
"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": {
|
"banners": {
|
||||||
@@ -1481,4 +1515,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Reparar datos de recetas",
|
||||||
|
"loading": "Reparando datos de recetas...",
|
||||||
|
"success": "Se repararon con éxito {count} recetas.",
|
||||||
|
"error": "Error al reparar recetas: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "Creador",
|
"creator": "Creador",
|
||||||
"title": "Título de la receta",
|
"title": "Título de la receta",
|
||||||
"loraName": "Nombre de archivo LoRA",
|
"loraName": "Nombre de archivo LoRA",
|
||||||
"loraModel": "Nombre del modelo LoRA"
|
"loraModel": "Nombre del modelo LoRA",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
|
"noTags": "Sin etiquetas",
|
||||||
"clearAll": "Limpiar todos los filtros"
|
"clearAll": "Limpiar todos los filtros"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "Reemplazar vista previa",
|
"replacePreview": "Reemplazar vista previa",
|
||||||
"setContentRating": "Establecer clasificación de contenido",
|
"setContentRating": "Establecer clasificación de contenido",
|
||||||
"moveToFolder": "Mover a carpeta",
|
"moveToFolder": "Mover a carpeta",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "Excluir modelo",
|
"excludeModel": "Excluir modelo",
|
||||||
"deleteModel": "Eliminar modelo",
|
"deleteModel": "Eliminar modelo",
|
||||||
"shareRecipe": "Compartir receta",
|
"shareRecipe": "Compartir receta",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
|
"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": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de recetas"
|
"title": "Actualizar lista de recetas"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtrado por LoRA"
|
"filteredByLora": "Filtrado por LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Mostrar solo favoritos",
|
||||||
|
"action": "Favoritos"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Se encontraron {count} grupos de duplicados",
|
"found": "Se encontraron {count} grupos de duplicados",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||||
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||||
"collapseAllDisabled": "No disponible en vista de lista",
|
"collapseAllDisabled": "No disponible en vista de lista",
|
||||||
"dragDrop": {
|
"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": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
||||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
"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": {
|
"banners": {
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Réparer les données de recettes",
|
||||||
|
"loading": "Réparation des données de recettes...",
|
||||||
|
"success": "{count} recettes réparées avec succès.",
|
||||||
|
"error": "Échec de la réparation des recettes : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "Créateur",
|
"creator": "Créateur",
|
||||||
"title": "Titre de la recipe",
|
"title": "Titre de la recipe",
|
||||||
"loraName": "Nom de fichier LoRA",
|
"loraName": "Nom de fichier LoRA",
|
||||||
"loraModel": "Nom du modèle LoRA"
|
"loraModel": "Nom du modèle LoRA",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
|
"noTags": "Aucun tag",
|
||||||
"clearAll": "Effacer tous les filtres"
|
"clearAll": "Effacer tous les filtres"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "Remplacer l'aperçu",
|
"replacePreview": "Remplacer l'aperçu",
|
||||||
"setContentRating": "Définir la classification du contenu",
|
"setContentRating": "Définir la classification du contenu",
|
||||||
"moveToFolder": "Déplacer vers un dossier",
|
"moveToFolder": "Déplacer vers un dossier",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "Exclure le modèle",
|
"excludeModel": "Exclure le modèle",
|
||||||
"deleteModel": "Supprimer le modèle",
|
"deleteModel": "Supprimer le modèle",
|
||||||
"shareRecipe": "Partager la recipe",
|
"shareRecipe": "Partager la recipe",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
|
"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": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des recipes"
|
"title": "Actualiser la liste des recipes"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtré par LoRA"
|
"filteredByLora": "Filtré par LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Afficher uniquement les favoris",
|
||||||
|
"action": "Favoris"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Trouvé {count} groupes de doublons",
|
"found": "Trouvé {count} groupes de doublons",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||||
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||||
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
|
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||||
"collapseAllDisabled": "Non disponible en vue liste",
|
"collapseAllDisabled": "Non disponible en vue liste",
|
||||||
"dragDrop": {
|
"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": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées 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": {
|
"banners": {
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "תיקון נתוני מתכונים",
|
||||||
|
"loading": "מתקן נתוני מתכונים...",
|
||||||
|
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||||
|
"error": "תיקון המתכונים נכשל: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "יוצר",
|
"creator": "יוצר",
|
||||||
"title": "כותרת מתכון",
|
"title": "כותרת מתכון",
|
||||||
"loraName": "שם קובץ LoRA",
|
"loraName": "שם קובץ LoRA",
|
||||||
"loraModel": "שם מודל LoRA"
|
"loraModel": "שם מודל LoRA",
|
||||||
|
"prompt": "הנחיה"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "רישיון",
|
"license": "רישיון",
|
||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
|
"noTags": "ללא תגיות",
|
||||||
"clearAll": "נקה את כל המסננים"
|
"clearAll": "נקה את כל המסננים"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -228,6 +236,7 @@
|
|||||||
"videoSettings": "הגדרות וידאו",
|
"videoSettings": "הגדרות וידאו",
|
||||||
"layoutSettings": "הגדרות פריסה",
|
"layoutSettings": "הגדרות פריסה",
|
||||||
"folderSettings": "הגדרות תיקייה",
|
"folderSettings": "הגדרות תיקייה",
|
||||||
|
"priorityTags": "תגיות עדיפות",
|
||||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||||
"exampleImages": "תמונות דוגמה",
|
"exampleImages": "תמונות דוגמה",
|
||||||
"updateFlags": "תגי עדכון",
|
"updateFlags": "תגי עדכון",
|
||||||
@@ -235,8 +244,7 @@
|
|||||||
"misc": "שונות",
|
"misc": "שונות",
|
||||||
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
|
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
|
||||||
"storageLocation": "מיקום ההגדרות",
|
"storageLocation": "מיקום ההגדרות",
|
||||||
"proxySettings": "הגדרות פרוקסי",
|
"proxySettings": "הגדרות פרוקסי"
|
||||||
"priorityTags": "תגיות עדיפות"
|
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"locationLabel": "מצב נייד",
|
"locationLabel": "מצב נייד",
|
||||||
@@ -309,6 +317,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||||
"noDefault": "אין ברירת מחדל"
|
"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": {
|
"downloadPathTemplates": {
|
||||||
"title": "תבניות נתיב הורדה",
|
"title": "תבניות נתיב הורדה",
|
||||||
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
|
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
|
||||||
@@ -320,8 +348,8 @@
|
|||||||
"byFirstTag": "לפי תגית ראשונה",
|
"byFirstTag": "לפי תגית ראשונה",
|
||||||
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
|
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
|
||||||
"baseModelAuthor": "מודל בסיס + יוצר",
|
"baseModelAuthor": "מודל בסיס + יוצר",
|
||||||
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
|
||||||
"authorFirstTag": "יוצר + תגית ראשונה",
|
"authorFirstTag": "יוצר + תגית ראשונה",
|
||||||
|
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
||||||
"customTemplate": "תבנית מותאמת אישית"
|
"customTemplate": "תבנית מותאמת אישית"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
|
||||||
@@ -409,26 +437,6 @@
|
|||||||
"proxyPassword": "סיסמה (אופציונלי)",
|
"proxyPassword": "סיסמה (אופציונלי)",
|
||||||
"proxyPasswordPlaceholder": "password",
|
"proxyPasswordPlaceholder": "password",
|
||||||
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
"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": {
|
"loras": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "החלף תצוגה מקדימה",
|
"replacePreview": "החלף תצוגה מקדימה",
|
||||||
"setContentRating": "הגדר דירוג תוכן",
|
"setContentRating": "הגדר דירוג תוכן",
|
||||||
"moveToFolder": "העבר לתיקייה",
|
"moveToFolder": "העבר לתיקייה",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "החרג מודל",
|
"excludeModel": "החרג מודל",
|
||||||
"deleteModel": "מחק מודל",
|
"deleteModel": "מחק מודל",
|
||||||
"shareRecipe": "שתף מתכון",
|
"shareRecipe": "שתף מתכון",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "מיון מתכונים לפי...",
|
||||||
|
"name": "שם",
|
||||||
|
"nameAsc": "א - ת",
|
||||||
|
"nameDesc": "ת - א",
|
||||||
|
"date": "תאריך",
|
||||||
|
"dateDesc": "הכי חדש",
|
||||||
|
"dateAsc": "הכי ישן",
|
||||||
|
"lorasCount": "מספר LoRAs",
|
||||||
|
"lorasCountDesc": "הכי הרבה",
|
||||||
|
"lorasCountAsc": "הכי פחות"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מתכונים"
|
"title": "רענן רשימת מתכונים"
|
||||||
},
|
},
|
||||||
"filteredByLora": "מסונן לפי LoRA"
|
"filteredByLora": "מסונן לפי LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "הצג מועדפים בלבד",
|
||||||
|
"action": "מועדפים"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "נמצאו {count} קבוצות כפולות",
|
"found": "נמצאו {count} קבוצות כפולות",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה."
|
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
||||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}"
|
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "レシピデータの修復",
|
||||||
|
"loading": "レシピデータを修復中...",
|
||||||
|
"success": "{count} 件のレシピを正常に修復しました。",
|
||||||
|
"error": "レシピの修復に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "作成者",
|
"creator": "作成者",
|
||||||
"title": "レシピタイトル",
|
"title": "レシピタイトル",
|
||||||
"loraName": "LoRAファイル名",
|
"loraName": "LoRAファイル名",
|
||||||
"loraModel": "LoRAモデル名"
|
"loraModel": "LoRAモデル名",
|
||||||
|
"prompt": "プロンプト"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
|
"noTags": "タグなし",
|
||||||
"clearAll": "すべてのフィルタをクリア"
|
"clearAll": "すべてのフィルタをクリア"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "プレビューを置換",
|
"replacePreview": "プレビューを置換",
|
||||||
"setContentRating": "コンテンツレーティングを設定",
|
"setContentRating": "コンテンツレーティングを設定",
|
||||||
"moveToFolder": "フォルダに移動",
|
"moveToFolder": "フォルダに移動",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "モデルを除外",
|
"excludeModel": "モデルを除外",
|
||||||
"deleteModel": "モデルを削除",
|
"deleteModel": "モデルを削除",
|
||||||
"shareRecipe": "レシピを共有",
|
"shareRecipe": "レシピを共有",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "レシピの並び替え...",
|
||||||
|
"name": "名前",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "日付",
|
||||||
|
"dateDesc": "新しい順",
|
||||||
|
"dateAsc": "古い順",
|
||||||
|
"lorasCount": "LoRA数",
|
||||||
|
"lorasCountDesc": "多い順",
|
||||||
|
"lorasCountAsc": "少ない順"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "レシピリストを更新"
|
"title": "レシピリストを更新"
|
||||||
},
|
},
|
||||||
"filteredByLora": "LoRAでフィルタ済み"
|
"filteredByLora": "LoRAでフィルタ済み",
|
||||||
|
"favorites": {
|
||||||
|
"title": "お気に入りのみ表示",
|
||||||
|
"action": "お気に入り"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} 個の重複グループが見つかりました",
|
"found": "{count} 個の重複グループが見つかりました",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||||
"collapseAllDisabled": "リストビューでは利用できません",
|
"collapseAllDisabled": "リストビューでは利用できません",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "移動先のパスを特定できません。"
|
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}"
|
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "레시피 데이터 복구",
|
||||||
|
"loading": "레시피 데이터 복구 중...",
|
||||||
|
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||||
|
"error": "레시피 복구 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "제작자",
|
"creator": "제작자",
|
||||||
"title": "레시피 제목",
|
"title": "레시피 제목",
|
||||||
"loraName": "LoRA 파일명",
|
"loraName": "LoRA 파일명",
|
||||||
"loraModel": "LoRA 모델명"
|
"loraModel": "LoRA 모델명",
|
||||||
|
"prompt": "프롬프트"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
|
"noTags": "태그 없음",
|
||||||
"clearAll": "모든 필터 지우기"
|
"clearAll": "모든 필터 지우기"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "미리보기 교체",
|
"replacePreview": "미리보기 교체",
|
||||||
"setContentRating": "콘텐츠 등급 설정",
|
"setContentRating": "콘텐츠 등급 설정",
|
||||||
"moveToFolder": "폴더로 이동",
|
"moveToFolder": "폴더로 이동",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "모델 제외",
|
"excludeModel": "모델 제외",
|
||||||
"deleteModel": "모델 삭제",
|
"deleteModel": "모델 삭제",
|
||||||
"shareRecipe": "레시피 공유",
|
"shareRecipe": "레시피 공유",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "레시피 정렬...",
|
||||||
|
"name": "이름",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "날짜",
|
||||||
|
"dateDesc": "최신순",
|
||||||
|
"dateAsc": "오래된순",
|
||||||
|
"lorasCount": "LoRA 수",
|
||||||
|
"lorasCountDesc": "많은순",
|
||||||
|
"lorasCountAsc": "적은순"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "레시피 목록 새로고침"
|
"title": "레시피 목록 새로고침"
|
||||||
},
|
},
|
||||||
"filteredByLora": "LoRA로 필터링됨"
|
"filteredByLora": "LoRA로 필터링됨",
|
||||||
|
"favorites": {
|
||||||
|
"title": "즐겨찾기만 표시",
|
||||||
|
"action": "즐겨찾기"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count}개의 중복 그룹 발견",
|
"found": "{count}개의 중복 그룹 발견",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다."
|
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}"
|
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Восстановить данные рецептов",
|
||||||
|
"loading": "Восстановление данных рецептов...",
|
||||||
|
"success": "Успешно восстановлено {count} рецептов.",
|
||||||
|
"error": "Ошибка восстановления рецептов: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "Автор",
|
"creator": "Автор",
|
||||||
"title": "Название рецепта",
|
"title": "Название рецепта",
|
||||||
"loraName": "Имя файла LoRA",
|
"loraName": "Имя файла LoRA",
|
||||||
"loraModel": "Название модели LoRA"
|
"loraModel": "Название модели LoRA",
|
||||||
|
"prompt": "Запрос"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
|
"noTags": "Без тегов",
|
||||||
"clearAll": "Очистить все фильтры"
|
"clearAll": "Очистить все фильтры"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "Заменить превью",
|
"replacePreview": "Заменить превью",
|
||||||
"setContentRating": "Установить рейтинг контента",
|
"setContentRating": "Установить рейтинг контента",
|
||||||
"moveToFolder": "Переместить в папку",
|
"moveToFolder": "Переместить в папку",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "Исключить модель",
|
"excludeModel": "Исключить модель",
|
||||||
"deleteModel": "Удалить модель",
|
"deleteModel": "Удалить модель",
|
||||||
"shareRecipe": "Поделиться рецептом",
|
"shareRecipe": "Поделиться рецептом",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Сортировка рецептов...",
|
||||||
|
"name": "Имя",
|
||||||
|
"nameAsc": "А - Я",
|
||||||
|
"nameDesc": "Я - А",
|
||||||
|
"date": "Дата",
|
||||||
|
"dateDesc": "Сначала новые",
|
||||||
|
"dateAsc": "Сначала старые",
|
||||||
|
"lorasCount": "Кол-во LoRA",
|
||||||
|
"lorasCountDesc": "Больше всего",
|
||||||
|
"lorasCountAsc": "Меньше всего"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список рецептов"
|
"title": "Обновить список рецептов"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Фильтр по LoRA"
|
"filteredByLora": "Фильтр по LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Только избранные",
|
||||||
|
"action": "Избранное"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Найдено {count} групп дубликатов",
|
"found": "Найдено {count} групп дубликатов",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||||
"collapseAllDisabled": "Недоступно в виде списка",
|
"collapseAllDisabled": "Недоступно в виде списка",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения."
|
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
||||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}"
|
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "修复配方数据",
|
||||||
|
"loading": "正在修复配方数据...",
|
||||||
|
"success": "成功修复了 {count} 个配方。",
|
||||||
|
"error": "配方修复失败:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "创作者",
|
"creator": "创作者",
|
||||||
"title": "配方标题",
|
"title": "配方标题",
|
||||||
"loraName": "LoRA 文件名",
|
"loraName": "LoRA 文件名",
|
||||||
"loraModel": "LoRA 模型名称"
|
"loraModel": "LoRA 模型名称",
|
||||||
|
"prompt": "提示词"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
|
"noTags": "无标签",
|
||||||
"clearAll": "清除所有筛选"
|
"clearAll": "清除所有筛选"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "替换预览",
|
"replacePreview": "替换预览",
|
||||||
"setContentRating": "设置内容评级",
|
"setContentRating": "设置内容评级",
|
||||||
"moveToFolder": "移动到文件夹",
|
"moveToFolder": "移动到文件夹",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
"deleteModel": "删除模型",
|
"deleteModel": "删除模型",
|
||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "请选择 LoRA 根目录"
|
"selectLoraRoot": "请选择 LoRA 根目录"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "配方排序...",
|
||||||
|
"name": "名称",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "时间",
|
||||||
|
"dateDesc": "最新",
|
||||||
|
"dateAsc": "最早",
|
||||||
|
"lorasCount": "LoRA 数量",
|
||||||
|
"lorasCountDesc": "最多",
|
||||||
|
"lorasCountAsc": "最少"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新配方列表"
|
"title": "刷新配方列表"
|
||||||
},
|
},
|
||||||
"filteredByLora": "按 LoRA 筛选"
|
"filteredByLora": "按 LoRA 筛选",
|
||||||
|
"favorites": {
|
||||||
|
"title": "仅显示收藏",
|
||||||
|
"action": "收藏"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "发现 {count} 个重复组",
|
"found": "发现 {count} 个重复组",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
"prepareError": "准备下载 LoRA 时出错:{message}"
|
"prepareError": "准备下载 LoRA 时出错:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||||
"collapseAllDisabled": "列表视图下不可用",
|
"collapseAllDisabled": "列表视图下不可用",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "无法确定移动的目标路径。"
|
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "移动失败:\n{failures}",
|
"bulkMoveFailures": "移动失败:\n{failures}",
|
||||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}"
|
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -154,11 +154,17 @@
|
|||||||
"error": "清理範例圖片資料夾失敗:{message}"
|
"error": "清理範例圖片資料夾失敗:{message}"
|
||||||
},
|
},
|
||||||
"fetchMissingLicenses": {
|
"fetchMissingLicenses": {
|
||||||
"label": "Refresh license metadata",
|
"label": "重新整理授權中繼資料",
|
||||||
"loading": "Refreshing license metadata for {typePlural}...",
|
"loading": "正在重新整理 {typePlural} 的授權中繼資料...",
|
||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "已更新 {count} 個 {typePlural} 的授權中繼資料",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "所有 {typePlural} 已具備授權中繼資料",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "修復配方資料",
|
||||||
|
"loading": "正在修復配方資料...",
|
||||||
|
"success": "成功修復 {count} 個配方。",
|
||||||
|
"error": "配方修復失敗:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -188,7 +194,8 @@
|
|||||||
"creator": "創作者",
|
"creator": "創作者",
|
||||||
"title": "配方標題",
|
"title": "配方標題",
|
||||||
"loraName": "LoRA 檔案名稱",
|
"loraName": "LoRA 檔案名稱",
|
||||||
"loraModel": "LoRA 模型名稱"
|
"loraModel": "LoRA 模型名稱",
|
||||||
|
"prompt": "提示詞"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -199,6 +206,7 @@
|
|||||||
"license": "授權",
|
"license": "授權",
|
||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
|
"noTags": "無標籤",
|
||||||
"clearAll": "清除所有篩選"
|
"clearAll": "清除所有篩選"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -521,6 +529,7 @@
|
|||||||
"replacePreview": "更換預覽圖",
|
"replacePreview": "更換預覽圖",
|
||||||
"setContentRating": "設定內容分級",
|
"setContentRating": "設定內容分級",
|
||||||
"moveToFolder": "移動到資料夾",
|
"moveToFolder": "移動到資料夾",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
"deleteModel": "刪除模型",
|
"deleteModel": "刪除模型",
|
||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
@@ -591,10 +600,26 @@
|
|||||||
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "配方排序...",
|
||||||
|
"name": "名稱",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "時間",
|
||||||
|
"dateDesc": "最新",
|
||||||
|
"dateAsc": "最舊",
|
||||||
|
"lorasCount": "LoRA 數量",
|
||||||
|
"lorasCountDesc": "最多",
|
||||||
|
"lorasCountAsc": "最少"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理配方列表"
|
"title": "重新整理配方列表"
|
||||||
},
|
},
|
||||||
"filteredByLora": "已依 LoRA 篩選"
|
"filteredByLora": "已依 LoRA 篩選",
|
||||||
|
"favorites": {
|
||||||
|
"title": "僅顯示收藏",
|
||||||
|
"action": "收藏"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "發現 {count} 組重複項",
|
"found": "發現 {count} 組重複項",
|
||||||
@@ -620,6 +645,13 @@
|
|||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -641,7 +673,8 @@
|
|||||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||||
"collapseAllDisabled": "列表檢視下不可用",
|
"collapseAllDisabled": "列表檢視下不可用",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "無法確定移動的目標路徑。"
|
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1463,7 +1496,8 @@
|
|||||||
"bulkMoveFailures": "移動失敗:\n{failures}",
|
"bulkMoveFailures": "移動失敗:\n{failures}",
|
||||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}"
|
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
131
py/config.py
131
py/config.py
@@ -1,11 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import folder_paths # type: ignore
|
import folder_paths # type: ignore
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import time
|
||||||
|
|
||||||
from .utils.settings_paths import ensure_settings_file, get_settings_dir, load_settings_template
|
from .utils.settings_paths import ensure_settings_file, get_settings_dir, load_settings_template
|
||||||
|
|
||||||
@@ -80,6 +82,8 @@ class Config:
|
|||||||
self._path_mappings: Dict[str, str] = {}
|
self._path_mappings: Dict[str, str] = {}
|
||||||
# Normalized preview root directories used to validate preview access
|
# Normalized preview root directories used to validate preview access
|
||||||
self._preview_root_paths: Set[Path] = set()
|
self._preview_root_paths: Set[Path] = set()
|
||||||
|
# Optional background rescan thread
|
||||||
|
self._rescan_thread: Optional[threading.Thread] = None
|
||||||
self.loras_roots = self._init_lora_paths()
|
self.loras_roots = self._init_lora_paths()
|
||||||
self.checkpoints_roots = None
|
self.checkpoints_roots = None
|
||||||
self.unet_roots = None
|
self.unet_roots = None
|
||||||
@@ -282,58 +286,25 @@ class Config:
|
|||||||
def _load_symlink_cache(self) -> bool:
|
def _load_symlink_cache(self) -> bool:
|
||||||
cache_path = self._get_symlink_cache_path()
|
cache_path = self._get_symlink_cache_path()
|
||||||
if not cache_path.exists():
|
if not cache_path.exists():
|
||||||
|
logger.info("Symlink cache not found at %s", cache_path)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with cache_path.open("r", encoding="utf-8") as handle:
|
with cache_path.open("r", encoding="utf-8") as handle:
|
||||||
payload = json.load(handle)
|
payload = json.load(handle)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to load symlink cache %s: %s", cache_path, exc)
|
logger.info("Failed to load symlink cache %s: %s", cache_path, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
|
logger.info("Symlink cache payload is not a dict: %s", type(payload))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
cached_fingerprint = payload.get("fingerprint")
|
|
||||||
cached_mappings = payload.get("path_mappings")
|
cached_mappings = payload.get("path_mappings")
|
||||||
if not isinstance(cached_fingerprint, dict) or not isinstance(cached_mappings, Mapping):
|
if not isinstance(cached_mappings, Mapping):
|
||||||
|
logger.info("Symlink cache missing path mappings")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
current_fingerprint = self._build_symlink_fingerprint()
|
|
||||||
cached_roots = cached_fingerprint.get("roots")
|
|
||||||
cached_stats = cached_fingerprint.get("stats")
|
|
||||||
if (
|
|
||||||
not isinstance(cached_roots, list)
|
|
||||||
or not isinstance(cached_stats, Mapping)
|
|
||||||
or sorted(cached_roots) != sorted(current_fingerprint["roots"]) # type: ignore[index]
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
for root in current_fingerprint["roots"]: # type: ignore[assignment]
|
|
||||||
cached_stat = cached_stats.get(root) if isinstance(cached_stats, Mapping) else None
|
|
||||||
current_stat = current_fingerprint["stats"].get(root) # type: ignore[index]
|
|
||||||
if not isinstance(cached_stat, Mapping) or not current_stat:
|
|
||||||
return False
|
|
||||||
|
|
||||||
cached_mtime = cached_stat.get("mtime_ns")
|
|
||||||
cached_inode = cached_stat.get("inode")
|
|
||||||
current_mtime = current_stat.get("mtime_ns")
|
|
||||||
current_inode = current_stat.get("inode")
|
|
||||||
|
|
||||||
if cached_inode != current_inode:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if cached_mtime != current_mtime:
|
|
||||||
cached_noise = cached_stat.get("noise_mtime_ns")
|
|
||||||
current_noise = current_stat.get("noise_mtime_ns")
|
|
||||||
if not (
|
|
||||||
cached_noise
|
|
||||||
and current_noise
|
|
||||||
and cached_mtime == cached_noise
|
|
||||||
and current_mtime == current_noise
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
normalized_mappings: Dict[str, str] = {}
|
normalized_mappings: Dict[str, str] = {}
|
||||||
for target, link in cached_mappings.items():
|
for target, link in cached_mappings.items():
|
||||||
if not isinstance(target, str) or not isinstance(link, str):
|
if not isinstance(target, str) or not isinstance(link, str):
|
||||||
@@ -341,6 +312,7 @@ class Config:
|
|||||||
normalized_mappings[self._normalize_path(target)] = self._normalize_path(link)
|
normalized_mappings[self._normalize_path(target)] = self._normalize_path(link)
|
||||||
|
|
||||||
self._path_mappings = normalized_mappings
|
self._path_mappings = normalized_mappings
|
||||||
|
logger.info("Symlink cache loaded with %d mappings", len(self._path_mappings))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _save_symlink_cache(self) -> None:
|
def _save_symlink_cache(self) -> None:
|
||||||
@@ -353,22 +325,75 @@ class Config:
|
|||||||
try:
|
try:
|
||||||
with cache_path.open("w", encoding="utf-8") as handle:
|
with cache_path.open("w", encoding="utf-8") as handle:
|
||||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||||
|
logger.info("Symlink cache saved to %s with %d mappings", cache_path, len(self._path_mappings))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to write symlink cache %s: %s", cache_path, exc)
|
logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
|
||||||
|
|
||||||
def _initialize_symlink_mappings(self) -> None:
|
def _initialize_symlink_mappings(self) -> None:
|
||||||
if not self._load_symlink_cache():
|
start = time.perf_counter()
|
||||||
self._scan_symbolic_links()
|
cache_loaded = self._load_symlink_cache()
|
||||||
self._save_symlink_cache()
|
|
||||||
else:
|
if cache_loaded:
|
||||||
logger.info("Loaded symlink mappings from cache")
|
logger.info(
|
||||||
|
"Symlink mappings restored from cache in %.2f ms",
|
||||||
|
(time.perf_counter() - start) * 1000,
|
||||||
|
)
|
||||||
|
self._rebuild_preview_roots()
|
||||||
|
self._schedule_symlink_rescan()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._scan_symbolic_links()
|
||||||
|
self._save_symlink_cache()
|
||||||
self._rebuild_preview_roots()
|
self._rebuild_preview_roots()
|
||||||
|
logger.info(
|
||||||
|
"Symlink mappings rebuilt and cached in %.2f ms",
|
||||||
|
(time.perf_counter() - start) * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
def _scan_symbolic_links(self):
|
def _scan_symbolic_links(self):
|
||||||
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
|
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
|
||||||
|
start = time.perf_counter()
|
||||||
|
# Reset mappings before rescanning to avoid stale entries
|
||||||
|
self._path_mappings.clear()
|
||||||
|
self._seed_root_symlink_mappings()
|
||||||
visited_dirs: Set[str] = set()
|
visited_dirs: Set[str] = set()
|
||||||
for root in self._symlink_roots():
|
for root in self._symlink_roots():
|
||||||
self._scan_directory_links(root, visited_dirs)
|
self._scan_directory_links(root, visited_dirs)
|
||||||
|
logger.info(
|
||||||
|
"Symlink scan finished in %.2f ms with %d mappings",
|
||||||
|
(time.perf_counter() - start) * 1000,
|
||||||
|
len(self._path_mappings),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _schedule_symlink_rescan(self) -> None:
|
||||||
|
"""Trigger a best-effort background rescan to refresh stale caches."""
|
||||||
|
|
||||||
|
if self._rescan_thread and self._rescan_thread.is_alive():
|
||||||
|
return
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
self._scan_symbolic_links()
|
||||||
|
self._save_symlink_cache()
|
||||||
|
self._rebuild_preview_roots()
|
||||||
|
logger.info("Background symlink rescan completed")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.info("Background symlink rescan failed: %s", exc)
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=worker,
|
||||||
|
name="lora-manager-symlink-rescan",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._rescan_thread = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _wait_for_rescan(self, timeout: Optional[float] = None) -> None:
|
||||||
|
"""Block until the background rescan completes (testing convenience)."""
|
||||||
|
|
||||||
|
thread = self._rescan_thread
|
||||||
|
if thread:
|
||||||
|
thread.join(timeout=timeout)
|
||||||
|
|
||||||
def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
|
def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
|
||||||
"""Iteratively scan directory symlinks to avoid deep recursion."""
|
"""Iteratively scan directory symlinks to avoid deep recursion."""
|
||||||
@@ -434,6 +459,22 @@ class Config:
|
|||||||
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
|
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
|
||||||
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
|
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
|
||||||
|
|
||||||
|
def _seed_root_symlink_mappings(self) -> None:
|
||||||
|
"""Ensure symlinked root folders are recorded before deep scanning."""
|
||||||
|
|
||||||
|
for root in self._symlink_roots():
|
||||||
|
if not root:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if not self._is_link(root):
|
||||||
|
continue
|
||||||
|
target_path = os.path.realpath(root)
|
||||||
|
if not os.path.isdir(target_path):
|
||||||
|
continue
|
||||||
|
self.add_path_mapping(root, target_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Skipping root symlink %s: %s", root, exc)
|
||||||
|
|
||||||
def _expand_preview_root(self, path: str) -> Set[Path]:
|
def _expand_preview_root(self, path: str) -> Set[Path]:
|
||||||
"""Return normalized ``Path`` objects representing a preview root."""
|
"""Return normalized ``Path`` objects representing a preview root."""
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,39 @@ class MetadataProcessor:
|
|||||||
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
|
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
|
||||||
candidate_samplers[node_id] = metadata[SAMPLING][node_id]
|
candidate_samplers[node_id] = metadata[SAMPLING][node_id]
|
||||||
|
|
||||||
# If we found candidate samplers, apply primary sampler logic to these candidates only
|
# If we found candidate samplers, apply primary sampler logic to these candidates only
|
||||||
if candidate_samplers:
|
|
||||||
|
# PRE-PROCESS: Ensure all candidate samplers have their parameters populated
|
||||||
|
# This is especially important for SamplerCustomAdvanced which needs tracing
|
||||||
|
prompt = metadata.get("current_prompt")
|
||||||
|
for node_id in candidate_samplers:
|
||||||
|
# If a sampler is missing common parameters like steps or denoise,
|
||||||
|
# try to populate them using tracing before ranking
|
||||||
|
sampler_info = candidate_samplers[node_id]
|
||||||
|
params = sampler_info.get("parameters", {})
|
||||||
|
|
||||||
|
if prompt and (params.get("steps") is None or params.get("denoise") is None):
|
||||||
|
# Create a temporary params dict to use the handler
|
||||||
|
temp_params = {
|
||||||
|
"steps": params.get("steps"),
|
||||||
|
"denoise": params.get("denoise"),
|
||||||
|
"sampler": params.get("sampler_name"),
|
||||||
|
"scheduler": params.get("scheduler")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if it's SamplerCustomAdvanced
|
||||||
|
if prompt.original_prompt and node_id in prompt.original_prompt:
|
||||||
|
if prompt.original_prompt[node_id].get("class_type") == "SamplerCustomAdvanced":
|
||||||
|
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, node_id, temp_params)
|
||||||
|
|
||||||
|
# Update the actual parameters with found values
|
||||||
|
params["steps"] = temp_params.get("steps")
|
||||||
|
params["denoise"] = temp_params.get("denoise")
|
||||||
|
if temp_params.get("sampler"):
|
||||||
|
params["sampler_name"] = temp_params.get("sampler")
|
||||||
|
if temp_params.get("scheduler"):
|
||||||
|
params["scheduler"] = temp_params.get("scheduler")
|
||||||
|
|
||||||
# Collect potential primary samplers based on different criteria
|
# Collect potential primary samplers based on different criteria
|
||||||
custom_advanced_samplers = []
|
custom_advanced_samplers = []
|
||||||
advanced_add_noise_samplers = []
|
advanced_add_noise_samplers = []
|
||||||
@@ -49,7 +80,6 @@ class MetadataProcessor:
|
|||||||
high_denoise_id = None
|
high_denoise_id = None
|
||||||
|
|
||||||
# First, check for SamplerCustomAdvanced among candidates
|
# First, check for SamplerCustomAdvanced among candidates
|
||||||
prompt = metadata.get("current_prompt")
|
|
||||||
if prompt and prompt.original_prompt:
|
if prompt and prompt.original_prompt:
|
||||||
for node_id in candidate_samplers:
|
for node_id in candidate_samplers:
|
||||||
node_info = prompt.original_prompt.get(node_id, {})
|
node_info = prompt.original_prompt.get(node_id, {})
|
||||||
@@ -77,15 +107,16 @@ class MetadataProcessor:
|
|||||||
# Combine all potential primary samplers
|
# Combine all potential primary samplers
|
||||||
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
|
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
|
||||||
|
|
||||||
# Find the most recent potential primary sampler (closest to downstream node)
|
# Find the first potential primary sampler (prefer base sampler over refine)
|
||||||
for i in range(downstream_index - 1, -1, -1):
|
# Use forward search to prioritize the first one in execution order
|
||||||
|
for i in range(downstream_index):
|
||||||
node_id = execution_order[i]
|
node_id = execution_order[i]
|
||||||
if node_id in potential_samplers:
|
if node_id in potential_samplers:
|
||||||
return node_id, candidate_samplers[node_id]
|
return node_id, candidate_samplers[node_id]
|
||||||
|
|
||||||
# If no potential sampler found from our criteria, return the most recent sampler
|
# If no potential sampler found from our criteria, return the first sampler
|
||||||
if candidate_samplers:
|
if candidate_samplers:
|
||||||
for i in range(downstream_index - 1, -1, -1):
|
for i in range(downstream_index):
|
||||||
node_id = execution_order[i]
|
node_id = execution_order[i]
|
||||||
if node_id in candidate_samplers:
|
if node_id in candidate_samplers:
|
||||||
return node_id, candidate_samplers[node_id]
|
return node_id, candidate_samplers[node_id]
|
||||||
@@ -176,8 +207,11 @@ class MetadataProcessor:
|
|||||||
found_node_id = input_value[0] # Connected node_id
|
found_node_id = input_value[0] # Connected node_id
|
||||||
|
|
||||||
# If we're looking for a specific node class
|
# If we're looking for a specific node class
|
||||||
if target_class and prompt.original_prompt[found_node_id].get("class_type") == target_class:
|
if target_class:
|
||||||
return found_node_id
|
if found_node_id not in prompt.original_prompt:
|
||||||
|
return None
|
||||||
|
if prompt.original_prompt[found_node_id].get("class_type") == target_class:
|
||||||
|
return found_node_id
|
||||||
|
|
||||||
# If we're not looking for a specific class, update the last valid node
|
# If we're not looking for a specific class, update the last valid node
|
||||||
if not target_class:
|
if not target_class:
|
||||||
@@ -185,11 +219,19 @@ class MetadataProcessor:
|
|||||||
|
|
||||||
# Continue tracing through intermediate nodes
|
# Continue tracing through intermediate nodes
|
||||||
current_node_id = found_node_id
|
current_node_id = found_node_id
|
||||||
# For most conditioning nodes, the input we want to follow is named "conditioning"
|
|
||||||
if "conditioning" in prompt.original_prompt[current_node_id].get("inputs", {}):
|
# Check if current source node exists
|
||||||
|
if current_node_id not in prompt.original_prompt:
|
||||||
|
return found_node_id if not target_class else None
|
||||||
|
|
||||||
|
# Determine which input to follow next on the source node
|
||||||
|
source_node_inputs = prompt.original_prompt[current_node_id].get("inputs", {})
|
||||||
|
if input_name in source_node_inputs:
|
||||||
|
current_input = input_name
|
||||||
|
elif "conditioning" in source_node_inputs:
|
||||||
current_input = "conditioning"
|
current_input = "conditioning"
|
||||||
else:
|
else:
|
||||||
# If there's no "conditioning" input, return the current node
|
# If there's no suitable input to follow, return the current node
|
||||||
# if we're not looking for a specific target_class
|
# if we're not looking for a specific target_class
|
||||||
return found_node_id if not target_class else None
|
return found_node_id if not target_class else None
|
||||||
else:
|
else:
|
||||||
@@ -202,12 +244,89 @@ class MetadataProcessor:
|
|||||||
return last_valid_node if not target_class else None
|
return last_valid_node if not target_class else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_primary_checkpoint(metadata):
|
def trace_model_path(metadata, prompt, start_node_id):
|
||||||
"""Find the primary checkpoint model in the workflow"""
|
"""
|
||||||
if not metadata.get(MODELS):
|
Trace the model connection path upstream to find the checkpoint
|
||||||
|
"""
|
||||||
|
if not prompt or not prompt.original_prompt:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# In most workflows, there's only one checkpoint, so we can just take the first one
|
current_node_id = start_node_id
|
||||||
|
depth = 0
|
||||||
|
max_depth = 50
|
||||||
|
|
||||||
|
while depth < max_depth:
|
||||||
|
# Check if current node is a registered checkpoint in our metadata
|
||||||
|
# This handles cached nodes correctly because metadata contains info for all nodes in the graph
|
||||||
|
if current_node_id in metadata.get(MODELS, {}):
|
||||||
|
if metadata[MODELS][current_node_id].get("type") == "checkpoint":
|
||||||
|
return current_node_id
|
||||||
|
|
||||||
|
if current_node_id not in prompt.original_prompt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
node = prompt.original_prompt[current_node_id]
|
||||||
|
inputs = node.get("inputs", {})
|
||||||
|
class_type = node.get("class_type", "")
|
||||||
|
|
||||||
|
# Determine which input to follow next
|
||||||
|
next_input_name = "model"
|
||||||
|
|
||||||
|
# Special handling for initial node
|
||||||
|
if depth == 0:
|
||||||
|
if class_type == "SamplerCustomAdvanced":
|
||||||
|
next_input_name = "guider"
|
||||||
|
|
||||||
|
# If the specific input doesn't exist, try generic 'model'
|
||||||
|
if next_input_name not in inputs:
|
||||||
|
if "model" in inputs:
|
||||||
|
next_input_name = "model"
|
||||||
|
elif "basic_pipe" in inputs:
|
||||||
|
# Handle pipe nodes like FromBasicPipe by following the pipeline
|
||||||
|
next_input_name = "basic_pipe"
|
||||||
|
else:
|
||||||
|
# Dead end - no model input to follow
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get connected node
|
||||||
|
input_val = inputs[next_input_name]
|
||||||
|
if isinstance(input_val, list) and len(input_val) > 0:
|
||||||
|
current_node_id = input_val[0]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
depth += 1
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_primary_checkpoint(metadata, downstream_id=None, primary_sampler_id=None):
|
||||||
|
"""
|
||||||
|
Find the primary checkpoint model in the workflow
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- metadata: The workflow metadata
|
||||||
|
- downstream_id: Optional ID of a downstream node to help identify the specific primary sampler
|
||||||
|
- primary_sampler_id: Optional ID of the primary sampler if already known
|
||||||
|
"""
|
||||||
|
if not metadata.get(MODELS):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Method 1: Topology-based tracing (More accurate for complex workflows)
|
||||||
|
# First, find the primary sampler if not provided
|
||||||
|
if not primary_sampler_id:
|
||||||
|
primary_sampler_id, _ = MetadataProcessor.find_primary_sampler(metadata, downstream_id)
|
||||||
|
|
||||||
|
if primary_sampler_id:
|
||||||
|
prompt = metadata.get("current_prompt")
|
||||||
|
if prompt:
|
||||||
|
# Trace back from the sampler to find the checkpoint
|
||||||
|
checkpoint_id = MetadataProcessor.trace_model_path(metadata, prompt, primary_sampler_id)
|
||||||
|
if checkpoint_id and checkpoint_id in metadata.get(MODELS, {}):
|
||||||
|
return metadata[MODELS][checkpoint_id].get("name")
|
||||||
|
|
||||||
|
# Method 2: Fallback to the first available checkpoint (Original behavior)
|
||||||
|
# In most simple workflows, there's only one checkpoint, so we can just take the first one
|
||||||
for node_id, model_info in metadata.get(MODELS, {}).items():
|
for node_id, model_info in metadata.get(MODELS, {}).items():
|
||||||
if model_info.get("type") == "checkpoint":
|
if model_info.get("type") == "checkpoint":
|
||||||
return model_info.get("name")
|
return model_info.get("name")
|
||||||
@@ -311,7 +430,8 @@ class MetadataProcessor:
|
|||||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
||||||
|
|
||||||
# Directly get checkpoint from metadata instead of tracing
|
# Directly get checkpoint from metadata instead of tracing
|
||||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata)
|
# Pass primary_sampler_id to avoid redundant calculation
|
||||||
|
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
||||||
if checkpoint:
|
if checkpoint:
|
||||||
params["checkpoint"] = checkpoint
|
params["checkpoint"] = checkpoint
|
||||||
|
|
||||||
@@ -445,6 +565,7 @@ class MetadataProcessor:
|
|||||||
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
|
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
|
||||||
params["steps"] = scheduler_params.get("steps")
|
params["steps"] = scheduler_params.get("steps")
|
||||||
params["scheduler"] = scheduler_params.get("scheduler")
|
params["scheduler"] = scheduler_params.get("scheduler")
|
||||||
|
params["denoise"] = scheduler_params.get("denoise")
|
||||||
|
|
||||||
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
|
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
|
||||||
if "sampler" in sampler_inputs:
|
if "sampler" in sampler_inputs:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from ..metadata_collector import get_metadata
|
|||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
import piexif
|
import piexif
|
||||||
|
|
||||||
class SaveImage:
|
class SaveImageLM:
|
||||||
NAME = "Save Image (LoraManager)"
|
NAME = "Save Image (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/utils"
|
CATEGORY = "Lora Manager/utils"
|
||||||
DESCRIPTION = "Save images with embedded generation metadata in compatible format"
|
DESCRIPTION = "Save images with embedded generation metadata in compatible format"
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
|
|||||||
|
|
||||||
# Save the transformer temporarily
|
# Save the transformer temporarily
|
||||||
model_wrapper.model = None
|
model_wrapper.model = None
|
||||||
ret_model = copy.deepcopy(model) # copy everything except the model
|
ret_model = model.clone()
|
||||||
ret_model_wrapper = ret_model.model.diffusion_model
|
ret_model_wrapper = ret_model.model.diffusion_model
|
||||||
|
|
||||||
# Restore the model and set it for the copy
|
# Restore the model and set it for the copy
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ class RecipeMetadataParser(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def populate_lora_from_civitai(self, lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
|
@staticmethod
|
||||||
|
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
|
||||||
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Populate a lora entry with information from Civitai API response
|
Populate a lora entry with information from Civitai API response
|
||||||
@@ -148,8 +149,9 @@ class RecipeMetadataParser(ABC):
|
|||||||
logger.error(f"Error populating lora from Civitai info: {e}")
|
logger.error(f"Error populating lora from Civitai info: {e}")
|
||||||
|
|
||||||
return lora_entry
|
return lora_entry
|
||||||
|
|
||||||
async def populate_checkpoint_from_civitai(self, checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
|
@staticmethod
|
||||||
|
async def populate_checkpoint_from_civitai(checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Populate checkpoint information from Civitai API response
|
Populate checkpoint information from Civitai API response
|
||||||
|
|
||||||
@@ -187,6 +189,7 @@ class RecipeMetadataParser(ABC):
|
|||||||
checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '')
|
checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '')
|
||||||
|
|
||||||
checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0))
|
checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0))
|
||||||
|
checkpoint['id'] = civitai_data.get('id', 0)
|
||||||
|
|
||||||
if 'files' in civitai_data:
|
if 'files' in civitai_data:
|
||||||
model_file = next(
|
model_file = next(
|
||||||
|
|||||||
216
py/recipes/enrichment.py
Normal file
216
py/recipes/enrichment.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from .merger import GenParamsMerger
|
||||||
|
from .base import RecipeMetadataParser
|
||||||
|
from ..services.metadata_service import get_default_metadata_provider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RecipeEnricher:
|
||||||
|
"""Service to enrich recipe metadata from multiple sources (Civitai, Embedded, User)."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def enrich_recipe(
|
||||||
|
recipe: Dict[str, Any],
|
||||||
|
civitai_client: Any,
|
||||||
|
request_params: Optional[Dict[str, Any]] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||||
|
civitai_client: Authenticated Civitai client instance.
|
||||||
|
request_params: (Optional) Parameters from a user request (e.g. import).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the recipe was modified, False otherwise.
|
||||||
|
"""
|
||||||
|
updated = False
|
||||||
|
gen_params = recipe.get("gen_params", {})
|
||||||
|
|
||||||
|
# 1. Fetch Civitai Info if available
|
||||||
|
civitai_meta = None
|
||||||
|
model_version_id = None
|
||||||
|
|
||||||
|
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||||
|
|
||||||
|
# Check if it's a Civitai image URL
|
||||||
|
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
|
||||||
|
if image_id_match:
|
||||||
|
image_id = image_id_match.group(1)
|
||||||
|
try:
|
||||||
|
image_info = await civitai_client.get_image_info(image_id)
|
||||||
|
if image_info:
|
||||||
|
# Handle nested meta often found in Civitai API responses
|
||||||
|
raw_meta = image_info.get("meta")
|
||||||
|
if isinstance(raw_meta, dict):
|
||||||
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
|
civitai_meta = raw_meta["meta"]
|
||||||
|
else:
|
||||||
|
civitai_meta = raw_meta
|
||||||
|
|
||||||
|
model_version_id = image_info.get("modelVersionId")
|
||||||
|
|
||||||
|
# If not at top level, check resources in meta
|
||||||
|
if not model_version_id and civitai_meta:
|
||||||
|
resources = civitai_meta.get("civitaiResources", [])
|
||||||
|
for res in resources:
|
||||||
|
if res.get("type") == "checkpoint":
|
||||||
|
model_version_id = res.get("modelVersionId")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||||
|
|
||||||
|
# 2. Merge Parameters
|
||||||
|
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||||
|
new_gen_params = GenParamsMerger.merge(
|
||||||
|
request_params=request_params,
|
||||||
|
civitai_meta=civitai_meta,
|
||||||
|
embedded_metadata=gen_params
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_gen_params != gen_params:
|
||||||
|
recipe["gen_params"] = new_gen_params
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# 3. Checkpoint Enrichment
|
||||||
|
# If we have a checkpoint entry, or we can find one
|
||||||
|
# Use 'id' (from Civitai version) as a marker that it's been enriched
|
||||||
|
checkpoint_entry = recipe.get("checkpoint")
|
||||||
|
has_full_checkpoint = checkpoint_entry and checkpoint_entry.get("name") and checkpoint_entry.get("id")
|
||||||
|
|
||||||
|
if not has_full_checkpoint:
|
||||||
|
# Helper to look up values in priority order
|
||||||
|
def start_lookup(keys):
|
||||||
|
for source in [request_params, civitai_meta, gen_params]:
|
||||||
|
if source:
|
||||||
|
if isinstance(keys, list):
|
||||||
|
for k in keys:
|
||||||
|
if k in source: return source[k]
|
||||||
|
else:
|
||||||
|
if keys in source: return source[keys]
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_version_id = model_version_id or start_lookup("modelVersionId")
|
||||||
|
|
||||||
|
# Also check existing checkpoint entry
|
||||||
|
if not target_version_id and checkpoint_entry:
|
||||||
|
target_version_id = checkpoint_entry.get("modelVersionId") or checkpoint_entry.get("id")
|
||||||
|
|
||||||
|
# Check for version ID in resources (which might be a string in gen_params)
|
||||||
|
if not target_version_id:
|
||||||
|
# Look in all sources for "Civitai resources"
|
||||||
|
resources_val = start_lookup(["Civitai resources", "civitai_resources", "resources"])
|
||||||
|
if resources_val:
|
||||||
|
target_version_id = RecipeEnricher._extract_version_id_from_resources({"Civitai resources": resources_val})
|
||||||
|
|
||||||
|
target_hash = start_lookup(["Model hash", "checkpoint_hash", "hashes"])
|
||||||
|
if not target_hash and checkpoint_entry:
|
||||||
|
target_hash = checkpoint_entry.get("hash") or checkpoint_entry.get("model_hash")
|
||||||
|
|
||||||
|
# Look for 'Model' which sometimes is the hash or name
|
||||||
|
model_val = start_lookup("Model")
|
||||||
|
|
||||||
|
# Look for Checkpoint name fallback
|
||||||
|
checkpoint_val = checkpoint_entry.get("name") if checkpoint_entry else None
|
||||||
|
if not checkpoint_val:
|
||||||
|
checkpoint_val = start_lookup(["Checkpoint", "checkpoint"])
|
||||||
|
|
||||||
|
checkpoint_updated = await RecipeEnricher._resolve_and_populate_checkpoint(
|
||||||
|
recipe, target_version_id, target_hash, model_val, checkpoint_val
|
||||||
|
)
|
||||||
|
if checkpoint_updated:
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
# Checkpoint exists, no need to sync to gen_params anymore.
|
||||||
|
pass
|
||||||
|
# base_model resolution moved to _resolve_and_populate_checkpoint to support strict formatting
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_version_id_from_resources(gen_params: Dict[str, Any]) -> Optional[Any]:
|
||||||
|
"""Try to find modelVersionId in Civitai resources parameter."""
|
||||||
|
civitai_resources_raw = gen_params.get("Civitai resources")
|
||||||
|
if not civitai_resources_raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
resources_list = None
|
||||||
|
if isinstance(civitai_resources_raw, str):
|
||||||
|
try:
|
||||||
|
resources_list = json.loads(civitai_resources_raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif isinstance(civitai_resources_raw, list):
|
||||||
|
resources_list = civitai_resources_raw
|
||||||
|
|
||||||
|
if isinstance(resources_list, list):
|
||||||
|
for res in resources_list:
|
||||||
|
if res.get("type") == "checkpoint":
|
||||||
|
return res.get("modelVersionId")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_and_populate_checkpoint(
|
||||||
|
recipe: Dict[str, Any],
|
||||||
|
target_version_id: Optional[Any],
|
||||||
|
target_hash: Optional[str],
|
||||||
|
model_val: Optional[str],
|
||||||
|
checkpoint_val: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
"""Find checkpoint metadata and populate it in the recipe."""
|
||||||
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
civitai_info = None
|
||||||
|
|
||||||
|
if target_version_id:
|
||||||
|
civitai_info = await metadata_provider.get_model_version_info(str(target_version_id))
|
||||||
|
elif target_hash:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(target_hash)
|
||||||
|
else:
|
||||||
|
# Look for 'Model' which sometimes is the hash or name
|
||||||
|
if model_val and len(model_val) == 10: # Likely a short hash
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(model_val)
|
||||||
|
|
||||||
|
if civitai_info and not (isinstance(civitai_info, tuple) and civitai_info[1] == "Model not found"):
|
||||||
|
# If we already have a partial checkpoint, use it as base
|
||||||
|
existing_cp = recipe.get("checkpoint")
|
||||||
|
if existing_cp is None:
|
||||||
|
existing_cp = {}
|
||||||
|
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
||||||
|
# 1. First, resolve base_model using full data before we format it away
|
||||||
|
current_base_model = recipe.get("base_model")
|
||||||
|
resolved_base_model = checkpoint_data.get("baseModel")
|
||||||
|
if resolved_base_model:
|
||||||
|
# Update if empty OR if it matches our generic prefix but is less specific
|
||||||
|
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
||||||
|
if is_generic and resolved_base_model != current_base_model:
|
||||||
|
recipe["base_model"] = resolved_base_model
|
||||||
|
|
||||||
|
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
|
||||||
|
formatted_checkpoint = {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"modelId": checkpoint_data.get("modelId"),
|
||||||
|
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
|
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
|
||||||
|
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
|
||||||
|
}
|
||||||
|
# Remove None values
|
||||||
|
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# Fallback to name extraction if we don't already have one
|
||||||
|
existing_cp = recipe.get("checkpoint")
|
||||||
|
if not existing_cp or not existing_cp.get("modelName"):
|
||||||
|
cp_name = checkpoint_val
|
||||||
|
if cp_name:
|
||||||
|
recipe["checkpoint"] = {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"modelName": cp_name
|
||||||
|
}
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
98
py/recipes/merger.py
Normal file
98
py/recipes/merger.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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", "meta",
|
||||||
|
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
||||||
|
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
||||||
|
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
||||||
|
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
||||||
|
"checkpoint", "checksum", "model_checksum"
|
||||||
|
}
|
||||||
|
|
||||||
|
NORMALIZATION_MAPPING = {
|
||||||
|
# Civitai specific
|
||||||
|
"cfgScale": "cfg_scale",
|
||||||
|
"clipSkip": "clip_skip",
|
||||||
|
"negativePrompt": "negative_prompt",
|
||||||
|
# Case variations
|
||||||
|
"Sampler": "sampler",
|
||||||
|
"Steps": "steps",
|
||||||
|
"Seed": "seed",
|
||||||
|
"Size": "size",
|
||||||
|
"Prompt": "prompt",
|
||||||
|
"Negative prompt": "negative_prompt",
|
||||||
|
"Cfg scale": "cfg_scale",
|
||||||
|
"Clip skip": "clip_skip",
|
||||||
|
"Denoising strength": "denoising_strength",
|
||||||
|
}
|
||||||
|
|
||||||
|
@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):
|
||||||
|
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
||||||
|
else:
|
||||||
|
# Otherwise assume the dict itself contains gen_params
|
||||||
|
GenParamsMerger._update_normalized(result, embedded_metadata)
|
||||||
|
|
||||||
|
# 2. Layer Civitai meta (medium priority)
|
||||||
|
if civitai_meta:
|
||||||
|
GenParamsMerger._update_normalized(result, civitai_meta)
|
||||||
|
|
||||||
|
# 3. Layer request params (highest priority)
|
||||||
|
if request_params:
|
||||||
|
GenParamsMerger._update_normalized(result, request_params)
|
||||||
|
|
||||||
|
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
|
||||||
|
final_result = {}
|
||||||
|
for k, v in result.items():
|
||||||
|
if k in GenParamsMerger.BLACKLISTED_KEYS:
|
||||||
|
continue
|
||||||
|
if k in GenParamsMerger.NORMALIZATION_MAPPING:
|
||||||
|
continue
|
||||||
|
final_result[k] = v
|
||||||
|
|
||||||
|
return final_result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||||
|
"""Update target dict with normalized keys from source."""
|
||||||
|
for k, v in source.items():
|
||||||
|
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
|
||||||
|
target[normalized_key] = v
|
||||||
|
# Also keep the original key for now if it's not the same,
|
||||||
|
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
|
||||||
|
# Actually, if we rename it, we should probably NOT keep both in 'target'
|
||||||
|
# because we want to filter them out at the end anyway.
|
||||||
|
if normalized_key != k:
|
||||||
|
# If we are overwriting an existing snake_case key with a camelCase one's value,
|
||||||
|
# that's fine because of the priority order of calls to _update_normalized.
|
||||||
|
pass
|
||||||
|
target[k] = v
|
||||||
@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
# Find all LoraLoader nodes
|
# Find all LoraLoader nodes
|
||||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
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
|
# Process each LoraLoader node
|
||||||
for node_id, node in lora_nodes.items():
|
for node_id, node in lora_nodes.items():
|
||||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||||
|
|||||||
@@ -79,26 +79,8 @@ class BaseRecipeRoutes:
|
|||||||
return
|
return
|
||||||
|
|
||||||
app.on_startup.append(self.attach_dependencies)
|
app.on_startup.append(self.attach_dependencies)
|
||||||
app.on_startup.append(self.prewarm_cache)
|
|
||||||
self._startup_hooks_registered = True
|
self._startup_hooks_registered = True
|
||||||
|
|
||||||
async def prewarm_cache(self, app: web.Application | None = None) -> None:
|
|
||||||
"""Pre-load recipe and LoRA caches on startup."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.attach_dependencies(app)
|
|
||||||
|
|
||||||
if self.lora_scanner is not None:
|
|
||||||
await self.lora_scanner.get_cached_data()
|
|
||||||
hash_index = getattr(self.lora_scanner, "_hash_index", None)
|
|
||||||
if hash_index is not None and hasattr(hash_index, "_hash_to_path"):
|
|
||||||
_ = len(hash_index._hash_to_path)
|
|
||||||
|
|
||||||
if self.recipe_scanner is not None:
|
|
||||||
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True)
|
|
||||||
|
|
||||||
def to_route_mapping(self) -> Mapping[str, Callable]:
|
def to_route_mapping(self) -> Mapping[str, Callable]:
|
||||||
"""Return a mapping of handler name to coroutine for registrar binding."""
|
"""Return a mapping of handler name to coroutine for registrar binding."""
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
|
||||||
|
|
||||||
@@ -61,6 +62,37 @@ class ModelPageView:
|
|||||||
self._settings = settings_service
|
self._settings = settings_service
|
||||||
self._server_i18n = server_i18n
|
self._server_i18n = server_i18n
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
self._app_version = self._get_app_version()
|
||||||
|
|
||||||
|
def _get_app_version(self) -> str:
|
||||||
|
version = "1.0.0"
|
||||||
|
short_hash = "stable"
|
||||||
|
try:
|
||||||
|
import toml
|
||||||
|
current_file = os.path.abspath(__file__)
|
||||||
|
# Navigate up from py/routes/handlers/model_handlers.py to project root
|
||||||
|
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file))))
|
||||||
|
pyproject_path = os.path.join(root_dir, 'pyproject.toml')
|
||||||
|
|
||||||
|
if os.path.exists(pyproject_path):
|
||||||
|
with open(pyproject_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = toml.load(f)
|
||||||
|
version = data.get('project', {}).get('version', '1.0.0').replace('v', '')
|
||||||
|
|
||||||
|
# Try to get git info for granular cache busting
|
||||||
|
git_dir = os.path.join(root_dir, '.git')
|
||||||
|
if os.path.exists(git_dir):
|
||||||
|
try:
|
||||||
|
import git
|
||||||
|
repo = git.Repo(root_dir)
|
||||||
|
short_hash = repo.head.commit.hexsha[:7]
|
||||||
|
except Exception:
|
||||||
|
# Fallback if git is not available or not a repo
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.debug(f"Failed to read version info for cache busting: {e}")
|
||||||
|
|
||||||
|
return f"{version}-{short_hash}"
|
||||||
|
|
||||||
async def handle(self, request: web.Request) -> web.Response:
|
async def handle(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -96,6 +128,7 @@ class ModelPageView:
|
|||||||
"request": request,
|
"request": request,
|
||||||
"folders": [],
|
"folders": [],
|
||||||
"t": self._server_i18n.get_translation,
|
"t": self._server_i18n.get_translation,
|
||||||
|
"version": self._app_version,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not is_initializing:
|
if not is_initializing:
|
||||||
@@ -128,9 +161,12 @@ class ModelListingHandler:
|
|||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
async def get_models(self, request: web.Request) -> web.Response:
|
async def get_models(self, request: web.Request) -> web.Response:
|
||||||
|
start_time = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
params = self._parse_common_params(request)
|
params = self._parse_common_params(request)
|
||||||
result = await self._service.get_paginated_data(**params)
|
result = await self._service.get_paginated_data(**params)
|
||||||
|
|
||||||
|
format_start = time.perf_counter()
|
||||||
formatted_result = {
|
formatted_result = {
|
||||||
"items": [await self._service.format_response(item) for item in result["items"]],
|
"items": [await self._service.format_response(item) for item in result["items"]],
|
||||||
"total": result["total"],
|
"total": result["total"],
|
||||||
@@ -138,6 +174,13 @@ class ModelListingHandler:
|
|||||||
"page_size": result["page_size"],
|
"page_size": result["page_size"],
|
||||||
"total_pages": result["total_pages"],
|
"total_pages": result["total_pages"],
|
||||||
}
|
}
|
||||||
|
format_duration = time.perf_counter() - format_start
|
||||||
|
|
||||||
|
duration = time.perf_counter() - start_time
|
||||||
|
self._logger.info(
|
||||||
|
"Request for %s/list took %.3fs (formatting: %.3fs)",
|
||||||
|
self._service.model_type, duration, format_duration
|
||||||
|
)
|
||||||
return web.json_response(formatted_result)
|
return web.json_response(formatted_result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error retrieving %ss: %s", self._service.model_type, exc, exc_info=True)
|
self._logger.error("Error retrieving %ss: %s", self._service.model_type, exc, exc_info=True)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
||||||
@@ -23,6 +24,11 @@ from ...services.recipes import (
|
|||||||
RecipeValidationError,
|
RecipeValidationError,
|
||||||
)
|
)
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
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
|
||||||
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
|
from ...services.websocket_manager import ws_manager as default_ws_manager
|
||||||
|
|
||||||
Logger = logging.Logger
|
Logger = logging.Logger
|
||||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||||
@@ -55,16 +61,25 @@ class RecipeHandlerSet:
|
|||||||
"delete_recipe": self.management.delete_recipe,
|
"delete_recipe": self.management.delete_recipe,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"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,
|
"share_recipe": self.sharing.share_recipe,
|
||||||
"download_shared_recipe": self.sharing.download_shared_recipe,
|
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||||
"update_recipe": self.management.update_recipe,
|
"update_recipe": self.management.update_recipe,
|
||||||
"reconnect_lora": self.management.reconnect_lora,
|
"reconnect_lora": self.management.reconnect_lora,
|
||||||
"find_duplicates": self.query.find_duplicates,
|
"find_duplicates": self.query.find_duplicates,
|
||||||
|
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||||
"bulk_delete": self.management.bulk_delete,
|
"bulk_delete": self.management.bulk_delete,
|
||||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||||
"scan_recipes": self.query.scan_recipes,
|
"scan_recipes": self.query.scan_recipes,
|
||||||
|
"move_recipe": self.management.move_recipe,
|
||||||
|
"repair_recipes": self.management.repair_recipes,
|
||||||
|
"repair_recipe": self.management.repair_recipe,
|
||||||
|
"get_repair_progress": self.management.get_repair_progress,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -148,12 +163,15 @@ class RecipeListingHandler:
|
|||||||
page_size = int(request.query.get("page_size", "20"))
|
page_size = int(request.query.get("page_size", "20"))
|
||||||
sort_by = request.query.get("sort_by", "date")
|
sort_by = request.query.get("sort_by", "date")
|
||||||
search = request.query.get("search")
|
search = request.query.get("search")
|
||||||
|
folder = request.query.get("folder")
|
||||||
|
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||||
|
|
||||||
search_options = {
|
search_options = {
|
||||||
"title": request.query.get("search_title", "true").lower() == "true",
|
"title": request.query.get("search_title", "true").lower() == "true",
|
||||||
"tags": request.query.get("search_tags", "true").lower() == "true",
|
"tags": request.query.get("search_tags", "true").lower() == "true",
|
||||||
"lora_name": request.query.get("search_lora_name", "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",
|
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
||||||
|
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
filters: Dict[str, Any] = {}
|
filters: Dict[str, Any] = {}
|
||||||
@@ -161,6 +179,9 @@ class RecipeListingHandler:
|
|||||||
if base_models:
|
if base_models:
|
||||||
filters["base_model"] = base_models.split(",")
|
filters["base_model"] = base_models.split(",")
|
||||||
|
|
||||||
|
if request.query.get("favorite", "false").lower() == "true":
|
||||||
|
filters["favorite"] = True
|
||||||
|
|
||||||
tag_filters: Dict[str, str] = {}
|
tag_filters: Dict[str, str] = {}
|
||||||
legacy_tags = request.query.get("tags")
|
legacy_tags = request.query.get("tags")
|
||||||
if legacy_tags:
|
if legacy_tags:
|
||||||
@@ -192,6 +213,8 @@ class RecipeListingHandler:
|
|||||||
filters=filters,
|
filters=filters,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
lora_hash=lora_hash,
|
lora_hash=lora_hash,
|
||||||
|
folder=folder,
|
||||||
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in result.get("items", []):
|
for item in result.get("items", []):
|
||||||
@@ -298,6 +321,58 @@ class RecipeQueryHandler:
|
|||||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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:
|
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -410,6 +485,7 @@ class RecipeManagementHandler:
|
|||||||
analysis_service: RecipeAnalysisService,
|
analysis_service: RecipeAnalysisService,
|
||||||
downloader_factory,
|
downloader_factory,
|
||||||
civitai_client_getter: CivitaiClientGetter,
|
civitai_client_getter: CivitaiClientGetter,
|
||||||
|
ws_manager=default_ws_manager,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._ensure_dependencies_ready = ensure_dependencies_ready
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
self._recipe_scanner_getter = recipe_scanner_getter
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
@@ -418,6 +494,7 @@ class RecipeManagementHandler:
|
|||||||
self._analysis_service = analysis_service
|
self._analysis_service = analysis_service
|
||||||
self._downloader_factory = downloader_factory
|
self._downloader_factory = downloader_factory
|
||||||
self._civitai_client_getter = civitai_client_getter
|
self._civitai_client_getter = civitai_client_getter
|
||||||
|
self._ws_manager = ws_manager
|
||||||
|
|
||||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -436,6 +513,7 @@ class RecipeManagementHandler:
|
|||||||
name=payload["name"],
|
name=payload["name"],
|
||||||
tags=payload["tags"],
|
tags=payload["tags"],
|
||||||
metadata=payload["metadata"],
|
metadata=payload["metadata"],
|
||||||
|
extension=payload.get("extension"),
|
||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
return web.json_response(result.payload, status=result.status)
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
@@ -444,17 +522,84 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def repair_recipes(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||||
|
|
||||||
|
# Check if already running
|
||||||
|
if self._ws_manager.get_recipe_repair_progress():
|
||||||
|
return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
|
||||||
|
|
||||||
|
async def progress_callback(data):
|
||||||
|
await self._ws_manager.broadcast_recipe_repair_progress(data)
|
||||||
|
|
||||||
|
# Run in background to avoid timeout
|
||||||
|
async def run_repair():
|
||||||
|
try:
|
||||||
|
await recipe_scanner.repair_all_recipes(
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
|
||||||
|
await self._ws_manager.broadcast_recipe_repair_progress({
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
# Keep the final status for a while so the UI can see it
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
self._ws_manager.cleanup_recipe_repair_progress()
|
||||||
|
|
||||||
|
asyncio.create_task(run_repair())
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "message": "Recipe repair started"})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def repair_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||||
|
return web.json_response(result)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_repair_progress(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
progress = self._ws_manager.get_recipe_repair_progress()
|
||||||
|
if progress:
|
||||||
|
return web.json_response({"success": True, "progress": progress})
|
||||||
|
return web.json_response({"success": False, "message": "No repair in progress"}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
recipe_scanner = self._recipe_scanner_getter()
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
# 1. Parse Parameters
|
||||||
params = request.rel_url.query
|
params = request.rel_url.query
|
||||||
image_url = params.get("image_url")
|
image_url = params.get("image_url")
|
||||||
name = params.get("name")
|
name = params.get("name")
|
||||||
resources_raw = params.get("resources")
|
resources_raw = params.get("resources")
|
||||||
|
|
||||||
if not image_url:
|
if not image_url:
|
||||||
raise RecipeValidationError("Missing required field: image_url")
|
raise RecipeValidationError("Missing required field: image_url")
|
||||||
if not name:
|
if not name:
|
||||||
@@ -463,27 +608,93 @@ class RecipeManagementHandler:
|
|||||||
raise RecipeValidationError("Missing required field: resources")
|
raise RecipeValidationError("Missing required field: resources")
|
||||||
|
|
||||||
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
||||||
gen_params = self._parse_gen_params(params.get("gen_params"))
|
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||||
|
|
||||||
|
# 2. Initial Metadata Construction
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"base_model": params.get("base_model", "") or "",
|
"base_model": params.get("base_model", "") or "",
|
||||||
"loras": lora_entries,
|
"loras": lora_entries,
|
||||||
|
"gen_params": gen_params_request or {},
|
||||||
|
"source_url": image_url
|
||||||
}
|
}
|
||||||
|
|
||||||
source_path = params.get("source_path")
|
source_path = params.get("source_path")
|
||||||
if source_path:
|
if source_path:
|
||||||
metadata["source_path"] = source_path
|
metadata["source_path"] = source_path
|
||||||
if gen_params is not None:
|
|
||||||
metadata["gen_params"] = gen_params
|
# Checkpoint handling
|
||||||
if checkpoint_entry:
|
if checkpoint_entry:
|
||||||
metadata["checkpoint"] = checkpoint_entry
|
metadata["checkpoint"] = checkpoint_entry
|
||||||
gen_params_ref = metadata.setdefault("gen_params", {})
|
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
||||||
if "checkpoint" not in gen_params_ref:
|
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
||||||
gen_params_ref["checkpoint"] = checkpoint_entry
|
|
||||||
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
# Try to resolve base model from checkpoint if not explicitly provided
|
||||||
if base_model_from_metadata:
|
if not metadata["base_model"]:
|
||||||
metadata["base_model"] = base_model_from_metadata
|
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||||
|
if base_model_from_metadata:
|
||||||
|
metadata["base_model"] = base_model_from_metadata
|
||||||
|
|
||||||
tags = self._parse_tags(params.get("tags"))
|
tags = self._parse_tags(params.get("tags"))
|
||||||
image_bytes = await self._download_image_bytes(image_url)
|
|
||||||
|
# 3. Download Image
|
||||||
|
image_bytes, extension, civitai_meta_from_download = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# 4. Extract Embedded Metadata
|
||||||
|
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
||||||
|
# with embedded data if we want it to merge it.
|
||||||
|
# However, logic in Enricher merges: request > civitai > embedded.
|
||||||
|
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
||||||
|
# OR pass them to enricher to handle?
|
||||||
|
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
||||||
|
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
||||||
|
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
||||||
|
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
||||||
|
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
||||||
|
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
||||||
|
|
||||||
|
# Let's extract embedded metadata first
|
||||||
|
embedded_gen_params = {}
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
else:
|
||||||
|
embedded_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)
|
||||||
|
|
||||||
|
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
|
||||||
|
if embedded_gen_params:
|
||||||
|
# Merge embedded into existing gen_params (which currently only has request params if any)
|
||||||
|
# But wait, we want request params to override everything.
|
||||||
|
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
||||||
|
metadata["gen_params"] = embedded_gen_params
|
||||||
|
|
||||||
|
# 5. Enrich with unified logic
|
||||||
|
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=gen_params_request # Pass explicit request params here to override
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
|
||||||
|
# we might want to manually merge it?
|
||||||
|
# But usually `import_remote_recipe` is used with Civitai URLs.
|
||||||
|
# For now, relying on Enricher's internal fetch is consistent with repair.
|
||||||
|
|
||||||
result = await self._persistence_service.save_recipe(
|
result = await self._persistence_service.save_recipe(
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
@@ -492,6 +703,7 @@ class RecipeManagementHandler:
|
|||||||
name=name,
|
name=name,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
|
extension=extension,
|
||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
return web.json_response(result.payload, status=result.status)
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
@@ -541,6 +753,64 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
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:
|
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -622,6 +892,7 @@ class RecipeManagementHandler:
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
metadata: Optional[Dict[str, Any]] = None
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
extension: Optional[str] = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
field = await reader.next()
|
field = await reader.next()
|
||||||
@@ -652,6 +923,8 @@ class RecipeManagementHandler:
|
|||||||
metadata = json.loads(metadata_text)
|
metadata = json.loads(metadata_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
metadata = {}
|
metadata = {}
|
||||||
|
elif field.name == "extension":
|
||||||
|
extension = await field.text()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"image_bytes": image_bytes,
|
"image_bytes": image_bytes,
|
||||||
@@ -659,6 +932,7 @@ class RecipeManagementHandler:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
"extension": extension,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
|
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
|
||||||
@@ -729,7 +1003,7 @@ class RecipeManagementHandler:
|
|||||||
"exclude": False,
|
"exclude": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _download_image_bytes(self, image_url: str) -> bytes:
|
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str]:
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
downloader = await self._downloader_factory()
|
downloader = await self._downloader_factory()
|
||||||
temp_path = None
|
temp_path = None
|
||||||
@@ -744,15 +1018,31 @@ class RecipeManagementHandler:
|
|||||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||||
if not image_info:
|
if not image_info:
|
||||||
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
||||||
download_url = image_info.get("url")
|
|
||||||
if not download_url:
|
media_url = image_info.get("url")
|
||||||
|
if not media_url:
|
||||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||||
|
|
||||||
|
# Use optimized preview URLs if possible
|
||||||
|
media_type = image_info.get("type")
|
||||||
|
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
|
||||||
|
if rewritten_url:
|
||||||
|
download_url = rewritten_url
|
||||||
|
else:
|
||||||
|
download_url = media_url
|
||||||
|
|
||||||
success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
|
success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
|
||||||
if not success:
|
if not success:
|
||||||
raise RecipeDownloadError(f"Failed to download image: {result}")
|
raise RecipeDownloadError(f"Failed to download image: {result}")
|
||||||
|
|
||||||
|
# Extract extension from URL
|
||||||
|
url_path = download_url.split('?')[0].split('#')[0]
|
||||||
|
extension = os.path.splitext(url_path)[1].lower()
|
||||||
|
if not extension:
|
||||||
|
extension = ".webp" # Default to webp if unknown
|
||||||
|
|
||||||
with open(temp_path, "rb") as file_obj:
|
with open(temp_path, "rb") as file_obj:
|
||||||
return file_obj.read()
|
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
except RecipeValidationError:
|
except RecipeValidationError:
|
||||||
@@ -766,6 +1056,7 @@ class RecipeManagementHandler:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(self, value: Any) -> int:
|
def _safe_int(self, value: Any) -> int:
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|||||||
@@ -27,16 +27,25 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
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/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", "share_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_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("GET", "/api/lm/recipes/syntax", "get_recipe_syntax"),
|
||||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
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("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import asyncio
|
|||||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
from ..utils.constants import VALID_LORA_TYPES
|
from ..utils.constants import VALID_LORA_TYPES
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
@@ -80,13 +81,20 @@ class BaseModelService(ABC):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Get paginated and filtered model data"""
|
"""Get paginated and filtered model data"""
|
||||||
|
overall_start = time.perf_counter()
|
||||||
|
|
||||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||||
if sort_params.key == 'usage':
|
if sort_params.key == 'usage':
|
||||||
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
||||||
else:
|
else:
|
||||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||||
|
fetch_duration = time.perf_counter() - t0
|
||||||
|
initial_count = len(sorted_data)
|
||||||
|
|
||||||
|
t1 = time.perf_counter()
|
||||||
if hash_filters:
|
if hash_filters:
|
||||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||||
else:
|
else:
|
||||||
@@ -116,17 +124,25 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
if allow_selling_generated_content is not None:
|
if allow_selling_generated_content is not None:
|
||||||
filtered_data = await self._apply_allow_selling_filter(filtered_data, allow_selling_generated_content)
|
filtered_data = await self._apply_allow_selling_filter(filtered_data, allow_selling_generated_content)
|
||||||
|
filter_duration = time.perf_counter() - t1
|
||||||
|
post_filter_count = len(filtered_data)
|
||||||
|
|
||||||
annotated_for_filter: Optional[List[Dict]] = None
|
annotated_for_filter: Optional[List[Dict]] = None
|
||||||
|
t2 = time.perf_counter()
|
||||||
if update_available_only:
|
if update_available_only:
|
||||||
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in annotated_for_filter
|
item for item in annotated_for_filter
|
||||||
if item.get('update_available')
|
if item.get('update_available')
|
||||||
]
|
]
|
||||||
|
update_filter_duration = time.perf_counter() - t2
|
||||||
|
final_count = len(filtered_data)
|
||||||
|
|
||||||
|
t3 = time.perf_counter()
|
||||||
paginated = self._paginate(filtered_data, page, page_size)
|
paginated = self._paginate(filtered_data, page, page_size)
|
||||||
|
pagination_duration = time.perf_counter() - t3
|
||||||
|
|
||||||
|
t4 = time.perf_counter()
|
||||||
if update_available_only:
|
if update_available_only:
|
||||||
# Items already include update flags thanks to the pre-filter annotation.
|
# Items already include update flags thanks to the pre-filter annotation.
|
||||||
paginated['items'] = list(paginated['items'])
|
paginated['items'] = list(paginated['items'])
|
||||||
@@ -134,6 +150,16 @@ class BaseModelService(ABC):
|
|||||||
paginated['items'] = await self._annotate_update_flags(
|
paginated['items'] = await self._annotate_update_flags(
|
||||||
paginated['items'],
|
paginated['items'],
|
||||||
)
|
)
|
||||||
|
annotate_duration = time.perf_counter() - t4
|
||||||
|
|
||||||
|
overall_duration = time.perf_counter() - overall_start
|
||||||
|
logger.info(
|
||||||
|
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
||||||
|
"Counts: initial=%d, post_filter=%d, final=%d",
|
||||||
|
self.__class__.__name__, overall_duration, fetch_duration, filter_duration,
|
||||||
|
update_filter_duration, pagination_duration, annotate_duration,
|
||||||
|
initial_count, post_filter_count, final_count
|
||||||
|
)
|
||||||
return paginated
|
return paginated
|
||||||
|
|
||||||
async def _fetch_with_usage_sort(self, sort_params):
|
async def _fetch_with_usage_sort(self, sort_params):
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
@@ -215,24 +219,25 @@ class ModelCache:
|
|||||||
|
|
||||||
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
||||||
"""Sort data by sort_key and order"""
|
"""Sort data by sort_key and order"""
|
||||||
|
start_time = time.perf_counter()
|
||||||
reverse = (order == 'desc')
|
reverse = (order == 'desc')
|
||||||
if sort_key == 'name':
|
if sort_key == 'name':
|
||||||
# Natural sort by configured display name, case-insensitive
|
# Natural sort by configured display name, case-insensitive
|
||||||
return natsorted(
|
result = natsorted(
|
||||||
data,
|
data,
|
||||||
key=lambda x: self._get_display_name(x).lower(),
|
key=lambda x: self._get_display_name(x).lower(),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'date':
|
elif sort_key == 'date':
|
||||||
# Sort by modified timestamp
|
# Sort by modified timestamp
|
||||||
return sorted(
|
result = sorted(
|
||||||
data,
|
data,
|
||||||
key=itemgetter('modified'),
|
key=itemgetter('modified'),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
)
|
)
|
||||||
elif sort_key == 'size':
|
elif sort_key == 'size':
|
||||||
# Sort by file size
|
# Sort by file size
|
||||||
return sorted(
|
result = sorted(
|
||||||
data,
|
data,
|
||||||
key=itemgetter('size'),
|
key=itemgetter('size'),
|
||||||
reverse=reverse
|
reverse=reverse
|
||||||
@@ -249,16 +254,28 @@ class ModelCache:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: no sort
|
# Fallback: no sort
|
||||||
return list(data)
|
result = list(data)
|
||||||
|
|
||||||
|
duration = time.perf_counter() - start_time
|
||||||
|
if duration > 0.05:
|
||||||
|
logger.info("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
|
||||||
|
return result
|
||||||
|
|
||||||
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]:
|
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]:
|
||||||
"""Get sorted data by sort_key and order, using cache if possible"""
|
"""Get sorted data by sort_key and order, using cache if possible"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if (sort_key, order) == self._last_sort:
|
if (sort_key, order) == self._last_sort:
|
||||||
return self._last_sorted_data
|
return self._last_sorted_data
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
||||||
self._last_sort = (sort_key, order)
|
self._last_sort = (sort_key, order)
|
||||||
self._last_sorted_data = sorted_data
|
self._last_sorted_data = sorted_data
|
||||||
|
|
||||||
|
duration = time.perf_counter() - start_time
|
||||||
|
if duration > 0.1:
|
||||||
|
logger.debug("ModelCache.get_sorted_data(%s, %s) took %.3fs", sort_key, order, duration)
|
||||||
|
|
||||||
return sorted_data
|
return sorted_data
|
||||||
|
|
||||||
async def update_name_display_mode(self, display_mode: str) -> None:
|
async def update_name_display_mode(self, display_mode: str) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|||||||
|
|
||||||
from ..utils.constants import NSFW_LEVELS
|
from ..utils.constants import NSFW_LEVELS
|
||||||
from ..utils.utils import fuzzy_match as default_fuzzy_match
|
from ..utils.utils import fuzzy_match as default_fuzzy_match
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CIVITAI_MODEL_TYPE = "LORA"
|
DEFAULT_CIVITAI_MODEL_TYPE = "LORA"
|
||||||
@@ -115,22 +119,33 @@ class ModelFilterSet:
|
|||||||
|
|
||||||
def apply(self, data: Iterable[Dict[str, Any]], criteria: FilterCriteria) -> List[Dict[str, Any]]:
|
def apply(self, data: Iterable[Dict[str, Any]], criteria: FilterCriteria) -> List[Dict[str, Any]]:
|
||||||
"""Return items that satisfy the provided criteria."""
|
"""Return items that satisfy the provided criteria."""
|
||||||
|
overall_start = time.perf_counter()
|
||||||
items = list(data)
|
items = list(data)
|
||||||
|
initial_count = len(items)
|
||||||
|
|
||||||
if self._settings.get("show_only_sfw", False):
|
if self._settings.get("show_only_sfw", False):
|
||||||
|
t0 = time.perf_counter()
|
||||||
threshold = self._nsfw_levels.get("R", 0)
|
threshold = self._nsfw_levels.get("R", 0)
|
||||||
items = [
|
items = [
|
||||||
item for item in items
|
item for item in items
|
||||||
if not item.get("preview_nsfw_level") or item.get("preview_nsfw_level") < threshold
|
if not item.get("preview_nsfw_level") or item.get("preview_nsfw_level") < threshold
|
||||||
]
|
]
|
||||||
|
sfw_duration = time.perf_counter() - t0
|
||||||
|
else:
|
||||||
|
sfw_duration = 0
|
||||||
|
|
||||||
|
favorites_duration = 0
|
||||||
if criteria.favorites_only:
|
if criteria.favorites_only:
|
||||||
|
t0 = time.perf_counter()
|
||||||
items = [item for item in items if item.get("favorite", False)]
|
items = [item for item in items if item.get("favorite", False)]
|
||||||
|
favorites_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
folder_duration = 0
|
||||||
folder = criteria.folder
|
folder = criteria.folder
|
||||||
options = criteria.search_options or {}
|
options = criteria.search_options or {}
|
||||||
recursive = bool(options.get("recursive", True))
|
recursive = bool(options.get("recursive", True))
|
||||||
if folder is not None:
|
if folder is not None:
|
||||||
|
t0 = time.perf_counter()
|
||||||
if recursive:
|
if recursive:
|
||||||
if folder:
|
if folder:
|
||||||
folder_with_sep = f"{folder}/"
|
folder_with_sep = f"{folder}/"
|
||||||
@@ -140,51 +155,82 @@ class ModelFilterSet:
|
|||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
items = [item for item in items if item.get("folder") == folder]
|
items = [item for item in items if item.get("folder") == folder]
|
||||||
|
folder_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
base_models_duration = 0
|
||||||
base_models = criteria.base_models or []
|
base_models = criteria.base_models or []
|
||||||
if base_models:
|
if base_models:
|
||||||
|
t0 = time.perf_counter()
|
||||||
base_model_set = set(base_models)
|
base_model_set = set(base_models)
|
||||||
items = [item for item in items if item.get("base_model") in base_model_set]
|
items = [item for item in items if item.get("base_model") in base_model_set]
|
||||||
|
base_models_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
tags_duration = 0
|
||||||
tag_filters = criteria.tags or {}
|
tag_filters = criteria.tags or {}
|
||||||
include_tags = set()
|
if tag_filters:
|
||||||
exclude_tags = set()
|
t0 = time.perf_counter()
|
||||||
if isinstance(tag_filters, dict):
|
include_tags = set()
|
||||||
for tag, state in tag_filters.items():
|
exclude_tags = set()
|
||||||
if not tag:
|
if isinstance(tag_filters, dict):
|
||||||
continue
|
for tag, state in tag_filters.items():
|
||||||
if state == "exclude":
|
if not tag:
|
||||||
exclude_tags.add(tag)
|
continue
|
||||||
else:
|
if state == "exclude":
|
||||||
include_tags.add(tag)
|
exclude_tags.add(tag)
|
||||||
else:
|
else:
|
||||||
include_tags = {tag for tag in tag_filters if tag}
|
include_tags.add(tag)
|
||||||
|
else:
|
||||||
|
include_tags = {tag for tag in tag_filters if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
items = [
|
def matches_include(item_tags):
|
||||||
item for item in items
|
if not item_tags and "__no_tags__" in include_tags:
|
||||||
if any(tag in include_tags for tag in (item.get("tags", []) or []))
|
return True
|
||||||
]
|
return any(tag in include_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
if exclude_tags:
|
items = [
|
||||||
items = [
|
item for item in items
|
||||||
item for item in items
|
if matches_include(item.get("tags"))
|
||||||
if not any(tag in exclude_tags for tag in (item.get("tags", []) or []))
|
]
|
||||||
]
|
|
||||||
|
|
||||||
|
if exclude_tags:
|
||||||
|
def matches_exclude(item_tags):
|
||||||
|
if not item_tags and "__no_tags__" in exclude_tags:
|
||||||
|
return True
|
||||||
|
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if not matches_exclude(item.get("tags"))
|
||||||
|
]
|
||||||
|
tags_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
model_types_duration = 0
|
||||||
model_types = criteria.model_types or []
|
model_types = criteria.model_types or []
|
||||||
normalized_model_types = {
|
if model_types:
|
||||||
model_type for model_type in (
|
t0 = time.perf_counter()
|
||||||
normalize_civitai_model_type(value) for value in model_types
|
normalized_model_types = {
|
||||||
)
|
model_type for model_type in (
|
||||||
if model_type
|
normalize_civitai_model_type(value) for value in model_types
|
||||||
}
|
)
|
||||||
if normalized_model_types:
|
if model_type
|
||||||
items = [
|
}
|
||||||
item for item in items
|
if normalized_model_types:
|
||||||
if normalize_civitai_model_type(resolve_civitai_model_type(item)) in normalized_model_types
|
items = [
|
||||||
]
|
item for item in items
|
||||||
|
if normalize_civitai_model_type(resolve_civitai_model_type(item)) in normalized_model_types
|
||||||
|
]
|
||||||
|
model_types_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
duration = time.perf_counter() - overall_start
|
||||||
|
if duration > 0.1: # Only log if it's potentially slow
|
||||||
|
logger.info(
|
||||||
|
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). "
|
||||||
|
"Count: %d -> %d",
|
||||||
|
duration, sfw_duration, favorites_duration, folder_duration,
|
||||||
|
base_models_duration, tags_duration, model_types_duration,
|
||||||
|
initial_count, len(items)
|
||||||
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ from natsort import natsorted
|
|||||||
@dataclass
|
@dataclass
|
||||||
class RecipeCache:
|
class RecipeCache:
|
||||||
"""Cache structure for Recipe data"""
|
"""Cache structure for Recipe data"""
|
||||||
|
|
||||||
raw_data: List[Dict]
|
raw_data: List[Dict]
|
||||||
sorted_by_name: List[Dict]
|
sorted_by_name: List[Dict]
|
||||||
sorted_by_date: List[Dict]
|
sorted_by_date: List[Dict]
|
||||||
|
folders: List[str] | None = None
|
||||||
|
folder_tree: Dict | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self._lock = asyncio.Lock()
|
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):
|
async def resort(self, name_only: bool = False):
|
||||||
"""Resort all cached data views"""
|
"""Resort all cached data views"""
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
from __future__ import annotations
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -14,6 +16,9 @@ from .recipes.errors import RecipeNotFoundError
|
|||||||
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
import sys
|
import sys
|
||||||
|
import re
|
||||||
|
from ..recipes.merger import GenParamsMerger
|
||||||
|
from ..recipes.enrichment import RecipeEnricher
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,6 +57,8 @@ class RecipeScanner:
|
|||||||
cls._instance._civitai_client = None # Will be lazily initialized
|
cls._instance._civitai_client = None # Will be lazily initialized
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
|
REPAIR_VERSION = 3
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
lora_scanner: Optional[LoraScanner] = None,
|
lora_scanner: Optional[LoraScanner] = None,
|
||||||
@@ -64,6 +71,7 @@ class RecipeScanner:
|
|||||||
self._initialization_task: Optional[asyncio.Task] = None
|
self._initialization_task: Optional[asyncio.Task] = None
|
||||||
self._is_initializing = False
|
self._is_initializing = False
|
||||||
self._mutation_lock = asyncio.Lock()
|
self._mutation_lock = asyncio.Lock()
|
||||||
|
self._post_scan_task: Optional[asyncio.Task] = None
|
||||||
self._resort_tasks: Set[asyncio.Task] = set()
|
self._resort_tasks: Set[asyncio.Task] = set()
|
||||||
if lora_scanner:
|
if lora_scanner:
|
||||||
self._lora_scanner = lora_scanner
|
self._lora_scanner = lora_scanner
|
||||||
@@ -84,6 +92,10 @@ class RecipeScanner:
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
self._resort_tasks.clear()
|
self._resort_tasks.clear()
|
||||||
|
|
||||||
|
if self._post_scan_task and not self._post_scan_task.done():
|
||||||
|
self._post_scan_task.cancel()
|
||||||
|
self._post_scan_task = None
|
||||||
|
|
||||||
self._cache = None
|
self._cache = None
|
||||||
self._initialization_task = None
|
self._initialization_task = None
|
||||||
self._is_initializing = False
|
self._is_initializing = False
|
||||||
@@ -102,19 +114,223 @@ class RecipeScanner:
|
|||||||
self._civitai_client = await ServiceRegistry.get_civitai_client()
|
self._civitai_client = await ServiceRegistry.get_civitai_client()
|
||||||
return self._civitai_client
|
return self._civitai_client
|
||||||
|
|
||||||
|
async def repair_all_recipes(
|
||||||
|
self,
|
||||||
|
progress_callback: Optional[Callable[[Dict], Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Repair all recipes by enrichment with Civitai and embedded metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
persistence_service: Service for saving updated recipes
|
||||||
|
progress_callback: Optional callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict summary of repair results
|
||||||
|
"""
|
||||||
|
async with self._mutation_lock:
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
all_recipes = list(cache.raw_data)
|
||||||
|
total = len(all_recipes)
|
||||||
|
repaired_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
errors_count = 0
|
||||||
|
|
||||||
|
civitai_client = await self._get_civitai_client()
|
||||||
|
|
||||||
|
for i, recipe in enumerate(all_recipes):
|
||||||
|
try:
|
||||||
|
# Report progress
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback({
|
||||||
|
"status": "processing",
|
||||||
|
"current": i + 1,
|
||||||
|
"total": total,
|
||||||
|
"recipe_name": recipe.get("name", "Unknown")
|
||||||
|
})
|
||||||
|
|
||||||
|
if await self._repair_single_recipe(recipe, civitai_client):
|
||||||
|
repaired_count += 1
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error repairing recipe {recipe.get('file_path')}: {e}")
|
||||||
|
errors_count += 1
|
||||||
|
|
||||||
|
# Final progress update
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback({
|
||||||
|
"status": "completed",
|
||||||
|
"repaired": repaired_count,
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"errors": errors_count,
|
||||||
|
"total": total
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"repaired": repaired_count,
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"errors": errors_count,
|
||||||
|
"total": total
|
||||||
|
}
|
||||||
|
|
||||||
|
async def repair_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||||
|
"""Repair a single recipe by its ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: ID of the recipe to repair
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict summary of repair result
|
||||||
|
"""
|
||||||
|
async with self._mutation_lock:
|
||||||
|
# Get raw recipe from cache directly to avoid formatted fields
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None)
|
||||||
|
|
||||||
|
if not recipe:
|
||||||
|
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
||||||
|
|
||||||
|
civitai_client = await self._get_civitai_client()
|
||||||
|
success = await self._repair_single_recipe(recipe, civitai_client)
|
||||||
|
|
||||||
|
# If successfully repaired, we should return the formatted version for the UI
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"repaired": 1 if success else 0,
|
||||||
|
"skipped": 0 if success else 1,
|
||||||
|
"recipe": await self.get_recipe_by_id(recipe_id) if success else recipe
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _repair_single_recipe(self, recipe: Dict[str, Any], civitai_client: Any) -> bool:
|
||||||
|
"""Internal helper to repair a single recipe object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe: The recipe dictionary to repair (modified in-place)
|
||||||
|
civitai_client: Authenticated Civitai client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if recipe was repaired or updated, False if skipped
|
||||||
|
"""
|
||||||
|
# 1. Skip if already at latest repair version
|
||||||
|
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Identification: Is repair needed?
|
||||||
|
has_checkpoint = "checkpoint" in recipe and recipe["checkpoint"] and recipe["checkpoint"].get("name")
|
||||||
|
gen_params = recipe.get("gen_params", {})
|
||||||
|
has_prompt = bool(gen_params.get("prompt"))
|
||||||
|
|
||||||
|
needs_repair = not has_checkpoint or not has_prompt
|
||||||
|
|
||||||
|
if not needs_repair:
|
||||||
|
# Even if no repair needed, we mark it with version if it was processed
|
||||||
|
# Always update and save because if we are here, the version is old (checked in step 1)
|
||||||
|
recipe["repair_version"] = self.REPAIR_VERSION
|
||||||
|
await self._save_recipe_persistently(recipe)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 3. Use Enricher to repair/enrich
|
||||||
|
try:
|
||||||
|
updated = await RecipeEnricher.enrich_recipe(recipe, civitai_client)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error enriching recipe {recipe.get('id')}: {e}")
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
# 4. Mark version and save if updated or just marking version
|
||||||
|
# If we updated it, OR if the version is old (which we know it is if we are here), save it.
|
||||||
|
# Actually, if we are here and updated is False, it means we tried to repair but couldn't/didn't need to.
|
||||||
|
# But we still want to mark it as processed so we don't try again until version bump.
|
||||||
|
if updated or recipe.get("repair_version", 0) < self.REPAIR_VERSION:
|
||||||
|
recipe["repair_version"] = self.REPAIR_VERSION
|
||||||
|
await self._save_recipe_persistently(recipe)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
||||||
|
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
||||||
|
recipe_id = recipe.get("id")
|
||||||
|
if not recipe_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Sanitize for storage (remove runtime convenience fields)
|
||||||
|
clean_recipe = self._sanitize_recipe_for_storage(recipe)
|
||||||
|
|
||||||
|
# 2. Update the original dictionary so that we persist the clean version
|
||||||
|
# globally if needed, effectively overwriting it in-place.
|
||||||
|
recipe.clear()
|
||||||
|
recipe.update(clean_recipe)
|
||||||
|
|
||||||
|
# 3. Save JSON
|
||||||
|
with open(recipe_json_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(recipe, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 4. Update EXIF if image exists
|
||||||
|
image_path = recipe.get('file_path')
|
||||||
|
if image_path and os.path.exists(image_path):
|
||||||
|
from ..utils.exif_utils import ExifUtils
|
||||||
|
ExifUtils.append_recipe_metadata(image_path, recipe)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error persisting recipe {recipe_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_recipe_for_storage(self, recipe: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Create a clean copy of the recipe without runtime convenience fields."""
|
||||||
|
import copy
|
||||||
|
clean = copy.deepcopy(recipe)
|
||||||
|
|
||||||
|
# 0. Clean top-level runtime fields
|
||||||
|
for key in ("file_url", "created_date_formatted", "modified_formatted"):
|
||||||
|
clean.pop(key, None)
|
||||||
|
|
||||||
|
# 1. Clean LORAs
|
||||||
|
if "loras" in clean and isinstance(clean["loras"], list):
|
||||||
|
for lora in clean["loras"]:
|
||||||
|
# Fields to remove (runtime only)
|
||||||
|
for key in ("inLibrary", "preview_url", "localPath"):
|
||||||
|
lora.pop(key, None)
|
||||||
|
|
||||||
|
# Normalize weight/strength if mapping is desired (standard in persistence_service)
|
||||||
|
if "weight" in lora and "strength" not in lora:
|
||||||
|
lora["strength"] = float(lora.pop("weight"))
|
||||||
|
|
||||||
|
# 2. Clean Checkpoint
|
||||||
|
if "checkpoint" in clean and isinstance(clean["checkpoint"], dict):
|
||||||
|
cp = clean["checkpoint"]
|
||||||
|
# Fields to remove (runtime only)
|
||||||
|
for key in ("inLibrary", "localPath", "preview_url", "thumbnailUrl", "size", "downloadUrl"):
|
||||||
|
cp.pop(key, None)
|
||||||
|
|
||||||
|
return clean
|
||||||
|
|
||||||
async def initialize_in_background(self) -> None:
|
async def initialize_in_background(self) -> None:
|
||||||
"""Initialize cache in background using thread pool"""
|
"""Initialize cache in background using thread pool"""
|
||||||
try:
|
try:
|
||||||
|
await self._wait_for_lora_scanner()
|
||||||
|
|
||||||
# Set initial empty cache to avoid None reference errors
|
# Set initial empty cache to avoid None reference errors
|
||||||
if self._cache is None:
|
if self._cache is None:
|
||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=[],
|
raw_data=[],
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark as initializing to prevent concurrent initializations
|
# Mark as initializing to prevent concurrent initializations
|
||||||
self._is_initializing = True
|
self._is_initializing = True
|
||||||
|
self._initialization_task = asyncio.current_task()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start timer
|
# Start timer
|
||||||
@@ -126,11 +342,14 @@ class RecipeScanner:
|
|||||||
None, # Use default thread pool
|
None, # Use default thread pool
|
||||||
self._initialize_recipe_cache_sync # Run synchronous version in thread
|
self._initialize_recipe_cache_sync # Run synchronous version in thread
|
||||||
)
|
)
|
||||||
|
if cache is not None:
|
||||||
|
self._cache = cache
|
||||||
|
|
||||||
# Calculate elapsed time and log it
|
# Calculate elapsed time and log it
|
||||||
elapsed_time = time.time() - start_time
|
elapsed_time = time.time() - start_time
|
||||||
recipe_count = len(cache.raw_data) if cache and hasattr(cache, 'raw_data') else 0
|
recipe_count = len(cache.raw_data) if cache and hasattr(cache, 'raw_data') else 0
|
||||||
logger.info(f"Recipe cache initialized in {elapsed_time:.2f} seconds. Found {recipe_count} recipes")
|
logger.info(f"Recipe cache initialized in {elapsed_time:.2f} seconds. Found {recipe_count} recipes")
|
||||||
|
self._schedule_post_scan_enrichment()
|
||||||
finally:
|
finally:
|
||||||
# Mark initialization as complete regardless of outcome
|
# Mark initialization as complete regardless of outcome
|
||||||
self._is_initializing = False
|
self._is_initializing = False
|
||||||
@@ -207,6 +426,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Update cache with the collected data
|
# Update cache with the collected data
|
||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
|
self._update_folder_metadata(self._cache)
|
||||||
|
|
||||||
# Create a simplified resort function that doesn't use await
|
# Create a simplified resort function that doesn't use await
|
||||||
if hasattr(self._cache, "resort"):
|
if hasattr(self._cache, "resort"):
|
||||||
@@ -237,12 +457,97 @@ class RecipeScanner:
|
|||||||
# Clean up the event loop
|
# Clean up the event loop
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
|
async def _wait_for_lora_scanner(self) -> None:
|
||||||
|
"""Ensure the LoRA scanner has initialized before recipe enrichment."""
|
||||||
|
|
||||||
|
if not getattr(self, "_lora_scanner", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
lora_scanner = self._lora_scanner
|
||||||
|
cache_ready = getattr(lora_scanner, "_cache", None) is not None
|
||||||
|
|
||||||
|
# If cache is already available, we can proceed
|
||||||
|
if cache_ready:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Await an existing initialization task if present
|
||||||
|
task = getattr(lora_scanner, "_initialization_task", None)
|
||||||
|
if task and hasattr(task, "done") and not task.done():
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except Exception: # pragma: no cover - defensive guard
|
||||||
|
pass
|
||||||
|
if getattr(lora_scanner, "_cache", None) is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Otherwise, request initialization and proceed once it completes
|
||||||
|
try:
|
||||||
|
await lora_scanner.initialize_in_background()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.debug("Recipe Scanner: LoRA init request failed: %s", exc)
|
||||||
|
|
||||||
|
def _schedule_post_scan_enrichment(self) -> None:
|
||||||
|
"""Kick off a non-blocking enrichment pass to fill remote metadata."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._post_scan_task and not self._post_scan_task.done():
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _run_enrichment():
|
||||||
|
try:
|
||||||
|
await self._enrich_cache_metadata()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.error("Recipe Scanner: error during post-scan enrichment: %s", exc, exc_info=True)
|
||||||
|
|
||||||
|
self._post_scan_task = loop.create_task(_run_enrichment(), name="recipe_cache_enrichment")
|
||||||
|
|
||||||
|
async def _enrich_cache_metadata(self) -> None:
|
||||||
|
"""Perform remote metadata enrichment after the initial scan."""
|
||||||
|
|
||||||
|
cache = self._cache
|
||||||
|
if cache is None or not getattr(cache, "raw_data", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
for index, recipe in enumerate(list(cache.raw_data)):
|
||||||
|
try:
|
||||||
|
metadata_updated = await self._update_lora_information(recipe)
|
||||||
|
if metadata_updated:
|
||||||
|
recipe_id = recipe.get("id")
|
||||||
|
if recipe_id:
|
||||||
|
recipe_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
||||||
|
if os.path.exists(recipe_path):
|
||||||
|
try:
|
||||||
|
self._write_recipe_file(recipe_path, recipe)
|
||||||
|
except Exception as exc: # pragma: no cover - best-effort persistence
|
||||||
|
logger.debug("Recipe Scanner: could not persist recipe %s: %s", recipe_id, exc)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Recipe Scanner: error enriching recipe %s: %s", recipe.get("id"), exc, exc_info=True)
|
||||||
|
|
||||||
|
if index % 10 == 0:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await cache.resort()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.debug("Recipe Scanner: error resorting cache after enrichment: %s", exc)
|
||||||
|
|
||||||
def _schedule_resort(self, *, name_only: bool = False) -> None:
|
def _schedule_resort(self, *, name_only: bool = False) -> None:
|
||||||
"""Schedule a background resort of the recipe cache."""
|
"""Schedule a background resort of the recipe cache."""
|
||||||
|
|
||||||
if not self._cache:
|
if not self._cache:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Keep folder metadata up to date alongside sort order
|
||||||
|
self._update_folder_metadata()
|
||||||
|
|
||||||
async def _resort_wrapper() -> None:
|
async def _resort_wrapper() -> None:
|
||||||
try:
|
try:
|
||||||
await self._cache.resort(name_only=name_only)
|
await self._cache.resort(name_only=name_only)
|
||||||
@@ -253,6 +558,75 @@ class RecipeScanner:
|
|||||||
self._resort_tasks.add(task)
|
self._resort_tasks.add(task)
|
||||||
task.add_done_callback(lambda finished: self._resort_tasks.discard(finished))
|
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
|
@property
|
||||||
def recipes_dir(self) -> str:
|
def recipes_dir(self) -> str:
|
||||||
"""Get path to recipes directory"""
|
"""Get path to recipes directory"""
|
||||||
@@ -269,11 +643,14 @@ class RecipeScanner:
|
|||||||
"""Get cached recipe data, refresh if needed"""
|
"""Get cached recipe data, refresh if needed"""
|
||||||
# If cache is already initialized and no refresh is needed, return it immediately
|
# If cache is already initialized and no refresh is needed, return it immediately
|
||||||
if self._cache is not None and not force_refresh:
|
if self._cache is not None and not force_refresh:
|
||||||
|
self._update_folder_metadata()
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
# If another initialization is already in progress, wait for it to complete
|
# If another initialization is already in progress, wait for it to complete
|
||||||
if self._is_initializing and not force_refresh:
|
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 is requested, initialize the cache directly
|
||||||
if force_refresh:
|
if force_refresh:
|
||||||
@@ -291,11 +668,14 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=raw_data,
|
raw_data=raw_data,
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resort cache
|
# Resort cache
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
self._update_folder_metadata(self._cache)
|
||||||
|
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
@@ -305,7 +685,9 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=[],
|
raw_data=[],
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
return self._cache
|
return self._cache
|
||||||
finally:
|
finally:
|
||||||
@@ -316,7 +698,9 @@ class RecipeScanner:
|
|||||||
logger.error(f"Unexpected error in get_cached_data: {e}")
|
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||||
|
|
||||||
# Return the cache (may be empty or partially initialized)
|
# 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:
|
async def refresh_cache(self, force: bool = False) -> RecipeCache:
|
||||||
"""Public helper to refresh or return the recipe cache."""
|
"""Public helper to refresh or return the recipe cache."""
|
||||||
@@ -331,6 +715,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
await cache.add_recipe(recipe_data, resort=False)
|
await cache.add_recipe(recipe_data, resort=False)
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
self._schedule_resort()
|
self._schedule_resort()
|
||||||
|
|
||||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||||
@@ -344,6 +729,7 @@ class RecipeScanner:
|
|||||||
if removed is None:
|
if removed is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
self._schedule_resort()
|
self._schedule_resort()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -428,6 +814,9 @@ class RecipeScanner:
|
|||||||
|
|
||||||
if path_updated:
|
if path_updated:
|
||||||
self._write_recipe_file(recipe_path, recipe_data)
|
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
|
# Ensure loras array exists
|
||||||
if 'loras' not in recipe_data:
|
if 'loras' not in recipe_data:
|
||||||
@@ -438,7 +827,7 @@ class RecipeScanner:
|
|||||||
recipe_data['gen_params'] = {}
|
recipe_data['gen_params'] = {}
|
||||||
|
|
||||||
# Update lora information with local paths and availability
|
# Update lora information with local paths and availability
|
||||||
await self._update_lora_information(recipe_data)
|
lora_metadata_updated = await self._update_lora_information(recipe_data)
|
||||||
|
|
||||||
if recipe_data.get('checkpoint'):
|
if recipe_data.get('checkpoint'):
|
||||||
checkpoint_entry = self._normalize_checkpoint_entry(recipe_data['checkpoint'])
|
checkpoint_entry = self._normalize_checkpoint_entry(recipe_data['checkpoint'])
|
||||||
@@ -459,6 +848,12 @@ class RecipeScanner:
|
|||||||
logger.info(f"Added fingerprint to recipe: {recipe_path}")
|
logger.info(f"Added fingerprint to recipe: {recipe_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error writing updated recipe with fingerprint: {e}")
|
logger.error(f"Error writing updated recipe with fingerprint: {e}")
|
||||||
|
elif lora_metadata_updated:
|
||||||
|
# Persist updates such as marking invalid entries as deleted
|
||||||
|
try:
|
||||||
|
self._write_recipe_file(recipe_path, recipe_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error writing updated recipe metadata: {e}")
|
||||||
|
|
||||||
return recipe_data
|
return recipe_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -519,7 +914,13 @@ class RecipeScanner:
|
|||||||
logger.warning(f"Marked lora with modelVersionId {model_version_id} as deleted")
|
logger.warning(f"Marked lora with modelVersionId {model_version_id} as deleted")
|
||||||
metadata_updated = True
|
metadata_updated = True
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Could not get hash for modelVersionId {model_version_id}")
|
# No hash returned; mark as deleted to avoid repeated lookups
|
||||||
|
lora['isDeleted'] = True
|
||||||
|
metadata_updated = True
|
||||||
|
logger.warning(
|
||||||
|
"Marked lora with modelVersionId %s as deleted after failed hash lookup",
|
||||||
|
model_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
# If has hash but no file_name, look up in lora library
|
# If has hash but no file_name, look up in lora library
|
||||||
if 'hash' in lora and (not lora.get('file_name') or not lora['file_name']):
|
if 'hash' in lora and (not lora.get('file_name') or not lora['file_name']):
|
||||||
@@ -809,7 +1210,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return await self._lora_scanner.get_model_info_by_name(name)
|
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
|
"""Get paginated and filtered recipe data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -821,11 +1222,20 @@ class RecipeScanner:
|
|||||||
search_options: Dictionary of search options to apply
|
search_options: Dictionary of search options to apply
|
||||||
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
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
|
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()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
# Get base dataset
|
# 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
|
# Apply SFW filtering if enabled
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
@@ -856,6 +1266,22 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
||||||
if not (lora_hash and bypass_filters):
|
if not (lora_hash and bypass_filters):
|
||||||
|
# Apply folder filter before other criteria
|
||||||
|
if folder is not None:
|
||||||
|
normalized_folder = folder.strip("/")
|
||||||
|
def matches_folder(item_folder: str) -> bool:
|
||||||
|
item_path = (item_folder or "").strip("/")
|
||||||
|
if recursive:
|
||||||
|
if not normalized_folder:
|
||||||
|
return True
|
||||||
|
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
|
# Apply search filter
|
||||||
if search:
|
if search:
|
||||||
# Default search options if none provided
|
# Default search options if none provided
|
||||||
@@ -892,6 +1318,14 @@ class RecipeScanner:
|
|||||||
if fuzzy_match(str(lora.get('modelName', '')), search):
|
if fuzzy_match(str(lora.get('modelName', '')), search):
|
||||||
return True
|
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
|
# No match found
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -907,6 +1341,13 @@ class RecipeScanner:
|
|||||||
if item.get('base_model', '') in filters['base_model']
|
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
|
# Filter by tags
|
||||||
if 'tags' in filters and filters['tags']:
|
if 'tags' in filters and filters['tags']:
|
||||||
tag_spec = filters['tags']
|
tag_spec = filters['tags']
|
||||||
@@ -925,17 +1366,41 @@ class RecipeScanner:
|
|||||||
include_tags = {tag for tag in tag_spec if tag}
|
include_tags = {tag for tag in tag_spec if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
|
def matches_include(item_tags):
|
||||||
|
if not item_tags and "__no_tags__" in include_tags:
|
||||||
|
return True
|
||||||
|
return any(tag in include_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
item for item in filtered_data
|
||||||
if any(tag in include_tags for tag in (item.get('tags', []) or []))
|
if matches_include(item.get('tags'))
|
||||||
]
|
]
|
||||||
|
|
||||||
if exclude_tags:
|
if exclude_tags:
|
||||||
|
def matches_exclude(item_tags):
|
||||||
|
if not item_tags and "__no_tags__" in exclude_tags:
|
||||||
|
return True
|
||||||
|
return any(tag in exclude_tags for tag in (item_tags or []))
|
||||||
|
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
item for item in filtered_data
|
||||||
if not any(tag in exclude_tags for tag in (item.get('tags', []) or []))
|
if not matches_exclude(item.get('tags'))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
# Calculate pagination
|
||||||
total_items = len(filtered_data)
|
total_items = len(filtered_data)
|
||||||
start_idx = (page - 1) * page_size
|
start_idx = (page - 1) * page_size
|
||||||
@@ -1031,6 +1496,30 @@ class RecipeScanner:
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
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:
|
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
|
"""Update recipe metadata (like title and tags) in both file system and cache
|
||||||
|
|
||||||
@@ -1041,13 +1530,9 @@ class RecipeScanner:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import json
|
|
||||||
|
|
||||||
# First, find the recipe JSON file path
|
# First, find the recipe JSON file path
|
||||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
if not os.path.exists(recipe_json_path):
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1096,8 +1581,8 @@ class RecipeScanner:
|
|||||||
if target_name is None:
|
if target_name is None:
|
||||||
raise ValueError("target_name must be provided")
|
raise ValueError("target_name must be provided")
|
||||||
|
|
||||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_json_path):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
async with self._mutation_lock:
|
async with self._mutation_lock:
|
||||||
@@ -1228,71 +1713,56 @@ class RecipeScanner:
|
|||||||
# Always use lowercase hash for consistency
|
# Always use lowercase hash for consistency
|
||||||
hash_value = hash_value.lower()
|
hash_value = hash_value.lower()
|
||||||
|
|
||||||
# Get recipes directory
|
# Get cache
|
||||||
recipes_dir = self.recipes_dir
|
cache = await self.get_cached_data()
|
||||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
if not cache or not cache.raw_data:
|
||||||
logger.warning(f"Recipes directory not found: {recipes_dir}")
|
return 0, 0
|
||||||
|
|
||||||
|
file_updated_count = 0
|
||||||
|
cache_updated_count = 0
|
||||||
|
|
||||||
|
# Find recipes that need updating from the cache
|
||||||
|
recipes_to_update = []
|
||||||
|
for recipe in cache.raw_data:
|
||||||
|
loras = recipe.get('loras', [])
|
||||||
|
if not isinstance(loras, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
has_match = False
|
||||||
|
for lora in loras:
|
||||||
|
if not isinstance(lora, dict):
|
||||||
|
continue
|
||||||
|
if (lora.get('hash') or '').lower() == hash_value:
|
||||||
|
if lora.get('file_name') != new_file_name:
|
||||||
|
lora['file_name'] = new_file_name
|
||||||
|
has_match = True
|
||||||
|
|
||||||
|
if has_match:
|
||||||
|
recipes_to_update.append(recipe)
|
||||||
|
cache_updated_count += 1
|
||||||
|
|
||||||
|
if not recipes_to_update:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
# Check if cache is initialized
|
# Persist changes to disk
|
||||||
cache_initialized = self._cache is not None
|
async with self._mutation_lock:
|
||||||
cache_updated_count = 0
|
for recipe in recipes_to_update:
|
||||||
file_updated_count = 0
|
recipe_id = recipe.get('id')
|
||||||
|
if not recipe_id:
|
||||||
# Get all recipe JSON files in the recipes directory
|
|
||||||
recipe_files = []
|
|
||||||
for root, _, files in os.walk(recipes_dir):
|
|
||||||
for file in files:
|
|
||||||
if file.lower().endswith('.recipe.json'):
|
|
||||||
recipe_files.append(os.path.join(root, file))
|
|
||||||
|
|
||||||
# Process each recipe file
|
|
||||||
for recipe_path in recipe_files:
|
|
||||||
try:
|
|
||||||
# Load the recipe data
|
|
||||||
with open(recipe_path, 'r', encoding='utf-8') as f:
|
|
||||||
recipe_data = json.load(f)
|
|
||||||
|
|
||||||
# Skip if no loras or invalid structure
|
|
||||||
if not recipe_data or not isinstance(recipe_data, dict) or 'loras' not in recipe_data:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if any lora has matching hash
|
|
||||||
file_updated = False
|
|
||||||
for lora in recipe_data.get('loras', []):
|
|
||||||
if 'hash' in lora and lora['hash'].lower() == hash_value:
|
|
||||||
# Update file_name
|
|
||||||
old_file_name = lora.get('file_name', '')
|
|
||||||
lora['file_name'] = new_file_name
|
|
||||||
file_updated = True
|
|
||||||
logger.info(f"Updated file_name in recipe {recipe_path}: {old_file_name} -> {new_file_name}")
|
|
||||||
|
|
||||||
# If updated, save the file
|
|
||||||
if file_updated:
|
|
||||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
|
||||||
file_updated_count += 1
|
|
||||||
|
|
||||||
# Also update in cache if it exists
|
recipe_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
||||||
if cache_initialized:
|
try:
|
||||||
recipe_id = recipe_data.get('id')
|
self._write_recipe_file(recipe_path, recipe)
|
||||||
if recipe_id:
|
file_updated_count += 1
|
||||||
for cache_item in self._cache.raw_data:
|
logger.info(f"Updated file_name in recipe {recipe_path}: -> {new_file_name}")
|
||||||
if cache_item.get('id') == recipe_id:
|
except Exception as e:
|
||||||
# Replace loras array with updated version
|
logger.error(f"Error updating recipe file {recipe_path}: {e}")
|
||||||
cache_item['loras'] = recipe_data['loras']
|
|
||||||
cache_updated_count += 1
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating recipe file {recipe_path}: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc(file=sys.stderr)
|
|
||||||
|
|
||||||
# Resort cache if updates were made
|
# We don't necessarily need to resort because LoRA file_name isn't a sort key,
|
||||||
if cache_initialized and cache_updated_count > 0:
|
# but we might want to schedule a resort if we're paranoid or if searching relies on sorted state.
|
||||||
await self._cache.resort()
|
# Given it's a rename of a dependency, search results might change if searching by LoRA name.
|
||||||
logger.info(f"Resorted recipe cache after updating {cache_updated_count} items")
|
self._schedule_resort()
|
||||||
|
|
||||||
return file_updated_count, cache_updated_count
|
return file_updated_count, cache_updated_count
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import numpy as np
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
RecipeNotFoundError,
|
RecipeNotFoundError,
|
||||||
@@ -94,18 +95,39 @@ class RecipeAnalysisService:
|
|||||||
if civitai_client is None:
|
if civitai_client is None:
|
||||||
raise RecipeServiceError("Civitai client unavailable")
|
raise RecipeServiceError("Civitai client unavailable")
|
||||||
|
|
||||||
temp_path = self._create_temp_path()
|
temp_path = None
|
||||||
metadata: Optional[dict[str, Any]] = None
|
metadata: Optional[dict[str, Any]] = None
|
||||||
|
is_video = False
|
||||||
|
extension = ".jpg" # Default
|
||||||
|
|
||||||
try:
|
try:
|
||||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
|
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
|
||||||
if civitai_match:
|
if civitai_match:
|
||||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||||
if not image_info:
|
if not image_info:
|
||||||
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
||||||
|
|
||||||
image_url = image_info.get("url")
|
image_url = image_info.get("url")
|
||||||
if not image_url:
|
if not image_url:
|
||||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||||
|
|
||||||
|
is_video = image_info.get("type") == "video"
|
||||||
|
|
||||||
|
# Use optimized preview URLs if possible
|
||||||
|
rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type"))
|
||||||
|
if rewritten_url:
|
||||||
|
image_url = rewritten_url
|
||||||
|
|
||||||
|
if is_video:
|
||||||
|
# Extract extension from URL
|
||||||
|
url_path = image_url.split('?')[0].split('#')[0]
|
||||||
|
extension = os.path.splitext(url_path)[1].lower() or ".mp4"
|
||||||
|
else:
|
||||||
|
extension = ".jpg"
|
||||||
|
|
||||||
|
temp_path = self._create_temp_path(suffix=extension)
|
||||||
await self._download_image(image_url, temp_path)
|
await self._download_image(image_url, temp_path)
|
||||||
|
|
||||||
metadata = image_info.get("meta") if "meta" in image_info else None
|
metadata = image_info.get("meta") if "meta" in image_info else None
|
||||||
if (
|
if (
|
||||||
isinstance(metadata, dict)
|
isinstance(metadata, dict)
|
||||||
@@ -114,22 +136,31 @@ class RecipeAnalysisService:
|
|||||||
):
|
):
|
||||||
metadata = metadata["meta"]
|
metadata = metadata["meta"]
|
||||||
else:
|
else:
|
||||||
|
# Basic extension detection for non-Civitai URLs
|
||||||
|
url_path = url.split('?')[0].split('#')[0]
|
||||||
|
extension = os.path.splitext(url_path)[1].lower()
|
||||||
|
if extension in [".mp4", ".webm"]:
|
||||||
|
is_video = True
|
||||||
|
else:
|
||||||
|
extension = ".jpg"
|
||||||
|
|
||||||
|
temp_path = self._create_temp_path(suffix=extension)
|
||||||
await self._download_image(url, temp_path)
|
await self._download_image(url, temp_path)
|
||||||
|
|
||||||
if metadata is None:
|
if metadata is None and not is_video:
|
||||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||||
|
|
||||||
if not metadata:
|
|
||||||
return self._metadata_not_found_response(temp_path)
|
|
||||||
|
|
||||||
return await self._parse_metadata(
|
return await self._parse_metadata(
|
||||||
metadata,
|
metadata or {},
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_path=temp_path,
|
image_path=temp_path,
|
||||||
include_image_base64=True,
|
include_image_base64=True,
|
||||||
|
is_video=is_video,
|
||||||
|
extension=extension,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._safe_cleanup(temp_path)
|
if temp_path:
|
||||||
|
self._safe_cleanup(temp_path)
|
||||||
|
|
||||||
async def analyze_local_image(
|
async def analyze_local_image(
|
||||||
self,
|
self,
|
||||||
@@ -198,12 +229,16 @@ class RecipeAnalysisService:
|
|||||||
recipe_scanner,
|
recipe_scanner,
|
||||||
image_path: Optional[str],
|
image_path: Optional[str],
|
||||||
include_image_base64: bool,
|
include_image_base64: bool,
|
||||||
|
is_video: bool = False,
|
||||||
|
extension: str = ".jpg",
|
||||||
) -> AnalysisResult:
|
) -> AnalysisResult:
|
||||||
parser = self._recipe_parser_factory.create_parser(metadata)
|
parser = self._recipe_parser_factory.create_parser(metadata)
|
||||||
if parser is None:
|
if parser is None:
|
||||||
payload = {"error": "No parser found for this image", "loras": []}
|
payload = {"error": "No parser found for this image", "loras": []}
|
||||||
if include_image_base64 and image_path:
|
if include_image_base64 and image_path:
|
||||||
payload["image_base64"] = self._encode_file(image_path)
|
payload["image_base64"] = self._encode_file(image_path)
|
||||||
|
payload["is_video"] = is_video
|
||||||
|
payload["extension"] = extension
|
||||||
return AnalysisResult(payload)
|
return AnalysisResult(payload)
|
||||||
|
|
||||||
result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner)
|
result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner)
|
||||||
@@ -211,6 +246,9 @@ class RecipeAnalysisService:
|
|||||||
if include_image_base64 and image_path:
|
if include_image_base64 and image_path:
|
||||||
result["image_base64"] = self._encode_file(image_path)
|
result["image_base64"] = self._encode_file(image_path)
|
||||||
|
|
||||||
|
result["is_video"] = is_video
|
||||||
|
result["extension"] = extension
|
||||||
|
|
||||||
if "error" in result and not result.get("loras"):
|
if "error" in result and not result.get("loras"):
|
||||||
return AnalysisResult(result)
|
return AnalysisResult(result)
|
||||||
|
|
||||||
@@ -241,8 +279,8 @@ class RecipeAnalysisService:
|
|||||||
temp_file.write(data)
|
temp_file.write(data)
|
||||||
return temp_file.name
|
return temp_file.name
|
||||||
|
|
||||||
def _create_temp_path(self) -> str:
|
def _create_temp_path(self, suffix: str = ".jpg") -> str:
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
||||||
return temp_file.name
|
return temp_file.name
|
||||||
|
|
||||||
def _safe_cleanup(self, path: Optional[str]) -> None:
|
def _safe_cleanup(self, path: Optional[str]) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -46,6 +47,7 @@ class RecipePersistenceService:
|
|||||||
name: str | None,
|
name: str | None,
|
||||||
tags: Iterable[str],
|
tags: Iterable[str],
|
||||||
metadata: Optional[dict[str, Any]],
|
metadata: Optional[dict[str, Any]],
|
||||||
|
extension: str | None = None,
|
||||||
) -> PersistenceResult:
|
) -> PersistenceResult:
|
||||||
"""Persist a user uploaded recipe."""
|
"""Persist a user uploaded recipe."""
|
||||||
|
|
||||||
@@ -64,13 +66,21 @@ class RecipePersistenceService:
|
|||||||
os.makedirs(recipes_dir, exist_ok=True)
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
|
|
||||||
recipe_id = str(uuid.uuid4())
|
recipe_id = str(uuid.uuid4())
|
||||||
optimized_image, extension = self._exif_utils.optimize_image(
|
|
||||||
image_data=resolved_image_bytes,
|
# Handle video formats by bypassing optimization and metadata embedding
|
||||||
target_width=self._card_preview_width,
|
is_video = extension in [".mp4", ".webm"]
|
||||||
format="webp",
|
if is_video:
|
||||||
quality=85,
|
optimized_image = resolved_image_bytes
|
||||||
preserve_metadata=True,
|
# extension is already set
|
||||||
)
|
else:
|
||||||
|
optimized_image, extension = self._exif_utils.optimize_image(
|
||||||
|
image_data=resolved_image_bytes,
|
||||||
|
target_width=self._card_preview_width,
|
||||||
|
format="webp",
|
||||||
|
quality=85,
|
||||||
|
preserve_metadata=True,
|
||||||
|
)
|
||||||
|
|
||||||
image_filename = f"{recipe_id}{extension}"
|
image_filename = f"{recipe_id}{extension}"
|
||||||
image_path = os.path.join(recipes_dir, image_filename)
|
image_path = os.path.join(recipes_dir, image_filename)
|
||||||
normalized_image_path = os.path.normpath(image_path)
|
normalized_image_path = os.path.normpath(image_path)
|
||||||
@@ -126,7 +136,8 @@ class RecipePersistenceService:
|
|||||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
|
if not is_video:
|
||||||
|
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
|
||||||
|
|
||||||
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
||||||
await recipe_scanner.add_recipe(recipe_data)
|
await recipe_scanner.add_recipe(recipe_data)
|
||||||
@@ -144,12 +155,8 @@ class RecipePersistenceService:
|
|||||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||||
"""Delete an existing recipe."""
|
"""Delete an existing recipe."""
|
||||||
|
|
||||||
recipes_dir = recipe_scanner.recipes_dir
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
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):
|
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||||
@@ -166,9 +173,9 @@ class RecipePersistenceService:
|
|||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
||||||
"""Update persisted metadata for a recipe."""
|
"""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(
|
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)
|
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||||
@@ -177,6 +184,163 @@ class RecipePersistenceService:
|
|||||||
|
|
||||||
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
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(
|
async def reconnect_lora(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -187,8 +351,8 @@ class RecipePersistenceService:
|
|||||||
) -> PersistenceResult:
|
) -> PersistenceResult:
|
||||||
"""Reconnect a LoRA entry within an existing recipe."""
|
"""Reconnect a LoRA entry within an existing recipe."""
|
||||||
|
|
||||||
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_path):
|
if not recipe_path or not os.path.exists(recipe_path):
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
target_lora = await recipe_scanner.get_local_lora(target_name)
|
||||||
@@ -233,16 +397,12 @@ class RecipePersistenceService:
|
|||||||
if not recipe_ids:
|
if not recipe_ids:
|
||||||
raise RecipeValidationError("No recipe IDs provided")
|
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] = []
|
deleted_recipes: list[str] = []
|
||||||
failed_recipes: list[dict[str, Any]] = []
|
failed_recipes: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for recipe_id in recipe_ids:
|
for recipe_id in recipe_ids:
|
||||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_json_path):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
|||||||
self._last_init_progress: Dict[str, Dict] = {}
|
self._last_init_progress: Dict[str, Dict] = {}
|
||||||
# Add auto-organize progress tracking
|
# Add auto-organize progress tracking
|
||||||
self._auto_organize_progress: Optional[Dict] = None
|
self._auto_organize_progress: Optional[Dict] = None
|
||||||
|
# Add recipe repair progress tracking
|
||||||
|
self._recipe_repair_progress: Optional[Dict] = None
|
||||||
self._auto_organize_lock = asyncio.Lock()
|
self._auto_organize_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
@@ -189,6 +191,14 @@ class WebSocketManager:
|
|||||||
# Broadcast via WebSocket
|
# Broadcast via WebSocket
|
||||||
await self.broadcast(data)
|
await self.broadcast(data)
|
||||||
|
|
||||||
|
async def broadcast_recipe_repair_progress(self, data: Dict):
|
||||||
|
"""Broadcast recipe repair progress to connected clients"""
|
||||||
|
# Store progress data in memory
|
||||||
|
self._recipe_repair_progress = data
|
||||||
|
|
||||||
|
# Broadcast via WebSocket
|
||||||
|
await self.broadcast(data)
|
||||||
|
|
||||||
def get_auto_organize_progress(self) -> Optional[Dict]:
|
def get_auto_organize_progress(self) -> Optional[Dict]:
|
||||||
"""Get current auto-organize progress"""
|
"""Get current auto-organize progress"""
|
||||||
return self._auto_organize_progress
|
return self._auto_organize_progress
|
||||||
@@ -197,6 +207,14 @@ class WebSocketManager:
|
|||||||
"""Clear auto-organize progress data"""
|
"""Clear auto-organize progress data"""
|
||||||
self._auto_organize_progress = None
|
self._auto_organize_progress = None
|
||||||
|
|
||||||
|
def get_recipe_repair_progress(self) -> Optional[Dict]:
|
||||||
|
"""Get current recipe repair progress"""
|
||||||
|
return self._recipe_repair_progress
|
||||||
|
|
||||||
|
def cleanup_recipe_repair_progress(self):
|
||||||
|
"""Clear recipe repair progress data"""
|
||||||
|
self._recipe_repair_progress = None
|
||||||
|
|
||||||
def is_auto_organize_running(self) -> bool:
|
def is_auto_organize_running(self) -> bool:
|
||||||
"""Check if auto-organize is currently running"""
|
"""Check if auto-organize is currently running"""
|
||||||
if not self._auto_organize_progress:
|
if not self._auto_organize_progress:
|
||||||
|
|||||||
@@ -1,82 +1,33 @@
|
|||||||
{
|
{
|
||||||
"id": "0448c06d-de1b-46ab-975c-c5aa60d90dbc",
|
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
|
||||||
"file_path": "D:/Workspace/ComfyUI/models/loras/recipes/0448c06d-de1b-46ab-975c-c5aa60d90dbc.jpg",
|
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
|
||||||
"title": "a mysterious, steampunk-inspired character standing in a dramatic pose",
|
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
|
||||||
"modified": 1741837612.3931093,
|
"modified": 1754897325.0507245,
|
||||||
"created_date": 1741492786.5581934,
|
"created_date": 1754897325.0507245,
|
||||||
"base_model": "Flux.1 D",
|
"base_model": "Illustrious",
|
||||||
"loras": [
|
"loras": [
|
||||||
{
|
{
|
||||||
"file_name": "ChronoDivinitiesFlux_r1",
|
"file_name": "",
|
||||||
"hash": "ddbc5abd00db46ad464f5e3ca85f8f7121bc14b594d6785f441d9b002fffe66a",
|
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
|
||||||
"strength": 0.8,
|
"strength": 1.0,
|
||||||
"modelVersionId": 1438879,
|
"modelVersionId": 2007092,
|
||||||
"modelName": "Chrono Divinities - By HailoKnight",
|
"modelName": "Pony: People's Works +",
|
||||||
"modelVersionName": "Flux"
|
"modelVersionName": "v8_Illusv1.0",
|
||||||
},
|
"isDeleted": false,
|
||||||
{
|
"exclude": false
|
||||||
"file_name": "flux.1_lora_flyway_ink-dynamic",
|
|
||||||
"hash": "4b4f3b469a0d5d3a04a46886abfa33daa37a905db070ccfbd10b345c6fb00eff",
|
|
||||||
"strength": 0.2,
|
|
||||||
"modelVersionId": 914935,
|
|
||||||
"modelName": "Ink-style",
|
|
||||||
"modelVersionName": "ink-dynamic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "ck-painterly-fantasy-000017",
|
|
||||||
"hash": "48c67064e2936aec342580a2a729d91d75eb818e45ecf993b9650cc66c94c420",
|
|
||||||
"strength": 0.2,
|
|
||||||
"modelVersionId": 1189379,
|
|
||||||
"modelName": "Painterly Fantasy by ChronoKnight - [FLUX & IL]",
|
|
||||||
"modelVersionName": "FLUX"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "RetroAnimeFluxV1",
|
|
||||||
"hash": "8f43c31b6c3238ac44195c970d511d759c5893bddd00f59f42b8fe51e8e76fa0",
|
|
||||||
"strength": 0.8,
|
|
||||||
"modelVersionId": 806265,
|
|
||||||
"modelName": "Retro Anime Flux - Style",
|
|
||||||
"modelVersionName": "v1.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "Mezzotint_Artstyle_for_Flux_-_by_Ethanar",
|
|
||||||
"hash": "e6961502769123bf23a66c5c5298d76264fd6b9610f018319a0ccb091bfc308e",
|
|
||||||
"strength": 0.2,
|
|
||||||
"modelVersionId": 757030,
|
|
||||||
"modelName": "Mezzotint Artstyle for Flux - by Ethanar",
|
|
||||||
"modelVersionName": "V1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "FluxMythG0thicL1nes",
|
|
||||||
"hash": "ecb03595de62bd6183a0dd2b38bea35669fd4d509f4bbae5aa0572cfb7ef4279",
|
|
||||||
"strength": 0.4,
|
|
||||||
"modelVersionId": 1202162,
|
|
||||||
"modelName": "Velvet's Mythic Fantasy Styles | Flux + Pony + illustrious",
|
|
||||||
"modelVersionName": "Flux Gothic Lines"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "Elden_Ring_-_Yoshitaka_Amano",
|
|
||||||
"hash": "c660c4c55320be7206cb6a917c59d8da3953cc07169fe10bda833a54ec0024f9",
|
|
||||||
"strength": 0.75,
|
|
||||||
"modelVersionId": 746484,
|
|
||||||
"modelName": "Elden Ring - Yoshitaka Amano",
|
|
||||||
"modelVersionName": "V1"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"gen_params": {
|
"gen_params": {
|
||||||
"prompt": "a mysterious, steampunk-inspired character standing in a dramatic pose. The character is dressed in a long, intricately detailed dark coat with ornate patterns, a wide-brimmed hat, and leather boots. The face is partially obscured by the hat's shadow, adding to the enigmatic aura. The background showcases a large, antique clock with Roman numerals, surrounded by dynamic lightning and ethereal white birds, enhancing the fantastical atmosphere. The color palette is dominated by dark tones with striking contrasts of white and blue lightning, creating a sense of tension and energy. The overall composition is vertical, with the character centrally positioned, exuding a sense of power and mystery. hkchrono",
|
"prompt": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect eyes, realistic eyes,\n(flat colors:1.5), (anime:1.5), (lineart:1.5),\nclose-up, solo, tongue, 1girl, food, (saliva:0.1), open mouth, candy, simple background, blue background, large lollipop, tongue out, fade background, lips, hand up, holding, looking at viewer, licking, seductive, half-closed eyes,",
|
||||||
"negative_prompt": "",
|
"negative_prompt": "shiny skin,",
|
||||||
"checkpoint": {
|
"steps": 19,
|
||||||
"type": "checkpoint",
|
"sampler": "Euler a",
|
||||||
"modelVersionId": 691639,
|
"cfg_scale": 5,
|
||||||
"modelName": "FLUX",
|
"seed": 1765271748,
|
||||||
"modelVersionName": "Dev"
|
|
||||||
},
|
|
||||||
"steps": "30",
|
|
||||||
"sampler": "Undefined",
|
|
||||||
"cfg_scale": "3.5",
|
|
||||||
"seed": "1472903449",
|
|
||||||
"size": "832x1216",
|
"size": "832x1216",
|
||||||
"clip_skip": "2"
|
"clip_skip": 2
|
||||||
}
|
},
|
||||||
|
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
|
||||||
|
"source_path": "https://civitai.com/images/92427432",
|
||||||
|
"folder": "test"
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
|
|||||||
self.locales_dir = locales_dir
|
self.locales_dir = locales_dir
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.reference_locale = 'en'
|
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'):
|
def log(self, message: str, level: str = 'INFO'):
|
||||||
"""Log a message if verbose mode is enabled."""
|
"""Log a message if verbose mode is enabled."""
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden; /* Disable default scrolling */
|
overflow: hidden;
|
||||||
|
/* Disable default scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对Firefox */
|
/* 针对Firefox */
|
||||||
@@ -58,12 +60,12 @@ html, body {
|
|||||||
--badge-update-bg: oklch(72% 0.2 220);
|
--badge-update-bg: oklch(72% 0.2 220);
|
||||||
--badge-update-text: oklch(28% 0.03 220);
|
--badge-update-text: oklch(28% 0.03 220);
|
||||||
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
||||||
|
|
||||||
/* Spacing Scale */
|
/* Spacing Scale */
|
||||||
--space-1: calc(8px * 1);
|
--space-1: calc(8px * 1);
|
||||||
--space-2: calc(8px * 2);
|
--space-2: calc(8px * 2);
|
||||||
--space-3: calc(8px * 3);
|
--space-3: calc(8px * 3);
|
||||||
|
|
||||||
/* Z-index Scale */
|
/* Z-index Scale */
|
||||||
--z-base: 10;
|
--z-base: 10;
|
||||||
--z-header: 100;
|
--z-header: 100;
|
||||||
@@ -75,8 +77,9 @@ html, body {
|
|||||||
--border-radius-sm: 8px;
|
--border-radius-sm: 8px;
|
||||||
--border-radius-xs: 4px;
|
--border-radius-xs: 4px;
|
||||||
|
|
||||||
--scrollbar-width: 8px; /* 添加滚动条宽度变量 */
|
--scrollbar-width: 8px;
|
||||||
|
/* 添加滚动条宽度变量 */
|
||||||
|
|
||||||
/* Shortcut styles */
|
/* Shortcut styles */
|
||||||
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
||||||
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
||||||
@@ -104,7 +107,8 @@ html[data-theme="light"] {
|
|||||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(98% 0.02 256);
|
--lora-text: oklch(98% 0.02 256);
|
||||||
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
|
--lora-warning: oklch(75% 0.25 80);
|
||||||
|
/* Modified to be used with oklch() */
|
||||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
||||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
||||||
--badge-update-bg: oklch(62% 0.18 220);
|
--badge-update-bg: oklch(62% 0.18 220);
|
||||||
@@ -118,5 +122,10 @@ body {
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: 0; /* Remove the padding-top */
|
padding-top: 0;
|
||||||
|
/* Remove the padding-top */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
/* Import Modal Styles */
|
/* Import Modal Styles */
|
||||||
.import-step {
|
.import-step {
|
||||||
margin: var(--space-2) 0;
|
margin: var(--space-2) 0;
|
||||||
transition: none !important; /* Disable any transitions that might affect display */
|
transition: none !important;
|
||||||
|
/* Disable any transitions that might affect display */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Import Mode Toggle */
|
/* Import Mode Toggle */
|
||||||
@@ -107,7 +108,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-image img {
|
.recipe-image img,
|
||||||
|
.recipe-preview-video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -379,7 +381,7 @@
|
|||||||
.recipe-details-layout {
|
.recipe-details-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-image-container {
|
.recipe-image-container {
|
||||||
height: 150px;
|
height: 150px;
|
||||||
}
|
}
|
||||||
@@ -512,14 +514,17 @@
|
|||||||
|
|
||||||
/* Prevent layout shift with scrollbar */
|
/* Prevent layout shift with scrollbar */
|
||||||
.modal-content {
|
.modal-content {
|
||||||
overflow-y: scroll; /* Always show scrollbar */
|
overflow-y: scroll;
|
||||||
scrollbar-gutter: stable; /* Reserve space for scrollbar */
|
/* Always show scrollbar */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
/* Reserve space for scrollbar */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For browsers that don't support scrollbar-gutter */
|
/* For browsers that don't support scrollbar-gutter */
|
||||||
@supports not (scrollbar-gutter: stable) {
|
@supports not (scrollbar-gutter: stable) {
|
||||||
.modal-content {
|
.modal-content {
|
||||||
padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */
|
padding-right: calc(var(--space-2) + var(--scrollbar-width));
|
||||||
|
/* Add extra padding for scrollbar */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +591,8 @@
|
|||||||
|
|
||||||
/* Remove the old warning-message styles that were causing layout issues */
|
/* Remove the old warning-message styles that were causing layout issues */
|
||||||
.warning-message {
|
.warning-message {
|
||||||
display: none; /* Hide the old style */
|
display: none;
|
||||||
|
/* Hide the old style */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update deleted badge to be more prominent */
|
/* Update deleted badge to be more prominent */
|
||||||
@@ -613,7 +619,8 @@
|
|||||||
color: var(--lora-error);
|
color: var(--lora-error);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
min-height: 20px; /* Ensure there's always space for the error message */
|
min-height: 20px;
|
||||||
|
/* Ensure there's always space for the error message */
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,8 +669,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(-10px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicate-warning {
|
.duplicate-warning {
|
||||||
@@ -779,6 +793,7 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,9 +808,9 @@
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicate-recipe-date,
|
.duplicate-recipe-date,
|
||||||
.duplicate-recipe-lora-count {
|
.duplicate-recipe-lora-count {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-header-row {
|
.modal-header-row {
|
||||||
width: 85%;
|
width: 84%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ body.modal-open {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover {
|
||||||
|
|||||||
@@ -242,6 +242,20 @@
|
|||||||
border-color: var(--lora-error-border);
|
border-color: var(--lora-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle styling for special system tags like "No tags" */
|
||||||
|
.filter-tag.special-tag {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure solid border and full opacity when active or excluded */
|
||||||
|
.filter-tag.special-tag.active,
|
||||||
|
.filter-tag.special-tag.exclude {
|
||||||
|
border-style: solid;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag filter styles */
|
/* Tag filter styles */
|
||||||
.tag-filter {
|
.tag-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import { RecipeCard } from '../components/RecipeCard.js';
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.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
|
* Fetch recipes with pagination for virtual scrolling
|
||||||
* @param {number} page - Page number to fetch
|
* @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) {
|
export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page,
|
page: page,
|
||||||
page_size: pageSize || pageState.pageSize || 20,
|
page_size: pageSize || pageState.pageSize || 20,
|
||||||
sort_by: pageState.sortBy
|
sort_by: pageState.sortBy
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pageState.showFavoritesOnly) {
|
||||||
|
params.append('favorite', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageState.activeFolder !== null && pageState.activeFolder !== undefined) {
|
||||||
|
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 we have a specific recipe ID to load
|
||||||
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||||
// Special case: load specific recipe
|
// 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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipe = await response.json();
|
const recipe = await response.json();
|
||||||
|
|
||||||
// Return in expected format
|
// Return in expected format
|
||||||
return {
|
return {
|
||||||
items: [recipe],
|
items: [recipe],
|
||||||
@@ -38,33 +78,34 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
hasMore: false
|
hasMore: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom filter for Lora if present
|
// Add custom filter for Lora if present
|
||||||
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
|
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
|
||||||
params.append('lora_hash', pageState.customFilter.loraHash);
|
params.append('lora_hash', pageState.customFilter.loraHash);
|
||||||
params.append('bypass_filters', 'true');
|
params.append('bypass_filters', 'true');
|
||||||
} else {
|
} else {
|
||||||
// Normal filtering logic
|
// Normal filtering logic
|
||||||
|
|
||||||
// Add search filter if present
|
// Add search filter if present
|
||||||
if (pageState.filters?.search) {
|
if (pageState.filters?.search) {
|
||||||
params.append('search', pageState.filters.search);
|
params.append('search', pageState.filters.search);
|
||||||
|
|
||||||
// Add search option parameters
|
// Add search option parameters
|
||||||
if (pageState.searchOptions) {
|
if (pageState.searchOptions) {
|
||||||
params.append('search_title', pageState.searchOptions.title.toString());
|
params.append('search_title', pageState.searchOptions.title.toString());
|
||||||
params.append('search_tags', pageState.searchOptions.tags.toString());
|
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||||
params.append('search_lora_name', pageState.searchOptions.loraName.toString());
|
params.append('search_lora_name', pageState.searchOptions.loraName.toString());
|
||||||
params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
|
params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
|
||||||
|
params.append('search_prompt', (pageState.searchOptions.prompt || false).toString());
|
||||||
params.append('fuzzy', 'true');
|
params.append('fuzzy', 'true');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add base model filters
|
// Add base model filters
|
||||||
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
|
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
|
||||||
params.append('base_models', pageState.filters.baseModel.join(','));
|
params.append('base_models', pageState.filters.baseModel.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tag filters
|
// Add tag filters
|
||||||
if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) {
|
if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) {
|
||||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||||
@@ -78,14 +119,14 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recipes
|
// Fetch recipes
|
||||||
const response = await fetch(`/api/lm/recipes?${params.toString()}`);
|
const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: data.items,
|
items: data.items,
|
||||||
totalItems: data.total,
|
totalItems: data.total,
|
||||||
@@ -111,29 +152,29 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
updateFolders = false,
|
updateFolders = false,
|
||||||
fetchPageFunction
|
fetchPageFunction
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pageState.isLoading = true;
|
pageState.isLoading = true;
|
||||||
|
|
||||||
// Reset page counter
|
// Reset page counter
|
||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
|
|
||||||
// Fetch the first page
|
// Fetch the first page
|
||||||
const result = await fetchPageFunction(1, pageState.pageSize || 50);
|
const result = await fetchPageFunction(1, pageState.pageSize || 50);
|
||||||
|
|
||||||
// Update the virtual scroller
|
// Update the virtual scroller
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
result.items,
|
result.items,
|
||||||
result.totalItems,
|
result.totalItems,
|
||||||
result.hasMore
|
result.hasMore
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = 2; // Next page will be 2
|
pageState.currentPage = 2; // Next page will be 2
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reloading ${modelType}s:`, error);
|
console.error(`Error reloading ${modelType}s:`, error);
|
||||||
@@ -156,32 +197,32 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
updateFolders = false,
|
updateFolders = false,
|
||||||
fetchPageFunction
|
fetchPageFunction
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start loading state
|
// Start loading state
|
||||||
pageState.isLoading = true;
|
pageState.isLoading = true;
|
||||||
|
|
||||||
// Reset to first page if requested
|
// Reset to first page if requested
|
||||||
if (resetPage) {
|
if (resetPage) {
|
||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the first page of data
|
// Fetch the first page of data
|
||||||
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
|
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
|
||||||
|
|
||||||
// Update virtual scroller with the new data
|
// Update virtual scroller with the new data
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
result.items,
|
result.items,
|
||||||
result.totalItems,
|
result.totalItems,
|
||||||
result.hasMore
|
result.hasMore
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = 2; // Next page to load would be 2
|
pageState.currentPage = 2; // Next page to load would be 2
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading ${modelType}s:`, error);
|
console.error(`Error loading ${modelType}s:`, error);
|
||||||
@@ -211,18 +252,18 @@ export async function resetAndReload(updateFolders = false) {
|
|||||||
export async function refreshRecipes() {
|
export async function refreshRecipes() {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
||||||
|
|
||||||
// Call the API endpoint to rebuild the recipe cache
|
// 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) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
throw new Error(data.error || 'Failed to refresh recipe cache');
|
||||||
}
|
}
|
||||||
|
|
||||||
// After successful cache rebuild, reload the recipes
|
// After successful cache rebuild, reload the recipes
|
||||||
await resetAndReload();
|
await resetAndReload();
|
||||||
|
|
||||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing recipes:', error);
|
console.error('Error refreshing recipes:', error);
|
||||||
@@ -240,7 +281,7 @@ export async function refreshRecipes() {
|
|||||||
*/
|
*/
|
||||||
export async function loadMoreRecipes(resetPage = false) {
|
export async function loadMoreRecipes(resetPage = false) {
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
// Use virtual scroller if available
|
// Use virtual scroller if available
|
||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
return loadMoreWithVirtualScroll({
|
return loadMoreWithVirtualScroll({
|
||||||
@@ -277,10 +318,12 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||||
|
|
||||||
// Extract recipeId from filePath (basename without extension)
|
// Extract recipeId from filePath (basename without extension)
|
||||||
const basename = filePath.split('/').pop().split('\\').pop();
|
const recipeId = extractRecipeId(filePath);
|
||||||
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
|
if (!recipeId) {
|
||||||
|
throw new Error('Unable to determine recipe ID');
|
||||||
const response = await fetch(`/api/lm/recipe/${recipeId}/update`, {
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -296,7 +339,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.virtualScroller.updateSingleItem(filePath, updates);
|
state.virtualScroller.updateSingleItem(filePath, updates);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating recipe:', error);
|
console.error('Error updating recipe:', error);
|
||||||
@@ -306,3 +349,187 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
state.loadingManager.hide();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,29 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
showMenu(x, y, origin = null) {
|
showMenu(x, y, origin = null) {
|
||||||
const contextOrigin = origin || { type: 'global' };
|
const contextOrigin = origin || { type: 'global' };
|
||||||
|
|
||||||
|
// Conditional visibility for recipes page
|
||||||
|
const isRecipesPage = state.currentPageType === 'recipes';
|
||||||
|
const modelUpdateItem = this.menu.querySelector('[data-action="check-model-updates"]');
|
||||||
|
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
|
||||||
|
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||||
|
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||||
|
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||||
|
|
||||||
|
if (isRecipesPage) {
|
||||||
|
modelUpdateItem?.classList.add('hidden');
|
||||||
|
licenseRefreshItem?.classList.add('hidden');
|
||||||
|
downloadExamplesItem?.classList.add('hidden');
|
||||||
|
cleanupExamplesItem?.classList.add('hidden');
|
||||||
|
repairRecipesItem?.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
modelUpdateItem?.classList.remove('hidden');
|
||||||
|
licenseRefreshItem?.classList.remove('hidden');
|
||||||
|
downloadExamplesItem?.classList.remove('hidden');
|
||||||
|
cleanupExamplesItem?.classList.remove('hidden');
|
||||||
|
repairRecipesItem?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
super.showMenu(x, y, contextOrigin);
|
super.showMenu(x, y, contextOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +63,11 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
console.error('Failed to refresh missing license metadata:', error);
|
console.error('Failed to refresh missing license metadata:', error);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'repair-recipes':
|
||||||
|
this.repairRecipes(menuItem).catch((error) => {
|
||||||
|
console.error('Failed to repair recipes:', error);
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Unhandled global context menu action: ${action}`);
|
console.warn(`Unhandled global context menu action: ${action}`);
|
||||||
break;
|
break;
|
||||||
@@ -235,4 +263,78 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
return `${displayName}s`;
|
return `${displayName}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async repairRecipes(menuItem) {
|
||||||
|
if (this._repairInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._repairInProgress = true;
|
||||||
|
menuItem?.classList.add('disabled');
|
||||||
|
|
||||||
|
const loadingMessage = translate(
|
||||||
|
'globalContextMenu.repairRecipes.loading',
|
||||||
|
{},
|
||||||
|
'Repairing recipe data...'
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/recipes/repair', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to start repair');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for progress (or wait for WebSocket if preferred, but polling is simpler for this implementation)
|
||||||
|
let isComplete = false;
|
||||||
|
while (!isComplete && this._repairInProgress) {
|
||||||
|
const progressResponse = await fetch('/api/lm/recipes/repair-progress');
|
||||||
|
if (progressResponse.ok) {
|
||||||
|
const progressResult = await progressResponse.json();
|
||||||
|
if (progressResult.success && progressResult.progress) {
|
||||||
|
const p = progressResult.progress;
|
||||||
|
if (p.status === 'processing') {
|
||||||
|
const percent = (p.current / p.total) * 100;
|
||||||
|
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
|
||||||
|
} else if (p.status === 'completed') {
|
||||||
|
isComplete = true;
|
||||||
|
progressUI?.complete(translate(
|
||||||
|
'globalContextMenu.repairRecipes.success',
|
||||||
|
{ count: p.repaired },
|
||||||
|
`Repaired ${p.repaired} recipes.`
|
||||||
|
));
|
||||||
|
showToast('globalContextMenu.repairRecipes.success', { count: p.repaired }, 'success');
|
||||||
|
// Refresh recipes page if active
|
||||||
|
if (window.recipesPage) {
|
||||||
|
window.recipesPage.refresh();
|
||||||
|
}
|
||||||
|
} else if (p.status === 'error') {
|
||||||
|
throw new Error(p.error || 'Repair failed');
|
||||||
|
}
|
||||||
|
} else if (progressResponse.status === 404) {
|
||||||
|
// Progress might have finished quickly and been cleaned up
|
||||||
|
isComplete = true;
|
||||||
|
progressUI?.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isComplete) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Recipe repair failed:', error);
|
||||||
|
progressUI?.complete(translate('globalContextMenu.repairRecipes.error', { message: error.message }, 'Repair failed: {message}'));
|
||||||
|
showToast('globalContextMenu.repairRecipes.error', { message: error.message }, 'error');
|
||||||
|
} finally {
|
||||||
|
this._repairInProgress = false;
|
||||||
|
menuItem?.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe
|
|||||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
export class RecipeContextMenu extends BaseContextMenu {
|
export class RecipeContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('recipeContextMenu', '.model-card');
|
super('recipeContextMenu', '.model-card');
|
||||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||||
this.modelType = 'recipe';
|
this.modelType = 'recipe';
|
||||||
|
|
||||||
this.initNSFWSelector();
|
this.initNSFWSelector();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,20 +25,20 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||||
return resetAndReload();
|
return resetAndReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
// Call the parent method first to handle basic positioning
|
// Call the parent method first to handle basic positioning
|
||||||
super.showMenu(x, y, card);
|
super.showMenu(x, y, card);
|
||||||
|
|
||||||
// Get recipe data to check for missing LoRAs
|
// Get recipe data to check for missing LoRAs
|
||||||
const recipeId = card.dataset.id;
|
const recipeId = card.dataset.id;
|
||||||
const missingLorasItem = this.menu.querySelector('.download-missing-item');
|
const missingLorasItem = this.menu.querySelector('.download-missing-item');
|
||||||
|
|
||||||
if (recipeId && missingLorasItem) {
|
if (recipeId && missingLorasItem) {
|
||||||
// Check if this card has missing LoRAs
|
// Check if this card has missing LoRAs
|
||||||
const loraCountElement = card.querySelector('.lora-count');
|
const loraCountElement = card.querySelector('.lora-count');
|
||||||
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
|
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
|
||||||
|
|
||||||
// Show/hide the download missing LoRAs option based on missing status
|
// Show/hide the download missing LoRAs option based on missing status
|
||||||
if (hasMissingLoras) {
|
if (hasMissingLoras) {
|
||||||
missingLorasItem.style.display = 'flex';
|
missingLorasItem.style.display = 'flex';
|
||||||
@@ -46,7 +47,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMenuAction(action) {
|
handleMenuAction(action) {
|
||||||
// First try to handle with common actions from ModelContextMenuMixin
|
// First try to handle with common actions from ModelContextMenuMixin
|
||||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||||
@@ -55,8 +56,8 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
// Handle recipe-specific actions
|
// Handle recipe-specific actions
|
||||||
const recipeId = this.currentCard.dataset.id;
|
const recipeId = this.currentCard.dataset.id;
|
||||||
|
|
||||||
switch(action) {
|
switch (action) {
|
||||||
case 'details':
|
case 'details':
|
||||||
// Show recipe details
|
// Show recipe details
|
||||||
this.currentCard.click();
|
this.currentCard.click();
|
||||||
@@ -77,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Share recipe
|
// Share recipe
|
||||||
this.currentCard.querySelector('.fa-share-alt')?.click();
|
this.currentCard.querySelector('.fa-share-alt')?.click();
|
||||||
break;
|
break;
|
||||||
|
case 'move':
|
||||||
|
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
// Delete recipe
|
// Delete recipe
|
||||||
this.currentCard.querySelector('.fa-trash')?.click();
|
this.currentCard.querySelector('.fa-trash')?.click();
|
||||||
@@ -89,9 +93,13 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Download missing LoRAs
|
// Download missing LoRAs
|
||||||
this.downloadMissingLoRAs(recipeId);
|
this.downloadMissingLoRAs(recipeId);
|
||||||
break;
|
break;
|
||||||
|
case 'repair':
|
||||||
|
// Repair recipe metadata
|
||||||
|
this.repairRecipe(recipeId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New method to copy recipe syntax to clipboard
|
// New method to copy recipe syntax to clipboard
|
||||||
copyRecipeSyntax() {
|
copyRecipeSyntax() {
|
||||||
const recipeId = this.currentCard.dataset.id;
|
const recipeId = this.currentCard.dataset.id;
|
||||||
@@ -114,7 +122,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
|
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// New method to send recipe to workflow
|
// New method to send recipe to workflow
|
||||||
sendRecipeToWorkflow(replaceMode) {
|
sendRecipeToWorkflow(replaceMode) {
|
||||||
const recipeId = this.currentCard.dataset.id;
|
const recipeId = this.currentCard.dataset.id;
|
||||||
@@ -137,14 +145,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
|
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// View all LoRAs in the recipe
|
// View all LoRAs in the recipe
|
||||||
viewRecipeLoRAs(recipeId) {
|
viewRecipeLoRAs(recipeId) {
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
|
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First get the recipe details to access its LoRAs
|
// First get the recipe details to access its LoRAs
|
||||||
fetch(`/api/lm/recipe/${recipeId}`)
|
fetch(`/api/lm/recipe/${recipeId}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@@ -154,17 +162,17 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
removeSessionItem('recipe_to_lora_filterLoraHashes');
|
removeSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
removeSessionItem('filterRecipeName');
|
removeSessionItem('filterRecipeName');
|
||||||
removeSessionItem('viewLoraDetail');
|
removeSessionItem('viewLoraDetail');
|
||||||
|
|
||||||
// Collect all hashes from the recipe's LoRAs
|
// Collect all hashes from the recipe's LoRAs
|
||||||
const loraHashes = recipe.loras
|
const loraHashes = recipe.loras
|
||||||
.filter(lora => lora.hash)
|
.filter(lora => lora.hash)
|
||||||
.map(lora => lora.hash.toLowerCase());
|
.map(lora => lora.hash.toLowerCase());
|
||||||
|
|
||||||
if (loraHashes.length > 0) {
|
if (loraHashes.length > 0) {
|
||||||
// Store the LoRA hashes and recipe name in session storage
|
// Store the LoRA hashes and recipe name in session storage
|
||||||
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
|
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
|
||||||
setSessionItem('filterRecipeName', recipe.title);
|
setSessionItem('filterRecipeName', recipe.title);
|
||||||
|
|
||||||
// Navigate to the LoRAs page
|
// Navigate to the LoRAs page
|
||||||
window.location.href = '/loras';
|
window.location.href = '/loras';
|
||||||
} else {
|
} else {
|
||||||
@@ -176,34 +184,34 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
|
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download missing LoRAs
|
// Download missing LoRAs
|
||||||
async downloadMissingLoRAs(recipeId) {
|
async downloadMissingLoRAs(recipeId) {
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
|
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First get the recipe details
|
// First get the recipe details
|
||||||
const response = await fetch(`/api/lm/recipe/${recipeId}`);
|
const response = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
const recipe = await response.json();
|
const recipe = await response.json();
|
||||||
|
|
||||||
// Get missing LoRAs
|
// Get missing LoRAs
|
||||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
||||||
|
|
||||||
if (missingLoras.length === 0) {
|
if (missingLoras.length === 0) {
|
||||||
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading toast
|
// Show loading toast
|
||||||
state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...');
|
state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...');
|
||||||
|
|
||||||
// Get version info for each missing LoRA
|
// Get version info for each missing LoRA
|
||||||
const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => {
|
const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => {
|
||||||
let endpoint;
|
let endpoint;
|
||||||
|
|
||||||
// Determine which endpoint to use based on available data
|
// Determine which endpoint to use based on available data
|
||||||
if (lora.modelVersionId) {
|
if (lora.modelVersionId) {
|
||||||
endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
|
endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
|
||||||
@@ -213,52 +221,52 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
console.error("Missing both hash and modelVersionId for lora:", lora);
|
console.error("Missing both hash and modelVersionId for lora:", lora);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionResponse = await fetch(endpoint);
|
const versionResponse = await fetch(endpoint);
|
||||||
const versionInfo = await versionResponse.json();
|
const versionInfo = await versionResponse.json();
|
||||||
|
|
||||||
// Return original lora data combined with version info
|
// Return original lora data combined with version info
|
||||||
return {
|
return {
|
||||||
...lora,
|
...lora,
|
||||||
civitaiInfo: versionInfo
|
civitaiInfo: versionInfo
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all API calls to complete
|
// Wait for all API calls to complete
|
||||||
const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises);
|
const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises);
|
||||||
|
|
||||||
// Filter out null values (failed requests)
|
// Filter out null values (failed requests)
|
||||||
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
||||||
|
|
||||||
if (validLoras.length === 0) {
|
if (validLoras.length === 0) {
|
||||||
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
|
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare data for import manager using the retrieved information
|
// Prepare data for import manager using the retrieved information
|
||||||
const recipeData = {
|
const recipeData = {
|
||||||
loras: validLoras.map(lora => {
|
loras: validLoras.map(lora => {
|
||||||
const civitaiInfo = lora.civitaiInfo;
|
const civitaiInfo = lora.civitaiInfo;
|
||||||
const modelFile = civitaiInfo.files ?
|
const modelFile = civitaiInfo.files ?
|
||||||
civitaiInfo.files.find(file => file.type === 'Model') : null;
|
civitaiInfo.files.find(file => file.type === 'Model') : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Basic lora info
|
// Basic lora info
|
||||||
name: civitaiInfo.model?.name || lora.name,
|
name: civitaiInfo.model?.name || lora.name,
|
||||||
version: civitaiInfo.name || '',
|
version: civitaiInfo.name || '',
|
||||||
strength: lora.strength || 1.0,
|
strength: lora.strength || 1.0,
|
||||||
|
|
||||||
// Model identifiers
|
// Model identifiers
|
||||||
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
||||||
modelVersionId: civitaiInfo.id || lora.modelVersionId,
|
modelVersionId: civitaiInfo.id || lora.modelVersionId,
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
||||||
baseModel: civitaiInfo.baseModel || '',
|
baseModel: civitaiInfo.baseModel || '',
|
||||||
downloadUrl: civitaiInfo.downloadUrl || '',
|
downloadUrl: civitaiInfo.downloadUrl || '',
|
||||||
size: modelFile ? (modelFile.sizeKB * 1024) : 0,
|
size: modelFile ? (modelFile.sizeKB * 1024) : 0,
|
||||||
file_name: modelFile ? modelFile.name.split('.')[0] : '',
|
file_name: modelFile ? modelFile.name.split('.')[0] : '',
|
||||||
|
|
||||||
// Status flags
|
// Status flags
|
||||||
existsLocally: false,
|
existsLocally: false,
|
||||||
isDeleted: civitaiInfo.error === "Model not found",
|
isDeleted: civitaiInfo.error === "Model not found",
|
||||||
@@ -267,7 +275,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call ImportManager's download missing LoRAs method
|
// Call ImportManager's download missing LoRAs method
|
||||||
window.importManager.downloadMissingLoras(recipeData, recipeId);
|
window.importManager.downloadMissingLoras(recipeData, recipeId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -279,6 +287,38 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repair recipe metadata
|
||||||
|
async repairRecipe(recipeId) {
|
||||||
|
if (!recipeId) {
|
||||||
|
showToast('recipes.contextMenu.repair.missingId', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showToast('recipes.contextMenu.repair.starting', {}, 'info');
|
||||||
|
|
||||||
|
const response = await fetch(`/api/lm/recipe/${recipeId}/repair`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.repaired > 0) {
|
||||||
|
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
||||||
|
// Refresh the current card or reload
|
||||||
|
this.resetAndReload();
|
||||||
|
} else {
|
||||||
|
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Repair failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error repairing recipe:', error);
|
||||||
|
showToast('recipes.contextMenu.repair.failed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mix in shared methods from ModelContextMenuMixin
|
// Mix in shared methods from ModelContextMenuMixin
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// Recipe Card Component
|
// Recipe Card Component
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
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 { modalManager } from '../managers/ModalManager.js';
|
||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||||
|
|
||||||
class RecipeCard {
|
class RecipeCard {
|
||||||
@@ -10,11 +13,11 @@ class RecipeCard {
|
|||||||
this.recipe = recipe;
|
this.recipe = recipe;
|
||||||
this.clickHandler = clickHandler;
|
this.clickHandler = clickHandler;
|
||||||
this.element = this.createCardElement();
|
this.element = this.createCardElement();
|
||||||
|
|
||||||
// Store reference to this instance on the DOM element for updates
|
// Store reference to this instance on the DOM element for updates
|
||||||
this.element._recipeCardInstance = this;
|
this.element._recipeCardInstance = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
createCardElement() {
|
createCardElement() {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'model-card';
|
card.className = 'model-card';
|
||||||
@@ -23,33 +26,48 @@ class RecipeCard {
|
|||||||
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
|
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
|
||||||
card.dataset.created = this.recipe.created_date;
|
card.dataset.created = this.recipe.created_date;
|
||||||
card.dataset.id = this.recipe.id || '';
|
card.dataset.id = this.recipe.id || '';
|
||||||
|
|
||||||
// Get base model with fallback
|
// Get base model with fallback
|
||||||
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
||||||
const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel);
|
const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel);
|
||||||
const baseModelDisplay = baseModelLabel === 'Unknown' ? 'Unknown' : baseModelAbbreviation;
|
const baseModelDisplay = baseModelLabel === 'Unknown' ? 'Unknown' : baseModelAbbreviation;
|
||||||
|
|
||||||
// Ensure loras array exists
|
// Ensure loras array exists
|
||||||
const loras = this.recipe.loras || [];
|
const loras = this.recipe.loras || [];
|
||||||
const lorasCount = loras.length;
|
const lorasCount = loras.length;
|
||||||
|
|
||||||
// Check if all LoRAs are available in the library
|
// Check if all LoRAs are available in the library
|
||||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||||
|
|
||||||
// Ensure file_url exists, fallback to file_path if needed
|
|
||||||
const imageUrl = this.recipe.file_url ||
|
|
||||||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
|
||||||
'/loras_static/images/no-preview.png');
|
|
||||||
|
|
||||||
// Check if in duplicates mode
|
// Ensure file_url exists, fallback to file_path if needed
|
||||||
const pageState = getCurrentPageState();
|
const previewUrl = this.recipe.file_url ||
|
||||||
const isDuplicatesMode = pageState.duplicatesMode;
|
(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 isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||||
|
const videoAttrs = [
|
||||||
|
'controls',
|
||||||
|
'muted',
|
||||||
|
'loop',
|
||||||
|
'playsinline',
|
||||||
|
'preload="none"',
|
||||||
|
`data-src="${previewUrl}"`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!autoplayOnHover) {
|
||||||
|
videoAttrs.push('data-autoplay="true"');
|
||||||
|
}
|
||||||
|
|
||||||
// NSFW blur logic - similar to LoraCard
|
// NSFW blur logic - similar to LoraCard
|
||||||
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
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;
|
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||||
|
|
||||||
if (shouldBlur) {
|
if (shouldBlur) {
|
||||||
card.classList.add('nsfw-content');
|
card.classList.add('nsfw-content');
|
||||||
}
|
}
|
||||||
@@ -66,15 +84,19 @@ class RecipeCard {
|
|||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
<img src="${imageUrl}" alt="${this.recipe.title}">
|
${isVideo ?
|
||||||
|
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||||
|
`<img src="${previewUrl}" alt="${this.recipe.title}">`
|
||||||
|
}
|
||||||
${!isDuplicatesMode ? `
|
${!isDuplicatesMode ? `
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
${shouldBlur ?
|
${shouldBlur ?
|
||||||
`<button class="toggle-blur-btn" title="Toggle blur">
|
`<button class="toggle-blur-btn" title="Toggle blur">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
|
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"></i>
|
||||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
||||||
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
||||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||||
@@ -102,30 +124,98 @@ class RecipeCard {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachEventListeners(card, isDuplicatesMode, shouldBlur);
|
this.attachEventListeners(card, isDuplicatesMode, shouldBlur);
|
||||||
|
|
||||||
|
// Add video auto-play on hover functionality if needed
|
||||||
|
const videoElement = card.querySelector('video');
|
||||||
|
if (videoElement) {
|
||||||
|
configureModelCardVideo(videoElement, autoplayOnHover);
|
||||||
|
}
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLoraStatusTitle(totalCount, missingCount) {
|
getLoraStatusTitle(totalCount, missingCount) {
|
||||||
if (totalCount === 0) return "No LoRAs in this recipe";
|
if (totalCount === 0) return "No LoRAs in this recipe";
|
||||||
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
||||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
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) {
|
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
|
||||||
// Add blur toggle functionality if content should be blurred
|
// Add blur toggle functionality if content should be blurred
|
||||||
if (shouldBlur) {
|
if (shouldBlur) {
|
||||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||||
const showBtn = card.querySelector('.show-content-btn');
|
const showBtn = card.querySelector('.show-content-btn');
|
||||||
|
|
||||||
if (toggleBtn) {
|
if (toggleBtn) {
|
||||||
toggleBtn.addEventListener('click', (e) => {
|
toggleBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.toggleBlurContent(card);
|
this.toggleBlurContent(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showBtn) {
|
if (showBtn) {
|
||||||
showBtn.addEventListener('click', (e) => {
|
showBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -137,21 +227,31 @@ class RecipeCard {
|
|||||||
// Recipe card click event - only attach if not in duplicates mode
|
// Recipe card click event - only attach if not in duplicates mode
|
||||||
if (!isDuplicatesMode) {
|
if (!isDuplicatesMode) {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
if (state.bulkMode) {
|
||||||
|
bulkManager.toggleCardSelection(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.clickHandler(this.recipe);
|
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
|
// Share button click event - prevent propagation to card
|
||||||
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.shareRecipe();
|
this.shareRecipe();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send button click event - prevent propagation to card
|
// Send button click event - prevent propagation to card
|
||||||
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
|
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.sendRecipeToWorkflow(e.shiftKey);
|
this.sendRecipeToWorkflow(e.shiftKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete button click event - prevent propagation to card
|
// Delete button click event - prevent propagation to card
|
||||||
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
|
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -159,19 +259,19 @@ class RecipeCard {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleBlurContent(card) {
|
toggleBlurContent(card) {
|
||||||
const preview = card.querySelector('.card-preview');
|
const preview = card.querySelector('.card-preview');
|
||||||
const isBlurred = preview.classList.toggle('blurred');
|
const isBlurred = preview.classList.toggle('blurred');
|
||||||
const icon = card.querySelector('.toggle-blur-btn i');
|
const icon = card.querySelector('.toggle-blur-btn i');
|
||||||
|
|
||||||
// Update the icon based on blur state
|
// Update the icon based on blur state
|
||||||
if (isBlurred) {
|
if (isBlurred) {
|
||||||
icon.className = 'fas fa-eye';
|
icon.className = 'fas fa-eye';
|
||||||
} else {
|
} else {
|
||||||
icon.className = 'fas fa-eye-slash';
|
icon.className = 'fas fa-eye-slash';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle the overlay visibility
|
// Toggle the overlay visibility
|
||||||
const overlay = card.querySelector('.nsfw-overlay');
|
const overlay = card.querySelector('.nsfw-overlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
@@ -182,13 +282,13 @@ class RecipeCard {
|
|||||||
showBlurredContent(card) {
|
showBlurredContent(card) {
|
||||||
const preview = card.querySelector('.card-preview');
|
const preview = card.querySelector('.card-preview');
|
||||||
preview.classList.remove('blurred');
|
preview.classList.remove('blurred');
|
||||||
|
|
||||||
// Update the toggle button icon
|
// Update the toggle button icon
|
||||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||||
if (toggleBtn) {
|
if (toggleBtn) {
|
||||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the overlay
|
// Hide the overlay
|
||||||
const overlay = card.querySelector('.nsfw-overlay');
|
const overlay = card.querySelector('.nsfw-overlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
@@ -223,7 +323,7 @@ class RecipeCard {
|
|||||||
showToast('toast.recipes.sendError', {}, 'error');
|
showToast('toast.recipes.sendError', {}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showDeleteConfirmation() {
|
showDeleteConfirmation() {
|
||||||
try {
|
try {
|
||||||
// Get recipe ID
|
// Get recipe ID
|
||||||
@@ -233,15 +333,21 @@ class RecipeCard {
|
|||||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create delete modal content
|
// Create delete modal content
|
||||||
|
const previewUrl = this.recipe.file_url || '/loras_static/images/no-preview.png';
|
||||||
|
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||||
|
|
||||||
const deleteModalContent = `
|
const deleteModalContent = `
|
||||||
<div class="modal-content delete-modal-content">
|
<div class="modal-content delete-modal-content">
|
||||||
<h2>Delete Recipe</h2>
|
<h2>Delete Recipe</h2>
|
||||||
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
||||||
<div class="delete-model-info">
|
<div class="delete-model-info">
|
||||||
<div class="delete-preview">
|
<div class="delete-preview">
|
||||||
<img src="${this.recipe.file_url || '/loras_static/images/no-preview.png'}" alt="${this.recipe.title}">
|
${isVideo ?
|
||||||
|
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||||
|
`<img src="${previewUrl}" alt="${this.recipe.title}">`
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="delete-info">
|
<div class="delete-info">
|
||||||
<h3>${this.recipe.title}</h3>
|
<h3>${this.recipe.title}</h3>
|
||||||
@@ -255,7 +361,7 @@ class RecipeCard {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Show the modal with custom content and setup callbacks
|
// Show the modal with custom content and setup callbacks
|
||||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||||
// This is the onClose callback
|
// This is the onClose callback
|
||||||
@@ -264,20 +370,20 @@ class RecipeCard {
|
|||||||
deleteBtn.textContent = 'Delete';
|
deleteBtn.textContent = 'Delete';
|
||||||
deleteBtn.disabled = false;
|
deleteBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up the delete and cancel buttons with proper event handlers
|
// Set up the delete and cancel buttons with proper event handlers
|
||||||
const deleteModal = document.getElementById('deleteModal');
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
||||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||||
|
|
||||||
// Store recipe ID in the modal for the delete confirmation handler
|
// Store recipe ID in the modal for the delete confirmation handler
|
||||||
deleteModal.dataset.recipeId = recipeId;
|
deleteModal.dataset.recipeId = recipeId;
|
||||||
deleteModal.dataset.filePath = filePath;
|
deleteModal.dataset.filePath = filePath;
|
||||||
|
|
||||||
// Update button event handlers
|
// Update button event handlers
|
||||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||||
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error showing delete confirmation:', error);
|
console.error('Error showing delete confirmation:', error);
|
||||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||||
@@ -287,19 +393,19 @@ class RecipeCard {
|
|||||||
confirmDeleteRecipe() {
|
confirmDeleteRecipe() {
|
||||||
const deleteModal = document.getElementById('deleteModal');
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
const recipeId = deleteModal.dataset.recipeId;
|
const recipeId = deleteModal.dataset.recipeId;
|
||||||
|
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||||
modalManager.closeModal('deleteModal');
|
modalManager.closeModal('deleteModal');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||||
const originalText = deleteBtn.textContent;
|
const originalText = deleteBtn.textContent;
|
||||||
deleteBtn.textContent = 'Deleting...';
|
deleteBtn.textContent = 'Deleting...';
|
||||||
deleteBtn.disabled = true;
|
deleteBtn.disabled = true;
|
||||||
|
|
||||||
// Call API to delete the recipe
|
// Call API to delete the recipe
|
||||||
fetch(`/api/lm/recipe/${recipeId}`, {
|
fetch(`/api/lm/recipe/${recipeId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -307,27 +413,27 @@ class RecipeCard {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete recipe');
|
throw new Error('Failed to delete recipe');
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||||
|
|
||||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||||
|
|
||||||
modalManager.closeModal('deleteModal');
|
modalManager.closeModal('deleteModal');
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error deleting recipe:', error);
|
console.error('Error deleting recipe:', error);
|
||||||
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||||
|
|
||||||
// Reset button state
|
// Reset button state
|
||||||
deleteBtn.textContent = originalText;
|
deleteBtn.textContent = originalText;
|
||||||
deleteBtn.disabled = false;
|
deleteBtn.disabled = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
shareRecipe() {
|
shareRecipe() {
|
||||||
@@ -338,10 +444,10 @@ class RecipeCard {
|
|||||||
showToast('toast.recipes.cannotShare', {}, 'error');
|
showToast('toast.recipes.cannotShare', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading toast
|
// Show loading toast
|
||||||
showToast('toast.recipes.preparingForSharing', {}, 'info');
|
showToast('toast.recipes.preparingForSharing', {}, 'info');
|
||||||
|
|
||||||
// Call the API to process the image with metadata
|
// Call the API to process the image with metadata
|
||||||
fetch(`/api/lm/recipe/${recipeId}/share`)
|
fetch(`/api/lm/recipe/${recipeId}/share`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -354,17 +460,17 @@ class RecipeCard {
|
|||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'Unknown error');
|
throw new Error(data.error || 'Unknown error');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary anchor element for download
|
// Create a temporary anchor element for download
|
||||||
const downloadLink = document.createElement('a');
|
const downloadLink = document.createElement('a');
|
||||||
downloadLink.href = data.download_url;
|
downloadLink.href = data.download_url;
|
||||||
downloadLink.download = data.filename;
|
downloadLink.download = data.filename;
|
||||||
|
|
||||||
// Append to body, click and remove
|
// Append to body, click and remove
|
||||||
document.body.appendChild(downloadLink);
|
document.body.appendChild(downloadLink);
|
||||||
downloadLink.click();
|
downloadLink.click();
|
||||||
document.body.removeChild(downloadLink);
|
document.body.removeChild(downloadLink);
|
||||||
|
|
||||||
showToast('toast.recipes.downloadStarted', {}, 'success');
|
showToast('toast.recipes.downloadStarted', {}, 'success');
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
@@ -77,11 +77,13 @@ export class SidebarManager {
|
|||||||
this.pageControls = pageControls;
|
this.pageControls = pageControls;
|
||||||
this.pageType = pageControls.pageType;
|
this.pageType = pageControls.pageType;
|
||||||
this.lastPageControls = pageControls;
|
this.lastPageControls = pageControls;
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = pageControls?.getSidebarApiClient?.()
|
||||||
|
|| pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
this.setInitialSidebarState();
|
this.setInitialSidebarState();
|
||||||
|
|
||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
this.initializeDragAndDrop();
|
this.initializeDragAndDrop();
|
||||||
this.updateSidebarTitle();
|
this.updateSidebarTitle();
|
||||||
@@ -92,13 +94,13 @@ export class SidebarManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.restoreSelectedFolder();
|
this.restoreSelectedFolder();
|
||||||
|
|
||||||
// Apply final state with animation after everything is loaded
|
// Apply final state with animation after everything is loaded
|
||||||
this.applyFinalSidebarState();
|
this.applyFinalSidebarState();
|
||||||
|
|
||||||
// Update container margin based on initial sidebar state
|
// Update container margin based on initial sidebar state
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
console.log(`SidebarManager initialized for ${this.pageType} page`);
|
console.log(`SidebarManager initialized for ${this.pageType} page`);
|
||||||
}
|
}
|
||||||
@@ -111,7 +113,7 @@ export class SidebarManager {
|
|||||||
clearTimeout(this.hoverTimeout);
|
clearTimeout(this.hoverTimeout);
|
||||||
this.hoverTimeout = null;
|
this.hoverTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up event handlers
|
// Clean up event handlers
|
||||||
this.removeEventHandlers();
|
this.removeEventHandlers();
|
||||||
|
|
||||||
@@ -141,13 +143,13 @@ export class SidebarManager {
|
|||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.recursiveSearchEnabled = true;
|
this.recursiveSearchEnabled = true;
|
||||||
|
|
||||||
// Reset container margin
|
// Reset container margin
|
||||||
const container = document.querySelector('.container');
|
const container = document.querySelector('.container');
|
||||||
if (container) {
|
if (container) {
|
||||||
container.style.marginLeft = '';
|
container.style.marginLeft = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove resize event listener
|
// Remove resize event listener
|
||||||
window.removeEventListener('resize', this.updateContainerMargin);
|
window.removeEventListener('resize', this.updateContainerMargin);
|
||||||
|
|
||||||
@@ -189,10 +191,10 @@ export class SidebarManager {
|
|||||||
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
|
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
|
||||||
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
|
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove document click handler
|
// Remove document click handler
|
||||||
document.removeEventListener('click', this.handleDocumentClick);
|
document.removeEventListener('click', this.handleDocumentClick);
|
||||||
|
|
||||||
// Remove resize event handler
|
// Remove resize event handler
|
||||||
window.removeEventListener('resize', this.updateContainerMargin);
|
window.removeEventListener('resize', this.updateContainerMargin);
|
||||||
|
|
||||||
@@ -205,6 +207,10 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeDragAndDrop() {
|
initializeDragAndDrop() {
|
||||||
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.dragHandlersInitialized) {
|
if (!this.dragHandlersInitialized) {
|
||||||
document.addEventListener('dragstart', this.handleCardDragStart);
|
document.addEventListener('dragstart', this.handleCardDragStart);
|
||||||
document.addEventListener('dragend', this.handleCardDragEnd);
|
document.addEventListener('dragend', this.handleCardDragEnd);
|
||||||
@@ -416,7 +422,14 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.apiClient) {
|
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, '/') : '';
|
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
||||||
@@ -470,21 +483,23 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||||
|
|| this.pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
this.setInitialSidebarState();
|
this.setInitialSidebarState();
|
||||||
|
|
||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
this.initializeDragAndDrop();
|
this.initializeDragAndDrop();
|
||||||
this.updateSidebarTitle();
|
this.updateSidebarTitle();
|
||||||
this.restoreSidebarState();
|
this.restoreSidebarState();
|
||||||
await this.loadFolderTree();
|
await this.loadFolderTree();
|
||||||
this.restoreSelectedFolder();
|
this.restoreSelectedFolder();
|
||||||
|
|
||||||
// Apply final state with animation after everything is loaded
|
// Apply final state with animation after everything is loaded
|
||||||
this.applyFinalSidebarState();
|
this.applyFinalSidebarState();
|
||||||
|
|
||||||
// Update container margin based on initial sidebar state
|
// Update container margin based on initial sidebar state
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
}
|
}
|
||||||
@@ -496,11 +511,11 @@ export class SidebarManager {
|
|||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||||
|
|
||||||
if (!sidebar || !hoverArea) return;
|
if (!sidebar || !hoverArea) return;
|
||||||
|
|
||||||
// Get stored pin state
|
// Get stored pin state
|
||||||
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
|
||||||
this.isPinned = isPinned;
|
this.isPinned = isPinned;
|
||||||
|
|
||||||
// Sidebar starts hidden by default (CSS handles this)
|
// Sidebar starts hidden by default (CSS handles this)
|
||||||
// Just set up the hover area state
|
// Just set up the hover area state
|
||||||
if (window.innerWidth <= 1024) {
|
if (window.innerWidth <= 1024) {
|
||||||
@@ -568,12 +583,12 @@ export class SidebarManager {
|
|||||||
// Hover detection for auto-hide
|
// Hover detection for auto-hide
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||||
|
|
||||||
if (sidebar) {
|
if (sidebar) {
|
||||||
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
|
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
|
||||||
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
|
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hoverArea) {
|
if (hoverArea) {
|
||||||
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
|
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
|
||||||
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
|
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
|
||||||
@@ -583,7 +598,7 @@ export class SidebarManager {
|
|||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (window.innerWidth <= 1024 && this.isVisible) {
|
if (window.innerWidth <= 1024 && this.isVisible) {
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
|
||||||
if (sidebar && !sidebar.contains(e.target)) {
|
if (sidebar && !sidebar.contains(e.target)) {
|
||||||
this.hideSidebar();
|
this.hideSidebar();
|
||||||
}
|
}
|
||||||
@@ -598,7 +613,7 @@ export class SidebarManager {
|
|||||||
|
|
||||||
// Add document click handler for closing dropdowns
|
// Add document click handler for closing dropdowns
|
||||||
document.addEventListener('click', this.handleDocumentClick);
|
document.addEventListener('click', this.handleDocumentClick);
|
||||||
|
|
||||||
// Add dedicated resize listener for container margin updates
|
// Add dedicated resize listener for container margin updates
|
||||||
window.addEventListener('resize', this.updateContainerMargin);
|
window.addEventListener('resize', this.updateContainerMargin);
|
||||||
|
|
||||||
@@ -645,7 +660,7 @@ export class SidebarManager {
|
|||||||
clearTimeout(this.hoverTimeout);
|
clearTimeout(this.hoverTimeout);
|
||||||
this.hoverTimeout = null;
|
this.hoverTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isPinned) {
|
if (!this.isPinned) {
|
||||||
this.showSidebar();
|
this.showSidebar();
|
||||||
}
|
}
|
||||||
@@ -695,9 +710,9 @@ export class SidebarManager {
|
|||||||
|
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||||
|
|
||||||
if (!sidebar || !hoverArea) return;
|
if (!sidebar || !hoverArea) return;
|
||||||
|
|
||||||
if (window.innerWidth <= 1024) {
|
if (window.innerWidth <= 1024) {
|
||||||
// Mobile: always use collapsed state
|
// Mobile: always use collapsed state
|
||||||
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
|
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
|
||||||
@@ -715,7 +730,7 @@ export class SidebarManager {
|
|||||||
sidebar.classList.remove('collapsed', 'visible');
|
sidebar.classList.remove('collapsed', 'visible');
|
||||||
sidebar.classList.add('auto-hide');
|
sidebar.classList.add('auto-hide');
|
||||||
hoverArea.classList.remove('disabled');
|
hoverArea.classList.remove('disabled');
|
||||||
|
|
||||||
if (this.isHovering) {
|
if (this.isHovering) {
|
||||||
sidebar.classList.add('hover-active');
|
sidebar.classList.add('hover-active');
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
@@ -724,7 +739,7 @@ export class SidebarManager {
|
|||||||
this.isVisible = false;
|
this.isVisible = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update container margin when sidebar state changes
|
// Update container margin when sidebar state changes
|
||||||
this.updateContainerMargin();
|
this.updateContainerMargin();
|
||||||
}
|
}
|
||||||
@@ -735,16 +750,16 @@ export class SidebarManager {
|
|||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
|
|
||||||
if (!container || !sidebar || this.isDisabledBySetting) return;
|
if (!container || !sidebar || this.isDisabledBySetting) return;
|
||||||
|
|
||||||
// Reset margin to default
|
// Reset margin to default
|
||||||
container.style.marginLeft = '';
|
container.style.marginLeft = '';
|
||||||
|
|
||||||
// Only adjust margin if sidebar is visible and pinned
|
// Only adjust margin if sidebar is visible and pinned
|
||||||
if ((this.isPinned || this.isHovering) && this.isVisible) {
|
if ((this.isPinned || this.isHovering) && this.isVisible) {
|
||||||
const sidebarWidth = sidebar.offsetWidth;
|
const sidebarWidth = sidebar.offsetWidth;
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const containerWidth = container.offsetWidth;
|
const containerWidth = container.offsetWidth;
|
||||||
|
|
||||||
// Check if there's enough space for both sidebar and container
|
// Check if there's enough space for both sidebar and container
|
||||||
// We need: sidebar width + container width + some padding < viewport width
|
// We need: sidebar width + container width + some padding < viewport width
|
||||||
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
|
||||||
@@ -822,8 +837,8 @@ export class SidebarManager {
|
|||||||
const pinBtn = document.getElementById('sidebarPinToggle');
|
const pinBtn = document.getElementById('sidebarPinToggle');
|
||||||
if (pinBtn) {
|
if (pinBtn) {
|
||||||
pinBtn.classList.toggle('active', this.isPinned);
|
pinBtn.classList.toggle('active', this.isPinned);
|
||||||
pinBtn.title = this.isPinned
|
pinBtn.title = this.isPinned
|
||||||
? translate('sidebar.unpinSidebar')
|
? translate('sidebar.unpinSidebar')
|
||||||
: translate('sidebar.pinSidebar');
|
: translate('sidebar.pinSidebar');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -868,13 +883,13 @@ export class SidebarManager {
|
|||||||
renderTreeNode(nodeData, basePath) {
|
renderTreeNode(nodeData, basePath) {
|
||||||
const entries = Object.entries(nodeData);
|
const entries = Object.entries(nodeData);
|
||||||
if (entries.length === 0) return '';
|
if (entries.length === 0) return '';
|
||||||
|
|
||||||
return entries.map(([folderName, children]) => {
|
return entries.map(([folderName, children]) => {
|
||||||
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
|
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
|
||||||
const hasChildren = Object.keys(children).length > 0;
|
const hasChildren = Object.keys(children).length > 0;
|
||||||
const isExpanded = this.expandedNodes.has(currentPath);
|
const isExpanded = this.expandedNodes.has(currentPath);
|
||||||
const isSelected = this.selectedPath === currentPath;
|
const isSelected = this.selectedPath === currentPath;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="sidebar-tree-node" data-path="${currentPath}">
|
<div class="sidebar-tree-node" data-path="${currentPath}">
|
||||||
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${currentPath}">
|
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${currentPath}">
|
||||||
@@ -919,7 +934,7 @@ export class SidebarManager {
|
|||||||
const foldersHtml = this.foldersList.map(folder => {
|
const foldersHtml = this.foldersList.map(folder => {
|
||||||
const displayName = folder === '' ? '/' : folder;
|
const displayName = folder === '' ? '/' : folder;
|
||||||
const isSelected = this.selectedPath === folder;
|
const isSelected = this.selectedPath === folder;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
|
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
|
||||||
<div class="sidebar-node-content" data-path="${folder}">
|
<div class="sidebar-node-content" data-path="${folder}">
|
||||||
@@ -941,13 +956,13 @@ export class SidebarManager {
|
|||||||
|
|
||||||
const expandIcon = event.target.closest('.sidebar-tree-expand-icon');
|
const expandIcon = event.target.closest('.sidebar-tree-expand-icon');
|
||||||
const nodeContent = event.target.closest('.sidebar-tree-node-content');
|
const nodeContent = event.target.closest('.sidebar-tree-node-content');
|
||||||
|
|
||||||
if (expandIcon) {
|
if (expandIcon) {
|
||||||
// Toggle expand/collapse
|
// Toggle expand/collapse
|
||||||
const treeNode = expandIcon.closest('.sidebar-tree-node');
|
const treeNode = expandIcon.closest('.sidebar-tree-node');
|
||||||
const path = treeNode.dataset.path;
|
const path = treeNode.dataset.path;
|
||||||
const children = treeNode.querySelector('.sidebar-tree-children');
|
const children = treeNode.querySelector('.sidebar-tree-children');
|
||||||
|
|
||||||
if (this.expandedNodes.has(path)) {
|
if (this.expandedNodes.has(path)) {
|
||||||
this.expandedNodes.delete(path);
|
this.expandedNodes.delete(path);
|
||||||
expandIcon.classList.remove('expanded');
|
expandIcon.classList.remove('expanded');
|
||||||
@@ -957,7 +972,7 @@ export class SidebarManager {
|
|||||||
expandIcon.classList.add('expanded');
|
expandIcon.classList.add('expanded');
|
||||||
if (children) children.classList.add('expanded');
|
if (children) children.classList.add('expanded');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saveExpandedState();
|
this.saveExpandedState();
|
||||||
} else if (nodeContent) {
|
} else if (nodeContent) {
|
||||||
// Select folder
|
// Select folder
|
||||||
@@ -970,7 +985,7 @@ export class SidebarManager {
|
|||||||
handleBreadcrumbClick(event) {
|
handleBreadcrumbClick(event) {
|
||||||
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
||||||
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
||||||
|
|
||||||
if (dropdownItem) {
|
if (dropdownItem) {
|
||||||
// Handle dropdown item selection
|
// Handle dropdown item selection
|
||||||
const path = dropdownItem.dataset.path || '';
|
const path = dropdownItem.dataset.path || '';
|
||||||
@@ -982,17 +997,17 @@ export class SidebarManager {
|
|||||||
const isPlaceholder = breadcrumbItem.classList.contains('placeholder');
|
const isPlaceholder = breadcrumbItem.classList.contains('placeholder');
|
||||||
const isActive = breadcrumbItem.classList.contains('active');
|
const isActive = breadcrumbItem.classList.contains('active');
|
||||||
const dropdown = breadcrumbItem.closest('.breadcrumb-dropdown');
|
const dropdown = breadcrumbItem.closest('.breadcrumb-dropdown');
|
||||||
|
|
||||||
if (isPlaceholder || (isActive && path === this.selectedPath)) {
|
if (isPlaceholder || (isActive && path === this.selectedPath)) {
|
||||||
// Open dropdown for placeholders or active items
|
// Open dropdown for placeholders or active items
|
||||||
// Close any open dropdown first
|
// Close any open dropdown first
|
||||||
if (this.openDropdown && this.openDropdown !== dropdown) {
|
if (this.openDropdown && this.openDropdown !== dropdown) {
|
||||||
this.openDropdown.classList.remove('open');
|
this.openDropdown.classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle current dropdown
|
// Toggle current dropdown
|
||||||
dropdown.classList.toggle('open');
|
dropdown.classList.toggle('open');
|
||||||
|
|
||||||
// Update open dropdown reference
|
// Update open dropdown reference
|
||||||
this.openDropdown = dropdown.classList.contains('open') ? dropdown : null;
|
this.openDropdown = dropdown.classList.contains('open') ? dropdown : null;
|
||||||
} else {
|
} else {
|
||||||
@@ -1010,21 +1025,24 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectFolder(path) {
|
async selectFolder(path) {
|
||||||
|
// Normalize path: null or undefined means root
|
||||||
|
const normalizedPath = (path === null || path === undefined) ? '' : path;
|
||||||
|
|
||||||
// Update selected path
|
// Update selected path
|
||||||
this.selectedPath = path;
|
this.selectedPath = normalizedPath;
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
this.updateTreeSelection();
|
this.updateTreeSelection();
|
||||||
this.updateBreadcrumbs();
|
this.updateBreadcrumbs();
|
||||||
this.updateSidebarHeader();
|
this.updateSidebarHeader();
|
||||||
|
|
||||||
// Update page state
|
// Update page state
|
||||||
this.pageControls.pageState.activeFolder = path;
|
this.pageControls.pageState.activeFolder = normalizedPath;
|
||||||
setStorageItem(`${this.pageType}_activeFolder`, path);
|
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
||||||
|
|
||||||
// Reload models with new filter
|
// Reload models with new filter
|
||||||
await this.pageControls.resetAndReload();
|
await this.pageControls.resetAndReload();
|
||||||
|
|
||||||
// Auto-hide sidebar on mobile after selection
|
// Auto-hide sidebar on mobile after selection
|
||||||
if (window.innerWidth <= 1024) {
|
if (window.innerWidth <= 1024) {
|
||||||
this.hideSidebar();
|
this.hideSidebar();
|
||||||
@@ -1033,7 +1051,7 @@ export class SidebarManager {
|
|||||||
|
|
||||||
handleFolderListClick(event) {
|
handleFolderListClick(event) {
|
||||||
const folderItem = event.target.closest('.sidebar-folder-item');
|
const folderItem = event.target.closest('.sidebar-folder-item');
|
||||||
|
|
||||||
if (folderItem) {
|
if (folderItem) {
|
||||||
const path = folderItem.dataset.path;
|
const path = folderItem.dataset.path;
|
||||||
this.selectFolder(path);
|
this.selectFolder(path);
|
||||||
@@ -1135,15 +1153,15 @@ export class SidebarManager {
|
|||||||
updateTreeSelection() {
|
updateTreeSelection() {
|
||||||
const folderTree = document.getElementById('sidebarFolderTree');
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
if (!folderTree) return;
|
if (!folderTree) return;
|
||||||
|
|
||||||
if (this.displayMode === 'list') {
|
if (this.displayMode === 'list') {
|
||||||
// Remove all selections in list mode
|
// Remove all selections in list mode
|
||||||
folderTree.querySelectorAll('.sidebar-folder-item').forEach(item => {
|
folderTree.querySelectorAll('.sidebar-folder-item').forEach(item => {
|
||||||
item.classList.remove('selected');
|
item.classList.remove('selected');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add selection to current path
|
// Add selection to current path
|
||||||
if (this.selectedPath !== null) {
|
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
||||||
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
|
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
selectedItem.classList.add('selected');
|
selectedItem.classList.add('selected');
|
||||||
@@ -1153,8 +1171,8 @@ export class SidebarManager {
|
|||||||
folderTree.querySelectorAll('.sidebar-tree-node-content').forEach(node => {
|
folderTree.querySelectorAll('.sidebar-tree-node-content').forEach(node => {
|
||||||
node.classList.remove('selected');
|
node.classList.remove('selected');
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.selectedPath) {
|
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
||||||
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
|
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
|
||||||
if (selectedNode) {
|
if (selectedNode) {
|
||||||
selectedNode.classList.add('selected');
|
selectedNode.classList.add('selected');
|
||||||
@@ -1166,15 +1184,15 @@ export class SidebarManager {
|
|||||||
|
|
||||||
expandPathParents(path) {
|
expandPathParents(path) {
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
|
|
||||||
const parts = path.split('/');
|
const parts = path.split('/');
|
||||||
let currentPath = '';
|
let currentPath = '';
|
||||||
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||||
this.expandedNodes.add(currentPath);
|
this.expandedNodes.add(currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.renderTree();
|
this.renderTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1184,7 +1202,7 @@ export class SidebarManager {
|
|||||||
// Root level siblings are top-level folders
|
// Root level siblings are top-level folders
|
||||||
return Object.keys(this.treeData);
|
return Object.keys(this.treeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to the parent folder to get siblings
|
// Navigate to the parent folder to get siblings
|
||||||
let currentNode = this.treeData;
|
let currentNode = this.treeData;
|
||||||
for (let i = 0; i < level; i++) {
|
for (let i = 0; i < level; i++) {
|
||||||
@@ -1193,7 +1211,7 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
currentNode = currentNode[pathParts[i]];
|
currentNode = currentNode[pathParts[i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(currentNode);
|
return Object.keys(currentNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,37 +1220,38 @@ export class SidebarManager {
|
|||||||
if (!path) {
|
if (!path) {
|
||||||
return Object.keys(this.treeData);
|
return Object.keys(this.treeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = path.split('/');
|
const parts = path.split('/');
|
||||||
let currentNode = this.treeData;
|
let currentNode = this.treeData;
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (!currentNode[part]) {
|
if (!currentNode[part]) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
currentNode = currentNode[part];
|
currentNode = currentNode[part];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(currentNode);
|
return Object.keys(currentNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBreadcrumbs() {
|
updateBreadcrumbs() {
|
||||||
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
|
||||||
if (!sidebarBreadcrumbNav) return;
|
if (!sidebarBreadcrumbNav) return;
|
||||||
|
|
||||||
const parts = this.selectedPath ? this.selectedPath.split('/') : [];
|
const parts = this.selectedPath ? this.selectedPath.split('/') : [];
|
||||||
let currentPath = '';
|
let currentPath = '';
|
||||||
|
|
||||||
// Start with root breadcrumb
|
// Start with root breadcrumb
|
||||||
const rootSiblings = Object.keys(this.treeData);
|
const rootSiblings = Object.keys(this.treeData);
|
||||||
|
const isRootSelected = !this.selectedPath;
|
||||||
const breadcrumbs = [`
|
const breadcrumbs = [`
|
||||||
<div class="breadcrumb-dropdown">
|
<div class="breadcrumb-dropdown">
|
||||||
<span class="sidebar-breadcrumb-item ${this.selectedPath == null ? 'active' : ''}" data-path="">
|
<span class="sidebar-breadcrumb-item ${isRootSelected ? 'active' : ''}" data-path="">
|
||||||
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
|
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`];
|
`];
|
||||||
|
|
||||||
// Add separator and placeholder for next level if we're at root
|
// Add separator and placeholder for next level if we're at root
|
||||||
if (!this.selectedPath) {
|
if (!this.selectedPath) {
|
||||||
const nextLevelFolders = rootSiblings;
|
const nextLevelFolders = rootSiblings;
|
||||||
@@ -1251,21 +1270,21 @@ export class SidebarManager {
|
|||||||
<div class="breadcrumb-dropdown-item" data-path="${folder}">
|
<div class="breadcrumb-dropdown-item" data-path="${folder}">
|
||||||
${folder}
|
${folder}
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add breadcrumb items for each path segment
|
// Add breadcrumb items for each path segment
|
||||||
parts.forEach((part, index) => {
|
parts.forEach((part, index) => {
|
||||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||||
const isLast = index === parts.length - 1;
|
const isLast = index === parts.length - 1;
|
||||||
|
|
||||||
// Get siblings for this level
|
// Get siblings for this level
|
||||||
const siblings = this.getSiblingFolders(parts, index);
|
const siblings = this.getSiblingFolders(parts, index);
|
||||||
|
|
||||||
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||||
breadcrumbs.push(`
|
breadcrumbs.push(`
|
||||||
<div class="breadcrumb-dropdown">
|
<div class="breadcrumb-dropdown">
|
||||||
@@ -1284,12 +1303,12 @@ export class SidebarManager {
|
|||||||
data-path="${currentPath.replace(part, folder)}">
|
data-path="${currentPath.replace(part, folder)}">
|
||||||
${folder}
|
${folder}
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Add separator and placeholder for next level if not the last item
|
// Add separator and placeholder for next level if not the last item
|
||||||
if (isLast) {
|
if (isLast) {
|
||||||
const childFolders = this.getChildFolders(currentPath);
|
const childFolders = this.getChildFolders(currentPath);
|
||||||
@@ -1308,22 +1327,22 @@ export class SidebarManager {
|
|||||||
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
|
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
|
||||||
${folder}
|
${folder}
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sidebarBreadcrumbNav.innerHTML = breadcrumbs.join('');
|
sidebarBreadcrumbNav.innerHTML = breadcrumbs.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSidebarHeader() {
|
updateSidebarHeader() {
|
||||||
const sidebarHeader = document.getElementById('sidebarHeader');
|
const sidebarHeader = document.getElementById('sidebarHeader');
|
||||||
if (!sidebarHeader) return;
|
if (!sidebarHeader) return;
|
||||||
|
|
||||||
if (this.selectedPath == null) {
|
if (!this.selectedPath) {
|
||||||
sidebarHeader.classList.add('root-selected');
|
sidebarHeader.classList.add('root-selected');
|
||||||
} else {
|
} else {
|
||||||
sidebarHeader.classList.remove('root-selected');
|
sidebarHeader.classList.remove('root-selected');
|
||||||
@@ -1333,11 +1352,11 @@ export class SidebarManager {
|
|||||||
toggleSidebar() {
|
toggleSidebar() {
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
||||||
|
|
||||||
if (!sidebar) return;
|
if (!sidebar) return;
|
||||||
|
|
||||||
this.isVisible = !this.isVisible;
|
this.isVisible = !this.isVisible;
|
||||||
|
|
||||||
if (this.isVisible) {
|
if (this.isVisible) {
|
||||||
sidebar.classList.remove('collapsed');
|
sidebar.classList.remove('collapsed');
|
||||||
sidebar.classList.add('visible');
|
sidebar.classList.add('visible');
|
||||||
@@ -1345,28 +1364,28 @@ export class SidebarManager {
|
|||||||
sidebar.classList.remove('visible');
|
sidebar.classList.remove('visible');
|
||||||
sidebar.classList.add('collapsed');
|
sidebar.classList.add('collapsed');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toggleBtn) {
|
if (toggleBtn) {
|
||||||
toggleBtn.classList.toggle('active', this.isVisible);
|
toggleBtn.classList.toggle('active', this.isVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saveSidebarState();
|
this.saveSidebarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
closeSidebar() {
|
closeSidebar() {
|
||||||
const sidebar = document.getElementById('folderSidebar');
|
const sidebar = document.getElementById('folderSidebar');
|
||||||
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
|
||||||
|
|
||||||
if (!sidebar) return;
|
if (!sidebar) return;
|
||||||
|
|
||||||
this.isVisible = false;
|
this.isVisible = false;
|
||||||
sidebar.classList.remove('visible');
|
sidebar.classList.remove('visible');
|
||||||
sidebar.classList.add('collapsed');
|
sidebar.classList.add('collapsed');
|
||||||
|
|
||||||
if (toggleBtn) {
|
if (toggleBtn) {
|
||||||
toggleBtn.classList.remove('active');
|
toggleBtn.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saveSidebarState();
|
this.saveSidebarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1375,12 +1394,12 @@ export class SidebarManager {
|
|||||||
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||||
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||||
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
||||||
|
|
||||||
this.isPinned = isPinned;
|
this.isPinned = isPinned;
|
||||||
this.expandedNodes = new Set(expandedPaths);
|
this.expandedNodes = new Set(expandedPaths);
|
||||||
this.displayMode = displayMode;
|
this.displayMode = displayMode;
|
||||||
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
this.recursiveSearchEnabled = recursiveSearchEnabled;
|
||||||
|
|
||||||
this.updatePinButton();
|
this.updatePinButton();
|
||||||
this.updateDisplayModeButton();
|
this.updateDisplayModeButton();
|
||||||
this.updateCollapseAllButton();
|
this.updateCollapseAllButton();
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { eventManager } from '../../utils/EventManager.js';
|
|||||||
// Helper function to get display name based on settings
|
// Helper function to get display name based on settings
|
||||||
function getDisplayName(model) {
|
function getDisplayName(model) {
|
||||||
const displayNameSetting = state.global.settings.model_name_display || 'model_name';
|
const displayNameSetting = state.global.settings.model_name_display || 'model_name';
|
||||||
|
|
||||||
if (displayNameSetting === 'file_name') {
|
if (displayNameSetting === 'file_name') {
|
||||||
return model.file_name || model.model_name || 'Unknown Model';
|
return model.file_name || model.model_name || 'Unknown Model';
|
||||||
}
|
}
|
||||||
|
|
||||||
return model.model_name || model.file_name || 'Unknown Model';
|
return model.model_name || model.file_name || 'Unknown Model';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function getDisplayName(model) {
|
|||||||
export function setupModelCardEventDelegation(modelType) {
|
export function setupModelCardEventDelegation(modelType) {
|
||||||
// Remove any existing handler first
|
// Remove any existing handler first
|
||||||
eventManager.removeHandler('click', 'modelCard-delegation');
|
eventManager.removeHandler('click', 'modelCard-delegation');
|
||||||
|
|
||||||
// Register model card event delegation with event manager
|
// Register model card event delegation with event manager
|
||||||
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
|
eventManager.addHandler('click', 'modelCard-delegation', (event) => {
|
||||||
return handleModelCardEvent_internal(event, modelType);
|
return handleModelCardEvent_internal(event, modelType);
|
||||||
@@ -42,26 +42,26 @@ function handleModelCardEvent_internal(event, modelType) {
|
|||||||
// Find the closest card element
|
// Find the closest card element
|
||||||
const card = event.target.closest('.model-card');
|
const card = event.target.closest('.model-card');
|
||||||
if (!card) return false; // Continue with other handlers
|
if (!card) return false; // Continue with other handlers
|
||||||
|
|
||||||
// Handle specific elements within the card
|
// Handle specific elements within the card
|
||||||
if (event.target.closest('.toggle-blur-btn')) {
|
if (event.target.closest('.toggle-blur-btn')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
toggleBlurContent(card);
|
toggleBlurContent(card);
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.show-content-btn')) {
|
if (event.target.closest('.show-content-btn')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
showBlurredContent(card);
|
showBlurredContent(card);
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-star')) {
|
if (event.target.closest('.fa-star')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
toggleFavorite(card);
|
toggleFavorite(card);
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-globe')) {
|
if (event.target.closest('.fa-globe')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (card.dataset.from_civitai === 'true') {
|
if (card.dataset.from_civitai === 'true') {
|
||||||
@@ -69,37 +69,37 @@ function handleModelCardEvent_internal(event, modelType) {
|
|||||||
}
|
}
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-paper-plane')) {
|
if (event.target.closest('.fa-paper-plane')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleSendToWorkflow(card, event.shiftKey, modelType);
|
handleSendToWorkflow(card, event.shiftKey, modelType);
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-copy')) {
|
if (event.target.closest('.fa-copy')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleCopyAction(card, modelType);
|
handleCopyAction(card, modelType);
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-trash')) {
|
if (event.target.closest('.fa-trash')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
showDeleteModal(card.dataset.filepath);
|
showDeleteModal(card.dataset.filepath);
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-image')) {
|
if (event.target.closest('.fa-image')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
getModelApiClient().replaceModelPreview(card.dataset.filepath);
|
getModelApiClient().replaceModelPreview(card.dataset.filepath);
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('.fa-folder-open')) {
|
if (event.target.closest('.fa-folder-open')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleExampleImagesAccess(card, modelType);
|
handleExampleImagesAccess(card, modelType);
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||||
handleCardClick(card, modelType);
|
handleCardClick(card, modelType);
|
||||||
return false; // Continue with other handlers (e.g., bulk selection)
|
return false; // Continue with other handlers (e.g., bulk selection)
|
||||||
@@ -110,14 +110,14 @@ function toggleBlurContent(card) {
|
|||||||
const preview = card.querySelector('.card-preview');
|
const preview = card.querySelector('.card-preview');
|
||||||
const isBlurred = preview.classList.toggle('blurred');
|
const isBlurred = preview.classList.toggle('blurred');
|
||||||
const icon = card.querySelector('.toggle-blur-btn i');
|
const icon = card.querySelector('.toggle-blur-btn i');
|
||||||
|
|
||||||
// Update the icon based on blur state
|
// Update the icon based on blur state
|
||||||
if (isBlurred) {
|
if (isBlurred) {
|
||||||
icon.className = 'fas fa-eye';
|
icon.className = 'fas fa-eye';
|
||||||
} else {
|
} else {
|
||||||
icon.className = 'fas fa-eye-slash';
|
icon.className = 'fas fa-eye-slash';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle the overlay visibility
|
// Toggle the overlay visibility
|
||||||
const overlay = card.querySelector('.nsfw-overlay');
|
const overlay = card.querySelector('.nsfw-overlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
@@ -128,13 +128,13 @@ function toggleBlurContent(card) {
|
|||||||
function showBlurredContent(card) {
|
function showBlurredContent(card) {
|
||||||
const preview = card.querySelector('.card-preview');
|
const preview = card.querySelector('.card-preview');
|
||||||
preview.classList.remove('blurred');
|
preview.classList.remove('blurred');
|
||||||
|
|
||||||
// Update the toggle button icon
|
// Update the toggle button icon
|
||||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||||
if (toggleBtn) {
|
if (toggleBtn) {
|
||||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the overlay
|
// Hide the overlay
|
||||||
const overlay = card.querySelector('.nsfw-overlay');
|
const overlay = card.querySelector('.nsfw-overlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
@@ -146,10 +146,10 @@ async function toggleFavorite(card) {
|
|||||||
const starIcon = card.querySelector('.fa-star');
|
const starIcon = card.querySelector('.fa-star');
|
||||||
const isFavorite = starIcon.classList.contains('fas');
|
const isFavorite = starIcon.classList.contains('fas');
|
||||||
const newFavoriteState = !isFavorite;
|
const newFavoriteState = !isFavorite;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
|
await getModelApiClient().saveModelMetadata(card.dataset.filepath, {
|
||||||
favorite: newFavoriteState
|
favorite: newFavoriteState
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newFavoriteState) {
|
if (newFavoriteState) {
|
||||||
@@ -239,11 +239,11 @@ function handleReplacePreview(filePath, modelType) {
|
|||||||
|
|
||||||
async function handleExampleImagesAccess(card, modelType) {
|
async function handleExampleImagesAccess(card, modelType) {
|
||||||
const modelHash = card.dataset.sha256;
|
const modelHash = card.dataset.sha256;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/lm/has-example-images?model_hash=${modelHash}`);
|
const response = await fetch(`/api/lm/has-example-images?model_hash=${modelHash}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.has_images) {
|
if (data.has_images) {
|
||||||
openExampleImagesFolder(modelHash);
|
openExampleImagesFolder(modelHash);
|
||||||
} else {
|
} else {
|
||||||
@@ -257,7 +257,7 @@ async function handleExampleImagesAccess(card, modelType) {
|
|||||||
|
|
||||||
function handleCardClick(card, modelType) {
|
function handleCardClick(card, modelType) {
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
if (state.bulkMode) {
|
if (state.bulkMode) {
|
||||||
// Toggle selection using the bulk manager
|
// Toggle selection using the bulk manager
|
||||||
bulkManager.toggleCardSelection(card);
|
bulkManager.toggleCardSelection(card);
|
||||||
@@ -294,7 +294,7 @@ async function showModelModalFromCard(card, modelType) {
|
|||||||
usage_tips: card.dataset.usage_tips,
|
usage_tips: card.dataset.usage_tips,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
await showModelModal(modelMeta, modelType);
|
await showModelModal(modelMeta, modelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,9 +310,9 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
try {
|
try {
|
||||||
const metaData = JSON.parse(card.dataset.meta || '{}');
|
const metaData = JSON.parse(card.dataset.meta || '{}');
|
||||||
hasRemoteExamples = metaData.images &&
|
hasRemoteExamples = metaData.images &&
|
||||||
Array.isArray(metaData.images) &&
|
Array.isArray(metaData.images) &&
|
||||||
metaData.images.length > 0 &&
|
metaData.images.length > 0 &&
|
||||||
metaData.images[0].url;
|
metaData.images[0].url;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing meta data:', e);
|
console.error('Error parsing meta data:', e);
|
||||||
}
|
}
|
||||||
@@ -329,10 +329,10 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
showToast('modelCard.exampleImages.missingHash', {}, 'error');
|
showToast('modelCard.exampleImages.missingHash', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the modal
|
// Close the modal
|
||||||
modalManager.closeModal('exampleAccessModal');
|
modalManager.closeModal('exampleAccessModal');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the appropriate model API client to download examples
|
// Use the appropriate model API client to download examples
|
||||||
const apiClient = getModelApiClient(modelType);
|
const apiClient = getModelApiClient(modelType);
|
||||||
@@ -462,7 +462,7 @@ export function createModelCard(model, modelType) {
|
|||||||
if (model.civitai) {
|
if (model.civitai) {
|
||||||
card.dataset.meta = JSON.stringify(model.civitai || {});
|
card.dataset.meta = JSON.stringify(model.civitai || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store tags if available
|
// Store tags if available
|
||||||
if (model.tags && Array.isArray(model.tags)) {
|
if (model.tags && Array.isArray(model.tags)) {
|
||||||
card.dataset.tags = JSON.stringify(model.tags);
|
card.dataset.tags = JSON.stringify(model.tags);
|
||||||
@@ -475,7 +475,7 @@ export function createModelCard(model, modelType) {
|
|||||||
// Store NSFW level if available
|
// Store NSFW level if available
|
||||||
const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0;
|
const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0;
|
||||||
card.dataset.nsfwLevel = nsfwLevel;
|
card.dataset.nsfwLevel = nsfwLevel;
|
||||||
|
|
||||||
// Determine if the preview should be blurred based on NSFW level and user settings
|
// Determine if the preview should be blurred based on NSFW level and user settings
|
||||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||||
if (shouldBlur) {
|
if (shouldBlur) {
|
||||||
@@ -506,7 +506,7 @@ export function createModelCard(model, modelType) {
|
|||||||
|
|
||||||
// Check if autoplayOnHover is enabled for video previews
|
// Check if autoplayOnHover is enabled for video previews
|
||||||
const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false;
|
const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false;
|
||||||
const isVideo = previewUrl.endsWith('.mp4');
|
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||||
const videoAttrs = [
|
const videoAttrs = [
|
||||||
'controls',
|
'controls',
|
||||||
'muted',
|
'muted',
|
||||||
@@ -527,10 +527,10 @@ export function createModelCard(model, modelType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate action icons based on model type with i18n support
|
// Generate action icons based on model type with i18n support
|
||||||
const favoriteTitle = isFavorite ?
|
const favoriteTitle = isFavorite ?
|
||||||
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
||||||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||||
const globeTitle = model.from_civitai ?
|
const globeTitle = model.from_civitai ?
|
||||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||||
let sendTitle;
|
let sendTitle;
|
||||||
@@ -582,13 +582,13 @@ export function createModelCard(model, modelType) {
|
|||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
|
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
|
||||||
}
|
}
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
${shouldBlur ?
|
${shouldBlur ?
|
||||||
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
|
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<div class="card-header-info">
|
<div class="card-header-info">
|
||||||
@@ -629,7 +629,7 @@ export function createModelCard(model, modelType) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add video auto-play on hover functionality if needed
|
// Add video auto-play on hover functionality if needed
|
||||||
const videoElement = card.querySelector('video');
|
const videoElement = card.querySelector('video');
|
||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
@@ -765,7 +765,7 @@ function cleanupHoverHandlers(videoElement) {
|
|||||||
function requestSafePlay(videoElement) {
|
function requestSafePlay(videoElement) {
|
||||||
const playPromise = videoElement.play();
|
const playPromise = videoElement.play();
|
||||||
if (playPromise && typeof playPromise.catch === 'function') {
|
if (playPromise && typeof playPromise.catch === 'function') {
|
||||||
playPromise.catch(() => {});
|
playPromise.catch(() => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,16 +887,16 @@ export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
|||||||
export function updateCardsForBulkMode(isBulkMode) {
|
export function updateCardsForBulkMode(isBulkMode) {
|
||||||
// Update the state
|
// Update the state
|
||||||
state.bulkMode = isBulkMode;
|
state.bulkMode = isBulkMode;
|
||||||
|
|
||||||
document.body.classList.toggle('bulk-mode', isBulkMode);
|
document.body.classList.toggle('bulk-mode', isBulkMode);
|
||||||
|
|
||||||
// Get all lora cards - this can now be from the DOM or through the virtual scroller
|
// Get all lora cards - this can now be from the DOM or through the virtual scroller
|
||||||
const loraCards = document.querySelectorAll('.model-card');
|
const loraCards = document.querySelectorAll('.model-card');
|
||||||
|
|
||||||
loraCards.forEach(card => {
|
loraCards.forEach(card => {
|
||||||
// Get all action containers for this card
|
// Get all action containers for this card
|
||||||
const actions = card.querySelectorAll('.card-actions');
|
const actions = card.querySelectorAll('.card-actions');
|
||||||
|
|
||||||
// Handle display property based on mode
|
// Handle display property based on mode
|
||||||
if (isBulkMode) {
|
if (isBulkMode) {
|
||||||
// Hide actions when entering bulk mode
|
// Hide actions when entering bulk mode
|
||||||
@@ -911,12 +911,12 @@ export function updateCardsForBulkMode(isBulkMode) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If using virtual scroller, we need to rerender after toggling bulk mode
|
// If using virtual scroller, we need to rerender after toggling bulk mode
|
||||||
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
|
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
|
||||||
state.virtualScroller.scheduleRender();
|
state.virtualScroller.scheduleRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply selection state to cards if entering bulk mode
|
// Apply selection state to cards if entering bulk mode
|
||||||
if (isBulkMode) {
|
if (isBulkMode) {
|
||||||
bulkManager.applySelectionState();
|
bulkManager.applySelectionState();
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import {
|
import {
|
||||||
toggleShowcase,
|
toggleShowcase,
|
||||||
setupShowcaseScroll,
|
setupShowcaseScroll,
|
||||||
scrollToTop,
|
scrollToTop,
|
||||||
loadExampleImages
|
loadExampleImages
|
||||||
} from './showcase/ShowcaseView.js';
|
} from './showcase/ShowcaseView.js';
|
||||||
import { setupTabSwitching } from './ModelDescription.js';
|
import { setupTabSwitching } from './ModelDescription.js';
|
||||||
import {
|
import {
|
||||||
setupModelNameEditing,
|
setupModelNameEditing,
|
||||||
setupBaseModelEditing,
|
setupBaseModelEditing,
|
||||||
setupFileNameEditing
|
setupFileNameEditing
|
||||||
} from './ModelMetadata.js';
|
} from './ModelMetadata.js';
|
||||||
import { setupTagEditMode } from './ModelTags.js';
|
import { setupTagEditMode } from './ModelTags.js';
|
||||||
@@ -242,7 +242,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
const modalTitle = model.model_name;
|
const modalTitle = model.model_name;
|
||||||
cleanupNavigationShortcuts();
|
cleanupNavigationShortcuts();
|
||||||
detachModalHandlers(modalId);
|
detachModalHandlers(modalId);
|
||||||
|
|
||||||
// Fetch complete civitai metadata
|
// Fetch complete civitai metadata
|
||||||
let completeCivitaiData = model.civitai || {};
|
let completeCivitaiData = model.civitai || {};
|
||||||
if (model.file_path) {
|
if (model.file_path) {
|
||||||
@@ -254,7 +254,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
// Continue with existing data if fetch fails
|
// Continue with existing data if fetch fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update model with complete civitai data
|
// Update model with complete civitai data
|
||||||
const modelWithFullData = {
|
const modelWithFullData = {
|
||||||
...model,
|
...model,
|
||||||
@@ -269,14 +269,14 @@ export async function showModelModal(model, modelType) {
|
|||||||
</div>`.trim() : '';
|
</div>`.trim() : '';
|
||||||
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
|
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
|
||||||
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
||||||
${modelWithFullData.civitai.creator.image ?
|
${modelWithFullData.civitai.creator.image ?
|
||||||
`<div class="creator-avatar">
|
`<div class="creator-avatar">
|
||||||
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='/loras_static/icons/user-placeholder.png';">
|
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='/loras_static/icons/user-placeholder.png';">
|
||||||
</div>` :
|
</div>` :
|
||||||
`<div class="creator-avatar creator-placeholder">
|
`<div class="creator-avatar creator-placeholder">
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
|
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
|
||||||
</div>`.trim() : '';
|
</div>`.trim() : '';
|
||||||
const creatorActionItems = [];
|
const creatorActionItems = [];
|
||||||
@@ -310,10 +310,10 @@ export async function showModelModal(model, modelType) {
|
|||||||
const hasUpdateAvailable = Boolean(modelWithFullData.update_available);
|
const hasUpdateAvailable = Boolean(modelWithFullData.update_available);
|
||||||
const updateAvailabilityState = { hasUpdateAvailable };
|
const updateAvailabilityState = { hasUpdateAvailable };
|
||||||
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
|
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
|
||||||
|
|
||||||
// Prepare LoRA specific data with complete civitai data
|
// Prepare LoRA specific data with complete civitai data
|
||||||
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
|
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
|
||||||
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
modelWithFullData.civitai.trainedWords : [];
|
||||||
|
|
||||||
// Generate model type specific content
|
// Generate model type specific content
|
||||||
let typeSpecificContent;
|
let typeSpecificContent;
|
||||||
@@ -343,7 +343,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
${versionsTabBadge}
|
${versionsTabBadge}
|
||||||
</button>`.trim();
|
</button>`.trim();
|
||||||
|
|
||||||
const tabsContent = modelType === 'loras' ?
|
const tabsContent = modelType === 'loras' ?
|
||||||
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
||||||
${versionsTabButton}
|
${versionsTabButton}
|
||||||
@@ -351,12 +351,12 @@ export async function showModelModal(model, modelType) {
|
|||||||
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
||||||
${versionsTabButton}`;
|
${versionsTabButton}`;
|
||||||
|
|
||||||
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
|
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
|
||||||
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
|
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
|
||||||
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
|
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
|
||||||
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
||||||
|
|
||||||
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
||||||
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
||||||
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
||||||
@@ -373,7 +373,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
</button>
|
</button>
|
||||||
</div>`.trim();
|
</div>`.trim();
|
||||||
|
|
||||||
const tabPanesContent = modelType === 'loras' ?
|
const tabPanesContent = modelType === 'loras' ?
|
||||||
`<div id="showcase-tab" class="tab-pane active">
|
`<div id="showcase-tab" class="tab-pane active">
|
||||||
<div class="example-images-loading">
|
<div class="example-images-loading">
|
||||||
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
||||||
@@ -518,7 +518,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function updateVersionsTabBadge(hasUpdate) {
|
function updateVersionsTabBadge(hasUpdate) {
|
||||||
const modalElement = document.getElementById(modalId);
|
const modalElement = document.getElementById(modalId);
|
||||||
if (!modalElement) return;
|
if (!modalElement) return;
|
||||||
@@ -594,10 +594,10 @@ export async function showModelModal(model, modelType) {
|
|||||||
updateVersionsTabBadge(hasUpdate);
|
updateVersionsTabBadge(hasUpdate);
|
||||||
updateCardUpdateAvailability(hasUpdate);
|
updateCardUpdateAvailability(hasUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
let showcaseCleanup;
|
let showcaseCleanup;
|
||||||
|
|
||||||
const onCloseCallback = function() {
|
const onCloseCallback = function () {
|
||||||
// Clean up all handlers when modal closes for LoRA
|
// Clean up all handlers when modal closes for LoRA
|
||||||
const modalElement = document.getElementById(modalId);
|
const modalElement = document.getElementById(modalId);
|
||||||
if (modalElement && modalElement._clickHandler) {
|
if (modalElement && modalElement._clickHandler) {
|
||||||
@@ -610,7 +610,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
}
|
}
|
||||||
cleanupNavigationShortcuts();
|
cleanupNavigationShortcuts();
|
||||||
};
|
};
|
||||||
|
|
||||||
modalManager.showModal(modalId, content, null, onCloseCallback);
|
modalManager.showModal(modalId, content, null, onCloseCallback);
|
||||||
const activeModalElement = document.getElementById(modalId);
|
const activeModalElement = document.getElementById(modalId);
|
||||||
if (activeModalElement) {
|
if (activeModalElement) {
|
||||||
@@ -643,17 +643,17 @@ export async function showModelModal(model, modelType) {
|
|||||||
setupEventHandlers(modelWithFullData.file_path, modelType);
|
setupEventHandlers(modelWithFullData.file_path, modelType);
|
||||||
setupNavigationShortcuts(modelType);
|
setupNavigationShortcuts(modelType);
|
||||||
updateNavigationControls();
|
updateNavigationControls();
|
||||||
|
|
||||||
// LoRA specific setup
|
// LoRA specific setup
|
||||||
if (modelType === 'loras' || modelType === 'embeddings') {
|
if (modelType === 'loras' || modelType === 'embeddings') {
|
||||||
setupTriggerWordsEditMode();
|
setupTriggerWordsEditMode();
|
||||||
|
|
||||||
if (modelType == 'loras') {
|
if (modelType == 'loras') {
|
||||||
// Load recipes for this LoRA
|
// Load recipes for this LoRA
|
||||||
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
|
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load example images asynchronously - merge regular and custom images
|
// Load example images asynchronously - merge regular and custom images
|
||||||
const regularImages = modelWithFullData.civitai?.images || [];
|
const regularImages = modelWithFullData.civitai?.images || [];
|
||||||
const customImages = modelWithFullData.civitai?.customImages || [];
|
const customImages = modelWithFullData.civitai?.customImages || [];
|
||||||
@@ -707,17 +707,17 @@ function detachModalHandlers(modalId) {
|
|||||||
*/
|
*/
|
||||||
function setupEventHandlers(filePath, modelType) {
|
function setupEventHandlers(filePath, modelType) {
|
||||||
const modalElement = document.getElementById('modelModal');
|
const modalElement = document.getElementById('modelModal');
|
||||||
|
|
||||||
// Remove existing event listeners first
|
// Remove existing event listeners first
|
||||||
modalElement.removeEventListener('click', handleModalClick);
|
modalElement.removeEventListener('click', handleModalClick);
|
||||||
|
|
||||||
// Create and store the handler function
|
// Create and store the handler function
|
||||||
function handleModalClick(event) {
|
function handleModalClick(event) {
|
||||||
const target = event.target.closest('[data-action]');
|
const target = event.target.closest('[data-action]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
const action = target.dataset.action;
|
const action = target.dataset.action;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'close-modal':
|
case 'close-modal':
|
||||||
modalManager.closeModal('modelModal');
|
modalManager.closeModal('modelModal');
|
||||||
@@ -748,10 +748,10 @@ function setupEventHandlers(filePath, modelType) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the event listener with the named function
|
// Add the event listener with the named function
|
||||||
modalElement.addEventListener('click', handleModalClick);
|
modalElement.addEventListener('click', handleModalClick);
|
||||||
|
|
||||||
// Store reference to the handler on the element for potential cleanup
|
// Store reference to the handler on the element for potential cleanup
|
||||||
modalElement._clickHandler = handleModalClick;
|
modalElement._clickHandler = handleModalClick;
|
||||||
}
|
}
|
||||||
@@ -763,15 +763,15 @@ function setupEventHandlers(filePath, modelType) {
|
|||||||
*/
|
*/
|
||||||
function setupEditableFields(filePath, modelType) {
|
function setupEditableFields(filePath, modelType) {
|
||||||
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
|
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
|
||||||
|
|
||||||
editableFields.forEach(field => {
|
editableFields.forEach(field => {
|
||||||
field.addEventListener('focus', function() {
|
field.addEventListener('focus', function () {
|
||||||
if (this.textContent === 'Add your notes here...') {
|
if (this.textContent === 'Add your notes here...') {
|
||||||
this.textContent = '';
|
this.textContent = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
field.addEventListener('blur', function() {
|
field.addEventListener('blur', function () {
|
||||||
if (this.textContent.trim() === '') {
|
if (this.textContent.trim() === '') {
|
||||||
if (this.classList.contains('notes-content')) {
|
if (this.classList.contains('notes-content')) {
|
||||||
this.textContent = 'Add your notes here...';
|
this.textContent = 'Add your notes here...';
|
||||||
@@ -783,7 +783,7 @@ function setupEditableFields(filePath, modelType) {
|
|||||||
// Add keydown event listeners for notes
|
// Add keydown event listeners for notes
|
||||||
const notesContent = document.querySelector('.notes-content');
|
const notesContent = document.querySelector('.notes-content');
|
||||||
if (notesContent) {
|
if (notesContent) {
|
||||||
notesContent.addEventListener('keydown', async function(e) {
|
notesContent.addEventListener('keydown', async function (e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
// Allow shift+enter for new line
|
// Allow shift+enter for new line
|
||||||
@@ -810,7 +810,7 @@ function setupLoraSpecificFields(filePath) {
|
|||||||
|
|
||||||
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
|
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
|
||||||
|
|
||||||
presetSelector.addEventListener('change', function() {
|
presetSelector.addEventListener('change', function () {
|
||||||
const selected = this.value;
|
const selected = this.value;
|
||||||
if (selected) {
|
if (selected) {
|
||||||
presetValue.style.display = 'inline-block';
|
presetValue.style.display = 'inline-block';
|
||||||
@@ -828,10 +828,10 @@ function setupLoraSpecificFields(filePath) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
addPresetBtn.addEventListener('click', async function() {
|
addPresetBtn.addEventListener('click', async function () {
|
||||||
const key = presetSelector.value;
|
const key = presetSelector.value;
|
||||||
const value = presetValue.value;
|
const value = presetValue.value;
|
||||||
|
|
||||||
if (!key || !value) return;
|
if (!key || !value) return;
|
||||||
|
|
||||||
const currentPath = resolveFilePath();
|
const currentPath = resolveFilePath();
|
||||||
@@ -839,21 +839,21 @@ function setupLoraSpecificFields(filePath) {
|
|||||||
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
|
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
|
||||||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||||
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
||||||
|
|
||||||
currentPresets[key] = parseFloat(value);
|
currentPresets[key] = parseFloat(value);
|
||||||
const newPresetsJson = JSON.stringify(currentPresets);
|
const newPresetsJson = JSON.stringify(currentPresets);
|
||||||
|
|
||||||
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
|
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
|
||||||
|
|
||||||
presetTags.innerHTML = renderPresetTags(currentPresets);
|
presetTags.innerHTML = renderPresetTags(currentPresets);
|
||||||
|
|
||||||
presetSelector.value = '';
|
presetSelector.value = '';
|
||||||
presetValue.value = '';
|
presetValue.value = '';
|
||||||
presetValue.style.display = 'none';
|
presetValue.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add keydown event for preset value
|
// Add keydown event for preset value
|
||||||
presetValue.addEventListener('keydown', function(e) {
|
presetValue.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addPresetBtn.click();
|
addPresetBtn.click();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { escapeAttribute } from './utils.js';
|
import { escapeAttribute, escapeHtml } from './utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch trained words for a model
|
* Fetch trained words for a model
|
||||||
@@ -17,7 +17,7 @@ async function fetchTrainedWords(filePath) {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/lm/trained-words?file_path=${encodeURIComponent(filePath)}`);
|
const response = await fetch(`/api/lm/trained-words?file_path=${encodeURIComponent(filePath)}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
return {
|
return {
|
||||||
trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs
|
trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs
|
||||||
@@ -43,11 +43,11 @@ async function fetchTrainedWords(filePath) {
|
|||||||
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
|
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'metadata-suggestions-dropdown';
|
dropdown.className = 'metadata-suggestions-dropdown';
|
||||||
|
|
||||||
// Create header
|
// Create header
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'metadata-suggestions-header';
|
header.className = 'metadata-suggestions-header';
|
||||||
|
|
||||||
// No suggestions case
|
// No suggestions case
|
||||||
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
|
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
|
||||||
header.innerHTML = `<span>${translate('modals.model.triggerWords.suggestions.noSuggestions')}</span>`;
|
header.innerHTML = `<span>${translate('modals.model.triggerWords.suggestions.noSuggestions')}</span>`;
|
||||||
@@ -55,12 +55,12 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
|
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
|
||||||
return dropdown;
|
return dropdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort trained words by frequency (highest first) if available
|
// Sort trained words by frequency (highest first) if available
|
||||||
if (trainedWords && trainedWords.length > 0) {
|
if (trainedWords && trainedWords.length > 0) {
|
||||||
trainedWords.sort((a, b) => b[1] - a[1]);
|
trainedWords.sort((a, b) => b[1] - a[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add class tokens section if available
|
// Add class tokens section if available
|
||||||
if (classTokens) {
|
if (classTokens) {
|
||||||
// Add class tokens header
|
// Add class tokens header
|
||||||
@@ -71,45 +71,47 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
<small>${translate('modals.model.triggerWords.suggestions.classTokenDescription')}</small>
|
<small>${translate('modals.model.triggerWords.suggestions.classTokenDescription')}</small>
|
||||||
`;
|
`;
|
||||||
dropdown.appendChild(classTokensHeader);
|
dropdown.appendChild(classTokensHeader);
|
||||||
|
|
||||||
// Add class tokens container
|
// Add class tokens container
|
||||||
const classTokensContainer = document.createElement('div');
|
const classTokensContainer = document.createElement('div');
|
||||||
classTokensContainer.className = 'class-tokens-container';
|
classTokensContainer.className = 'class-tokens-container';
|
||||||
|
|
||||||
// Create a special item for the class token
|
// Create a special item for the class token
|
||||||
const tokenItem = document.createElement('div');
|
const tokenItem = document.createElement('div');
|
||||||
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
|
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
|
||||||
tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`;
|
tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`;
|
||||||
|
|
||||||
|
const escapedToken = escapeHtml(classTokens);
|
||||||
tokenItem.innerHTML = `
|
tokenItem.innerHTML = `
|
||||||
<span class="metadata-suggestion-text">${classTokens}</span>
|
<span class="metadata-suggestion-text">${escapedToken}</span>
|
||||||
<div class="metadata-suggestion-meta">
|
<div class="metadata-suggestion-meta">
|
||||||
<span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span>
|
<span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span>
|
||||||
${existingWords.includes(classTokens) ?
|
${existingWords.includes(classTokens) ?
|
||||||
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
|
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add click handler if not already added
|
// Add click handler if not already added
|
||||||
if (!existingWords.includes(classTokens)) {
|
if (!existingWords.includes(classTokens)) {
|
||||||
tokenItem.addEventListener('click', () => {
|
tokenItem.addEventListener('click', () => {
|
||||||
// Automatically add this word
|
// Automatically add this word
|
||||||
addNewTriggerWord(classTokens);
|
addNewTriggerWord(classTokens);
|
||||||
|
|
||||||
// Also populate the input field for potential editing
|
// Also populate the input field for potential editing
|
||||||
const input = document.querySelector('.metadata-input');
|
const input = document.querySelector('.metadata-input');
|
||||||
if (input) input.value = classTokens;
|
if (input) input.value = classTokens;
|
||||||
|
|
||||||
// Focus on the input
|
// Focus on the input
|
||||||
if (input) input.focus();
|
if (input) input.focus();
|
||||||
|
|
||||||
// Update dropdown without removing it
|
// Update dropdown without removing it
|
||||||
updateTrainedWordsDropdown();
|
updateTrainedWordsDropdown();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
classTokensContainer.appendChild(tokenItem);
|
classTokensContainer.appendChild(tokenItem);
|
||||||
dropdown.appendChild(classTokensContainer);
|
dropdown.appendChild(classTokensContainer);
|
||||||
|
|
||||||
// Add separator if we also have trained words
|
// Add separator if we also have trained words
|
||||||
if (trainedWords && trainedWords.length > 0) {
|
if (trainedWords && trainedWords.length > 0) {
|
||||||
const separator = document.createElement('div');
|
const separator = document.createElement('div');
|
||||||
@@ -117,7 +119,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
dropdown.appendChild(separator);
|
dropdown.appendChild(separator);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add trained words header if we have any
|
// Add trained words header if we have any
|
||||||
if (trainedWords && trainedWords.length > 0) {
|
if (trainedWords && trainedWords.length > 0) {
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
@@ -125,52 +127,54 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
<small>${translate('modals.model.triggerWords.suggestions.wordsFound', { count: trainedWords.length })}</small>
|
<small>${translate('modals.model.triggerWords.suggestions.wordsFound', { count: trainedWords.length })}</small>
|
||||||
`;
|
`;
|
||||||
dropdown.appendChild(header);
|
dropdown.appendChild(header);
|
||||||
|
|
||||||
// Create tag container for trained words
|
// Create tag container for trained words
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'metadata-suggestions-container';
|
container.className = 'metadata-suggestions-container';
|
||||||
|
|
||||||
// Add each trained word as a tag
|
// Add each trained word as a tag
|
||||||
trainedWords.forEach(([word, frequency]) => {
|
trainedWords.forEach(([word, frequency]) => {
|
||||||
const isAdded = existingWords.includes(word);
|
const isAdded = existingWords.includes(word);
|
||||||
|
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
|
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
|
||||||
item.title = word; // Show full word on hover if truncated
|
item.title = word; // Show full word on hover if truncated
|
||||||
|
|
||||||
|
const escapedWord = escapeHtml(word);
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<span class="metadata-suggestion-text">${word}</span>
|
<span class="metadata-suggestion-text">${escapedWord}</span>
|
||||||
<div class="metadata-suggestion-meta">
|
<div class="metadata-suggestion-meta">
|
||||||
<span class="trained-word-freq">${frequency}</span>
|
<span class="trained-word-freq">${frequency}</span>
|
||||||
${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
|
${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!isAdded) {
|
if (!isAdded) {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
// Automatically add this word
|
// Automatically add this word
|
||||||
addNewTriggerWord(word);
|
addNewTriggerWord(word);
|
||||||
|
|
||||||
// Also populate the input field for potential editing
|
// Also populate the input field for potential editing
|
||||||
const input = document.querySelector('.metadata-input');
|
const input = document.querySelector('.metadata-input');
|
||||||
if (input) input.value = word;
|
if (input) input.value = word;
|
||||||
|
|
||||||
// Focus on the input
|
// Focus on the input
|
||||||
if (input) input.focus();
|
if (input) input.focus();
|
||||||
|
|
||||||
// Update dropdown without removing it
|
// Update dropdown without removing it
|
||||||
updateTrainedWordsDropdown();
|
updateTrainedWordsDropdown();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
container.appendChild(item);
|
container.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
dropdown.appendChild(container);
|
dropdown.appendChild(container);
|
||||||
} else if (!classTokens) {
|
} else if (!classTokens) {
|
||||||
// If we have neither class tokens nor trained words
|
// If we have neither class tokens nor trained words
|
||||||
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
|
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return dropdown;
|
return dropdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +208,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="info-item full-width trigger-words">
|
<div class="info-item full-width trigger-words">
|
||||||
<div class="trigger-words-header">
|
<div class="trigger-words-header">
|
||||||
@@ -215,9 +219,12 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="trigger-words-content">
|
<div class="trigger-words-content">
|
||||||
<div class="trigger-words-tags">
|
<div class="trigger-words-tags">
|
||||||
${words.map(word => `
|
${words.map(word => {
|
||||||
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')" title="${translate('modals.model.triggerWords.copyWord')}">
|
const escapedWord = escapeHtml(word);
|
||||||
<span class="trigger-word-content">${word}</span>
|
const escapedAttr = escapeAttribute(word);
|
||||||
|
return `
|
||||||
|
<div class="trigger-word-tag" data-word="${escapedAttr}" onclick="copyTriggerWord(this.dataset.word)" title="${translate('modals.model.triggerWords.copyWord')}">
|
||||||
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy">
|
<span class="trigger-word-copy">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</span>
|
</span>
|
||||||
@@ -225,7 +232,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`}).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls" style="display:none;">
|
<div class="metadata-edit-controls" style="display:none;">
|
||||||
@@ -250,68 +257,68 @@ export function setupTriggerWordsEditMode() {
|
|||||||
let isTrainedWordsLoaded = false;
|
let isTrainedWordsLoaded = false;
|
||||||
// Store original trigger words for restoring on cancel
|
// Store original trigger words for restoring on cancel
|
||||||
let originalTriggerWords = [];
|
let originalTriggerWords = [];
|
||||||
|
|
||||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||||
if (!editBtn) return;
|
if (!editBtn) return;
|
||||||
|
|
||||||
editBtn.addEventListener('click', async function() {
|
editBtn.addEventListener('click', async function () {
|
||||||
const triggerWordsSection = this.closest('.trigger-words');
|
const triggerWordsSection = this.closest('.trigger-words');
|
||||||
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
||||||
const filePath = this.dataset.filePath;
|
const filePath = this.dataset.filePath;
|
||||||
|
|
||||||
// Toggle edit mode UI elements
|
// Toggle edit mode UI elements
|
||||||
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||||
const editControls = triggerWordsSection.querySelector('.metadata-edit-controls');
|
const editControls = triggerWordsSection.querySelector('.metadata-edit-controls');
|
||||||
const addForm = triggerWordsSection.querySelector('.metadata-add-form');
|
const addForm = triggerWordsSection.querySelector('.metadata-add-form');
|
||||||
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
||||||
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
||||||
this.title = translate('modals.model.triggerWords.cancel');
|
this.title = translate('modals.model.triggerWords.cancel');
|
||||||
|
|
||||||
// Store original trigger words for potential restoration
|
// Store original trigger words for potential restoration
|
||||||
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||||
|
|
||||||
// Show edit controls and input form
|
// Show edit controls and input form
|
||||||
editControls.style.display = 'flex';
|
editControls.style.display = 'flex';
|
||||||
addForm.style.display = 'flex';
|
addForm.style.display = 'flex';
|
||||||
|
|
||||||
// If we have no trigger words yet, hide the "No trigger word needed" text
|
// If we have no trigger words yet, hide the "No trigger word needed" text
|
||||||
// and show the empty tags container
|
// and show the empty tags container
|
||||||
if (noTriggerWords) {
|
if (noTriggerWords) {
|
||||||
noTriggerWords.style.display = 'none';
|
noTriggerWords.style.display = 'none';
|
||||||
if (tagsContainer) tagsContainer.style.display = 'flex';
|
if (tagsContainer) tagsContainer.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable click-to-copy and show delete buttons
|
// Disable click-to-copy and show delete buttons
|
||||||
triggerWordTags.forEach(tag => {
|
triggerWordTags.forEach(tag => {
|
||||||
tag.onclick = null;
|
tag.onclick = null;
|
||||||
const copyIcon = tag.querySelector('.trigger-word-copy');
|
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||||
|
|
||||||
if (copyIcon) copyIcon.style.display = 'none';
|
if (copyIcon) copyIcon.style.display = 'none';
|
||||||
if (deleteBtn) {
|
if (deleteBtn) {
|
||||||
deleteBtn.style.display = 'block';
|
deleteBtn.style.display = 'block';
|
||||||
|
|
||||||
// Re-attach event listener to ensure it works every time
|
// Re-attach event listener to ensure it works every time
|
||||||
// First remove any existing listeners to avoid duplication
|
// First remove any existing listeners to avoid duplication
|
||||||
deleteBtn.removeEventListener('click', deleteTriggerWord);
|
deleteBtn.removeEventListener('click', deleteTriggerWord);
|
||||||
deleteBtn.addEventListener('click', deleteTriggerWord);
|
deleteBtn.addEventListener('click', deleteTriggerWord);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load trained words and display dropdown when entering edit mode
|
// Load trained words and display dropdown when entering edit mode
|
||||||
// Add loading indicator
|
// Add loading indicator
|
||||||
const loadingIndicator = document.createElement('div');
|
const loadingIndicator = document.createElement('div');
|
||||||
loadingIndicator.className = 'metadata-loading';
|
loadingIndicator.className = 'metadata-loading';
|
||||||
loadingIndicator.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${translate('modals.model.triggerWords.suggestions.loading')}`;
|
loadingIndicator.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${translate('modals.model.triggerWords.suggestions.loading')}`;
|
||||||
addForm.appendChild(loadingIndicator);
|
addForm.appendChild(loadingIndicator);
|
||||||
|
|
||||||
// Get currently added trigger words
|
// Get currently added trigger words
|
||||||
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||||
|
|
||||||
// Asynchronously load trained words if not already loaded
|
// Asynchronously load trained words if not already loaded
|
||||||
if (!isTrainedWordsLoaded) {
|
if (!isTrainedWordsLoaded) {
|
||||||
const result = await fetchTrainedWords(filePath);
|
const result = await fetchTrainedWords(filePath);
|
||||||
@@ -319,25 +326,25 @@ export function setupTriggerWordsEditMode() {
|
|||||||
classTokensValue = result.classTokens;
|
classTokensValue = result.classTokens;
|
||||||
isTrainedWordsLoaded = true;
|
isTrainedWordsLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove loading indicator
|
// Remove loading indicator
|
||||||
loadingIndicator.remove();
|
loadingIndicator.remove();
|
||||||
|
|
||||||
// Create and display suggestion dropdown
|
// Create and display suggestion dropdown
|
||||||
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
|
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
|
||||||
addForm.appendChild(dropdown);
|
addForm.appendChild(dropdown);
|
||||||
|
|
||||||
// Focus the input
|
// Focus the input
|
||||||
addForm.querySelector('input').focus();
|
addForm.querySelector('input').focus();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||||
this.title = translate('modals.model.triggerWords.edit');
|
this.title = translate('modals.model.triggerWords.edit');
|
||||||
|
|
||||||
// Hide edit controls and input form
|
// Hide edit controls and input form
|
||||||
editControls.style.display = 'none';
|
editControls.style.display = 'none';
|
||||||
addForm.style.display = 'none';
|
addForm.style.display = 'none';
|
||||||
|
|
||||||
// Check if we're exiting edit mode due to "Save" or "Cancel"
|
// Check if we're exiting edit mode due to "Save" or "Cancel"
|
||||||
if (!this.dataset.skipRestore) {
|
if (!this.dataset.skipRestore) {
|
||||||
// If canceling, restore original trigger words
|
// If canceling, restore original trigger words
|
||||||
@@ -348,7 +355,7 @@ export function setupTriggerWordsEditMode() {
|
|||||||
// Reset the skip restore flag
|
// Reset the skip restore flag
|
||||||
delete this.dataset.skipRestore;
|
delete this.dataset.skipRestore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have no trigger words, show the "No trigger word needed" text
|
// If we have no trigger words, show the "No trigger word needed" text
|
||||||
// and hide the empty tags container
|
// and hide the empty tags container
|
||||||
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||||
@@ -356,19 +363,19 @@ export function setupTriggerWordsEditMode() {
|
|||||||
noTriggerWords.style.display = '';
|
noTriggerWords.style.display = '';
|
||||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove dropdown if present
|
// Remove dropdown if present
|
||||||
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
|
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
|
||||||
if (dropdown) dropdown.remove();
|
if (dropdown) dropdown.remove();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up input for adding trigger words
|
// Set up input for adding trigger words
|
||||||
const triggerWordInput = document.querySelector('.metadata-input');
|
const triggerWordInput = document.querySelector('.metadata-input');
|
||||||
|
|
||||||
if (triggerWordInput) {
|
if (triggerWordInput) {
|
||||||
// Add keydown event to input
|
// Add keydown event to input
|
||||||
triggerWordInput.addEventListener('keydown', function(e) {
|
triggerWordInput.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addNewTriggerWord(this.value);
|
addNewTriggerWord(this.value);
|
||||||
@@ -376,13 +383,13 @@ export function setupTriggerWordsEditMode() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up save button
|
// Set up save button
|
||||||
const saveBtn = document.querySelector('.metadata-save-btn');
|
const saveBtn = document.querySelector('.metadata-save-btn');
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
saveBtn.addEventListener('click', saveTriggerWords);
|
saveBtn.addEventListener('click', saveTriggerWords);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up delete buttons
|
// Set up delete buttons
|
||||||
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
|
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
|
||||||
// Remove any existing listeners to avoid duplication
|
// Remove any existing listeners to avoid duplication
|
||||||
@@ -399,7 +406,7 @@ function deleteTriggerWord(e) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const tag = this.closest('.trigger-word-tag');
|
const tag = this.closest('.trigger-word-tag');
|
||||||
tag.remove();
|
tag.remove();
|
||||||
|
|
||||||
// Update status of items in the trained words dropdown
|
// Update status of items in the trained words dropdown
|
||||||
updateTrainedWordsDropdown();
|
updateTrainedWordsDropdown();
|
||||||
}
|
}
|
||||||
@@ -410,15 +417,15 @@ function deleteTriggerWord(e) {
|
|||||||
*/
|
*/
|
||||||
function resetTriggerWordsUIState(section) {
|
function resetTriggerWordsUIState(section) {
|
||||||
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
|
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
|
||||||
|
|
||||||
triggerWordTags.forEach(tag => {
|
triggerWordTags.forEach(tag => {
|
||||||
const word = tag.dataset.word;
|
const word = tag.dataset.word;
|
||||||
const copyIcon = tag.querySelector('.trigger-word-copy');
|
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||||
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
const deleteBtn = tag.querySelector('.metadata-delete-btn');
|
||||||
|
|
||||||
// Restore click-to-copy functionality
|
// Restore click-to-copy functionality
|
||||||
tag.onclick = () => copyTriggerWord(word);
|
tag.onclick = () => copyTriggerWord(tag.dataset.word);
|
||||||
|
|
||||||
// Show copy icon, hide delete button
|
// Show copy icon, hide delete button
|
||||||
if (copyIcon) copyIcon.style.display = '';
|
if (copyIcon) copyIcon.style.display = '';
|
||||||
if (deleteBtn) deleteBtn.style.display = 'none';
|
if (deleteBtn) deleteBtn.style.display = 'none';
|
||||||
@@ -433,30 +440,32 @@ function resetTriggerWordsUIState(section) {
|
|||||||
function restoreOriginalTriggerWords(section, originalWords) {
|
function restoreOriginalTriggerWords(section, originalWords) {
|
||||||
const tagsContainer = section.querySelector('.trigger-words-tags');
|
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||||
const noTriggerWords = section.querySelector('.no-trigger-words');
|
const noTriggerWords = section.querySelector('.no-trigger-words');
|
||||||
|
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
// Clear current tags
|
// Clear current tags
|
||||||
tagsContainer.innerHTML = '';
|
tagsContainer.innerHTML = '';
|
||||||
|
|
||||||
if (originalWords.length === 0) {
|
if (originalWords.length === 0) {
|
||||||
if (noTriggerWords) noTriggerWords.style.display = '';
|
if (noTriggerWords) noTriggerWords.style.display = '';
|
||||||
tagsContainer.style.display = 'none';
|
tagsContainer.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide "no trigger words" message
|
// Hide "no trigger words" message
|
||||||
if (noTriggerWords) noTriggerWords.style.display = 'none';
|
if (noTriggerWords) noTriggerWords.style.display = 'none';
|
||||||
tagsContainer.style.display = 'flex';
|
tagsContainer.style.display = 'flex';
|
||||||
|
|
||||||
// Recreate original tags
|
// Recreate original tags
|
||||||
originalWords.forEach(word => {
|
originalWords.forEach(word => {
|
||||||
const tag = document.createElement('div');
|
const tag = document.createElement('div');
|
||||||
tag.className = 'trigger-word-tag';
|
tag.className = 'trigger-word-tag';
|
||||||
tag.dataset.word = word;
|
tag.dataset.word = word;
|
||||||
tag.onclick = () => copyTriggerWord(word);
|
tag.onclick = () => copyTriggerWord(tag.dataset.word);
|
||||||
|
|
||||||
|
const escapedWord = escapeHtml(word);
|
||||||
tag.innerHTML = `
|
tag.innerHTML = `
|
||||||
<span class="trigger-word-content">${word}</span>
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy">
|
<span class="trigger-word-copy">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</span>
|
</span>
|
||||||
@@ -475,10 +484,10 @@ function restoreOriginalTriggerWords(section, originalWords) {
|
|||||||
function addNewTriggerWord(word) {
|
function addNewTriggerWord(word) {
|
||||||
word = word.trim();
|
word = word.trim();
|
||||||
if (!word) return;
|
if (!word) return;
|
||||||
|
|
||||||
const triggerWordsSection = document.querySelector('.trigger-words');
|
const triggerWordsSection = document.querySelector('.trigger-words');
|
||||||
let tagsContainer = document.querySelector('.trigger-words-tags');
|
let tagsContainer = document.querySelector('.trigger-words-tags');
|
||||||
|
|
||||||
// Ensure tags container exists and is visible
|
// Ensure tags container exists and is visible
|
||||||
if (tagsContainer) {
|
if (tagsContainer) {
|
||||||
tagsContainer.style.display = 'flex';
|
tagsContainer.style.display = 'flex';
|
||||||
@@ -491,41 +500,43 @@ function addNewTriggerWord(word) {
|
|||||||
contentDiv.appendChild(tagsContainer);
|
contentDiv.appendChild(tagsContainer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
// Hide "no trigger words" message if it exists
|
// Hide "no trigger words" message if it exists
|
||||||
const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words');
|
const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words');
|
||||||
if (noTriggerWordsMsg) {
|
if (noTriggerWordsMsg) {
|
||||||
noTriggerWordsMsg.style.display = 'none';
|
noTriggerWordsMsg.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check length
|
// Validation: Check length
|
||||||
if (word.split(/\s+/).length > 100) {
|
if (word.split(/\s+/).length > 100) {
|
||||||
showToast('toast.triggerWords.tooLong', {}, 'error');
|
showToast('toast.triggerWords.tooLong', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check total number
|
// Validation: Check total number
|
||||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||||
if (currentTags.length >= 30) {
|
if (currentTags.length >= 30) {
|
||||||
showToast('toast.triggerWords.tooMany', {}, 'error');
|
showToast('toast.triggerWords.tooMany', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check for duplicates
|
// Validation: Check for duplicates
|
||||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||||
if (existingWords.includes(word)) {
|
if (existingWords.includes(word)) {
|
||||||
showToast('toast.triggerWords.alreadyExists', {}, 'error');
|
showToast('toast.triggerWords.alreadyExists', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new tag
|
// Create new tag
|
||||||
const newTag = document.createElement('div');
|
const newTag = document.createElement('div');
|
||||||
newTag.className = 'trigger-word-tag';
|
newTag.className = 'trigger-word-tag';
|
||||||
newTag.dataset.word = word;
|
newTag.dataset.word = word;
|
||||||
|
|
||||||
|
const escapedWord = escapeHtml(word);
|
||||||
newTag.innerHTML = `
|
newTag.innerHTML = `
|
||||||
<span class="trigger-word-content">${word}</span>
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy" style="display:none;">
|
<span class="trigger-word-copy" style="display:none;">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</span>
|
</span>
|
||||||
@@ -533,13 +544,13 @@ function addNewTriggerWord(word) {
|
|||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add event listener to delete button
|
// Add event listener to delete button
|
||||||
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
|
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
|
||||||
deleteBtn.addEventListener('click', deleteTriggerWord);
|
deleteBtn.addEventListener('click', deleteTriggerWord);
|
||||||
|
|
||||||
tagsContainer.appendChild(newTag);
|
tagsContainer.appendChild(newTag);
|
||||||
|
|
||||||
// Update status of items in the trained words dropdown
|
// Update status of items in the trained words dropdown
|
||||||
updateTrainedWordsDropdown();
|
updateTrainedWordsDropdown();
|
||||||
}
|
}
|
||||||
@@ -550,19 +561,19 @@ function addNewTriggerWord(word) {
|
|||||||
function updateTrainedWordsDropdown() {
|
function updateTrainedWordsDropdown() {
|
||||||
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
|
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
|
||||||
if (!dropdown) return;
|
if (!dropdown) return;
|
||||||
|
|
||||||
// Get all current trigger words
|
// Get all current trigger words
|
||||||
const currentTags = document.querySelectorAll('.trigger-word-tag');
|
const currentTags = document.querySelectorAll('.trigger-word-tag');
|
||||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||||
|
|
||||||
// Update status of each item in dropdown
|
// Update status of each item in dropdown
|
||||||
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
|
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
|
||||||
const wordText = item.querySelector('.metadata-suggestion-text').textContent;
|
const wordText = item.querySelector('.metadata-suggestion-text').textContent;
|
||||||
const isAdded = existingWords.includes(wordText);
|
const isAdded = existingWords.includes(wordText);
|
||||||
|
|
||||||
if (isAdded) {
|
if (isAdded) {
|
||||||
item.classList.add('already-added');
|
item.classList.add('already-added');
|
||||||
|
|
||||||
// Add indicator if it doesn't exist
|
// Add indicator if it doesn't exist
|
||||||
let indicator = item.querySelector('.added-indicator');
|
let indicator = item.querySelector('.added-indicator');
|
||||||
if (!indicator) {
|
if (!indicator) {
|
||||||
@@ -572,27 +583,27 @@ function updateTrainedWordsDropdown() {
|
|||||||
indicator.innerHTML = '<i class="fas fa-check"></i>';
|
indicator.innerHTML = '<i class="fas fa-check"></i>';
|
||||||
meta.appendChild(indicator);
|
meta.appendChild(indicator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove click event
|
// Remove click event
|
||||||
item.onclick = null;
|
item.onclick = null;
|
||||||
} else {
|
} else {
|
||||||
// Re-enable items that are no longer in the list
|
// Re-enable items that are no longer in the list
|
||||||
item.classList.remove('already-added');
|
item.classList.remove('already-added');
|
||||||
|
|
||||||
// Remove indicator if it exists
|
// Remove indicator if it exists
|
||||||
const indicator = item.querySelector('.added-indicator');
|
const indicator = item.querySelector('.added-indicator');
|
||||||
if (indicator) indicator.remove();
|
if (indicator) indicator.remove();
|
||||||
|
|
||||||
// Restore click event if not already set
|
// Restore click event if not already set
|
||||||
if (!item.onclick) {
|
if (!item.onclick) {
|
||||||
item.onclick = () => {
|
item.onclick = () => {
|
||||||
const word = item.querySelector('.metadata-suggestion-text').textContent;
|
const word = item.querySelector('.metadata-suggestion-text').textContent;
|
||||||
addNewTriggerWord(word);
|
addNewTriggerWord(word);
|
||||||
|
|
||||||
// Also populate the input field
|
// Also populate the input field
|
||||||
const input = document.querySelector('.metadata-input');
|
const input = document.querySelector('.metadata-input');
|
||||||
if (input) input.value = word;
|
if (input) input.value = word;
|
||||||
|
|
||||||
// Focus the input
|
// Focus the input
|
||||||
if (input) input.focus();
|
if (input) input.focus();
|
||||||
};
|
};
|
||||||
@@ -610,19 +621,19 @@ async function saveTriggerWords() {
|
|||||||
const triggerWordsSection = editBtn.closest('.trigger-words');
|
const triggerWordsSection = editBtn.closest('.trigger-words');
|
||||||
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||||
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Special format for updating nested civitai.trainedWords
|
// Special format for updating nested civitai.trainedWords
|
||||||
await getModelApiClient().saveModelMetadata(filePath, {
|
await getModelApiClient().saveModelMetadata(filePath, {
|
||||||
civitai: { trainedWords: words }
|
civitai: { trainedWords: words }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set flag to skip restoring original words when exiting edit mode
|
// Set flag to skip restoring original words when exiting edit mode
|
||||||
editBtn.dataset.skipRestore = "true";
|
editBtn.dataset.skipRestore = "true";
|
||||||
|
|
||||||
// Exit edit mode without restoring original trigger words
|
// Exit edit mode without restoring original trigger words
|
||||||
editBtn.click();
|
editBtn.click();
|
||||||
|
|
||||||
// If we saved an empty array and there's a no-trigger-words element, show it
|
// If we saved an empty array and there's a no-trigger-words element, show it
|
||||||
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
||||||
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
||||||
@@ -630,7 +641,7 @@ async function saveTriggerWords() {
|
|||||||
noTriggerWords.style.display = '';
|
noTriggerWords.style.display = '';
|
||||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('toast.triggerWords.updateSuccess', {}, 'success');
|
showToast('toast.triggerWords.updateSuccess', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving trigger words:', error);
|
console.error('Error saving trigger words:', error);
|
||||||
@@ -642,7 +653,7 @@ async function saveTriggerWords() {
|
|||||||
* Copy a trigger word to clipboard
|
* Copy a trigger word to clipboard
|
||||||
* @param {string} word - Word to copy
|
* @param {string} word - Word to copy
|
||||||
*/
|
*/
|
||||||
window.copyTriggerWord = async function(word) {
|
window.copyTriggerWord = async function (word) {
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(word, 'Trigger word copied');
|
await copyToClipboard(word, 'Trigger word copied');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -60,14 +60,12 @@ export class AppCore {
|
|||||||
initTheme();
|
initTheme();
|
||||||
initBackToTop();
|
initBackToTop();
|
||||||
|
|
||||||
// Initialize the bulk manager and context menu only if not on recipes page
|
// Initialize the bulk manager and context menu
|
||||||
if (state.currentPageType !== 'recipes') {
|
bulkManager.initialize();
|
||||||
bulkManager.initialize();
|
|
||||||
|
|
||||||
// Initialize bulk context menu
|
// Initialize bulk context menu
|
||||||
const bulkContextMenu = new BulkContextMenu();
|
const bulkContextMenu = new BulkContextMenu();
|
||||||
bulkManager.setBulkContextMenu(bulkContextMenu);
|
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the example images manager
|
// Initialize the example images manager
|
||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
@@ -84,10 +82,7 @@ export class AppCore {
|
|||||||
|
|
||||||
// Start onboarding if needed (after everything is initialized)
|
// Start onboarding if needed (after everything is initialized)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Do not show onboarding if version-mismatch banner is visible
|
onboardingManager.start();
|
||||||
if (!bannerService.isBannerVisible('version-mismatch')) {
|
|
||||||
onboardingManager.start();
|
|
||||||
}
|
|
||||||
}, 1000); // Small delay to ensure all elements are rendered
|
}, 1000); // Small delay to ensure all elements are rendered
|
||||||
|
|
||||||
// Return the core instance for chaining
|
// Return the core instance for chaining
|
||||||
@@ -124,4 +119,4 @@ export class AppCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create and export a singleton instance
|
// Create and export a singleton instance
|
||||||
export const appCore = new AppCore();
|
export const appCore = new AppCore();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const AFDIAN_URL = 'https://afdian.com/a/pixelpawsai';
|
|||||||
const BANNER_HISTORY_KEY = 'banner_history';
|
const BANNER_HISTORY_KEY = 'banner_history';
|
||||||
const BANNER_HISTORY_VIEWED_AT_KEY = 'banner_history_viewed_at';
|
const BANNER_HISTORY_VIEWED_AT_KEY = 'banner_history_viewed_at';
|
||||||
const BANNER_HISTORY_LIMIT = 20;
|
const BANNER_HISTORY_LIMIT = 20;
|
||||||
const HISTORY_EXCLUDED_IDS = new Set(['version-mismatch']);
|
const HISTORY_EXCLUDED_IDS = new Set([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Banner Service for managing notification banners
|
* Banner Service for managing notification banners
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
|||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
|
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
@@ -62,9 +63,22 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: 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', () => {
|
window.addEventListener('lm:priority-tags-updated', () => {
|
||||||
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
|
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -87,9 +101,6 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
// Do not initialize on recipes page
|
|
||||||
if (state.currentPageType === 'recipes') return;
|
|
||||||
|
|
||||||
// Register with event manager for coordinated event handling
|
// Register with event manager for coordinated event handling
|
||||||
this.registerEventHandlers();
|
this.registerEventHandlers();
|
||||||
|
|
||||||
@@ -97,6 +108,23 @@ export class BulkManager {
|
|||||||
eventManager.setState('bulkMode', state.bulkMode || false);
|
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) {
|
setBulkContextMenu(bulkContextMenu) {
|
||||||
this.bulkContextMenu = bulkContextMenu;
|
this.bulkContextMenu = bulkContextMenu;
|
||||||
}
|
}
|
||||||
@@ -240,7 +268,9 @@ export class BulkManager {
|
|||||||
// Update event manager state
|
// Update event manager state
|
||||||
eventManager.setState('bulkMode', state.bulkMode);
|
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);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
@@ -504,13 +534,13 @@ export class BulkManager {
|
|||||||
modalManager.closeModal('bulkDeleteModal');
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this.getActiveApiClient();
|
||||||
const filePaths = Array.from(state.selectedModels);
|
const filePaths = Array.from(state.selectedModels);
|
||||||
|
|
||||||
const result = await apiClient.bulkDeleteModels(filePaths);
|
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.deletedSuccessfully', {
|
showToast('toast.models.deletedSuccessfully', {
|
||||||
count: result.deleted_count,
|
count: result.deleted_count,
|
||||||
type: currentConfig.displayName.toLowerCase()
|
type: currentConfig.displayName.toLowerCase()
|
||||||
@@ -570,7 +600,7 @@ export class BulkManager {
|
|||||||
this.applySelectionState();
|
this.applySelectionState();
|
||||||
|
|
||||||
const newlySelected = state.selectedModels.size - oldCount;
|
const newlySelected = state.selectedModels.size - oldCount;
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.selectedAdditional', {
|
showToast('toast.models.selectedAdditional', {
|
||||||
count: newlySelected,
|
count: newlySelected,
|
||||||
type: currentConfig.displayName.toLowerCase()
|
type: currentConfig.displayName.toLowerCase()
|
||||||
@@ -622,8 +652,7 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentType = state.currentPageType;
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA];
|
|
||||||
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
||||||
|
|
||||||
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
||||||
@@ -969,7 +998,7 @@ export class BulkManager {
|
|||||||
modalManager.closeModal('bulkAddTagsModal');
|
modalManager.closeModal('bulkAddTagsModal');
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
||||||
showToast(toastKey, {
|
showToast(toastKey, {
|
||||||
count: successCount,
|
count: successCount,
|
||||||
|
|||||||
@@ -3,32 +3,33 @@ import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
|||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
export class FilterManager {
|
export class FilterManager {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.options = {
|
this.options = {
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
this.currentPage = options.page || document.body.dataset.page || 'loras';
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
this.filters = this.initializeFilters(pageState ? pageState.filters : undefined);
|
this.filters = this.initializeFilters(pageState ? pageState.filters : undefined);
|
||||||
|
|
||||||
this.filterPanel = document.getElementById('filterPanel');
|
this.filterPanel = document.getElementById('filterPanel');
|
||||||
this.filterButton = document.getElementById('filterButton');
|
this.filterButton = document.getElementById('filterButton');
|
||||||
this.activeFiltersCount = document.getElementById('activeFiltersCount');
|
this.activeFiltersCount = document.getElementById('activeFiltersCount');
|
||||||
this.tagsLoaded = false;
|
this.tagsLoaded = false;
|
||||||
|
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
// Store this instance in the state
|
// Store this instance in the state
|
||||||
if (pageState) {
|
if (pageState) {
|
||||||
pageState.filterManager = this;
|
pageState.filterManager = this;
|
||||||
pageState.filters = this.cloneFilters();
|
pageState.filters = this.cloneFilters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
// Create base model filter tags if they exist
|
// Create base model filter tags if they exist
|
||||||
if (document.getElementById('baseModelTags')) {
|
if (document.getElementById('baseModelTags')) {
|
||||||
@@ -50,39 +51,39 @@ export class FilterManager {
|
|||||||
this.toggleFilterPanel();
|
this.toggleFilterPanel();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close filter panel when clicking outside
|
// Close filter panel when clicking outside
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
|
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
|
||||||
e.target !== this.filterButton &&
|
e.target !== this.filterButton &&
|
||||||
!this.filterButton.contains(e.target) &&
|
!this.filterButton.contains(e.target) &&
|
||||||
!this.filterPanel.classList.contains('hidden')) {
|
!this.filterPanel.classList.contains('hidden')) {
|
||||||
this.closeFilterPanel();
|
this.closeFilterPanel();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize active filters from localStorage if available
|
// Initialize active filters from localStorage if available
|
||||||
this.loadFiltersFromStorage();
|
this.loadFiltersFromStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadTopTags() {
|
async loadTopTags() {
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||||
|
|
||||||
// Determine the API endpoint based on the page type
|
// Determine the API endpoint based on the page type
|
||||||
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
|
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
|
||||||
|
|
||||||
const response = await fetch(tagsEndpoint);
|
const response = await fetch(tagsEndpoint);
|
||||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.tags) {
|
if (data.success && data.tags) {
|
||||||
this.createTagFilterElements(data.tags);
|
this.createTagFilterElements(data.tags);
|
||||||
|
|
||||||
// After creating tag elements, mark any previously selected ones
|
// After creating tag elements, mark any previously selected ones
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
} else {
|
} else {
|
||||||
@@ -96,57 +97,79 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createTagFilterElements(tags) {
|
createTagFilterElements(tags) {
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
tagsContainer.innerHTML = '';
|
tagsContainer.innerHTML = '';
|
||||||
|
|
||||||
if (!tags.length) {
|
if (!tags.length) {
|
||||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const tagEl = document.createElement('div');
|
const tagEl = document.createElement('div');
|
||||||
tagEl.className = 'filter-tag tag-filter';
|
tagEl.className = 'filter-tag tag-filter';
|
||||||
const tagName = tag.tag;
|
const tagName = tag.tag;
|
||||||
tagEl.dataset.tag = tagName;
|
tagEl.dataset.tag = tagName;
|
||||||
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
||||||
|
|
||||||
// Add click handler to cycle through tri-state filter and automatically apply
|
// Add click handler to cycle through tri-state filter and automatically apply
|
||||||
tagEl.addEventListener('click', async () => {
|
tagEl.addEventListener('click', async () => {
|
||||||
const currentState = (this.filters.tags && this.filters.tags[tagName]) || 'none';
|
const currentState = (this.filters.tags && this.filters.tags[tagName]) || 'none';
|
||||||
const newState = this.getNextTriStateState(currentState);
|
const newState = this.getNextTriStateState(currentState);
|
||||||
this.setTagFilterState(tagName, newState);
|
this.setTagFilterState(tagName, newState);
|
||||||
this.applyTagElementState(tagEl, newState);
|
this.applyTagElementState(tagEl, newState);
|
||||||
|
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Auto-apply filter when tag is clicked
|
// Auto-apply filter when tag is clicked
|
||||||
await this.applyFilters(false);
|
await this.applyFilters(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
|
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
|
||||||
tagsContainer.appendChild(tagEl);
|
tagsContainer.appendChild(tagEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add "No tags" as a special filter at the end
|
||||||
|
const noTagsEl = document.createElement('div');
|
||||||
|
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||||
|
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||||
|
const noTagsKey = '__no_tags__';
|
||||||
|
noTagsEl.dataset.tag = noTagsKey;
|
||||||
|
noTagsEl.innerHTML = noTagsLabel;
|
||||||
|
|
||||||
|
noTagsEl.addEventListener('click', async () => {
|
||||||
|
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
||||||
|
const newState = this.getNextTriStateState(currentState);
|
||||||
|
this.setTagFilterState(noTagsKey, newState);
|
||||||
|
this.applyTagElementState(noTagsEl, newState);
|
||||||
|
|
||||||
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
|
await this.applyFilters(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none');
|
||||||
|
tagsContainer.appendChild(noTagsEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeLicenseFilters() {
|
initializeLicenseFilters() {
|
||||||
const licenseTags = document.querySelectorAll('.license-tag');
|
const licenseTags = document.querySelectorAll('.license-tag');
|
||||||
licenseTags.forEach(tag => {
|
licenseTags.forEach(tag => {
|
||||||
tag.addEventListener('click', async () => {
|
tag.addEventListener('click', async () => {
|
||||||
const licenseType = tag.dataset.license;
|
const licenseType = tag.dataset.license;
|
||||||
|
|
||||||
// Ensure license object exists
|
// Ensure license object exists
|
||||||
if (!this.filters.license) {
|
if (!this.filters.license) {
|
||||||
this.filters.license = {};
|
this.filters.license = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current state
|
// Get current state
|
||||||
let currentState = this.filters.license[licenseType] || 'none'; // none, include, exclude
|
let currentState = this.filters.license[licenseType] || 'none'; // none, include, exclude
|
||||||
|
|
||||||
// Cycle through states: none -> include -> exclude -> none
|
// Cycle through states: none -> include -> exclude -> none
|
||||||
let newState;
|
let newState;
|
||||||
switch (currentState) {
|
switch (currentState) {
|
||||||
@@ -165,7 +188,7 @@ export class FilterManager {
|
|||||||
tag.classList.remove('active', 'exclude');
|
tag.classList.remove('active', 'exclude');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update filter state
|
// Update filter state
|
||||||
if (newState === 'none') {
|
if (newState === 'none') {
|
||||||
delete this.filters.license[licenseType];
|
delete this.filters.license[licenseType];
|
||||||
@@ -176,27 +199,27 @@ export class FilterManager {
|
|||||||
} else {
|
} else {
|
||||||
this.filters.license[licenseType] = newState;
|
this.filters.license[licenseType] = newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Auto-apply filter when tag is clicked
|
// Auto-apply filter when tag is clicked
|
||||||
await this.applyFilters(false);
|
await this.applyFilters(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update selections based on stored filters
|
// Update selections based on stored filters
|
||||||
this.updateLicenseSelections();
|
this.updateLicenseSelections();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLicenseSelections() {
|
updateLicenseSelections() {
|
||||||
const licenseTags = document.querySelectorAll('.license-tag');
|
const licenseTags = document.querySelectorAll('.license-tag');
|
||||||
licenseTags.forEach(tag => {
|
licenseTags.forEach(tag => {
|
||||||
const licenseType = tag.dataset.license;
|
const licenseType = tag.dataset.license;
|
||||||
const state = (this.filters.license && this.filters.license[licenseType]) || 'none';
|
const state = (this.filters.license && this.filters.license[licenseType]) || 'none';
|
||||||
|
|
||||||
// Reset classes
|
// Reset classes
|
||||||
tag.classList.remove('active', 'exclude');
|
tag.classList.remove('active', 'exclude');
|
||||||
|
|
||||||
// Apply appropriate class based on state
|
// Apply appropriate class based on state
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'include':
|
case 'include':
|
||||||
@@ -211,31 +234,31 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createBaseModelTags() {
|
createBaseModelTags() {
|
||||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||||
if (!baseModelTagsContainer) return;
|
if (!baseModelTagsContainer) return;
|
||||||
|
|
||||||
// Set the API endpoint based on current page
|
// Set the API endpoint based on current page
|
||||||
const apiEndpoint = `/api/lm/${this.currentPage}/base-models`;
|
const apiEndpoint = `/api/lm/${this.currentPage}/base-models`;
|
||||||
|
|
||||||
// Fetch base models
|
// Fetch base models
|
||||||
fetch(apiEndpoint)
|
fetch(apiEndpoint)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.base_models) {
|
if (data.success && data.base_models) {
|
||||||
baseModelTagsContainer.innerHTML = '';
|
baseModelTagsContainer.innerHTML = '';
|
||||||
|
|
||||||
data.base_models.forEach(model => {
|
data.base_models.forEach(model => {
|
||||||
const tag = document.createElement('div');
|
const tag = document.createElement('div');
|
||||||
tag.className = `filter-tag base-model-tag`;
|
tag.className = `filter-tag base-model-tag`;
|
||||||
tag.dataset.baseModel = model.name;
|
tag.dataset.baseModel = model.name;
|
||||||
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
||||||
|
|
||||||
// Add click handler to toggle selection and automatically apply
|
// Add click handler to toggle selection and automatically apply
|
||||||
tag.addEventListener('click', async () => {
|
tag.addEventListener('click', async () => {
|
||||||
tag.classList.toggle('active');
|
tag.classList.toggle('active');
|
||||||
|
|
||||||
if (tag.classList.contains('active')) {
|
if (tag.classList.contains('active')) {
|
||||||
if (!this.filters.baseModel.includes(model.name)) {
|
if (!this.filters.baseModel.includes(model.name)) {
|
||||||
this.filters.baseModel.push(model.name);
|
this.filters.baseModel.push(model.name);
|
||||||
@@ -243,24 +266,24 @@ export class FilterManager {
|
|||||||
} else {
|
} else {
|
||||||
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Auto-apply filter when tag is clicked
|
// Auto-apply filter when tag is clicked
|
||||||
await this.applyFilters(false);
|
await this.applyFilters(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
baseModelTagsContainer.appendChild(tag);
|
baseModelTagsContainer.appendChild(tag);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update selections based on stored filters
|
// Update selections based on stored filters
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(`Error fetching base models for ${this.currentPage}:`, error);
|
console.error(`Error fetching base models for ${this.currentPage}:`, error);
|
||||||
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
|
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createModelTypeTags() {
|
async createModelTypeTags() {
|
||||||
@@ -336,18 +359,18 @@ export class FilterManager {
|
|||||||
modelTypeContainer.innerHTML = '<div class="tags-error">Failed to load model types</div>';
|
modelTypeContainer.innerHTML = '<div class="tags-error">Failed to load model types</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFilterPanel() {
|
toggleFilterPanel() {
|
||||||
if (this.filterPanel) {
|
if (this.filterPanel) {
|
||||||
const isHidden = this.filterPanel.classList.contains('hidden');
|
const isHidden = this.filterPanel.classList.contains('hidden');
|
||||||
|
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
// Update panel positions before showing
|
// Update panel positions before showing
|
||||||
updatePanelPositions();
|
updatePanelPositions();
|
||||||
|
|
||||||
this.filterPanel.classList.remove('hidden');
|
this.filterPanel.classList.remove('hidden');
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
|
|
||||||
// Load tags if they haven't been loaded yet
|
// Load tags if they haven't been loaded yet
|
||||||
if (!this.tagsLoaded) {
|
if (!this.tagsLoaded) {
|
||||||
this.loadTopTags();
|
this.loadTopTags();
|
||||||
@@ -358,7 +381,7 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeFilterPanel() {
|
closeFilterPanel() {
|
||||||
if (this.filterPanel) {
|
if (this.filterPanel) {
|
||||||
this.filterPanel.classList.add('hidden');
|
this.filterPanel.classList.add('hidden');
|
||||||
@@ -367,7 +390,7 @@ export class FilterManager {
|
|||||||
this.filterButton.classList.remove('active');
|
this.filterButton.classList.remove('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTagSelections() {
|
updateTagSelections() {
|
||||||
// Update base model tags
|
// Update base model tags
|
||||||
const baseModelTags = document.querySelectorAll('.base-model-tag');
|
const baseModelTags = document.querySelectorAll('.base-model-tag');
|
||||||
@@ -379,7 +402,7 @@ export class FilterManager {
|
|||||||
tag.classList.remove('active');
|
tag.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update model tags
|
// Update model tags
|
||||||
const modelTags = document.querySelectorAll('.tag-filter');
|
const modelTags = document.querySelectorAll('.tag-filter');
|
||||||
modelTags.forEach(tag => {
|
modelTags.forEach(tag => {
|
||||||
@@ -387,7 +410,7 @@ export class FilterManager {
|
|||||||
const state = (this.filters.tags && this.filters.tags[tagName]) || 'none';
|
const state = (this.filters.tags && this.filters.tags[tagName]) || 'none';
|
||||||
this.applyTagElementState(tag, state);
|
this.applyTagElementState(tag, state);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update license tags if visible on this page
|
// Update license tags if visible on this page
|
||||||
if (this.shouldShowLicenseFilters()) {
|
if (this.shouldShowLicenseFilters()) {
|
||||||
this.updateLicenseSelections();
|
this.updateLicenseSelections();
|
||||||
@@ -406,13 +429,13 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateActiveFiltersCount() {
|
updateActiveFiltersCount() {
|
||||||
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||||
const modelTypeFilterCount = this.filters.modelTypes.length;
|
const modelTypeFilterCount = this.filters.modelTypes.length;
|
||||||
const totalActiveFilters = this.filters.baseModel.length + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
const totalActiveFilters = this.filters.baseModel.length + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||||
|
|
||||||
if (this.activeFiltersCount) {
|
if (this.activeFiltersCount) {
|
||||||
if (totalActiveFilters > 0) {
|
if (totalActiveFilters > 0) {
|
||||||
this.activeFiltersCount.textContent = totalActiveFilters;
|
this.activeFiltersCount.textContent = totalActiveFilters;
|
||||||
@@ -422,18 +445,18 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyFilters(showToastNotification = true) {
|
async applyFilters(showToastNotification = true) {
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
const storageKey = `${this.currentPage}_filters`;
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
|
|
||||||
// Save filters to localStorage
|
// Save filters to localStorage
|
||||||
const filtersSnapshot = this.cloneFilters();
|
const filtersSnapshot = this.cloneFilters();
|
||||||
setStorageItem(storageKey, filtersSnapshot);
|
setStorageItem(storageKey, filtersSnapshot);
|
||||||
|
|
||||||
// Update state with current filters
|
// Update state with current filters
|
||||||
pageState.filters = filtersSnapshot;
|
pageState.filters = filtersSnapshot;
|
||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes(true);
|
||||||
@@ -441,14 +464,14 @@ export class FilterManager {
|
|||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update filter button to show active state
|
// Update filter button to show active state
|
||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
if (showToastNotification) {
|
if (showToastNotification) {
|
||||||
const baseModelCount = this.filters.baseModel.length;
|
const baseModelCount = this.filters.baseModel.length;
|
||||||
const tagsCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagsCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
|
|
||||||
let message = '';
|
let message = '';
|
||||||
if (baseModelCount > 0 && tagsCount > 0) {
|
if (baseModelCount > 0 && tagsCount > 0) {
|
||||||
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||||
@@ -457,7 +480,7 @@ export class FilterManager {
|
|||||||
} else if (tagsCount > 0) {
|
} else if (tagsCount > 0) {
|
||||||
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('toast.filters.applied', { message }, 'success');
|
showToast('toast.filters.applied', { message }, 'success');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -467,7 +490,7 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearFilters() {
|
async clearFilters() {
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
this.filters = this.initializeFilters({
|
this.filters = this.initializeFilters({
|
||||||
@@ -477,52 +500,52 @@ export class FilterManager {
|
|||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: []
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.filters = this.cloneFilters();
|
pageState.filters = this.cloneFilters();
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
// Remove from local Storage
|
// Remove from local Storage
|
||||||
const storageKey = `${this.currentPage}_filters`;
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
removeStorageItem(storageKey);
|
removeStorageItem(storageKey);
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
this.filterButton.classList.remove('active');
|
this.filterButton.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('toast.filters.cleared', {}, 'info');
|
showToast('toast.filters.cleared', {}, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFiltersFromStorage() {
|
loadFiltersFromStorage() {
|
||||||
const storageKey = `${this.currentPage}_filters`;
|
const storageKey = `${this.currentPage}_filters`;
|
||||||
const savedFilters = getStorageItem(storageKey);
|
const savedFilters = getStorageItem(storageKey);
|
||||||
|
|
||||||
if (savedFilters) {
|
if (savedFilters) {
|
||||||
try {
|
try {
|
||||||
// Ensure backward compatibility with older filter format
|
// Ensure backward compatibility with older filter format
|
||||||
this.filters = this.initializeFilters(savedFilters);
|
this.filters = this.initializeFilters(savedFilters);
|
||||||
|
|
||||||
// Update state with loaded filters
|
// Update state with loaded filters
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.filters = this.cloneFilters();
|
pageState.filters = this.cloneFilters();
|
||||||
|
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|
||||||
if (this.hasActiveFilters()) {
|
if (this.hasActiveFilters()) {
|
||||||
this.filterButton.classList.add('active');
|
this.filterButton.classList.add('active');
|
||||||
}
|
}
|
||||||
@@ -531,7 +554,7 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasActiveFilters() {
|
hasActiveFilters() {
|
||||||
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { state, getCurrentPageState } from '../state/index.js';
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { bulkManager } from './BulkManager.js';
|
import { bulkManager } from './BulkManager.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { sidebarManager } from '../components/SidebarManager.js';
|
import { sidebarManager } from '../components/SidebarManager.js';
|
||||||
|
|
||||||
@@ -12,11 +13,22 @@ class MoveManager {
|
|||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
this.folderTreeManager = new FolderTreeManager();
|
this.folderTreeManager = new FolderTreeManager();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
this.recipeApiClient = null;
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.updateTargetPath = this.updateTargetPath.bind(this);
|
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() {
|
initializeEventListeners() {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
@@ -36,7 +48,7 @@ class MoveManager {
|
|||||||
this.currentFilePath = null;
|
this.currentFilePath = null;
|
||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
|
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient(modelType);
|
||||||
const currentPageType = state.currentPageType;
|
const currentPageType = state.currentPageType;
|
||||||
const modelConfig = apiClient.apiConfig.config;
|
const modelConfig = apiClient.apiConfig.config;
|
||||||
|
|
||||||
@@ -121,7 +133,7 @@ class MoveManager {
|
|||||||
|
|
||||||
async initializeFolderTree() {
|
async initializeFolderTree() {
|
||||||
try {
|
try {
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
// Fetch unified folder tree
|
// Fetch unified folder tree
|
||||||
const treeData = await apiClient.fetchUnifiedFolderTree();
|
const treeData = await apiClient.fetchUnifiedFolderTree();
|
||||||
|
|
||||||
@@ -141,7 +153,7 @@ class MoveManager {
|
|||||||
updateTargetPath() {
|
updateTargetPath() {
|
||||||
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||||
const modelRoot = document.getElementById('moveModelRoot').value;
|
const modelRoot = document.getElementById('moveModelRoot').value;
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
const config = apiClient.apiConfig.config;
|
const config = apiClient.apiConfig.config;
|
||||||
|
|
||||||
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
||||||
@@ -158,7 +170,7 @@ class MoveManager {
|
|||||||
|
|
||||||
async moveModel() {
|
async moveModel() {
|
||||||
const selectedRoot = document.getElementById('moveModelRoot').value;
|
const selectedRoot = document.getElementById('moveModelRoot').value;
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
const config = apiClient.apiConfig.config;
|
const config = apiClient.apiConfig.config;
|
||||||
|
|
||||||
if (!selectedRoot) {
|
if (!selectedRoot) {
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import {
|
|||||||
setStorageItem,
|
setStorageItem,
|
||||||
getStoredVersionInfo,
|
getStoredVersionInfo,
|
||||||
setStoredVersionInfo,
|
setStoredVersionInfo,
|
||||||
isVersionMatch,
|
isVersionMatch
|
||||||
resetDismissedBanner
|
|
||||||
} from '../utils/storageHelpers.js';
|
} from '../utils/storageHelpers.js';
|
||||||
import { bannerService } from './BannerService.js';
|
import { bannerService } from './BannerService.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
@@ -753,94 +752,14 @@ export class UpdateService {
|
|||||||
stored: getStoredVersionInfo()
|
stored: getStoredVersionInfo()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset dismissed status for version mismatch banner
|
// Silently update stored version info as cache busting handles the resource updates
|
||||||
resetDismissedBanner('version-mismatch');
|
setStoredVersionInfo(this.currentVersionInfo);
|
||||||
|
|
||||||
// Register and show the version mismatch banner
|
|
||||||
this.registerVersionMismatchBanner();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check version info:', error);
|
console.error('Failed to check version info:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerVersionMismatchBanner() {
|
|
||||||
// Get stored and current version for display
|
|
||||||
const storedVersion = getStoredVersionInfo() || translate('common.status.unknown');
|
|
||||||
const currentVersion = this.currentVersionInfo || translate('common.status.unknown');
|
|
||||||
|
|
||||||
bannerService.registerBanner('version-mismatch', {
|
|
||||||
id: 'version-mismatch',
|
|
||||||
title: translate('banners.versionMismatch.title', {}, 'Application Update Detected'),
|
|
||||||
content: translate('banners.versionMismatch.content', {
|
|
||||||
storedVersion,
|
|
||||||
currentVersion
|
|
||||||
}, `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`),
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
text: translate('banners.versionMismatch.refreshNow', {}, 'Refresh Now'),
|
|
||||||
icon: 'fas fa-sync',
|
|
||||||
action: 'hardRefresh',
|
|
||||||
type: 'primary'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
dismissible: false,
|
|
||||||
priority: 10,
|
|
||||||
countdown: 15,
|
|
||||||
onRegister: (bannerElement) => {
|
|
||||||
// Add countdown element
|
|
||||||
const countdownEl = document.createElement('div');
|
|
||||||
countdownEl.className = 'banner-countdown';
|
|
||||||
countdownEl.innerHTML = `<span>${translate('banners.versionMismatch.refreshingIn', {}, 'Refreshing in')} <strong>15</strong> ${translate('banners.versionMismatch.seconds', {}, 'seconds')}...</span>`;
|
|
||||||
bannerElement.querySelector('.banner-content').appendChild(countdownEl);
|
|
||||||
|
|
||||||
// Start countdown
|
|
||||||
let seconds = 15;
|
|
||||||
const countdownInterval = setInterval(() => {
|
|
||||||
seconds--;
|
|
||||||
const strongEl = countdownEl.querySelector('strong');
|
|
||||||
if (strongEl) strongEl.textContent = seconds;
|
|
||||||
|
|
||||||
if (seconds <= 0) {
|
|
||||||
clearInterval(countdownInterval);
|
|
||||||
this.performHardRefresh();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// Store interval ID for cleanup
|
|
||||||
bannerElement.dataset.countdownInterval = countdownInterval;
|
|
||||||
|
|
||||||
// Add action button event handler
|
|
||||||
const actionBtn = bannerElement.querySelector('.banner-action[data-action="hardRefresh"]');
|
|
||||||
if (actionBtn) {
|
|
||||||
actionBtn.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
clearInterval(countdownInterval);
|
|
||||||
this.performHardRefresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRemove: (bannerElement) => {
|
|
||||||
// Clear any existing interval
|
|
||||||
const intervalId = bannerElement.dataset.countdownInterval;
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(parseInt(intervalId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
performHardRefresh() {
|
|
||||||
// Update stored version info before refreshing
|
|
||||||
setStoredVersionInfo(this.currentVersionInfo);
|
|
||||||
|
|
||||||
// Force a hard refresh by adding cache-busting parameter
|
|
||||||
const cacheBuster = new Date().getTime();
|
|
||||||
window.location.href = window.location.pathname +
|
|
||||||
(window.location.search ? window.location.search + '&' : '?') +
|
|
||||||
`cache=${cacheBuster}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and export singleton instance
|
// Create and export singleton instance
|
||||||
|
|||||||
@@ -12,21 +12,21 @@ export class DownloadManager {
|
|||||||
async saveRecipe() {
|
async saveRecipe() {
|
||||||
// Check if we're in download-only mode (for existing recipe)
|
// Check if we're in download-only mode (for existing recipe)
|
||||||
const isDownloadOnly = !!this.importManager.recipeId;
|
const isDownloadOnly = !!this.importManager.recipeId;
|
||||||
|
|
||||||
if (!isDownloadOnly && !this.importManager.recipeName) {
|
if (!isDownloadOnly && !this.importManager.recipeName) {
|
||||||
showToast('toast.recipes.enterRecipeName', {}, 'error');
|
showToast('toast.recipes.enterRecipeName', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show progress indicator
|
// Show progress indicator
|
||||||
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
|
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
|
||||||
|
|
||||||
// Only send the complete recipe to save if not in download-only mode
|
// Only send the complete recipe to save if not in download-only mode
|
||||||
if (!isDownloadOnly) {
|
if (!isDownloadOnly) {
|
||||||
// Create FormData object for saving recipe
|
// Create FormData object for saving recipe
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
// Add image data - depends on import mode
|
// Add image data - depends on import mode
|
||||||
if (this.importManager.recipeImage) {
|
if (this.importManager.recipeImage) {
|
||||||
// Direct upload
|
// Direct upload
|
||||||
@@ -45,10 +45,10 @@ export class DownloadManager {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error('No image data available');
|
throw new Error('No image data available');
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.append('name', this.importManager.recipeName);
|
formData.append('name', this.importManager.recipeName);
|
||||||
formData.append('tags', JSON.stringify(this.importManager.recipeTags));
|
formData.append('tags', JSON.stringify(this.importManager.recipeTags));
|
||||||
|
|
||||||
// Prepare complete metadata including generation parameters
|
// Prepare complete metadata including generation parameters
|
||||||
const completeMetadata = {
|
const completeMetadata = {
|
||||||
base_model: this.importManager.recipeData.base_model || "",
|
base_model: this.importManager.recipeData.base_model || "",
|
||||||
@@ -65,7 +65,11 @@ export class DownloadManager {
|
|||||||
if (checkpointMetadata && typeof checkpointMetadata === 'object') {
|
if (checkpointMetadata && typeof checkpointMetadata === 'object') {
|
||||||
completeMetadata.checkpoint = checkpointMetadata;
|
completeMetadata.checkpoint = checkpointMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.importManager.recipeData && this.importManager.recipeData.extension) {
|
||||||
|
formData.append('extension', this.importManager.recipeData.extension);
|
||||||
|
}
|
||||||
|
|
||||||
// Add source_path to metadata to track where the recipe was imported from
|
// Add source_path to metadata to track where the recipe was imported from
|
||||||
if (this.importManager.importMode === 'url') {
|
if (this.importManager.importMode === 'url') {
|
||||||
const urlInput = document.getElementById('imageUrlInput');
|
const urlInput = document.getElementById('imageUrlInput');
|
||||||
@@ -73,15 +77,15 @@ export class DownloadManager {
|
|||||||
completeMetadata.source_path = urlInput.value;
|
completeMetadata.source_path = urlInput.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.append('metadata', JSON.stringify(completeMetadata));
|
formData.append('metadata', JSON.stringify(completeMetadata));
|
||||||
|
|
||||||
// Send save request
|
// Send save request
|
||||||
const response = await fetch('/api/lm/recipes/save', {
|
const response = await fetch('/api/lm/recipes/save', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -102,19 +106,19 @@ export class DownloadManager {
|
|||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
if (isDownloadOnly) {
|
if (isDownloadOnly) {
|
||||||
if (failedDownloads === 0) {
|
if (failedDownloads === 0) {
|
||||||
showToast('toast.loras.downloadSuccessful', {}, 'success');
|
showToast('toast.loras.downloadSuccessful', {}, 'success');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
|
showToast('toast.recipes.nameSaved', { name: this.importManager.recipeName }, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
// Refresh the recipe
|
||||||
window.recipeManager.loadRecipes();
|
window.recipeManager.loadRecipes();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
showToast('toast.recipes.processingError', { message: error.message }, 'error');
|
showToast('toast.recipes.processingError', { message: error.message }, 'error');
|
||||||
@@ -129,49 +133,49 @@ export class DownloadManager {
|
|||||||
if (!loraRoot) {
|
if (!loraRoot) {
|
||||||
throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory'));
|
throw new Error(translate('recipes.controls.import.errors.selectLoraRoot', {}, 'Please select a LoRA root directory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build target path
|
// Build target path
|
||||||
let targetPath = '';
|
let targetPath = '';
|
||||||
if (this.importManager.selectedFolder) {
|
if (this.importManager.selectedFolder) {
|
||||||
targetPath = this.importManager.selectedFolder;
|
targetPath = this.importManager.selectedFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique ID for this batch download
|
// Generate a unique ID for this batch download
|
||||||
const batchDownloadId = Date.now().toString();
|
const batchDownloadId = Date.now().toString();
|
||||||
|
|
||||||
// Set up WebSocket for progress updates
|
// Set up WebSocket for progress updates
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
|
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
|
||||||
|
|
||||||
// Show enhanced loading with progress details for multiple items
|
// Show enhanced loading with progress details for multiple items
|
||||||
const updateProgress = this.importManager.loadingManager.showDownloadProgress(
|
const updateProgress = this.importManager.loadingManager.showDownloadProgress(
|
||||||
this.importManager.downloadableLoRAs.length
|
this.importManager.downloadableLoRAs.length
|
||||||
);
|
);
|
||||||
|
|
||||||
let completedDownloads = 0;
|
let completedDownloads = 0;
|
||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
let accessFailures = 0;
|
let accessFailures = 0;
|
||||||
let currentLoraProgress = 0;
|
let currentLoraProgress = 0;
|
||||||
|
|
||||||
// Set up progress tracking for current download
|
// Set up progress tracking for current download
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
// Handle download ID confirmation
|
// Handle download ID confirmation
|
||||||
if (data.type === 'download_id') {
|
if (data.type === 'download_id') {
|
||||||
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
|
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process progress updates for our current active download
|
// Process progress updates for our current active download
|
||||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||||
// Update current LoRA progress
|
// Update current LoRA progress
|
||||||
currentLoraProgress = data.progress;
|
currentLoraProgress = data.progress;
|
||||||
|
|
||||||
// Get current LoRA name
|
// Get current LoRA name
|
||||||
const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads];
|
const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads];
|
||||||
const loraName = currentLora ? currentLora.name : '';
|
const loraName = currentLora ? currentLora.name : '';
|
||||||
|
|
||||||
// Update progress display
|
// Update progress display
|
||||||
const metrics = {
|
const metrics = {
|
||||||
bytesDownloaded: data.bytes_downloaded,
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
@@ -180,7 +184,7 @@ export class DownloadManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
|
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
|
||||||
|
|
||||||
// Add more detailed status messages based on progress
|
// Add more detailed status messages based on progress
|
||||||
if (currentLoraProgress < 3) {
|
if (currentLoraProgress < 3) {
|
||||||
this.importManager.loadingManager.setStatus(
|
this.importManager.loadingManager.setStatus(
|
||||||
@@ -203,17 +207,17 @@ export class DownloadManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
|
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
|
||||||
|
|
||||||
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
|
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
|
||||||
const lora = this.importManager.downloadableLoRAs[i];
|
const lora = this.importManager.downloadableLoRAs[i];
|
||||||
|
|
||||||
// Reset current LoRA progress for new download
|
// Reset current LoRA progress for new download
|
||||||
currentLoraProgress = 0;
|
currentLoraProgress = 0;
|
||||||
|
|
||||||
// Initial status update for new LoRA
|
// Initial status update for new LoRA
|
||||||
this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i+1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`));
|
this.importManager.loadingManager.setStatus(translate('recipes.controls.import.startingDownload', { current: i + 1, total: this.importManager.downloadableLoRAs.length }, `Starting download for LoRA ${i + 1}/${this.importManager.downloadableLoRAs.length}`));
|
||||||
updateProgress(0, completedDownloads, lora.name);
|
updateProgress(0, completedDownloads, lora.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Download the LoRA with download ID
|
// Download the LoRA with download ID
|
||||||
const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel(
|
const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel(
|
||||||
@@ -224,7 +228,7 @@ export class DownloadManager {
|
|||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
batchDownloadId
|
batchDownloadId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
|
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
|
||||||
|
|
||||||
@@ -248,28 +252,28 @@ export class DownloadManager {
|
|||||||
// Continue with next download
|
// Continue with next download
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close WebSocket
|
// Close WebSocket
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|
||||||
// Show appropriate completion message based on results
|
// Show appropriate completion message based on results
|
||||||
if (failedDownloads === 0) {
|
if (failedDownloads === 0) {
|
||||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||||
} else {
|
} else {
|
||||||
if (accessFailures > 0) {
|
if (accessFailures > 0) {
|
||||||
showToast('toast.loras.downloadPartialWithAccess', {
|
showToast('toast.loras.downloadPartialWithAccess', {
|
||||||
completed: completedDownloads,
|
completed: completedDownloads,
|
||||||
total: this.importManager.downloadableLoRAs.length,
|
total: this.importManager.downloadableLoRAs.length,
|
||||||
accessFailures: accessFailures
|
accessFailures: accessFailures
|
||||||
}, 'error');
|
}, 'error');
|
||||||
} else {
|
} else {
|
||||||
showToast('toast.loras.downloadPartialSuccess', {
|
showToast('toast.loras.downloadPartialSuccess', {
|
||||||
completed: completedDownloads,
|
completed: completedDownloads,
|
||||||
total: this.importManager.downloadableLoRAs.length
|
total: this.importManager.downloadableLoRAs.length
|
||||||
}, 'error');
|
}, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return failedDownloads;
|
return failedDownloads;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ export class RecipeDataManager {
|
|||||||
|
|
||||||
showRecipeDetailsStep() {
|
showRecipeDetailsStep() {
|
||||||
this.importManager.stepManager.showStep('detailsStep');
|
this.importManager.stepManager.showStep('detailsStep');
|
||||||
|
|
||||||
// Set default recipe name from prompt or image filename
|
// Set default recipe name from prompt or image filename
|
||||||
const recipeName = document.getElementById('recipeName');
|
const recipeName = document.getElementById('recipeName');
|
||||||
|
|
||||||
// Check if we have recipe metadata from a shared recipe
|
// Check if we have recipe metadata from a shared recipe
|
||||||
if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) {
|
if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) {
|
||||||
// Use title from recipe metadata
|
// Use title from recipe metadata
|
||||||
@@ -19,24 +19,24 @@ export class RecipeDataManager {
|
|||||||
recipeName.value = this.importManager.recipeData.title;
|
recipeName.value = this.importManager.recipeData.title;
|
||||||
this.importManager.recipeName = this.importManager.recipeData.title;
|
this.importManager.recipeName = this.importManager.recipeData.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use tags from recipe metadata
|
// Use tags from recipe metadata
|
||||||
if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) {
|
if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) {
|
||||||
this.importManager.recipeTags = [...this.importManager.recipeData.tags];
|
this.importManager.recipeTags = [...this.importManager.recipeData.tags];
|
||||||
this.updateTagsDisplay();
|
this.updateTagsDisplay();
|
||||||
}
|
}
|
||||||
} else if (this.importManager.recipeData &&
|
} else if (this.importManager.recipeData &&
|
||||||
this.importManager.recipeData.gen_params &&
|
this.importManager.recipeData.gen_params &&
|
||||||
this.importManager.recipeData.gen_params.prompt) {
|
this.importManager.recipeData.gen_params.prompt) {
|
||||||
// Use the first 10 words from the prompt as the default recipe name
|
// Use the first 10 words from the prompt as the default recipe name
|
||||||
const promptWords = this.importManager.recipeData.gen_params.prompt.split(' ');
|
const promptWords = this.importManager.recipeData.gen_params.prompt.split(' ');
|
||||||
const truncatedPrompt = promptWords.slice(0, 10).join(' ');
|
const truncatedPrompt = promptWords.slice(0, 10).join(' ');
|
||||||
recipeName.value = truncatedPrompt;
|
recipeName.value = truncatedPrompt;
|
||||||
this.importManager.recipeName = truncatedPrompt;
|
this.importManager.recipeName = truncatedPrompt;
|
||||||
|
|
||||||
// Set up click handler to select all text for easy editing
|
// Set up click handler to select all text for easy editing
|
||||||
if (!recipeName.hasSelectAllHandler) {
|
if (!recipeName.hasSelectAllHandler) {
|
||||||
recipeName.addEventListener('click', function() {
|
recipeName.addEventListener('click', function () {
|
||||||
this.select();
|
this.select();
|
||||||
});
|
});
|
||||||
recipeName.hasSelectAllHandler = true;
|
recipeName.hasSelectAllHandler = true;
|
||||||
@@ -47,15 +47,15 @@ export class RecipeDataManager {
|
|||||||
recipeName.value = fileName;
|
recipeName.value = fileName;
|
||||||
this.importManager.recipeName = fileName;
|
this.importManager.recipeName = fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always set up click handler for easy editing if not already set
|
// Always set up click handler for easy editing if not already set
|
||||||
if (!recipeName.hasSelectAllHandler) {
|
if (!recipeName.hasSelectAllHandler) {
|
||||||
recipeName.addEventListener('click', function() {
|
recipeName.addEventListener('click', function () {
|
||||||
this.select();
|
this.select();
|
||||||
});
|
});
|
||||||
recipeName.hasSelectAllHandler = true;
|
recipeName.hasSelectAllHandler = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display the uploaded image in the preview
|
// Display the uploaded image in the preview
|
||||||
const imagePreview = document.getElementById('recipeImagePreview');
|
const imagePreview = document.getElementById('recipeImagePreview');
|
||||||
if (imagePreview) {
|
if (imagePreview) {
|
||||||
@@ -67,13 +67,24 @@ export class RecipeDataManager {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(this.importManager.recipeImage);
|
reader.readAsDataURL(this.importManager.recipeImage);
|
||||||
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
|
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
|
||||||
// For URL mode - use the base64 image data returned from the backend
|
// For URL mode - use the base64 data returned from the backend
|
||||||
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">`;
|
if (this.importManager.recipeData.is_video) {
|
||||||
|
const mimeType = this.importManager.recipeData.extension === '.webm' ? 'video/webm' : 'video/mp4';
|
||||||
|
imagePreview.innerHTML = `<video src="data:${mimeType};base64,${this.importManager.recipeData.image_base64}" controls autoplay loop muted class="recipe-preview-video"></video>`;
|
||||||
|
} else {
|
||||||
|
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}">`;
|
||||||
|
}
|
||||||
} else if (this.importManager.importMode === 'url') {
|
} else if (this.importManager.importMode === 'url') {
|
||||||
// Fallback for URL mode if no base64 data
|
// Fallback for URL mode if no base64 data
|
||||||
const urlInput = document.getElementById('imageUrlInput');
|
const urlInput = document.getElementById('imageUrlInput');
|
||||||
if (urlInput && urlInput.value) {
|
if (urlInput && urlInput.value) {
|
||||||
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}" crossorigin="anonymous">`;
|
const url = urlInput.value.toLowerCase();
|
||||||
|
if (url.endsWith('.mp4') || url.endsWith('.webm')) {
|
||||||
|
const mimeType = url.endsWith('.webm') ? 'video/webm' : 'video/mp4';
|
||||||
|
imagePreview.innerHTML = `<video src="${urlInput.value}" controls autoplay loop muted class="recipe-preview-video"></video>`;
|
||||||
|
} else {
|
||||||
|
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="${translate('recipes.controls.import.recipePreviewAlt', {}, 'Recipe preview')}" crossorigin="anonymous">`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +96,7 @@ export class RecipeDataManager {
|
|||||||
if (loraCountInfo) {
|
if (loraCountInfo) {
|
||||||
loraCountInfo.textContent = translate('recipes.controls.import.loraCountInfo', { existing: existingLoras, total: totalLoras }, `(${existingLoras}/${totalLoras} in library)`);
|
loraCountInfo.textContent = translate('recipes.controls.import.loraCountInfo', { existing: existingLoras, total: totalLoras }, `(${existingLoras}/${totalLoras} in library)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display LoRAs list
|
// Display LoRAs list
|
||||||
const lorasList = document.getElementById('lorasList');
|
const lorasList = document.getElementById('lorasList');
|
||||||
if (lorasList) {
|
if (lorasList) {
|
||||||
@@ -94,7 +105,7 @@ export class RecipeDataManager {
|
|||||||
const isDeleted = lora.isDeleted;
|
const isDeleted = lora.isDeleted;
|
||||||
const isEarlyAccess = lora.isEarlyAccess;
|
const isEarlyAccess = lora.isEarlyAccess;
|
||||||
const localPath = lora.localPath || '';
|
const localPath = lora.localPath || '';
|
||||||
|
|
||||||
// Create status badge based on LoRA status
|
// Create status badge based on LoRA status
|
||||||
let statusBadge;
|
let statusBadge;
|
||||||
if (isDeleted) {
|
if (isDeleted) {
|
||||||
@@ -102,7 +113,7 @@ export class RecipeDataManager {
|
|||||||
<i class="fas fa-exclamation-circle"></i> ${translate('recipes.controls.import.deletedFromCivitai', {}, 'Deleted from Civitai')}
|
<i class="fas fa-exclamation-circle"></i> ${translate('recipes.controls.import.deletedFromCivitai', {}, 'Deleted from Civitai')}
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
statusBadge = existsLocally ?
|
statusBadge = existsLocally ?
|
||||||
`<div class="local-badge">
|
`<div class="local-badge">
|
||||||
<i class="fas fa-check"></i> ${translate('recipes.controls.import.inLibrary', {}, 'In Library')}
|
<i class="fas fa-check"></i> ${translate('recipes.controls.import.inLibrary', {}, 'In Library')}
|
||||||
<div class="local-path">${localPath}</div>
|
<div class="local-path">${localPath}</div>
|
||||||
@@ -126,7 +137,7 @@ export class RecipeDataManager {
|
|||||||
console.warn('Failed to format early access date', e);
|
console.warn('Failed to format early access date', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
earlyAccessBadge = `<div class="early-access-badge">
|
earlyAccessBadge = `<div class="early-access-badge">
|
||||||
<i class="fas fa-clock"></i> ${translate('recipes.controls.import.earlyAccess', {}, 'Early Access')}
|
<i class="fas fa-clock"></i> ${translate('recipes.controls.import.earlyAccess', {}, 'Early Access')}
|
||||||
<div class="early-access-info">${earlyAccessInfo} ${translate('recipes.controls.import.verifyEarlyAccess', {}, 'Verify that you have purchased early access before downloading.')}</div>
|
<div class="early-access-info">${earlyAccessInfo} ${translate('recipes.controls.import.verifyEarlyAccess', {}, 'Verify that you have purchased early access before downloading.')}</div>
|
||||||
@@ -134,7 +145,7 @@ export class RecipeDataManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Format size if available
|
// Format size if available
|
||||||
const sizeDisplay = lora.size ?
|
const sizeDisplay = lora.size ?
|
||||||
`<div class="size-badge">${this.importManager.formatFileSize(lora.size)}</div>` : '';
|
`<div class="size-badge">${this.importManager.formatFileSize(lora.size)}</div>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -161,9 +172,9 @@ export class RecipeDataManager {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for early access loras and show warning if any exist
|
// Check for early access loras and show warning if any exist
|
||||||
const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora =>
|
const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora =>
|
||||||
lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted);
|
lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted);
|
||||||
if (earlyAccessLoras.length > 0) {
|
if (earlyAccessLoras.length > 0) {
|
||||||
// Show a warning about early access loras
|
// Show a warning about early access loras
|
||||||
@@ -179,7 +190,7 @@ export class RecipeDataManager {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Show the warning message
|
// Show the warning message
|
||||||
const buttonsContainer = document.querySelector('#detailsStep .modal-actions');
|
const buttonsContainer = document.querySelector('#detailsStep .modal-actions');
|
||||||
if (buttonsContainer) {
|
if (buttonsContainer) {
|
||||||
@@ -188,7 +199,7 @@ export class RecipeDataManager {
|
|||||||
if (existingWarning) {
|
if (existingWarning) {
|
||||||
existingWarning.remove();
|
existingWarning.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new warning
|
// Add new warning
|
||||||
const warningContainer = document.createElement('div');
|
const warningContainer = document.createElement('div');
|
||||||
warningContainer.id = 'earlyAccessWarning';
|
warningContainer.id = 'earlyAccessWarning';
|
||||||
@@ -196,27 +207,27 @@ export class RecipeDataManager {
|
|||||||
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
|
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate recipes and display warning if found
|
// Check for duplicate recipes and display warning if found
|
||||||
this.checkAndDisplayDuplicates();
|
this.checkAndDisplayDuplicates();
|
||||||
|
|
||||||
// Update Next button state based on missing LoRAs and duplicates
|
// Update Next button state based on missing LoRAs and duplicates
|
||||||
this.updateNextButtonState();
|
this.updateNextButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAndDisplayDuplicates() {
|
checkAndDisplayDuplicates() {
|
||||||
// Check if we have duplicate recipes
|
// Check if we have duplicate recipes
|
||||||
if (this.importManager.recipeData &&
|
if (this.importManager.recipeData &&
|
||||||
this.importManager.recipeData.matching_recipes &&
|
this.importManager.recipeData.matching_recipes &&
|
||||||
this.importManager.recipeData.matching_recipes.length > 0) {
|
this.importManager.recipeData.matching_recipes.length > 0) {
|
||||||
|
|
||||||
// Store duplicates in the importManager for later use
|
// Store duplicates in the importManager for later use
|
||||||
this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes;
|
this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes;
|
||||||
|
|
||||||
// Create duplicate warning container
|
// Create duplicate warning container
|
||||||
const duplicateContainer = document.getElementById('duplicateRecipesContainer') ||
|
const duplicateContainer = document.getElementById('duplicateRecipesContainer') ||
|
||||||
this.createDuplicateContainer();
|
this.createDuplicateContainer();
|
||||||
|
|
||||||
// Format date helper function
|
// Format date helper function
|
||||||
const formatDate = (timestamp) => {
|
const formatDate = (timestamp) => {
|
||||||
try {
|
try {
|
||||||
@@ -226,7 +237,7 @@ export class RecipeDataManager {
|
|||||||
return 'Unknown date';
|
return 'Unknown date';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate the HTML for duplicate recipes warning
|
// Generate the HTML for duplicate recipes warning
|
||||||
duplicateContainer.innerHTML = `
|
duplicateContainer.innerHTML = `
|
||||||
<div class="duplicate-warning">
|
<div class="duplicate-warning">
|
||||||
@@ -262,10 +273,10 @@ export class RecipeDataManager {
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Show the duplicate container
|
// Show the duplicate container
|
||||||
duplicateContainer.style.display = 'block';
|
duplicateContainer.style.display = 'block';
|
||||||
|
|
||||||
// Add click event for the toggle button
|
// Add click event for the toggle button
|
||||||
const toggleButton = document.getElementById('toggleDuplicatesList');
|
const toggleButton = document.getElementById('toggleDuplicatesList');
|
||||||
if (toggleButton) {
|
if (toggleButton) {
|
||||||
@@ -290,49 +301,49 @@ export class RecipeDataManager {
|
|||||||
if (duplicateContainer) {
|
if (duplicateContainer) {
|
||||||
duplicateContainer.style.display = 'none';
|
duplicateContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset duplicate tracking
|
// Reset duplicate tracking
|
||||||
this.importManager.duplicateRecipes = [];
|
this.importManager.duplicateRecipes = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createDuplicateContainer() {
|
createDuplicateContainer() {
|
||||||
// Find where to insert the duplicate container
|
// Find where to insert the duplicate container
|
||||||
const lorasListContainer = document.querySelector('.input-group:has(#lorasList)');
|
const lorasListContainer = document.querySelector('.input-group:has(#lorasList)');
|
||||||
|
|
||||||
if (!lorasListContainer) return null;
|
if (!lorasListContainer) return null;
|
||||||
|
|
||||||
// Create container
|
// Create container
|
||||||
const duplicateContainer = document.createElement('div');
|
const duplicateContainer = document.createElement('div');
|
||||||
duplicateContainer.id = 'duplicateRecipesContainer';
|
duplicateContainer.id = 'duplicateRecipesContainer';
|
||||||
duplicateContainer.className = 'duplicate-recipes-container';
|
duplicateContainer.className = 'duplicate-recipes-container';
|
||||||
|
|
||||||
// Insert before the LoRA list
|
// Insert before the LoRA list
|
||||||
lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer);
|
lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer);
|
||||||
|
|
||||||
return duplicateContainer;
|
return duplicateContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNextButtonState() {
|
updateNextButtonState() {
|
||||||
const nextButton = document.querySelector('#detailsStep .primary-btn');
|
const nextButton = document.querySelector('#detailsStep .primary-btn');
|
||||||
const actionsContainer = document.querySelector('#detailsStep .modal-actions');
|
const actionsContainer = document.querySelector('#detailsStep .modal-actions');
|
||||||
if (!nextButton || !actionsContainer) return;
|
if (!nextButton || !actionsContainer) return;
|
||||||
|
|
||||||
// Always clean up previous warnings and buttons first
|
// Always clean up previous warnings and buttons first
|
||||||
const existingWarning = document.getElementById('deletedLorasWarning');
|
const existingWarning = document.getElementById('deletedLorasWarning');
|
||||||
if (existingWarning) {
|
if (existingWarning) {
|
||||||
existingWarning.remove();
|
existingWarning.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any existing "import anyway" button
|
// Remove any existing "import anyway" button
|
||||||
const importAnywayBtn = document.getElementById('importAnywayBtn');
|
const importAnywayBtn = document.getElementById('importAnywayBtn');
|
||||||
if (importAnywayBtn) {
|
if (importAnywayBtn) {
|
||||||
importAnywayBtn.remove();
|
importAnywayBtn.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count deleted LoRAs
|
// Count deleted LoRAs
|
||||||
const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length;
|
const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length;
|
||||||
|
|
||||||
// If we have deleted LoRAs, show a warning
|
// If we have deleted LoRAs, show a warning
|
||||||
if (deletedLoras > 0) {
|
if (deletedLoras > 0) {
|
||||||
// Create a new warning container above the buttons
|
// Create a new warning container above the buttons
|
||||||
@@ -340,7 +351,7 @@ export class RecipeDataManager {
|
|||||||
const warningContainer = document.createElement('div');
|
const warningContainer = document.createElement('div');
|
||||||
warningContainer.id = 'deletedLorasWarning';
|
warningContainer.id = 'deletedLorasWarning';
|
||||||
warningContainer.className = 'deleted-loras-warning';
|
warningContainer.className = 'deleted-loras-warning';
|
||||||
|
|
||||||
// Create warning message
|
// Create warning message
|
||||||
warningContainer.innerHTML = `
|
warningContainer.innerHTML = `
|
||||||
<div class="warning-icon"><i class="fas fa-exclamation-triangle"></i></div>
|
<div class="warning-icon"><i class="fas fa-exclamation-triangle"></i></div>
|
||||||
@@ -349,19 +360,19 @@ export class RecipeDataManager {
|
|||||||
<div class="warning-text">These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.</div>
|
<div class="warning-text">These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Insert before the buttons container
|
// Insert before the buttons container
|
||||||
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
|
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicates but don't change button actions
|
// Check for duplicates but don't change button actions
|
||||||
const missingNotDeleted = this.importManager.recipeData.loras.filter(
|
const missingNotDeleted = this.importManager.recipeData.loras.filter(
|
||||||
lora => !lora.existsLocally && !lora.isDeleted
|
lora => !lora.existsLocally && !lora.isDeleted
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Standard button behavior regardless of duplicates
|
// Standard button behavior regardless of duplicates
|
||||||
nextButton.classList.remove('warning-btn');
|
nextButton.classList.remove('warning-btn');
|
||||||
|
|
||||||
if (missingNotDeleted > 0) {
|
if (missingNotDeleted > 0) {
|
||||||
nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
|
nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
|
||||||
} else {
|
} else {
|
||||||
@@ -372,30 +383,30 @@ export class RecipeDataManager {
|
|||||||
addTag() {
|
addTag() {
|
||||||
const tagInput = document.getElementById('tagInput');
|
const tagInput = document.getElementById('tagInput');
|
||||||
const tag = tagInput.value.trim();
|
const tag = tagInput.value.trim();
|
||||||
|
|
||||||
if (!tag) return;
|
if (!tag) return;
|
||||||
|
|
||||||
if (!this.importManager.recipeTags.includes(tag)) {
|
if (!this.importManager.recipeTags.includes(tag)) {
|
||||||
this.importManager.recipeTags.push(tag);
|
this.importManager.recipeTags.push(tag);
|
||||||
this.updateTagsDisplay();
|
this.updateTagsDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
tagInput.value = '';
|
tagInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
removeTag(tag) {
|
removeTag(tag) {
|
||||||
this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag);
|
this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag);
|
||||||
this.updateTagsDisplay();
|
this.updateTagsDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTagsDisplay() {
|
updateTagsDisplay() {
|
||||||
const tagsContainer = document.getElementById('tagsContainer');
|
const tagsContainer = document.getElementById('tagsContainer');
|
||||||
|
|
||||||
if (this.importManager.recipeTags.length === 0) {
|
if (this.importManager.recipeTags.length === 0) {
|
||||||
tagsContainer.innerHTML = `<div class="empty-tags">${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}</div>`;
|
tagsContainer.innerHTML = `<div class="empty-tags">${translate('recipes.controls.import.noTagsAdded', {}, 'No tags added')}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => `
|
tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => `
|
||||||
<div class="recipe-tag">
|
<div class="recipe-tag">
|
||||||
${tag}
|
${tag}
|
||||||
@@ -410,7 +421,7 @@ export class RecipeDataManager {
|
|||||||
showToast('toast.recipes.enterRecipeName', {}, 'error');
|
showToast('toast.recipes.enterRecipeName', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically mark all deleted LoRAs as excluded
|
// Automatically mark all deleted LoRAs as excluded
|
||||||
if (this.importManager.recipeData && this.importManager.recipeData.loras) {
|
if (this.importManager.recipeData && this.importManager.recipeData.loras) {
|
||||||
this.importManager.recipeData.loras.forEach(lora => {
|
this.importManager.recipeData.loras.forEach(lora => {
|
||||||
@@ -419,11 +430,11 @@ export class RecipeDataManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update missing LoRAs list to exclude deleted LoRAs
|
// Update missing LoRAs list to exclude deleted LoRAs
|
||||||
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora =>
|
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora =>
|
||||||
!lora.existsLocally && !lora.isDeleted);
|
!lora.existsLocally && !lora.isDeleted);
|
||||||
|
|
||||||
// If we have downloadable missing LoRAs, go to location step
|
// If we have downloadable missing LoRAs, go to location step
|
||||||
if (this.importManager.missingLoras.length > 0) {
|
if (this.importManager.missingLoras.length > 0) {
|
||||||
// Store only downloadable LoRAs for the download step
|
// Store only downloadable LoRAs for the download step
|
||||||
|
|||||||
@@ -2,31 +2,60 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { ImportManager } from './managers/ImportManager.js';
|
import { ImportManager } from './managers/ImportManager.js';
|
||||||
import { RecipeModal } from './components/RecipeModal.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 { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } 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 {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Get page state
|
// Get page state
|
||||||
this.pageState = getCurrentPageState();
|
this.pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
// Page controls for shared sidebar behaviors
|
||||||
|
this.pageControls = new RecipePageControls();
|
||||||
|
|
||||||
// Initialize ImportManager
|
// Initialize ImportManager
|
||||||
this.importManager = new ImportManager();
|
this.importManager = new ImportManager();
|
||||||
|
|
||||||
// Initialize RecipeModal
|
// Initialize RecipeModal
|
||||||
this.recipeModal = new RecipeModal();
|
this.recipeModal = new RecipeModal();
|
||||||
|
|
||||||
// Initialize DuplicatesManager
|
// Initialize DuplicatesManager
|
||||||
this.duplicatesManager = new DuplicatesManager(this);
|
this.duplicatesManager = new DuplicatesManager(this);
|
||||||
|
|
||||||
// Add state tracking for infinite scroll
|
// Add state tracking for infinite scroll
|
||||||
this.pageState.isLoading = false;
|
this.pageState.isLoading = false;
|
||||||
this.pageState.hasMore = true;
|
this.pageState.hasMore = true;
|
||||||
|
|
||||||
// Custom filter state - move to pageState for compatibility with virtual scrolling
|
// Custom filter state - move to pageState for compatibility with virtual scrolling
|
||||||
this.pageState.customFilter = {
|
this.pageState.customFilter = {
|
||||||
active: false,
|
active: false,
|
||||||
@@ -35,27 +64,40 @@ class RecipeManager {
|
|||||||
recipeId: null
|
recipeId: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
// Initialize event listeners
|
// Initialize event listeners
|
||||||
this.initEventListeners();
|
this.initEventListeners();
|
||||||
|
|
||||||
// Set default search options if not already defined
|
// Set default search options if not already defined
|
||||||
this._initSearchOptions();
|
this._initSearchOptions();
|
||||||
|
|
||||||
// Initialize context menu
|
// Initialize context menu
|
||||||
new RecipeContextMenu();
|
new RecipeContextMenu();
|
||||||
|
|
||||||
// Check for custom filter parameters in session storage
|
// Check for custom filter parameters in session storage
|
||||||
this._checkCustomFilter();
|
this._checkCustomFilter();
|
||||||
|
|
||||||
// Expose necessary functions to the page
|
// Expose necessary functions to the page
|
||||||
this._exposeGlobalFunctions();
|
this._exposeGlobalFunctions();
|
||||||
|
|
||||||
|
// Initialize sidebar navigation
|
||||||
|
await this._initSidebar();
|
||||||
|
|
||||||
// Initialize common page features
|
// Initialize common page features
|
||||||
appCore.initializePageFeatures();
|
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() {
|
_initSearchOptions() {
|
||||||
// Ensure recipes search options are properly initialized
|
// Ensure recipes search options are properly initialized
|
||||||
if (!this.pageState.searchOptions) {
|
if (!this.pageState.searchOptions) {
|
||||||
@@ -63,25 +105,27 @@ class RecipeManager {
|
|||||||
title: true, // Recipe title
|
title: true, // Recipe title
|
||||||
tags: true, // Recipe tags
|
tags: true, // Recipe tags
|
||||||
loraName: true, // LoRA file name
|
loraName: true, // LoRA file name
|
||||||
loraModel: true // LoRA model name
|
loraModel: true, // LoRA model name
|
||||||
|
prompt: true, // Prompt search
|
||||||
|
recursive: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_exposeGlobalFunctions() {
|
_exposeGlobalFunctions() {
|
||||||
// Only expose what's needed for the page
|
// Only expose what's needed for the page
|
||||||
window.recipeManager = this;
|
window.recipeManager = this;
|
||||||
window.importManager = this.importManager;
|
window.importManager = this.importManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
_checkCustomFilter() {
|
_checkCustomFilter() {
|
||||||
// Check for Lora filter
|
// Check for Lora filter
|
||||||
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
|
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
|
||||||
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
|
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
|
||||||
|
|
||||||
// Check for specific recipe ID
|
// Check for specific recipe ID
|
||||||
const viewRecipeId = getSessionItem('viewRecipeId');
|
const viewRecipeId = getSessionItem('viewRecipeId');
|
||||||
|
|
||||||
// Set custom filter if any parameter is present
|
// Set custom filter if any parameter is present
|
||||||
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
||||||
this.pageState.customFilter = {
|
this.pageState.customFilter = {
|
||||||
@@ -90,35 +134,35 @@ class RecipeManager {
|
|||||||
loraHash: filterLoraHash,
|
loraHash: filterLoraHash,
|
||||||
recipeId: viewRecipeId
|
recipeId: viewRecipeId
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show custom filter indicator
|
// Show custom filter indicator
|
||||||
this._showCustomFilterIndicator();
|
this._showCustomFilterIndicator();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_showCustomFilterIndicator() {
|
_showCustomFilterIndicator() {
|
||||||
const indicator = document.getElementById('customFilterIndicator');
|
const indicator = document.getElementById('customFilterIndicator');
|
||||||
const textElement = document.getElementById('customFilterText');
|
const textElement = document.getElementById('customFilterText');
|
||||||
|
|
||||||
if (!indicator || !textElement) return;
|
if (!indicator || !textElement) return;
|
||||||
|
|
||||||
// Update text based on filter type
|
// Update text based on filter type
|
||||||
let filterText = '';
|
let filterText = '';
|
||||||
|
|
||||||
if (this.pageState.customFilter.recipeId) {
|
if (this.pageState.customFilter.recipeId) {
|
||||||
filterText = 'Viewing specific recipe';
|
filterText = 'Viewing specific recipe';
|
||||||
} else if (this.pageState.customFilter.loraName) {
|
} else if (this.pageState.customFilter.loraName) {
|
||||||
// Format with Lora name
|
// Format with Lora name
|
||||||
const loraName = this.pageState.customFilter.loraName;
|
const loraName = this.pageState.customFilter.loraName;
|
||||||
const displayName = loraName.length > 25 ?
|
const displayName = loraName.length > 25 ?
|
||||||
loraName.substring(0, 22) + '...' :
|
loraName.substring(0, 22) + '...' :
|
||||||
loraName;
|
loraName;
|
||||||
|
|
||||||
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
|
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
|
||||||
} else {
|
} else {
|
||||||
filterText = 'Filtered recipes';
|
filterText = 'Filtered recipes';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update indicator text and show it
|
// Update indicator text and show it
|
||||||
textElement.innerHTML = filterText;
|
textElement.innerHTML = filterText;
|
||||||
// Add title attribute to show the lora name as a tooltip
|
// Add title attribute to show the lora name as a tooltip
|
||||||
@@ -126,14 +170,14 @@ class RecipeManager {
|
|||||||
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
||||||
}
|
}
|
||||||
indicator.classList.remove('hidden');
|
indicator.classList.remove('hidden');
|
||||||
|
|
||||||
// Add pulse animation
|
// Add pulse animation
|
||||||
const filterElement = indicator.querySelector('.filter-active');
|
const filterElement = indicator.querySelector('.filter-active');
|
||||||
if (filterElement) {
|
if (filterElement) {
|
||||||
filterElement.classList.add('animate');
|
filterElement.classList.add('animate');
|
||||||
setTimeout(() => filterElement.classList.remove('animate'), 600);
|
setTimeout(() => filterElement.classList.remove('animate'), 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handler for clear filter button
|
// Add click handler for clear filter button
|
||||||
const clearFilterBtn = indicator.querySelector('.clear-filter');
|
const clearFilterBtn = indicator.querySelector('.clear-filter');
|
||||||
if (clearFilterBtn) {
|
if (clearFilterBtn) {
|
||||||
@@ -143,7 +187,7 @@ class RecipeManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearCustomFilter() {
|
_clearCustomFilter() {
|
||||||
// Reset custom filter
|
// Reset custom filter
|
||||||
this.pageState.customFilter = {
|
this.pageState.customFilter = {
|
||||||
@@ -152,33 +196,48 @@ class RecipeManager {
|
|||||||
loraHash: null,
|
loraHash: null,
|
||||||
recipeId: null
|
recipeId: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hide indicator
|
// Hide indicator
|
||||||
const indicator = document.getElementById('customFilterIndicator');
|
const indicator = document.getElementById('customFilterIndicator');
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.classList.add('hidden');
|
indicator.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any session storage items
|
// Clear any session storage items
|
||||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||||
removeSessionItem('viewRecipeId');
|
removeSessionItem('viewRecipeId');
|
||||||
|
|
||||||
// Reset and refresh the virtual scroller
|
// Reset and refresh the virtual scroller
|
||||||
refreshVirtualScroll();
|
refreshVirtualScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
initEventListeners() {
|
initEventListeners() {
|
||||||
// Sort select
|
// Sort select
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
this.pageState.sortBy = sortSelect.value;
|
||||||
refreshVirtualScroll();
|
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
|
// This method is kept for compatibility but now uses virtual scrolling
|
||||||
async loadRecipes(resetPage = true) {
|
async loadRecipes(resetPage = true) {
|
||||||
// Skip loading if in duplicates mode
|
// Skip loading if in duplicates mode
|
||||||
@@ -186,32 +245,32 @@ class RecipeManager {
|
|||||||
if (pageState.duplicatesMode) {
|
if (pageState.duplicatesMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resetPage) {
|
if (resetPage) {
|
||||||
refreshVirtualScroll();
|
refreshVirtualScroll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||||
*/
|
*/
|
||||||
async refreshRecipes() {
|
async refreshRecipes() {
|
||||||
return refreshRecipes();
|
return refreshRecipes();
|
||||||
}
|
}
|
||||||
|
|
||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
this.recipeModal.showRecipeDetails(recipe);
|
this.recipeModal.showRecipeDetails(recipe);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate detection and management methods
|
// Duplicate detection and management methods
|
||||||
async findDuplicateRecipes() {
|
async findDuplicateRecipes() {
|
||||||
return await this.duplicatesManager.findDuplicates();
|
return await this.duplicatesManager.findDuplicates();
|
||||||
}
|
}
|
||||||
|
|
||||||
selectLatestDuplicates() {
|
selectLatestDuplicates() {
|
||||||
this.duplicatesManager.selectLatestDuplicates();
|
this.duplicatesManager.selectLatestDuplicates();
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSelectedDuplicates() {
|
deleteSelectedDuplicates() {
|
||||||
this.duplicatesManager.deleteSelectedDuplicates();
|
this.duplicatesManager.deleteSelectedDuplicates();
|
||||||
}
|
}
|
||||||
@@ -219,14 +278,14 @@ class RecipeManager {
|
|||||||
confirmDeleteDuplicates() {
|
confirmDeleteDuplicates() {
|
||||||
this.duplicatesManager.confirmDeleteDuplicates();
|
this.duplicatesManager.confirmDeleteDuplicates();
|
||||||
}
|
}
|
||||||
|
|
||||||
exitDuplicateMode() {
|
exitDuplicateMode() {
|
||||||
// Clear the grid first to prevent showing old content temporarily
|
// Clear the grid first to prevent showing old content temporarily
|
||||||
const recipeGrid = document.getElementById('recipeGrid');
|
const recipeGrid = document.getElementById('recipeGrid');
|
||||||
if (recipeGrid) {
|
if (recipeGrid) {
|
||||||
recipeGrid.innerHTML = '';
|
recipeGrid.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.duplicatesManager.exitDuplicateMode();
|
this.duplicatesManager.exitDuplicateMode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,11 +294,11 @@ class RecipeManager {
|
|||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Initialize core application
|
// Initialize core application
|
||||||
await appCore.initialize();
|
await appCore.initialize();
|
||||||
|
|
||||||
// Initialize recipe manager
|
// Initialize recipe manager
|
||||||
const recipeManager = new RecipeManager();
|
const recipeManager = new RecipeManager();
|
||||||
await recipeManager.initialize();
|
await recipeManager.initialize();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export for use in other modules
|
// Export for use in other modules
|
||||||
export { RecipeManager };
|
export { RecipeManager };
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const state = {
|
|||||||
loadingManager: null,
|
loadingManager: null,
|
||||||
observer: null,
|
observer: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page-specific states
|
// Page-specific states
|
||||||
pages: {
|
pages: {
|
||||||
[MODEL_TYPES.LORA]: {
|
[MODEL_TYPES.LORA]: {
|
||||||
@@ -69,20 +69,20 @@ export const state = {
|
|||||||
activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`),
|
activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`),
|
||||||
activeLetterFilter: null,
|
activeLetterFilter: null,
|
||||||
previewVersions: loraPreviewVersions,
|
previewVersions: loraPreviewVersions,
|
||||||
searchManager: null,
|
searchManager: null,
|
||||||
searchOptions: {
|
searchOptions: {
|
||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
creator: false,
|
creator: false,
|
||||||
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
|
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: []
|
||||||
},
|
},
|
||||||
bulkMode: false,
|
bulkMode: false,
|
||||||
selectedLoras: new Set(),
|
selectedLoras: new Set(),
|
||||||
loraMetadataCache: new Map(),
|
loraMetadataCache: new Map(),
|
||||||
@@ -90,33 +90,35 @@ export const state = {
|
|||||||
showUpdateAvailableOnly: false,
|
showUpdateAvailableOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
recipes: {
|
recipes: {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
sortBy: 'date',
|
sortBy: 'date:desc',
|
||||||
searchManager: null,
|
activeFolder: getStorageItem('recipes_activeFolder'),
|
||||||
searchOptions: {
|
searchManager: null,
|
||||||
title: true,
|
searchOptions: {
|
||||||
tags: true,
|
title: true,
|
||||||
loraName: true,
|
tags: true,
|
||||||
loraModel: true
|
loraName: true,
|
||||||
},
|
loraModel: true,
|
||||||
filters: {
|
recursive: getStorageItem('recipes_recursiveSearch', true),
|
||||||
baseModel: [],
|
},
|
||||||
tags: {},
|
filters: {
|
||||||
license: {},
|
baseModel: [],
|
||||||
modelTypes: [],
|
tags: {},
|
||||||
search: ''
|
license: {},
|
||||||
},
|
modelTypes: [],
|
||||||
|
search: ''
|
||||||
|
},
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
bulkMode: false,
|
bulkMode: false,
|
||||||
selectedModels: new Set(),
|
selectedModels: new Set(),
|
||||||
},
|
},
|
||||||
|
|
||||||
[MODEL_TYPES.CHECKPOINT]: {
|
[MODEL_TYPES.CHECKPOINT]: {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -124,19 +126,19 @@ export const state = {
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`),
|
activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`),
|
||||||
previewVersions: checkpointPreviewVersions,
|
previewVersions: checkpointPreviewVersions,
|
||||||
searchManager: null,
|
searchManager: null,
|
||||||
searchOptions: {
|
searchOptions: {
|
||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
creator: false,
|
creator: false,
|
||||||
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
|
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: []
|
||||||
},
|
},
|
||||||
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||||
bulkMode: false,
|
bulkMode: false,
|
||||||
selectedModels: new Set(),
|
selectedModels: new Set(),
|
||||||
@@ -145,7 +147,7 @@ export const state = {
|
|||||||
showUpdateAvailableOnly: false,
|
showUpdateAvailableOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -154,20 +156,20 @@ export const state = {
|
|||||||
activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`),
|
activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`),
|
||||||
activeLetterFilter: null,
|
activeLetterFilter: null,
|
||||||
previewVersions: embeddingPreviewVersions,
|
previewVersions: embeddingPreviewVersions,
|
||||||
searchManager: null,
|
searchManager: null,
|
||||||
searchOptions: {
|
searchOptions: {
|
||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
creator: false,
|
creator: false,
|
||||||
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
|
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: []
|
||||||
},
|
},
|
||||||
bulkMode: false,
|
bulkMode: false,
|
||||||
selectedModels: new Set(),
|
selectedModels: new Set(),
|
||||||
metadataCache: new Map(),
|
metadataCache: new Map(),
|
||||||
@@ -176,45 +178,45 @@ export const state = {
|
|||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Current active page - use MODEL_TYPES constants
|
// Current active page - use MODEL_TYPES constants
|
||||||
currentPageType: MODEL_TYPES.LORA,
|
currentPageType: MODEL_TYPES.LORA,
|
||||||
|
|
||||||
// Backward compatibility - proxy properties
|
// Backward compatibility - proxy properties
|
||||||
get currentPage() { return this.pages[this.currentPageType].currentPage; },
|
get currentPage() { return this.pages[this.currentPageType].currentPage; },
|
||||||
set currentPage(value) { this.pages[this.currentPageType].currentPage = value; },
|
set currentPage(value) { this.pages[this.currentPageType].currentPage = value; },
|
||||||
|
|
||||||
get isLoading() { return this.pages[this.currentPageType].isLoading; },
|
get isLoading() { return this.pages[this.currentPageType].isLoading; },
|
||||||
set isLoading(value) { this.pages[this.currentPageType].isLoading = value; },
|
set isLoading(value) { this.pages[this.currentPageType].isLoading = value; },
|
||||||
|
|
||||||
get hasMore() { return this.pages[this.currentPageType].hasMore; },
|
get hasMore() { return this.pages[this.currentPageType].hasMore; },
|
||||||
set hasMore(value) { this.pages[this.currentPageType].hasMore = value; },
|
set hasMore(value) { this.pages[this.currentPageType].hasMore = value; },
|
||||||
|
|
||||||
get sortBy() { return this.pages[this.currentPageType].sortBy; },
|
get sortBy() { return this.pages[this.currentPageType].sortBy; },
|
||||||
set sortBy(value) { this.pages[this.currentPageType].sortBy = value; },
|
set sortBy(value) { this.pages[this.currentPageType].sortBy = value; },
|
||||||
|
|
||||||
get activeFolder() { return this.pages[this.currentPageType].activeFolder; },
|
get activeFolder() { return this.pages[this.currentPageType].activeFolder; },
|
||||||
set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; },
|
set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; },
|
||||||
|
|
||||||
get loadingManager() { return this.global.loadingManager; },
|
get loadingManager() { return this.global.loadingManager; },
|
||||||
set loadingManager(value) { this.global.loadingManager = value; },
|
set loadingManager(value) { this.global.loadingManager = value; },
|
||||||
|
|
||||||
get observer() { return this.global.observer; },
|
get observer() { return this.global.observer; },
|
||||||
set observer(value) { this.global.observer = value; },
|
set observer(value) { this.global.observer = value; },
|
||||||
|
|
||||||
get previewVersions() { return this.pages.loras.previewVersions; },
|
get previewVersions() { return this.pages.loras.previewVersions; },
|
||||||
set previewVersions(value) { this.pages.loras.previewVersions = value; },
|
set previewVersions(value) { this.pages.loras.previewVersions = value; },
|
||||||
|
|
||||||
get searchManager() { return this.pages[this.currentPageType].searchManager; },
|
get searchManager() { return this.pages[this.currentPageType].searchManager; },
|
||||||
set searchManager(value) { this.pages[this.currentPageType].searchManager = value; },
|
set searchManager(value) { this.pages[this.currentPageType].searchManager = value; },
|
||||||
|
|
||||||
get searchOptions() { return this.pages[this.currentPageType].searchOptions; },
|
get searchOptions() { return this.pages[this.currentPageType].searchOptions; },
|
||||||
set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; },
|
set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; },
|
||||||
|
|
||||||
get filters() { return this.pages[this.currentPageType].filters; },
|
get filters() { return this.pages[this.currentPageType].filters; },
|
||||||
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
||||||
|
|
||||||
get bulkMode() {
|
get bulkMode() {
|
||||||
const currentType = this.currentPageType;
|
const currentType = this.currentPageType;
|
||||||
if (currentType === MODEL_TYPES.LORA) {
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
return this.pages.loras.bulkMode;
|
return this.pages.loras.bulkMode;
|
||||||
@@ -222,7 +224,7 @@ export const state = {
|
|||||||
return this.pages[currentType].bulkMode;
|
return this.pages[currentType].bulkMode;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
set bulkMode(value) {
|
set bulkMode(value) {
|
||||||
const currentType = this.currentPageType;
|
const currentType = this.currentPageType;
|
||||||
if (currentType === MODEL_TYPES.LORA) {
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
this.pages.loras.bulkMode = value;
|
this.pages.loras.bulkMode = value;
|
||||||
@@ -230,11 +232,11 @@ export const state = {
|
|||||||
this.pages[currentType].bulkMode = value;
|
this.pages[currentType].bulkMode = value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
get selectedLoras() { return this.pages.loras.selectedLoras; },
|
get selectedLoras() { return this.pages.loras.selectedLoras; },
|
||||||
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
|
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
|
||||||
|
|
||||||
get selectedModels() {
|
get selectedModels() {
|
||||||
const currentType = this.currentPageType;
|
const currentType = this.currentPageType;
|
||||||
if (currentType === MODEL_TYPES.LORA) {
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
return this.pages.loras.selectedLoras;
|
return this.pages.loras.selectedLoras;
|
||||||
@@ -242,7 +244,7 @@ export const state = {
|
|||||||
return this.pages[currentType].selectedModels;
|
return this.pages[currentType].selectedModels;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
set selectedModels(value) {
|
set selectedModels(value) {
|
||||||
const currentType = this.currentPageType;
|
const currentType = this.currentPageType;
|
||||||
if (currentType === MODEL_TYPES.LORA) {
|
if (currentType === MODEL_TYPES.LORA) {
|
||||||
this.pages.loras.selectedLoras = value;
|
this.pages.loras.selectedLoras = value;
|
||||||
@@ -250,10 +252,10 @@ export const state = {
|
|||||||
this.pages[currentType].selectedModels = value;
|
this.pages[currentType].selectedModels = value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
|
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
|
||||||
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
||||||
|
|
||||||
get settings() { return this.global.settings; },
|
get settings() { return this.global.settings; },
|
||||||
set settings(value) { this.global.settings = value; }
|
set settings(value) { this.global.settings = value; }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export class VirtualScroller {
|
|||||||
this.scrollContainer = options.scrollContainer || this.containerElement;
|
this.scrollContainer = options.scrollContainer || this.containerElement;
|
||||||
this.batchSize = options.batchSize || 50;
|
this.batchSize = options.batchSize || 50;
|
||||||
this.pageSize = options.pageSize || 100;
|
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)
|
this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
|
||||||
|
|
||||||
// Add container padding properties
|
// Add container padding properties
|
||||||
this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
|
this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
|
||||||
this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS
|
this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS
|
||||||
|
|
||||||
// Add data windowing enable/disable flag
|
// Add data windowing enable/disable flag
|
||||||
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
|
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
|
||||||
|
|
||||||
@@ -73,15 +73,15 @@ export class VirtualScroller {
|
|||||||
this.spacerElement.style.width = '100%';
|
this.spacerElement.style.width = '100%';
|
||||||
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
|
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
|
||||||
this.spacerElement.style.pointerEvents = 'none';
|
this.spacerElement.style.pointerEvents = 'none';
|
||||||
|
|
||||||
// The grid will be used for the actual visible items
|
// The grid will be used for the actual visible items
|
||||||
this.gridElement.style.position = 'relative';
|
this.gridElement.style.position = 'relative';
|
||||||
this.gridElement.style.minHeight = '0';
|
this.gridElement.style.minHeight = '0';
|
||||||
|
|
||||||
// Apply padding directly to ensure consistency
|
// Apply padding directly to ensure consistency
|
||||||
this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
|
this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
|
||||||
this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;
|
this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;
|
||||||
|
|
||||||
// Place the spacer inside the grid container
|
// Place the spacer inside the grid container
|
||||||
this.gridElement.appendChild(this.spacerElement);
|
this.gridElement.appendChild(this.spacerElement);
|
||||||
}
|
}
|
||||||
@@ -97,16 +97,16 @@ export class VirtualScroller {
|
|||||||
const containerStyle = getComputedStyle(this.containerElement);
|
const containerStyle = getComputedStyle(this.containerElement);
|
||||||
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
|
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
|
||||||
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
|
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
|
||||||
|
|
||||||
// Calculate available content width (excluding padding)
|
// Calculate available content width (excluding padding)
|
||||||
const availableContentWidth = containerWidth - paddingLeft - paddingRight;
|
const availableContentWidth = containerWidth - paddingLeft - paddingRight;
|
||||||
|
|
||||||
// Get display density setting
|
// Get display density setting
|
||||||
const displayDensity = state.global.settings?.display_density || 'default';
|
const displayDensity = state.global.settings?.display_density || 'default';
|
||||||
|
|
||||||
// Set exact column counts and grid widths to match CSS container widths
|
// Set exact column counts and grid widths to match CSS container widths
|
||||||
let maxColumns, maxGridWidth;
|
let maxColumns, maxGridWidth;
|
||||||
|
|
||||||
// Match exact column counts and CSS container width values based on density
|
// Match exact column counts and CSS container width values based on density
|
||||||
if (window.innerWidth >= 3000) { // 4K
|
if (window.innerWidth >= 3000) { // 4K
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
@@ -137,17 +137,17 @@ export class VirtualScroller {
|
|||||||
}
|
}
|
||||||
maxGridWidth = 1400; // Match exact CSS container width for 1080p
|
maxGridWidth = 1400; // Match exact CSS container width for 1080p
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate baseCardWidth based on desired column count and available space
|
// Calculate baseCardWidth based on desired column count and available space
|
||||||
// Formula: (maxGridWidth - (columns-1)*gap) / columns
|
// Formula: (maxGridWidth - (columns-1)*gap) / columns
|
||||||
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
|
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
|
||||||
|
|
||||||
// Use the smaller of available content width or max grid width
|
// Use the smaller of available content width or max grid width
|
||||||
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
||||||
|
|
||||||
// Set exact column count based on screen size and mode
|
// Set exact column count based on screen size and mode
|
||||||
this.columnsCount = maxColumns;
|
this.columnsCount = maxColumns;
|
||||||
|
|
||||||
// When available width is smaller than maxGridWidth, recalculate columns
|
// When available width is smaller than maxGridWidth, recalculate columns
|
||||||
if (availableContentWidth < maxGridWidth) {
|
if (availableContentWidth < maxGridWidth) {
|
||||||
// Calculate how many columns can fit in the available space
|
// Calculate how many columns can fit in the available space
|
||||||
@@ -155,30 +155,30 @@ export class VirtualScroller {
|
|||||||
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
|
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate actual item width
|
// Calculate actual item width
|
||||||
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
|
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
|
||||||
|
|
||||||
// Calculate height based on aspect ratio
|
// Calculate height based on aspect ratio
|
||||||
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
||||||
|
|
||||||
// Calculate the left offset to center the grid within the content area
|
// Calculate the left offset to center the grid within the content area
|
||||||
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
|
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
|
||||||
|
|
||||||
// Update grid element max-width to match available width
|
// Update grid element max-width to match available width
|
||||||
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
||||||
|
|
||||||
// Add or remove density classes for style adjustments
|
// Add or remove density classes for style adjustments
|
||||||
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
||||||
this.gridElement.classList.add(`${displayDensity}-density`);
|
this.gridElement.classList.add(`${displayDensity}-density`);
|
||||||
|
|
||||||
// Update spacer height
|
// Update spacer height
|
||||||
this.updateSpacerHeight();
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
// Re-render with new layout
|
// Re-render with new layout
|
||||||
this.clearRenderedItems();
|
this.clearRenderedItems();
|
||||||
this.scheduleRender();
|
this.scheduleRender();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,20 +186,20 @@ export class VirtualScroller {
|
|||||||
// Debounced scroll handler
|
// Debounced scroll handler
|
||||||
this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
|
this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
|
||||||
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
||||||
|
|
||||||
// Window resize handler for layout recalculation
|
// Window resize handler for layout recalculation
|
||||||
this.resizeHandler = this.debounce(() => {
|
this.resizeHandler = this.debounce(() => {
|
||||||
this.calculateLayout();
|
this.calculateLayout();
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
window.addEventListener('resize', this.resizeHandler);
|
window.addEventListener('resize', this.resizeHandler);
|
||||||
|
|
||||||
// Use ResizeObserver for more accurate container size detection
|
// Use ResizeObserver for more accurate container size detection
|
||||||
if (typeof ResizeObserver !== 'undefined') {
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
this.resizeObserver = new ResizeObserver(this.debounce(() => {
|
this.resizeObserver = new ResizeObserver(this.debounce(() => {
|
||||||
this.calculateLayout();
|
this.calculateLayout();
|
||||||
}, 150));
|
}, 150));
|
||||||
|
|
||||||
this.resizeObserver.observe(this.containerElement);
|
this.resizeObserver.observe(this.containerElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,35 +217,35 @@ export class VirtualScroller {
|
|||||||
async loadInitialBatch() {
|
async loadInitialBatch() {
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
if (this.isLoading) return;
|
if (this.isLoading) return;
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.setLoadingTimeout(); // Add loading timeout safety
|
this.setLoadingTimeout(); // Add loading timeout safety
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
|
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
|
||||||
|
|
||||||
// Initialize the data window with the first batch of items
|
// Initialize the data window with the first batch of items
|
||||||
this.items = items || [];
|
this.items = items || [];
|
||||||
this.totalItems = totalItems || 0;
|
this.totalItems = totalItems || 0;
|
||||||
this.hasMore = hasMore;
|
this.hasMore = hasMore;
|
||||||
this.dataWindow = { start: 0, end: this.items.length };
|
this.dataWindow = { start: 0, end: this.items.length };
|
||||||
this.absoluteWindowStart = 0;
|
this.absoluteWindowStart = 0;
|
||||||
|
|
||||||
// Update the spacer height based on the total number of items
|
// Update the spacer height based on the total number of items
|
||||||
this.updateSpacerHeight();
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
// Check if there are no items and show placeholder if needed
|
// Check if there are no items and show placeholder if needed
|
||||||
if (this.items.length === 0) {
|
if (this.items.length === 0) {
|
||||||
this.showNoItemsPlaceholder();
|
this.showNoItemsPlaceholder();
|
||||||
} else {
|
} else {
|
||||||
this.removeNoItemsPlaceholder();
|
this.removeNoItemsPlaceholder();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset page state to sync with our virtual scroller
|
// Reset page state to sync with our virtual scroller
|
||||||
pageState.currentPage = 2; // Next page to load would be 2
|
pageState.currentPage = 2; // Next page to load would be 2
|
||||||
pageState.hasMore = this.hasMore;
|
pageState.hasMore = this.hasMore;
|
||||||
pageState.isLoading = false;
|
pageState.isLoading = false;
|
||||||
|
|
||||||
return { items, totalItems, hasMore };
|
return { items, totalItems, hasMore };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load initial batch:', err);
|
console.error('Failed to load initial batch:', err);
|
||||||
@@ -260,36 +260,36 @@ export class VirtualScroller {
|
|||||||
async loadMoreItems() {
|
async loadMoreItems() {
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
if (this.isLoading || !this.hasMore) return;
|
if (this.isLoading || !this.hasMore) return;
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
pageState.isLoading = true;
|
pageState.isLoading = true;
|
||||||
this.setLoadingTimeout(); // Add loading timeout safety
|
this.setLoadingTimeout(); // Add loading timeout safety
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Loading more items, page:', pageState.currentPage);
|
console.log('Loading more items, page:', pageState.currentPage);
|
||||||
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
|
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
|
||||||
|
|
||||||
if (items && items.length > 0) {
|
if (items && items.length > 0) {
|
||||||
this.items = [...this.items, ...items];
|
this.items = [...this.items, ...items];
|
||||||
this.hasMore = hasMore;
|
this.hasMore = hasMore;
|
||||||
pageState.hasMore = hasMore;
|
pageState.hasMore = hasMore;
|
||||||
|
|
||||||
// Update page for next request
|
// Update page for next request
|
||||||
pageState.currentPage++;
|
pageState.currentPage++;
|
||||||
|
|
||||||
// Update the spacer height
|
// Update the spacer height
|
||||||
this.updateSpacerHeight();
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
// Render the newly loaded items if they're in view
|
// Render the newly loaded items if they're in view
|
||||||
this.scheduleRender();
|
this.scheduleRender();
|
||||||
|
|
||||||
console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
|
console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
|
||||||
} else {
|
} else {
|
||||||
this.hasMore = false;
|
this.hasMore = false;
|
||||||
pageState.hasMore = false;
|
pageState.hasMore = false;
|
||||||
console.log('No more items to load');
|
console.log('No more items to load');
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load more items:', err);
|
console.error('Failed to load more items:', err);
|
||||||
@@ -305,7 +305,7 @@ export class VirtualScroller {
|
|||||||
setLoadingTimeout() {
|
setLoadingTimeout() {
|
||||||
// Clear any existing timeout first
|
// Clear any existing timeout first
|
||||||
this.clearLoadingTimeout();
|
this.clearLoadingTimeout();
|
||||||
|
|
||||||
// Set a new timeout to prevent loading state from getting stuck
|
// Set a new timeout to prevent loading state from getting stuck
|
||||||
this.loadingTimeout = setTimeout(() => {
|
this.loadingTimeout = setTimeout(() => {
|
||||||
if (this.isLoading) {
|
if (this.isLoading) {
|
||||||
@@ -326,15 +326,15 @@ export class VirtualScroller {
|
|||||||
|
|
||||||
updateSpacerHeight() {
|
updateSpacerHeight() {
|
||||||
if (this.columnsCount === 0) return;
|
if (this.columnsCount === 0) return;
|
||||||
|
|
||||||
// Calculate total rows needed based on total items and columns
|
// Calculate total rows needed based on total items and columns
|
||||||
const totalRows = Math.ceil(this.totalItems / this.columnsCount);
|
const totalRows = Math.ceil(this.totalItems / this.columnsCount);
|
||||||
// Add row gaps to the total height calculation
|
// Add row gaps to the total height calculation
|
||||||
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
|
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
|
||||||
|
|
||||||
// Include container padding in the total height
|
// Include container padding in the total height
|
||||||
const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom;
|
const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom;
|
||||||
|
|
||||||
// Update spacer height to represent all items
|
// Update spacer height to represent all items
|
||||||
this.spacerElement.style.height = `${spacerHeight}px`;
|
this.spacerElement.style.height = `${spacerHeight}px`;
|
||||||
}
|
}
|
||||||
@@ -342,28 +342,28 @@ export class VirtualScroller {
|
|||||||
getVisibleRange() {
|
getVisibleRange() {
|
||||||
const scrollTop = this.scrollContainer.scrollTop;
|
const scrollTop = this.scrollContainer.scrollTop;
|
||||||
const viewportHeight = this.scrollContainer.clientHeight;
|
const viewportHeight = this.scrollContainer.clientHeight;
|
||||||
|
|
||||||
// Calculate the visible row range, accounting for row gaps
|
// Calculate the visible row range, accounting for row gaps
|
||||||
const rowHeight = this.itemHeight + this.rowGap;
|
const rowHeight = this.itemHeight + this.rowGap;
|
||||||
const startRow = Math.floor(scrollTop / rowHeight);
|
const startRow = Math.floor(scrollTop / rowHeight);
|
||||||
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
||||||
|
|
||||||
// Add overscan for smoother scrolling
|
// Add overscan for smoother scrolling
|
||||||
const overscanRows = this.overscan;
|
const overscanRows = this.overscan;
|
||||||
const firstRow = Math.max(0, startRow - overscanRows);
|
const firstRow = Math.max(0, startRow - overscanRows);
|
||||||
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
|
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
|
||||||
|
|
||||||
// Calculate item indices
|
// Calculate item indices
|
||||||
const firstIndex = firstRow * this.columnsCount;
|
const firstIndex = firstRow * this.columnsCount;
|
||||||
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
|
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
|
||||||
|
|
||||||
return { start: firstIndex, end: lastIndex };
|
return { start: firstIndex, end: lastIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the scheduleRender method to check for disabled state
|
// Update the scheduleRender method to check for disabled state
|
||||||
scheduleRender() {
|
scheduleRender() {
|
||||||
if (this.disabled || this.renderScheduled) return;
|
if (this.disabled || this.renderScheduled) return;
|
||||||
|
|
||||||
this.renderScheduled = true;
|
this.renderScheduled = true;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this.renderItems();
|
this.renderItems();
|
||||||
@@ -374,25 +374,25 @@ export class VirtualScroller {
|
|||||||
// Update the renderItems method to check for disabled state
|
// Update the renderItems method to check for disabled state
|
||||||
renderItems() {
|
renderItems() {
|
||||||
if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;
|
if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;
|
||||||
|
|
||||||
const { start, end } = this.getVisibleRange();
|
const { start, end } = this.getVisibleRange();
|
||||||
|
|
||||||
// Check if render range has significantly changed
|
// Check if render range has significantly changed
|
||||||
const isSameRange =
|
const isSameRange =
|
||||||
start >= this.lastRenderRange.start &&
|
start >= this.lastRenderRange.start &&
|
||||||
end <= this.lastRenderRange.end &&
|
end <= this.lastRenderRange.end &&
|
||||||
Math.abs(start - this.lastRenderRange.start) < 10;
|
Math.abs(start - this.lastRenderRange.start) < 10;
|
||||||
|
|
||||||
if (isSameRange) return;
|
if (isSameRange) return;
|
||||||
|
|
||||||
this.lastRenderRange = { start, end };
|
this.lastRenderRange = { start, end };
|
||||||
|
|
||||||
// Determine which items need to be added and removed
|
// Determine which items need to be added and removed
|
||||||
const currentIndices = new Set();
|
const currentIndices = new Set();
|
||||||
for (let i = start; i < end && i < this.items.length; i++) {
|
for (let i = start; i < end && i < this.items.length; i++) {
|
||||||
currentIndices.add(i);
|
currentIndices.add(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove items that are no longer visible
|
// Remove items that are no longer visible
|
||||||
for (const [index, element] of this.renderedItems.entries()) {
|
for (const [index, element] of this.renderedItems.entries()) {
|
||||||
if (!currentIndices.has(index)) {
|
if (!currentIndices.has(index)) {
|
||||||
@@ -400,10 +400,10 @@ export class VirtualScroller {
|
|||||||
this.renderedItems.delete(index);
|
this.renderedItems.delete(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use DocumentFragment for batch DOM operations
|
// Use DocumentFragment for batch DOM operations
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
// Add new visible items to the fragment
|
// Add new visible items to the fragment
|
||||||
for (let i = start; i < end && i < this.items.length; i++) {
|
for (let i = start; i < end && i < this.items.length; i++) {
|
||||||
if (!this.renderedItems.has(i)) {
|
if (!this.renderedItems.has(i)) {
|
||||||
@@ -413,17 +413,17 @@ export class VirtualScroller {
|
|||||||
this.renderedItems.set(i, element);
|
this.renderedItems.set(i, element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the fragment to the grid (single DOM operation)
|
// Add the fragment to the grid (single DOM operation)
|
||||||
if (fragment.childNodes.length > 0) {
|
if (fragment.childNodes.length > 0) {
|
||||||
this.gridElement.appendChild(fragment);
|
this.gridElement.appendChild(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're close to the end and have more items to load, fetch them
|
// 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) {
|
if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) {
|
||||||
this.loadMoreItems();
|
this.loadMoreItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we need to slide the data window
|
// Check if we need to slide the data window
|
||||||
this.slideDataWindow();
|
this.slideDataWindow();
|
||||||
}
|
}
|
||||||
@@ -439,14 +439,14 @@ export class VirtualScroller {
|
|||||||
this.totalItems = totalItems || 0;
|
this.totalItems = totalItems || 0;
|
||||||
this.hasMore = hasMore;
|
this.hasMore = hasMore;
|
||||||
this.updateSpacerHeight();
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
// Check if there are no items and show placeholder if needed
|
// Check if there are no items and show placeholder if needed
|
||||||
if (this.items.length === 0) {
|
if (this.items.length === 0) {
|
||||||
this.showNoItemsPlaceholder();
|
this.showNoItemsPlaceholder();
|
||||||
} else {
|
} else {
|
||||||
this.removeNoItemsPlaceholder();
|
this.removeNoItemsPlaceholder();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all rendered items and redraw
|
// Clear all rendered items and redraw
|
||||||
this.clearRenderedItems();
|
this.clearRenderedItems();
|
||||||
this.scheduleRender();
|
this.scheduleRender();
|
||||||
@@ -455,29 +455,29 @@ export class VirtualScroller {
|
|||||||
createItemElement(item, index) {
|
createItemElement(item, index) {
|
||||||
// Create the DOM element
|
// Create the DOM element
|
||||||
const element = this.createItemFn(item);
|
const element = this.createItemFn(item);
|
||||||
|
|
||||||
// Add virtual scroll item class
|
// Add virtual scroll item class
|
||||||
element.classList.add('virtual-scroll-item');
|
element.classList.add('virtual-scroll-item');
|
||||||
|
|
||||||
// Calculate the position
|
// Calculate the position
|
||||||
const row = Math.floor(index / this.columnsCount);
|
const row = Math.floor(index / this.columnsCount);
|
||||||
const col = index % this.columnsCount;
|
const col = index % this.columnsCount;
|
||||||
|
|
||||||
// Calculate precise positions with row gap included
|
// Calculate precise positions with row gap included
|
||||||
// Add the top padding to account for container padding
|
// Add the top padding to account for container padding
|
||||||
const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap));
|
const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap));
|
||||||
|
|
||||||
// Position correctly with leftOffset (no need to add padding as absolute
|
// Position correctly with leftOffset (no need to add padding as absolute
|
||||||
// positioning is already relative to the padding edge of the container)
|
// positioning is already relative to the padding edge of the container)
|
||||||
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
|
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
|
||||||
|
|
||||||
// Position the element with absolute positioning
|
// Position the element with absolute positioning
|
||||||
element.style.position = 'absolute';
|
element.style.position = 'absolute';
|
||||||
element.style.left = `${leftPos}px`;
|
element.style.left = `${leftPos}px`;
|
||||||
element.style.top = `${topPos}px`;
|
element.style.top = `${topPos}px`;
|
||||||
element.style.width = `${this.itemWidth}px`;
|
element.style.width = `${this.itemWidth}px`;
|
||||||
element.style.height = `${this.itemHeight}px`;
|
element.style.height = `${this.itemHeight}px`;
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,17 +486,17 @@ export class VirtualScroller {
|
|||||||
const scrollTop = this.scrollContainer.scrollTop;
|
const scrollTop = this.scrollContainer.scrollTop;
|
||||||
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
|
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
|
||||||
this.lastScrollTop = scrollTop;
|
this.lastScrollTop = scrollTop;
|
||||||
|
|
||||||
// Handle large jumps in scroll position - check if we need to fetch a new window
|
// Handle large jumps in scroll position - check if we need to fetch a new window
|
||||||
const { scrollHeight } = this.scrollContainer;
|
const { scrollHeight } = this.scrollContainer;
|
||||||
const scrollRatio = scrollTop / scrollHeight;
|
const scrollRatio = scrollTop / scrollHeight;
|
||||||
|
|
||||||
// Only perform data windowing if the feature is enabled
|
// Only perform data windowing if the feature is enabled
|
||||||
if (this.enableDataWindowing && this.totalItems > this.windowSize) {
|
if (this.enableDataWindowing && this.totalItems > this.windowSize) {
|
||||||
const estimatedIndex = Math.floor(scrollRatio * this.totalItems);
|
const estimatedIndex = Math.floor(scrollRatio * this.totalItems);
|
||||||
const currentWindowStart = this.absoluteWindowStart;
|
const currentWindowStart = this.absoluteWindowStart;
|
||||||
const currentWindowEnd = currentWindowStart + this.items.length;
|
const currentWindowEnd = currentWindowStart + this.items.length;
|
||||||
|
|
||||||
// If the estimated position is outside our current window by a significant amount
|
// If the estimated position is outside our current window by a significant amount
|
||||||
if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) {
|
if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) {
|
||||||
// Fetch a new data window centered on the estimated position
|
// 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
|
return; // Skip normal rendering until new data is loaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render visible items
|
// Render visible items
|
||||||
this.scheduleRender();
|
this.scheduleRender();
|
||||||
|
|
||||||
// If we're near the bottom and have more items, load them
|
// If we're near the bottom and have more items, load them
|
||||||
const { clientHeight } = this.scrollContainer;
|
const { clientHeight } = this.scrollContainer;
|
||||||
const scrollBottom = scrollTop + clientHeight;
|
const scrollBottom = scrollTop + clientHeight;
|
||||||
|
|
||||||
// Fix the threshold calculation - use percentage of remaining height instead
|
// Fix the threshold calculation - use percentage of remaining height instead
|
||||||
// We'll trigger loading when within 20% of the bottom of rendered content
|
// We'll trigger loading when within 20% of the bottom of rendered content
|
||||||
const remainingScroll = scrollHeight - scrollBottom;
|
const remainingScroll = scrollHeight - scrollBottom;
|
||||||
@@ -521,9 +521,9 @@ export class VirtualScroller {
|
|||||||
// Or when within 2 rows of content from the bottom, whichever is larger
|
// Or when within 2 rows of content from the bottom, whichever is larger
|
||||||
(this.itemHeight + this.rowGap) * 2
|
(this.itemHeight + this.rowGap) * 2
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldLoadMore = remainingScroll <= scrollThreshold;
|
const shouldLoadMore = remainingScroll <= scrollThreshold;
|
||||||
|
|
||||||
if (shouldLoadMore && this.hasMore && !this.isLoading) {
|
if (shouldLoadMore && this.hasMore && !this.isLoading) {
|
||||||
this.loadMoreItems();
|
this.loadMoreItems();
|
||||||
}
|
}
|
||||||
@@ -533,40 +533,40 @@ export class VirtualScroller {
|
|||||||
async fetchDataWindow(targetIndex) {
|
async fetchDataWindow(targetIndex) {
|
||||||
// Skip if data windowing is disabled or already fetching
|
// Skip if data windowing is disabled or already fetching
|
||||||
if (!this.enableDataWindowing || this.fetchingWindow) return;
|
if (!this.enableDataWindowing || this.fetchingWindow) return;
|
||||||
|
|
||||||
this.fetchingWindow = true;
|
this.fetchingWindow = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Calculate which page we need to fetch based on target index
|
// Calculate which page we need to fetch based on target index
|
||||||
const targetPage = Math.floor(targetIndex / this.pageSize) + 1;
|
const targetPage = Math.floor(targetIndex / this.pageSize) + 1;
|
||||||
console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`);
|
console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`);
|
||||||
|
|
||||||
const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize);
|
const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize);
|
||||||
|
|
||||||
if (items && items.length > 0) {
|
if (items && items.length > 0) {
|
||||||
// Calculate new absolute window start
|
// Calculate new absolute window start
|
||||||
this.absoluteWindowStart = (targetPage - 1) * this.pageSize;
|
this.absoluteWindowStart = (targetPage - 1) * this.pageSize;
|
||||||
|
|
||||||
// Replace the entire data window with new items
|
// Replace the entire data window with new items
|
||||||
this.items = items;
|
this.items = items;
|
||||||
this.dataWindow = {
|
this.dataWindow = {
|
||||||
start: 0,
|
start: 0,
|
||||||
end: items.length
|
end: items.length
|
||||||
};
|
};
|
||||||
|
|
||||||
this.totalItems = totalItems || 0;
|
this.totalItems = totalItems || 0;
|
||||||
this.hasMore = hasMore;
|
this.hasMore = hasMore;
|
||||||
|
|
||||||
// Update the current page for future fetches
|
// Update the current page for future fetches
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.currentPage = targetPage + 1;
|
pageState.currentPage = targetPage + 1;
|
||||||
pageState.hasMore = hasMore;
|
pageState.hasMore = hasMore;
|
||||||
|
|
||||||
// Update the spacer height and clear current rendered items
|
// Update the spacer height and clear current rendered items
|
||||||
this.updateSpacerHeight();
|
this.updateSpacerHeight();
|
||||||
this.clearRenderedItems();
|
this.clearRenderedItems();
|
||||||
this.scheduleRender();
|
this.scheduleRender();
|
||||||
|
|
||||||
console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`);
|
console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -581,37 +581,37 @@ export class VirtualScroller {
|
|||||||
async slideDataWindow() {
|
async slideDataWindow() {
|
||||||
// Skip if data windowing is disabled
|
// Skip if data windowing is disabled
|
||||||
if (!this.enableDataWindowing) return;
|
if (!this.enableDataWindowing) return;
|
||||||
|
|
||||||
const { start, end } = this.getVisibleRange();
|
const { start, end } = this.getVisibleRange();
|
||||||
const windowStart = this.dataWindow.start;
|
const windowStart = this.dataWindow.start;
|
||||||
const windowEnd = this.dataWindow.end;
|
const windowEnd = this.dataWindow.end;
|
||||||
const absoluteIndex = this.absoluteWindowStart + windowStart;
|
const absoluteIndex = this.absoluteWindowStart + windowStart;
|
||||||
|
|
||||||
// Calculate the midpoint of the visible range
|
// Calculate the midpoint of the visible range
|
||||||
const visibleMidpoint = Math.floor((start + end) / 2);
|
const visibleMidpoint = Math.floor((start + end) / 2);
|
||||||
const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint;
|
const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint;
|
||||||
|
|
||||||
// Check if we're too close to the window edges
|
// Check if we're too close to the window edges
|
||||||
const closeToStart = start - windowStart < this.windowPadding;
|
const closeToStart = start - windowStart < this.windowPadding;
|
||||||
const closeToEnd = windowEnd - end < this.windowPadding;
|
const closeToEnd = windowEnd - end < this.windowPadding;
|
||||||
|
|
||||||
// If we're close to either edge and have total items > window size
|
// If we're close to either edge and have total items > window size
|
||||||
if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) {
|
if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) {
|
||||||
// Calculate a new target index centered around the current viewport
|
// Calculate a new target index centered around the current viewport
|
||||||
const halfWindow = Math.floor(this.windowSize / 2);
|
const halfWindow = Math.floor(this.windowSize / 2);
|
||||||
const targetIndex = Math.max(0, absoluteMidpoint - halfWindow);
|
const targetIndex = Math.max(0, absoluteMidpoint - halfWindow);
|
||||||
|
|
||||||
// Don't fetch a new window if we're already showing items near the beginning
|
// Don't fetch a new window if we're already showing items near the beginning
|
||||||
if (targetIndex === 0 && this.absoluteWindowStart === 0) {
|
if (targetIndex === 0 && this.absoluteWindowStart === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't fetch if we're showing the end of the list and are near the end
|
// 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) {
|
this.totalItems - end < halfWindow) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the new data window
|
// Fetch the new data window
|
||||||
await this.fetchDataWindow(targetIndex);
|
await this.fetchDataWindow(targetIndex);
|
||||||
}
|
}
|
||||||
@@ -620,18 +620,18 @@ export class VirtualScroller {
|
|||||||
reset() {
|
reset() {
|
||||||
// Remove all rendered items
|
// Remove all rendered items
|
||||||
this.clearRenderedItems();
|
this.clearRenderedItems();
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
this.items = [];
|
this.items = [];
|
||||||
this.totalItems = 0;
|
this.totalItems = 0;
|
||||||
this.hasMore = true;
|
this.hasMore = true;
|
||||||
|
|
||||||
// Reset spacer height
|
// Reset spacer height
|
||||||
this.spacerElement.style.height = '0px';
|
this.spacerElement.style.height = '0px';
|
||||||
|
|
||||||
// Remove any placeholder
|
// Remove any placeholder
|
||||||
this.removeNoItemsPlaceholder();
|
this.removeNoItemsPlaceholder();
|
||||||
|
|
||||||
// Schedule a re-render
|
// Schedule a re-render
|
||||||
this.scheduleRender();
|
this.scheduleRender();
|
||||||
}
|
}
|
||||||
@@ -640,21 +640,21 @@ export class VirtualScroller {
|
|||||||
// Remove event listeners
|
// Remove event listeners
|
||||||
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
||||||
window.removeEventListener('resize', this.resizeHandler);
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
|
|
||||||
// Clean up the resize observer if present
|
// Clean up the resize observer if present
|
||||||
if (this.resizeObserver) {
|
if (this.resizeObserver) {
|
||||||
this.resizeObserver.disconnect();
|
this.resizeObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove rendered elements
|
// Remove rendered elements
|
||||||
this.clearRenderedItems();
|
this.clearRenderedItems();
|
||||||
|
|
||||||
// Remove spacer
|
// Remove spacer
|
||||||
this.spacerElement.remove();
|
this.spacerElement.remove();
|
||||||
|
|
||||||
// Remove virtual scroll class
|
// Remove virtual scroll class
|
||||||
this.gridElement.classList.remove('virtual-scroll');
|
this.gridElement.classList.remove('virtual-scroll');
|
||||||
|
|
||||||
// Clear any pending timeout
|
// Clear any pending timeout
|
||||||
this.clearLoadingTimeout();
|
this.clearLoadingTimeout();
|
||||||
}
|
}
|
||||||
@@ -663,19 +663,19 @@ export class VirtualScroller {
|
|||||||
showNoItemsPlaceholder(message) {
|
showNoItemsPlaceholder(message) {
|
||||||
// Remove any existing placeholder first
|
// Remove any existing placeholder first
|
||||||
this.removeNoItemsPlaceholder();
|
this.removeNoItemsPlaceholder();
|
||||||
|
|
||||||
// Create placeholder message
|
// Create placeholder message
|
||||||
const placeholder = document.createElement('div');
|
const placeholder = document.createElement('div');
|
||||||
placeholder.className = 'placeholder-message';
|
placeholder.className = 'placeholder-message';
|
||||||
|
|
||||||
// Determine appropriate message based on page type
|
// Determine appropriate message based on page type
|
||||||
let placeholderText = '';
|
let placeholderText = '';
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
placeholderText = message;
|
placeholderText = message;
|
||||||
} else {
|
} else {
|
||||||
const pageType = state.currentPageType;
|
const pageType = state.currentPageType;
|
||||||
|
|
||||||
if (pageType === 'recipes') {
|
if (pageType === 'recipes') {
|
||||||
placeholderText = `
|
placeholderText = `
|
||||||
<p>No recipes found</p>
|
<p>No recipes found</p>
|
||||||
@@ -698,10 +698,10 @@ export class VirtualScroller {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
placeholder.innerHTML = placeholderText;
|
placeholder.innerHTML = placeholderText;
|
||||||
placeholder.id = 'virtualScrollPlaceholder';
|
placeholder.id = 'virtualScrollPlaceholder';
|
||||||
|
|
||||||
// Append placeholder to the grid
|
// Append placeholder to the grid
|
||||||
this.gridElement.appendChild(placeholder);
|
this.gridElement.appendChild(placeholder);
|
||||||
}
|
}
|
||||||
@@ -716,7 +716,7 @@ export class VirtualScroller {
|
|||||||
// Utility method for debouncing
|
// Utility method for debouncing
|
||||||
debounce(func, wait) {
|
debounce(func, wait) {
|
||||||
let timeout;
|
let timeout;
|
||||||
return function(...args) {
|
return function (...args) {
|
||||||
const context = this;
|
const context = this;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = setTimeout(() => func.apply(context, args), wait);
|
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||||
@@ -727,55 +727,55 @@ export class VirtualScroller {
|
|||||||
disable() {
|
disable() {
|
||||||
// Detach scroll event listener
|
// Detach scroll event listener
|
||||||
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
||||||
|
|
||||||
// Clear all rendered items from the DOM
|
// Clear all rendered items from the DOM
|
||||||
this.clearRenderedItems();
|
this.clearRenderedItems();
|
||||||
|
|
||||||
// Hide the spacer element
|
// Hide the spacer element
|
||||||
if (this.spacerElement) {
|
if (this.spacerElement) {
|
||||||
this.spacerElement.style.display = 'none';
|
this.spacerElement.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag as disabled
|
// Flag as disabled
|
||||||
this.disabled = true;
|
this.disabled = true;
|
||||||
|
|
||||||
console.log('Virtual scroller disabled');
|
console.log('Virtual scroller disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add enable method to resume rendering and events
|
// Add enable method to resume rendering and events
|
||||||
enable() {
|
enable() {
|
||||||
if (!this.disabled) return;
|
if (!this.disabled) return;
|
||||||
|
|
||||||
// Reattach scroll event listener
|
// Reattach scroll event listener
|
||||||
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
||||||
|
|
||||||
// Check if spacer element exists in the DOM, if not, recreate it
|
// Check if spacer element exists in the DOM, if not, recreate it
|
||||||
if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
|
if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
|
||||||
console.log('Spacer element not found in DOM, recreating it');
|
console.log('Spacer element not found in DOM, recreating it');
|
||||||
|
|
||||||
// Create a new spacer element
|
// Create a new spacer element
|
||||||
this.spacerElement = document.createElement('div');
|
this.spacerElement = document.createElement('div');
|
||||||
this.spacerElement.className = 'virtual-scroll-spacer';
|
this.spacerElement.className = 'virtual-scroll-spacer';
|
||||||
this.spacerElement.style.width = '100%';
|
this.spacerElement.style.width = '100%';
|
||||||
this.spacerElement.style.height = '0px';
|
this.spacerElement.style.height = '0px';
|
||||||
this.spacerElement.style.pointerEvents = 'none';
|
this.spacerElement.style.pointerEvents = 'none';
|
||||||
|
|
||||||
// Append it to the grid
|
// Append it to the grid
|
||||||
this.gridElement.appendChild(this.spacerElement);
|
this.gridElement.appendChild(this.spacerElement);
|
||||||
|
|
||||||
// Update the spacer height
|
// Update the spacer height
|
||||||
this.updateSpacerHeight();
|
this.updateSpacerHeight();
|
||||||
} else {
|
} else {
|
||||||
// Show the spacer element if it exists
|
// Show the spacer element if it exists
|
||||||
this.spacerElement.style.display = 'block';
|
this.spacerElement.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag as enabled
|
// Flag as enabled
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
|
|
||||||
// Re-render items
|
// Re-render items
|
||||||
this.scheduleRender();
|
this.scheduleRender();
|
||||||
|
|
||||||
console.log('Virtual scroller enabled');
|
console.log('Virtual scroller enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,31 +783,30 @@ export class VirtualScroller {
|
|||||||
deepMerge(target, source) {
|
deepMerge(target, source) {
|
||||||
if (!source || !target) return target;
|
if (!source || !target) return target;
|
||||||
|
|
||||||
|
// Initialize result with a copy of target
|
||||||
const result = { ...target };
|
const result = { ...target };
|
||||||
|
|
||||||
// Only iterate over keys that exist in target
|
if (!source) return result;
|
||||||
Object.keys(target).forEach(key => {
|
|
||||||
// Check if source has this key
|
|
||||||
if (source.hasOwnProperty(key)) {
|
|
||||||
const targetValue = target[key];
|
|
||||||
const sourceValue = source[key];
|
|
||||||
|
|
||||||
// If both values are non-null objects and not arrays, merge recursively
|
// Iterate over all keys in the source object
|
||||||
if (
|
Object.keys(source).forEach(key => {
|
||||||
targetValue !== null &&
|
const targetValue = target[key];
|
||||||
typeof targetValue === 'object' &&
|
const sourceValue = source[key];
|
||||||
!Array.isArray(targetValue) &&
|
|
||||||
sourceValue !== null &&
|
// If both values are non-null objects and not arrays, merge recursively
|
||||||
typeof sourceValue === 'object' &&
|
if (
|
||||||
!Array.isArray(sourceValue)
|
targetValue !== null &&
|
||||||
) {
|
typeof targetValue === 'object' &&
|
||||||
result[key] = this.deepMerge(targetValue, sourceValue);
|
!Array.isArray(targetValue) &&
|
||||||
} else {
|
sourceValue !== null &&
|
||||||
// For primitive types, arrays, or null, use the value from source
|
typeof sourceValue === 'object' &&
|
||||||
result[key] = sourceValue;
|
!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;
|
return result;
|
||||||
@@ -828,43 +827,43 @@ export class VirtualScroller {
|
|||||||
|
|
||||||
// Update the item data using deep merge
|
// Update the item data using deep merge
|
||||||
this.items[index] = this.deepMerge(this.items[index], updatedItem);
|
this.items[index] = this.deepMerge(this.items[index], updatedItem);
|
||||||
|
|
||||||
// If the item is currently rendered, update its DOM representation
|
// If the item is currently rendered, update its DOM representation
|
||||||
if (this.renderedItems.has(index)) {
|
if (this.renderedItems.has(index)) {
|
||||||
const element = this.renderedItems.get(index);
|
const element = this.renderedItems.get(index);
|
||||||
|
|
||||||
// Remove the old element
|
// Remove the old element
|
||||||
element.remove();
|
element.remove();
|
||||||
this.renderedItems.delete(index);
|
this.renderedItems.delete(index);
|
||||||
|
|
||||||
// Create and render the updated element
|
// Create and render the updated element
|
||||||
const updatedElement = this.createItemElement(this.items[index], index);
|
const updatedElement = this.createItemElement(this.items[index], index);
|
||||||
|
|
||||||
// Add update indicator visual effects
|
// Add update indicator visual effects
|
||||||
updatedElement.classList.add('updated');
|
updatedElement.classList.add('updated');
|
||||||
|
|
||||||
// Add temporary update tag
|
// Add temporary update tag
|
||||||
const updateIndicator = document.createElement('div');
|
const updateIndicator = document.createElement('div');
|
||||||
updateIndicator.className = 'update-indicator';
|
updateIndicator.className = 'update-indicator';
|
||||||
updateIndicator.textContent = 'Updated';
|
updateIndicator.textContent = 'Updated';
|
||||||
updatedElement.querySelector('.card-preview').appendChild(updateIndicator);
|
updatedElement.querySelector('.card-preview').appendChild(updateIndicator);
|
||||||
|
|
||||||
// Automatically remove the updated class after animation completes
|
// Automatically remove the updated class after animation completes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
updatedElement.classList.remove('updated');
|
updatedElement.classList.remove('updated');
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
// Automatically remove the indicator after animation completes
|
// Automatically remove the indicator after animation completes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (updateIndicator && updateIndicator.parentNode) {
|
if (updateIndicator && updateIndicator.parentNode) {
|
||||||
updateIndicator.remove();
|
updateIndicator.remove();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
this.renderedItems.set(index, updatedElement);
|
this.renderedItems.set(index, updatedElement);
|
||||||
this.gridElement.appendChild(updatedElement);
|
this.gridElement.appendChild(updatedElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,26 +881,26 @@ export class VirtualScroller {
|
|||||||
|
|
||||||
// Remove the item from the data array
|
// Remove the item from the data array
|
||||||
this.items.splice(index, 1);
|
this.items.splice(index, 1);
|
||||||
|
|
||||||
// Decrement total count
|
// Decrement total count
|
||||||
this.totalItems = Math.max(0, this.totalItems - 1);
|
this.totalItems = Math.max(0, this.totalItems - 1);
|
||||||
|
|
||||||
// Remove the item from rendered items if it exists
|
// Remove the item from rendered items if it exists
|
||||||
if (this.renderedItems.has(index)) {
|
if (this.renderedItems.has(index)) {
|
||||||
this.renderedItems.get(index).remove();
|
this.renderedItems.get(index).remove();
|
||||||
this.renderedItems.delete(index);
|
this.renderedItems.delete(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift all rendered items with higher indices down by 1
|
// Shift all rendered items with higher indices down by 1
|
||||||
const indicesToUpdate = [];
|
const indicesToUpdate = [];
|
||||||
|
|
||||||
// Collect all indices that need to be updated
|
// Collect all indices that need to be updated
|
||||||
for (const [idx, element] of this.renderedItems.entries()) {
|
for (const [idx, element] of this.renderedItems.entries()) {
|
||||||
if (idx > index) {
|
if (idx > index) {
|
||||||
indicesToUpdate.push(idx);
|
indicesToUpdate.push(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the elements and map entries
|
// Update the elements and map entries
|
||||||
for (const idx of indicesToUpdate) {
|
for (const idx of indicesToUpdate) {
|
||||||
const element = this.renderedItems.get(idx);
|
const element = this.renderedItems.get(idx);
|
||||||
@@ -909,14 +908,14 @@ export class VirtualScroller {
|
|||||||
// The item is now at the previous index
|
// The item is now at the previous index
|
||||||
this.renderedItems.set(idx - 1, element);
|
this.renderedItems.set(idx - 1, element);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the spacer height to reflect the new total
|
// Update the spacer height to reflect the new total
|
||||||
this.updateSpacerHeight();
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
// Re-render to ensure proper layout
|
// Re-render to ensure proper layout
|
||||||
this.clearRenderedItems();
|
this.clearRenderedItems();
|
||||||
this.scheduleRender();
|
this.scheduleRender();
|
||||||
|
|
||||||
console.log(`Removed item with file path ${filePath} from virtual scroller data`);
|
console.log(`Removed item with file path ${filePath} from virtual scroller data`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -929,28 +928,28 @@ export class VirtualScroller {
|
|||||||
return; // Ignore rapid repeated triggers
|
return; // Ignore rapid repeated triggers
|
||||||
}
|
}
|
||||||
this.lastPageNavTime = now;
|
this.lastPageNavTime = now;
|
||||||
|
|
||||||
const scrollContainer = this.scrollContainer;
|
const scrollContainer = this.scrollContainer;
|
||||||
const viewportHeight = scrollContainer.clientHeight;
|
const viewportHeight = scrollContainer.clientHeight;
|
||||||
|
|
||||||
// Calculate scroll distance (one viewport minus 10% overlap for context)
|
// Calculate scroll distance (one viewport minus 10% overlap for context)
|
||||||
const scrollDistance = viewportHeight * 0.9;
|
const scrollDistance = viewportHeight * 0.9;
|
||||||
|
|
||||||
// Determine the new scroll position
|
// Determine the new scroll position
|
||||||
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
|
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
|
||||||
|
|
||||||
// Remove any existing transition indicators
|
// Remove any existing transition indicators
|
||||||
this.removeExistingTransitionIndicator();
|
this.removeExistingTransitionIndicator();
|
||||||
|
|
||||||
// Scroll to the new position with smooth animation
|
// Scroll to the new position with smooth animation
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: newScrollTop,
|
top: newScrollTop,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Page transition indicator removed
|
// Page transition indicator removed
|
||||||
// this.showTransitionIndicator();
|
// this.showTransitionIndicator();
|
||||||
|
|
||||||
// Force render after scrolling
|
// Force render after scrolling
|
||||||
setTimeout(() => this.renderItems(), 100);
|
setTimeout(() => this.renderItems(), 100);
|
||||||
setTimeout(() => this.renderItems(), 300);
|
setTimeout(() => this.renderItems(), 300);
|
||||||
@@ -966,25 +965,25 @@ export class VirtualScroller {
|
|||||||
|
|
||||||
scrollToTop() {
|
scrollToTop() {
|
||||||
this.removeExistingTransitionIndicator();
|
this.removeExistingTransitionIndicator();
|
||||||
|
|
||||||
// Page transition indicator removed
|
// Page transition indicator removed
|
||||||
// this.showTransitionIndicator();
|
// this.showTransitionIndicator();
|
||||||
|
|
||||||
this.scrollContainer.scrollTo({
|
this.scrollContainer.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force render after scrolling
|
// Force render after scrolling
|
||||||
setTimeout(() => this.renderItems(), 100);
|
setTimeout(() => this.renderItems(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom() {
|
scrollToBottom() {
|
||||||
this.removeExistingTransitionIndicator();
|
this.removeExistingTransitionIndicator();
|
||||||
|
|
||||||
// Page transition indicator removed
|
// Page transition indicator removed
|
||||||
// this.showTransitionIndicator();
|
// this.showTransitionIndicator();
|
||||||
|
|
||||||
// Start loading all remaining pages to ensure content is available
|
// Start loading all remaining pages to ensure content is available
|
||||||
this.loadRemainingPages().then(() => {
|
this.loadRemainingPages().then(() => {
|
||||||
// After loading all content, scroll to the very bottom
|
// After loading all content, scroll to the very bottom
|
||||||
@@ -995,27 +994,27 @@ export class VirtualScroller {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// New method to load all remaining pages
|
// New method to load all remaining pages
|
||||||
async loadRemainingPages() {
|
async loadRemainingPages() {
|
||||||
// If we're already at the end or loading, don't proceed
|
// If we're already at the end or loading, don't proceed
|
||||||
if (!this.hasMore || this.isLoading) return;
|
if (!this.hasMore || this.isLoading) return;
|
||||||
|
|
||||||
console.log('Loading all remaining pages for End key navigation...');
|
console.log('Loading all remaining pages for End key navigation...');
|
||||||
|
|
||||||
// Keep loading pages until we reach the end
|
// Keep loading pages until we reach the end
|
||||||
while (this.hasMore && !this.isLoading) {
|
while (this.hasMore && !this.isLoading) {
|
||||||
await this.loadMoreItems();
|
await this.loadMoreItems();
|
||||||
|
|
||||||
// Force render after each page load
|
// Force render after each page load
|
||||||
this.renderItems();
|
this.renderItems();
|
||||||
|
|
||||||
// Small delay to prevent overwhelming the browser
|
// Small delay to prevent overwhelming the browser
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Finished loading all pages');
|
console.log('Finished loading all pages');
|
||||||
|
|
||||||
// Final render to ensure all content is displayed
|
// Final render to ensure all content is displayed
|
||||||
this.renderItems();
|
this.renderItems();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>{% block title %}{{ t('header.appTitle') }}{% endblock %}</title>
|
<title>{% block title %}{{ t('header.appTitle') }}{% endblock %}</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/loras_static/css/style.css">
|
<link rel="stylesheet" href="/loras_static/css/style.css?v={{ version }}">
|
||||||
<link rel="stylesheet" href="/loras_static/css/onboarding.css">
|
<link rel="stylesheet" href="/loras_static/css/onboarding.css?v={{ version }}">
|
||||||
<link rel="stylesheet" href="/loras_static/vendor/flag-icons/flag-icons.min.css">
|
<link rel="stylesheet" href="/loras_static/vendor/flag-icons/flag-icons.min.css">
|
||||||
{% block page_css %}{% endblock %}
|
{% block page_css %}{% endblock %}
|
||||||
<link rel="stylesheet" href="/loras_static/vendor/font-awesome/css/all.min.css"
|
<link rel="stylesheet" href="/loras_static/vendor/font-awesome/css/all.min.css"
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
{% if is_initializing %}
|
{% if is_initializing %}
|
||||||
<!-- Load initialization JavaScript -->
|
<!-- Load initialization JavaScript -->
|
||||||
<script type="module" src="/loras_static/js/components/initialization.js"></script>
|
<script type="module" src="/loras_static/js/components/initialization.js?v={{ version }}"></script>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% block main_script %}{% endblock %}
|
{% block main_script %}{% endblock %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -40,5 +40,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main_script %}
|
{% block main_script %}
|
||||||
<script type="module" src="/loras_static/js/checkpoints.js"></script>
|
<script type="module" src="/loras_static/js/checkpoints.js?v={{ version }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -102,6 +102,9 @@
|
|||||||
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
||||||
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="repair-recipes">
|
||||||
|
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="nsfwLevelSelector" class="nsfw-level-selector">
|
<div id="nsfwLevelSelector" class="nsfw-level-selector">
|
||||||
@@ -110,7 +113,8 @@
|
|||||||
<button class="close-nsfw-selector"><i class="fas fa-times"></i></button>
|
<button class="close-nsfw-selector"><i class="fas fa-times"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="nsfw-level-content">
|
<div class="nsfw-level-content">
|
||||||
<div class="current-level"><span>{{ t('modals.contentRating.current') }}:</span> <span id="currentNSFWLevel">{{ t('common.status.unknown') }}</span></div>
|
<div class="current-level"><span>{{ t('modals.contentRating.current') }}:</span> <span id="currentNSFWLevel">{{
|
||||||
|
t('common.status.unknown') }}</span></div>
|
||||||
<div class="nsfw-level-options">
|
<div class="nsfw-level-options">
|
||||||
<button class="nsfw-level-btn" data-level="1">{{ t('modals.contentRating.levels.pg') }}</button>
|
<button class="nsfw-level-btn" data-level="1">{{ t('modals.contentRating.levels.pg') }}</button>
|
||||||
<button class="nsfw-level-btn" data-level="2">{{ t('modals.contentRating.levels.pg13') }}</button>
|
<button class="nsfw-level-btn" data-level="2">{{ t('modals.contentRating.levels.pg13') }}</button>
|
||||||
@@ -123,4 +127,4 @@
|
|||||||
|
|
||||||
<div id="nodeSelector" class="node-selector">
|
<div id="nodeSelector" class="node-selector">
|
||||||
<!-- Dynamic node list will be populated here -->
|
<!-- Dynamic node list will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
@@ -8,52 +8,60 @@
|
|||||||
</div>
|
</div>
|
||||||
{% set current_path = request.path %}
|
{% set current_path = request.path %}
|
||||||
{% if current_path.startswith('/loras/recipes') %}
|
{% if current_path.startswith('/loras/recipes') %}
|
||||||
{% set current_page = 'recipes' %}
|
{% set current_page = 'recipes' %}
|
||||||
{% elif current_path.startswith('/checkpoints') %}
|
{% elif current_path.startswith('/checkpoints') %}
|
||||||
{% set current_page = 'checkpoints' %}
|
{% set current_page = 'checkpoints' %}
|
||||||
{% elif current_path.startswith('/embeddings') %}
|
{% elif current_path.startswith('/embeddings') %}
|
||||||
{% set current_page = 'embeddings' %}
|
{% set current_page = 'embeddings' %}
|
||||||
{% elif current_path.startswith('/statistics') %}
|
{% elif current_path.startswith('/statistics') %}
|
||||||
{% set current_page = 'statistics' %}
|
{% set current_page = 'statistics' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set current_page = 'loras' %}
|
{% set current_page = 'loras' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set search_disabled = current_page == 'statistics' %}
|
{% 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' %}
|
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||||
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}" id="recipesNavItem">
|
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
||||||
|
id="recipesNavItem">
|
||||||
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}" id="checkpointsNavItem">
|
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
||||||
|
id="checkpointsNavItem">
|
||||||
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}" id="embeddingsNavItem">
|
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
||||||
|
id="embeddingsNavItem">
|
||||||
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}" id="statisticsNavItem">
|
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||||
|
id="statisticsNavItem">
|
||||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Context-aware search container -->
|
<!-- Context-aware search container -->
|
||||||
<div class="{{ header_search_class }}" id="headerSearch">
|
<div class="{{ header_search_class }}" id="headerSearch">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}"{% if search_disabled %} disabled{% endif %} />
|
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||||
|
disabled{% endif %} />
|
||||||
<i class="fas fa-search search-icon"></i>
|
<i class="fas fa-search search-icon"></i>
|
||||||
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}"{% if search_disabled %} disabled aria-disabled="true"{% endif %}>
|
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}" {% if
|
||||||
|
search_disabled %} disabled aria-disabled="true" {% endif %}>
|
||||||
<i class="fas fa-sliders-h"></i>
|
<i class="fas fa-sliders-h"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}"{% if search_disabled %} disabled aria-disabled="true"{% endif %}>
|
<button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}" {% if
|
||||||
|
search_disabled %} disabled aria-disabled="true" {% endif %}>
|
||||||
<i class="fas fa-filter"></i>
|
<i class="fas fa-filter"></i>
|
||||||
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
|
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<!-- Integrated corner controls -->
|
<!-- Integrated corner controls -->
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
@@ -97,6 +105,7 @@
|
|||||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||||
<div class="search-option-tag active" data-option="loraName">{{ t('header.search.filters.loraName') }}</div>
|
<div class="search-option-tag active" data-option="loraName">{{ t('header.search.filters.loraName') }}</div>
|
||||||
<div class="search-option-tag active" data-option="loraModel">{{ t('header.search.filters.loraModel') }}</div>
|
<div class="search-option-tag active" data-option="loraModel">{{ t('header.search.filters.loraModel') }}</div>
|
||||||
|
<div class="search-option-tag active" data-option="prompt">{{ t('header.search.filters.prompt') }}</div>
|
||||||
{% elif request.path == '/checkpoints' %}
|
{% elif request.path == '/checkpoints' %}
|
||||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||||
@@ -165,4 +174,4 @@
|
|||||||
{{ t('header.filter.clearAll') }}
|
{{ t('header.filter.clearAll') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,5 +40,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main_script %}
|
{% block main_script %}
|
||||||
<script type="module" src="/loras_static/js/embeddings.js"></script>
|
<script type="module" src="/loras_static/js/embeddings.js?v={{ version }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -24,6 +24,6 @@
|
|||||||
|
|
||||||
{% block main_script %}
|
{% block main_script %}
|
||||||
{% if not is_initializing %}
|
{% if not is_initializing %}
|
||||||
<script type="module" src="/loras_static/js/loras.js"></script>
|
<script type="module" src="/loras_static/js/loras.js?v={{ version }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
{% block page_id %}recipes{% endblock %}
|
{% block page_id %}recipes{% endblock %}
|
||||||
|
|
||||||
{% block page_css %}
|
{% block page_css %}
|
||||||
<link rel="stylesheet" href="/loras_static/css/components/card.css">
|
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
|
||||||
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css">
|
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
|
||||||
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css">
|
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_components %}
|
{% block additional_components %}
|
||||||
@@ -15,17 +15,29 @@
|
|||||||
|
|
||||||
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||||
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
||||||
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> {{ t('loras.contextMenu.shareRecipe') }}</div>
|
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> {{
|
||||||
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyRecipeSyntax') }}</div>
|
t('loras.contextMenu.shareRecipe') }}</div>
|
||||||
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> {{ t('loras.contextMenu.sendToWorkflowAppend') }}</div>
|
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{
|
||||||
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> {{ t('loras.contextMenu.sendToWorkflowReplace') }}</div>
|
t('loras.contextMenu.copyRecipeSyntax') }}</div>
|
||||||
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> {{ t('loras.contextMenu.viewAllLoras') }}</div>
|
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> {{
|
||||||
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingLoras') }}</div>
|
t('loras.contextMenu.sendToWorkflowAppend') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> {{
|
||||||
|
t('loras.contextMenu.sendToWorkflowReplace') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> {{
|
||||||
|
t('loras.contextMenu.viewAllLoras') }}</div>
|
||||||
|
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i>
|
||||||
|
{{ t('loras.contextMenu.downloadMissingLoras') }}</div>
|
||||||
<div class="context-menu-item" data-action="set-nsfw">
|
<div class="context-menu-item" data-action="set-nsfw">
|
||||||
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
|
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="repair">
|
||||||
|
<i class="fas fa-tools"></i> {{ t('loras.contextMenu.repairMetadata') }}
|
||||||
|
</div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{ t('loras.contextMenu.deleteRecipe') }}</div>
|
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{
|
||||||
|
t('loras.contextMenu.moveToFolder') }}</div>
|
||||||
|
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{
|
||||||
|
t('loras.contextMenu.deleteRecipe') }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -34,55 +46,131 @@
|
|||||||
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
|
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Recipe controls -->
|
<!-- Recipe controls -->
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
<div class="actions">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
|
<div class="control-group">
|
||||||
|
<select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
|
||||||
|
<optgroup label="{{ t('recipes.controls.sort.name') }}">
|
||||||
|
<option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
|
||||||
|
<option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="{{ t('recipes.controls.sort.date') }}">
|
||||||
|
<option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
|
||||||
|
<option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||||
|
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||||
|
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
||||||
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh') }}</button>
|
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{
|
||||||
|
t('common.actions.refresh')
|
||||||
|
}}</button>
|
||||||
</div>
|
</div>
|
||||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
|
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||||
|
t('recipes.controls.import.action') }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
|
||||||
|
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||||
|
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
|
||||||
|
<div class="shortcut-key">B</div>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add duplicate detection button -->
|
<!-- Add duplicate detection button -->
|
||||||
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
||||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{ t('loras.controls.duplicates.action') }}</button>
|
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
|
||||||
|
t('loras.controls.duplicates.action') }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
|
||||||
|
title="{{ t('recipes.controls.favorites.title') }}">
|
||||||
|
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Custom filter indicator button (hidden by default) -->
|
<!-- Custom filter indicator button (hidden by default) -->
|
||||||
<div id="customFilterIndicator" class="control-group hidden">
|
<div id="customFilterIndicator" class="control-group hidden">
|
||||||
<div class="filter-active">
|
<div class="filter-active">
|
||||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora') }}</span>
|
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
|
||||||
|
}}</span>
|
||||||
<i class="fas fa-times-circle clear-filter"></i>
|
<i class="fas fa-times-circle clear-filter"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Duplicates banner (hidden by default) -->
|
<div class="controls-right">
|
||||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
<div class="keyboard-nav-hint tooltip">
|
||||||
<div class="banner-content">
|
<i class="fas fa-keyboard"></i>
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
<span class="tooltiptext">
|
||||||
<span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span>
|
<span>{{ t('keyboard.navigation') }}</span>
|
||||||
<div class="banner-actions">
|
<table class="keyboard-shortcuts">
|
||||||
<button class="btn-select-latest" onclick="recipeManager.selectLatestDuplicates()">
|
<tr>
|
||||||
{{ t('recipes.duplicates.keepLatest') }}
|
<td><span class="key">Page Up</span></td>
|
||||||
</button>
|
<td>{{ t('keyboard.shortcuts.pageUp') }}</td>
|
||||||
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()">
|
</tr>
|
||||||
{{ t('recipes.duplicates.deleteSelected') }} (<span id="duplicatesSelectedCount">0</span>)
|
<tr>
|
||||||
</button>
|
<td><span class="key">Page Down</span></td>
|
||||||
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
|
<td>{{ t('keyboard.shortcuts.pageDown') }}</td>
|
||||||
<i class="fas fa-times"></i>
|
</tr>
|
||||||
</button>
|
<tr>
|
||||||
</div>
|
<td><span class="key">Home</span></td>
|
||||||
|
<td>{{ t('keyboard.shortcuts.home') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><span class="key">End</span></td>
|
||||||
|
<td>{{ t('keyboard.shortcuts.end') }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recipe grid -->
|
<!-- Breadcrumb Navigation -->
|
||||||
<div class="card-grid" id="recipeGrid">
|
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
||||||
<!-- Remove the server-side conditional rendering and placeholder -->
|
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
||||||
<!-- Virtual scrolling will handle the display logic on the client side -->
|
<!-- Breadcrumbs will be populated by JavaScript -->
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duplicates banner (hidden by default) -->
|
||||||
|
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||||
|
<div class="banner-content">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button class="btn-select-latest" onclick="recipeManager.selectLatestDuplicates()">
|
||||||
|
{{ t('recipes.duplicates.keepLatest') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()">
|
||||||
|
{{ t('recipes.duplicates.deleteSelected') }} (<span id="duplicatesSelectedCount">0</span>)
|
||||||
|
</button>
|
||||||
|
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
<!-- Recipe grid -->
|
||||||
|
<div class="card-grid" id="recipeGrid">
|
||||||
|
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||||
|
<!-- Virtual scrolling will handle the display logic on the client side -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block overlay %}
|
||||||
|
<div class="bulk-mode-overlay"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main_script %}
|
{% block main_script %}
|
||||||
<script type="module" src="/loras_static/js/recipes.js"></script>
|
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -192,6 +192,6 @@
|
|||||||
|
|
||||||
{% block main_script %}
|
{% block main_script %}
|
||||||
{% if not is_initializing %}
|
{% if not is_initializing %}
|
||||||
<script type="module" src="/loras_static/js/statistics.js"></script>
|
<script type="module" src="/loras_static/js/statistics.js?v={{ version }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -62,6 +63,7 @@ def test_symlink_scan_skips_file_links(monkeypatch: pytest.MonkeyPatch, tmp_path
|
|||||||
|
|
||||||
def test_symlink_cache_reuses_previous_scan(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
def test_symlink_cache_reuses_previous_scan(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||||
loras_dir, settings_dir = _setup_paths(monkeypatch, tmp_path)
|
loras_dir, settings_dir = _setup_paths(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr(config_module.Config, "_schedule_symlink_rescan", lambda self: None)
|
||||||
|
|
||||||
target_dir = loras_dir / "target"
|
target_dir = loras_dir / "target"
|
||||||
target_dir.mkdir()
|
target_dir.mkdir()
|
||||||
@@ -85,6 +87,7 @@ def test_symlink_cache_reuses_previous_scan(monkeypatch: pytest.MonkeyPatch, tmp
|
|||||||
|
|
||||||
def test_symlink_cache_survives_noise_mtime(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
def test_symlink_cache_survives_noise_mtime(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||||
loras_dir, settings_dir = _setup_paths(monkeypatch, tmp_path)
|
loras_dir, settings_dir = _setup_paths(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setattr(config_module.Config, "_schedule_symlink_rescan", lambda self: None)
|
||||||
|
|
||||||
target_dir = loras_dir / "target"
|
target_dir = loras_dir / "target"
|
||||||
target_dir.mkdir()
|
target_dir.mkdir()
|
||||||
@@ -109,3 +112,72 @@ def test_symlink_cache_survives_noise_mtime(monkeypatch: pytest.MonkeyPatch, tmp
|
|||||||
|
|
||||||
second_cfg = config_module.Config()
|
second_cfg = config_module.Config()
|
||||||
assert second_cfg.map_path_to_link(str(target_dir)) == _normalize(str(dir_link))
|
assert second_cfg.map_path_to_link(str(target_dir)) == _normalize(str(dir_link))
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_rescan_refreshes_cache(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||||
|
loras_dir, _ = _setup_paths(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
target_dir = loras_dir / "target"
|
||||||
|
target_dir.mkdir()
|
||||||
|
dir_link = loras_dir / "dir_link"
|
||||||
|
dir_link.symlink_to(target_dir, target_is_directory=True)
|
||||||
|
|
||||||
|
# Build initial cache pointing at the first target
|
||||||
|
first_cfg = config_module.Config()
|
||||||
|
old_real = _normalize(os.path.realpath(target_dir))
|
||||||
|
assert first_cfg.map_path_to_link(str(target_dir)) == _normalize(str(dir_link))
|
||||||
|
|
||||||
|
# Retarget the symlink to a new directory without touching the cache file
|
||||||
|
new_target = loras_dir / "target_v2"
|
||||||
|
new_target.mkdir()
|
||||||
|
dir_link.unlink()
|
||||||
|
dir_link.symlink_to(new_target, target_is_directory=True)
|
||||||
|
|
||||||
|
second_cfg = config_module.Config()
|
||||||
|
|
||||||
|
# Cache may still point at the old real path immediately after load
|
||||||
|
initial_mapping = second_cfg.map_path_to_link(str(new_target))
|
||||||
|
assert initial_mapping in {str(new_target), _normalize(str(dir_link))}
|
||||||
|
|
||||||
|
# Background rescan should refresh the mapping to the new target and update the cache file
|
||||||
|
second_cfg._wait_for_rescan(timeout=2.0)
|
||||||
|
new_real = _normalize(os.path.realpath(new_target))
|
||||||
|
assert second_cfg._path_mappings.get(new_real) == _normalize(str(dir_link))
|
||||||
|
assert second_cfg.map_path_to_link(str(new_target)) == _normalize(str(dir_link))
|
||||||
|
|
||||||
|
|
||||||
|
def test_symlink_roots_are_preserved(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||||
|
settings_dir = tmp_path / "settings"
|
||||||
|
real_loras = tmp_path / "loras_real"
|
||||||
|
real_loras.mkdir()
|
||||||
|
loras_link = tmp_path / "loras_link"
|
||||||
|
loras_link.symlink_to(real_loras, target_is_directory=True)
|
||||||
|
|
||||||
|
checkpoints_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoints_dir.mkdir()
|
||||||
|
embedding_dir = tmp_path / "embeddings"
|
||||||
|
embedding_dir.mkdir()
|
||||||
|
|
||||||
|
def fake_get_folder_paths(kind: str):
|
||||||
|
mapping = {
|
||||||
|
"loras": [str(loras_link)],
|
||||||
|
"checkpoints": [str(checkpoints_dir)],
|
||||||
|
"unet": [],
|
||||||
|
"embeddings": [str(embedding_dir)],
|
||||||
|
}
|
||||||
|
return mapping.get(kind, [])
|
||||||
|
|
||||||
|
monkeypatch.setattr(config_module.folder_paths, "get_folder_paths", fake_get_folder_paths)
|
||||||
|
monkeypatch.setattr(config_module, "standalone_mode", True)
|
||||||
|
monkeypatch.setattr(config_module, "get_settings_dir", lambda create=True: str(settings_dir))
|
||||||
|
monkeypatch.setattr(config_module.Config, "_schedule_symlink_rescan", lambda self: None)
|
||||||
|
|
||||||
|
cfg = config_module.Config()
|
||||||
|
|
||||||
|
normalized_real = _normalize(os.path.realpath(real_loras))
|
||||||
|
normalized_link = _normalize(str(loras_link))
|
||||||
|
assert cfg._path_mappings[normalized_real] == normalized_link
|
||||||
|
|
||||||
|
cache_path = settings_dir / "cache" / "symlink_map.json"
|
||||||
|
payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
|
assert payload["path_mappings"][normalized_real] == normalized_link
|
||||||
|
|||||||
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||||
|
return {
|
||||||
|
showToast: showToastMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||||
|
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
loadingManager: loadingManagerMock,
|
||||||
|
},
|
||||||
|
getCurrentPageState: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
|
||||||
|
|
||||||
|
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends recipe IDs when moving in bulk', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
recipe_id: 'abc',
|
||||||
|
original_file_path: '/recipes/abc.webp',
|
||||||
|
new_file_path: '/recipes/target/abc.webp',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_count: 1,
|
||||||
|
failure_count: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await api.moveBulkModels(['/recipes/abc.webp'], '/target/folder');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipes/move-bulk',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { body } = global.fetch.mock.calls[0][1];
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
recipe_ids: ['abc'],
|
||||||
|
target_path: '/target/folder',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.bulkMoveSuccess',
|
||||||
|
{ successCount: 1, type: 'Recipe' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
expect(results[0].recipe_id).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts recipe IDs for bulk delete', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
total_deleted: 2,
|
||||||
|
total_failed: 0,
|
||||||
|
failed: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipes/bulk-delete',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
expect(parsedBody.recipe_ids).toEqual(['a', 'b']);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
});
|
||||||
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tests/frontend/components/triggerWords.escaping.test.js
Normal file
55
tests/frontend/components/triggerWords.escaping.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
TRIGGER_WORDS_MODULE,
|
||||||
|
UTILS_MODULE,
|
||||||
|
I18N_HELPERS_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
|
||||||
|
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
|
||||||
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
translate: vi.fn((key, params, fallback) => fallback || key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
getModelApiClient: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("TriggerWords HTML Escaping", () => {
|
||||||
|
let renderTriggerWords;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const module = await import(TRIGGER_WORDS_MODULE);
|
||||||
|
renderTriggerWords = module.renderTriggerWords;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML tags in trigger words rendering", () => {
|
||||||
|
const words = ["<style>guangying</style>", "fym <artist>"];
|
||||||
|
const html = renderTriggerWords(words, "test.safetensors");
|
||||||
|
|
||||||
|
expect(html).toContain("<style>guangying</style>");
|
||||||
|
expect(html).toContain("fym <artist>");
|
||||||
|
expect(html).not.toContain("<style>guangying</style>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses dataset for copyTriggerWord to safely handle special characters", () => {
|
||||||
|
const words = ["word'with'quotes", "<tag>"];
|
||||||
|
const html = renderTriggerWords(words, "test.safetensors");
|
||||||
|
|
||||||
|
// Check for dataset-word attribute
|
||||||
|
expect(html).toContain('data-word="word'with'quotes"');
|
||||||
|
expect(html).toContain('data-word="<tag>"');
|
||||||
|
|
||||||
|
// Check for the onclick handler
|
||||||
|
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -234,7 +234,6 @@ describe('AppCore initialization flow', () => {
|
|||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
expect(onboardingManager.start).toHaveBeenCalledTimes(1);
|
expect(onboardingManager.start).toHaveBeenCalledTimes(1);
|
||||||
expect(bannerService.isBannerVisible).toHaveBeenCalledWith('version-mismatch');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not reinitialize once initialized', async () => {
|
it('does not reinitialize once initialized', async () => {
|
||||||
@@ -253,22 +252,13 @@ describe('AppCore initialization flow', () => {
|
|||||||
expect(onboardingManager.start).not.toHaveBeenCalled();
|
expect(onboardingManager.start).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips bulk setup when viewing recipes', async () => {
|
it('initializes bulk setup when viewing recipes', async () => {
|
||||||
state.currentPageType = 'recipes';
|
state.currentPageType = 'recipes';
|
||||||
|
|
||||||
await appCore.initialize();
|
await appCore.initialize();
|
||||||
|
|
||||||
expect(bulkManager.initialize).not.toHaveBeenCalled();
|
expect(bulkManager.initialize).toHaveBeenCalledTimes(1);
|
||||||
expect(BulkContextMenu).not.toHaveBeenCalled();
|
expect(BulkContextMenu).toHaveBeenCalledTimes(1);
|
||||||
expect(bulkManager.setBulkContextMenu).not.toHaveBeenCalled();
|
expect(bulkManager.setBulkContextMenu).toHaveBeenCalledTimes(1);
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses onboarding when version mismatch banner is visible', async () => {
|
|
||||||
bannerService.isBannerVisible.mockReturnValueOnce(true);
|
|
||||||
|
|
||||||
await appCore.initialize();
|
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
|
|
||||||
expect(onboardingManager.start).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const removeSessionItemMock = vi.fn();
|
|||||||
const RecipeContextMenuMock = vi.fn();
|
const RecipeContextMenuMock = vi.fn();
|
||||||
const refreshVirtualScrollMock = vi.fn();
|
const refreshVirtualScrollMock = vi.fn();
|
||||||
const refreshRecipesMock = vi.fn();
|
const refreshRecipesMock = vi.fn();
|
||||||
|
const fetchUnifiedFolderTreeMock = vi.fn();
|
||||||
|
const fetchModelFoldersMock = vi.fn();
|
||||||
|
|
||||||
let importManagerInstance;
|
let importManagerInstance;
|
||||||
let recipeModalInstance;
|
let recipeModalInstance;
|
||||||
@@ -35,6 +37,15 @@ vi.mock('../../../static/js/components/RecipeModal.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/state/index.js', () => ({
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
getCurrentPageState: getCurrentPageStateMock,
|
getCurrentPageState: getCurrentPageStateMock,
|
||||||
|
state: {
|
||||||
|
currentPageType: 'recipes',
|
||||||
|
global: { settings: {} },
|
||||||
|
virtualScroller: {
|
||||||
|
removeItemByFilePath: vi.fn(),
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
refreshWithData: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
@@ -56,6 +67,14 @@ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
refreshRecipes: refreshRecipesMock,
|
refreshRecipes: refreshRecipesMock,
|
||||||
|
RecipeSidebarApiClient: vi.fn(() => ({
|
||||||
|
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
|
||||||
|
fetchUnifiedFolderTree: fetchUnifiedFolderTreeMock.mockResolvedValue({ success: true, tree: {} }),
|
||||||
|
fetchModelFolders: fetchModelFoldersMock.mockResolvedValue({ success: true, folders: [] }),
|
||||||
|
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
|
||||||
|
moveBulkModels: vi.fn(),
|
||||||
|
moveSingleModel: vi.fn(),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('RecipeManager', () => {
|
describe('RecipeManager', () => {
|
||||||
@@ -81,7 +100,7 @@ describe('RecipeManager', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pageState = {
|
pageState = {
|
||||||
sortBy: 'date',
|
sortBy: 'date:desc',
|
||||||
searchOptions: undefined,
|
searchOptions: undefined,
|
||||||
customFilter: undefined,
|
customFilter: undefined,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
@@ -91,7 +110,7 @@ describe('RecipeManager', () => {
|
|||||||
initializeAppMock.mockResolvedValue(undefined);
|
initializeAppMock.mockResolvedValue(undefined);
|
||||||
initializePageFeaturesMock.mockResolvedValue(undefined);
|
initializePageFeaturesMock.mockResolvedValue(undefined);
|
||||||
refreshVirtualScrollMock.mockReset();
|
refreshVirtualScrollMock.mockReset();
|
||||||
refreshVirtualScrollMock.mockImplementation(() => {});
|
refreshVirtualScrollMock.mockImplementation(() => { });
|
||||||
refreshRecipesMock.mockResolvedValue('refreshed');
|
refreshRecipesMock.mockResolvedValue('refreshed');
|
||||||
|
|
||||||
getSessionItemMock.mockImplementation((key) => {
|
getSessionItemMock.mockImplementation((key) => {
|
||||||
@@ -102,7 +121,7 @@ describe('RecipeManager', () => {
|
|||||||
};
|
};
|
||||||
return map[key] ?? null;
|
return map[key] ?? null;
|
||||||
});
|
});
|
||||||
removeSessionItemMock.mockImplementation(() => {});
|
removeSessionItemMock.mockImplementation(() => { });
|
||||||
|
|
||||||
renderRecipesPage();
|
renderRecipesPage();
|
||||||
|
|
||||||
@@ -118,8 +137,8 @@ describe('RecipeManager', () => {
|
|||||||
const sortSelectElement = document.createElement('select');
|
const sortSelectElement = document.createElement('select');
|
||||||
sortSelectElement.id = 'sortSelect';
|
sortSelectElement.id = 'sortSelect';
|
||||||
sortSelectElement.innerHTML = `
|
sortSelectElement.innerHTML = `
|
||||||
<option value="date">Date</option>
|
<option value="date:desc">Newest</option>
|
||||||
<option value="name">Name</option>
|
<option value="name:asc">Name A-Z</option>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(sortSelectElement);
|
document.body.appendChild(sortSelectElement);
|
||||||
|
|
||||||
@@ -139,6 +158,8 @@ describe('RecipeManager', () => {
|
|||||||
tags: true,
|
tags: true,
|
||||||
loraName: true,
|
loraName: true,
|
||||||
loraModel: true,
|
loraModel: true,
|
||||||
|
prompt: true,
|
||||||
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(pageState.customFilter).toEqual({
|
expect(pageState.customFilter).toEqual({
|
||||||
@@ -162,10 +183,10 @@ describe('RecipeManager', () => {
|
|||||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
sortSelect.value = 'name';
|
sortSelect.value = 'name:asc';
|
||||||
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
expect(pageState.sortBy).toBe('name');
|
expect(pageState.sortBy).toBe('name:asc');
|
||||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2);
|
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2);
|
||||||
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
|
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
98
tests/metadata_collector/test_pipe_tracer.py
Normal file
98
tests/metadata_collector/test_pipe_tracer.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
|
||||||
|
import pytest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from py.metadata_collector.metadata_processor import MetadataProcessor
|
||||||
|
from py.metadata_collector.constants import MODELS, SAMPLING, IS_SAMPLER
|
||||||
|
|
||||||
|
class TestPipeTracer:
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pipe_workflow_metadata(self):
|
||||||
|
"""
|
||||||
|
Creates a mock metadata structure matching the one provided in refs/tmp.
|
||||||
|
Structure:
|
||||||
|
Load Checkpoint(28) -> Lora Loader(52) -> ToBasicPipe(69) -> FromBasicPipe(71) -> KSampler(32)
|
||||||
|
"""
|
||||||
|
|
||||||
|
original_prompt = {
|
||||||
|
'28': {
|
||||||
|
'inputs': {'ckpt_name': 'Illustrious\\bananaSplitzXL_vee5PointOh.safetensors'},
|
||||||
|
'class_type': 'CheckpointLoaderSimple'
|
||||||
|
},
|
||||||
|
'52': {
|
||||||
|
'inputs': {
|
||||||
|
'model': ['28', 0],
|
||||||
|
'clip': ['28', 1]
|
||||||
|
},
|
||||||
|
'class_type': 'Lora Loader (LoraManager)'
|
||||||
|
},
|
||||||
|
'69': {
|
||||||
|
'inputs': {
|
||||||
|
'model': ['52', 0],
|
||||||
|
'clip': ['52', 1],
|
||||||
|
'vae': ['28', 2],
|
||||||
|
'positive': ['75', 0],
|
||||||
|
'negative': ['30', 0]
|
||||||
|
},
|
||||||
|
'class_type': 'ToBasicPipe'
|
||||||
|
},
|
||||||
|
'71': {
|
||||||
|
'inputs': {'basic_pipe': ['69', 0]},
|
||||||
|
'class_type': 'FromBasicPipe'
|
||||||
|
},
|
||||||
|
'32': {
|
||||||
|
'inputs': {
|
||||||
|
'seed': 131755205602911,
|
||||||
|
'steps': 5,
|
||||||
|
'cfg': 8.0,
|
||||||
|
'sampler_name': 'euler_ancestral',
|
||||||
|
'scheduler': 'karras',
|
||||||
|
'denoise': 1.0,
|
||||||
|
'model': ['71', 0],
|
||||||
|
'positive': ['71', 3],
|
||||||
|
'negative': ['71', 4],
|
||||||
|
'latent_image': ['76', 0]
|
||||||
|
},
|
||||||
|
'class_type': 'KSampler'
|
||||||
|
},
|
||||||
|
'75': {'inputs': {'text': 'positive', 'clip': ['52', 1]}, 'class_type': 'CLIPTextEncode'},
|
||||||
|
'30': {'inputs': {'text': 'negative', 'clip': ['52', 1]}, 'class_type': 'CLIPTextEncode'},
|
||||||
|
'76': {'inputs': {'width': 832, 'height': 1216, 'batch_size': 1}, 'class_type': 'EmptyLatentImage'}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"current_prompt": SimpleNamespace(original_prompt=original_prompt),
|
||||||
|
MODELS: {
|
||||||
|
"28": {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"name": "bananaSplitzXL_vee5PointOh.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SAMPLING: {
|
||||||
|
"32": {
|
||||||
|
IS_SAMPLER: True,
|
||||||
|
"parameters": {
|
||||||
|
"sampler_name": "euler_ancestral",
|
||||||
|
"scheduler": "karras"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def test_trace_model_path_through_pipe(self, pipe_workflow_metadata):
|
||||||
|
"""Verify trace_model_path can follow: KSampler -> FromBasicPipe -> ToBasicPipe -> Lora -> Checkpoint."""
|
||||||
|
prompt = pipe_workflow_metadata["current_prompt"]
|
||||||
|
|
||||||
|
# Start trace from KSampler (32)
|
||||||
|
ckpt_id = MetadataProcessor.trace_model_path(pipe_workflow_metadata, prompt, "32")
|
||||||
|
|
||||||
|
assert ckpt_id == "28"
|
||||||
|
|
||||||
|
def test_find_primary_checkpoint_with_pipe(self, pipe_workflow_metadata):
|
||||||
|
"""Verify find_primary_checkpoint returns the correct name even with pipe nodes."""
|
||||||
|
# Providing sampler_id to test the optimization as well
|
||||||
|
name = MetadataProcessor.find_primary_checkpoint(pipe_workflow_metadata, primary_sampler_id="32")
|
||||||
|
|
||||||
|
assert name == "bananaSplitzXL_vee5PointOh.safetensors"
|
||||||
172
tests/metadata_collector/test_tracer.py
Normal file
172
tests/metadata_collector/test_tracer.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
|
||||||
|
import pytest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from py.metadata_collector.metadata_processor import MetadataProcessor
|
||||||
|
from py.metadata_collector.constants import MODELS, SAMPLING, IS_SAMPLER
|
||||||
|
|
||||||
|
class TestMetadataTracer:
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_workflow_metadata(self):
|
||||||
|
"""
|
||||||
|
Creates a mock metadata structure with a complex workflow graph.
|
||||||
|
Structure:
|
||||||
|
Sampler(246) -> Guider(241) -> LoraLoader(264) -> CheckpointLoader(238)
|
||||||
|
|
||||||
|
Also includes a "Decoy" checkpoint (ID 999) that is NOT connected,
|
||||||
|
to verify we found the *connected* one, not just *any* one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 1. Define the Graph (Original Prompt)
|
||||||
|
# Using IDs as strings to match typical ComfyUI behavior in metadata
|
||||||
|
original_prompt = {
|
||||||
|
"246": {
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"inputs": {
|
||||||
|
"guider": ["241", 0],
|
||||||
|
"noise": ["255", 0],
|
||||||
|
"sampler": ["247", 0],
|
||||||
|
"sigmas": ["248", 0],
|
||||||
|
"latent_image": ["153", 0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"241": {
|
||||||
|
"class_type": "CFGGuider",
|
||||||
|
"inputs": {
|
||||||
|
"model": ["264", 0],
|
||||||
|
"positive": ["239", 0],
|
||||||
|
"negative": ["240", 0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"264": {
|
||||||
|
"class_type": "LoraLoader", # Simplified name
|
||||||
|
"inputs": {
|
||||||
|
"model": ["238", 0],
|
||||||
|
"lora_name": "some_style_lora.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"238": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {
|
||||||
|
"ckpt_name": "Correct_Model.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
# unconnected / decoy nodes
|
||||||
|
"999": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {
|
||||||
|
"ckpt_name": "Decoy_Model.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"154": { # Downstream VAE Decode
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["246", 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Define the Metadata (Collected execution data)
|
||||||
|
metadata = {
|
||||||
|
"current_prompt": SimpleNamespace(original_prompt=original_prompt),
|
||||||
|
"execution_order": ["238", "264", "241", "246", "154", "999"], # 999 execs last or separately
|
||||||
|
|
||||||
|
# Models Registry
|
||||||
|
MODELS: {
|
||||||
|
"238": {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"name": "Correct_Model.safetensors"
|
||||||
|
},
|
||||||
|
"999": {
|
||||||
|
"type": "checkpoint",
|
||||||
|
"name": "Decoy_Model.safetensors"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
# Sampling Registry
|
||||||
|
SAMPLING: {
|
||||||
|
"246": {
|
||||||
|
IS_SAMPLER: True,
|
||||||
|
"parameters": {
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "normal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"images": {
|
||||||
|
"first_decode": {
|
||||||
|
"node_id": "154"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def test_find_primary_sampler_identifies_correct_node(self, mock_workflow_metadata):
|
||||||
|
"""Verify find_primary_sampler correctly identifies the sampler connected to the downstream decode."""
|
||||||
|
sampler_id, sampler_info = MetadataProcessor.find_primary_sampler(mock_workflow_metadata, downstream_id="154")
|
||||||
|
|
||||||
|
assert sampler_id == "246"
|
||||||
|
assert sampler_info is not None
|
||||||
|
assert sampler_info["parameters"]["sampler_name"] == "euler"
|
||||||
|
|
||||||
|
def test_trace_model_path_follows_topology(self, mock_workflow_metadata):
|
||||||
|
"""Verify trace_model_path follows: Sampler -> Guider -> Lora -> Checkpoint."""
|
||||||
|
prompt = mock_workflow_metadata["current_prompt"]
|
||||||
|
|
||||||
|
# Start trace from Sampler (246)
|
||||||
|
# Should find Checkpoint (238)
|
||||||
|
ckpt_id = MetadataProcessor.trace_model_path(mock_workflow_metadata, prompt, "246")
|
||||||
|
|
||||||
|
assert ckpt_id == "238" # Should be the ID of the connected checkpoint
|
||||||
|
|
||||||
|
def test_find_primary_checkpoint_prioritizes_connected_model(self, mock_workflow_metadata):
|
||||||
|
"""Verify find_primary_checkpoint returns the NAME of the topologically connected checkpoint, honoring the graph."""
|
||||||
|
name = MetadataProcessor.find_primary_checkpoint(mock_workflow_metadata, downstream_id="154")
|
||||||
|
|
||||||
|
assert name == "Correct_Model.safetensors"
|
||||||
|
assert name != "Decoy_Model.safetensors"
|
||||||
|
|
||||||
|
def test_trace_model_path_simple_direct_connection(self):
|
||||||
|
"""Verify it works for a simple Sampler -> Checkpoint connection."""
|
||||||
|
original_prompt = {
|
||||||
|
"100": { # Sampler
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"model": ["101", 0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"101": { # Checkpoint
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"current_prompt": SimpleNamespace(original_prompt=original_prompt),
|
||||||
|
MODELS: {
|
||||||
|
"101": {"type": "checkpoint", "name": "Simple_Model.safetensors"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ckpt_id = MetadataProcessor.trace_model_path(metadata, metadata["current_prompt"], "100")
|
||||||
|
assert ckpt_id == "101"
|
||||||
|
|
||||||
|
def test_trace_stops_at_max_depth(self):
|
||||||
|
"""Verify logic halts if graph is infinitely cyclic or too deep."""
|
||||||
|
# Create a cycle: Node 1 -> Node 2 -> Node 1
|
||||||
|
original_prompt = {
|
||||||
|
"1": {"inputs": {"model": ["2", 0]}},
|
||||||
|
"2": {"inputs": {"model": ["1", 0]}}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"current_prompt": SimpleNamespace(original_prompt=original_prompt),
|
||||||
|
MODELS: {} # No checkpoints registered
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should return None, not hang forever
|
||||||
|
ckpt_id = MetadataProcessor.trace_model_path(metadata, metadata["current_prompt"], "1")
|
||||||
|
assert ckpt_id is None
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import copy
|
||||||
|
|
||||||
from py.nodes.utils import nunchaku_load_lora
|
from py.nodes.utils import nunchaku_load_lora
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ class _DummyModel:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.model = _DummyModelWrapper()
|
self.model = _DummyModelWrapper()
|
||||||
|
|
||||||
|
def clone(self):
|
||||||
|
return copy.deepcopy(self)
|
||||||
|
|
||||||
|
|
||||||
def test_nunchaku_load_lora_skips_missing_lora(monkeypatch, caplog):
|
def test_nunchaku_load_lora_skips_missing_lora(monkeypatch, caplog):
|
||||||
import folder_paths
|
import folder_paths
|
||||||
|
|||||||
@@ -103,8 +103,7 @@ def test_register_startup_hooks_appends_once():
|
|||||||
]
|
]
|
||||||
|
|
||||||
assert routes.attach_dependencies in startup_bound_to_routes
|
assert routes.attach_dependencies in startup_bound_to_routes
|
||||||
assert routes.prewarm_cache in startup_bound_to_routes
|
assert len(startup_bound_to_routes) == 1
|
||||||
assert len(startup_bound_to_routes) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_route_mapping_uses_handler_set():
|
def test_to_route_mapping_uses_handler_set():
|
||||||
@@ -212,4 +211,4 @@ def test_recipe_routes_setup_routes_uses_registrar(monkeypatch: pytest.MonkeyPat
|
|||||||
if isinstance(getattr(cb, "__self__", None), recipe_routes.RecipeRoutes)
|
if isinstance(getattr(cb, "__self__", None), recipe_routes.RecipeRoutes)
|
||||||
}
|
}
|
||||||
assert {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes.RecipeRoutes}
|
assert {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes.RecipeRoutes}
|
||||||
assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies", "prewarm_cache"}
|
assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies"}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class RecipeRouteHarness:
|
|||||||
persistence: "StubPersistenceService"
|
persistence: "StubPersistenceService"
|
||||||
sharing: "StubSharingService"
|
sharing: "StubSharingService"
|
||||||
downloader: "StubDownloader"
|
downloader: "StubDownloader"
|
||||||
|
civitai: "StubCivitaiClient"
|
||||||
tmp_dir: Path
|
tmp_dir: Path
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +69,10 @@ class StubRecipeScanner:
|
|||||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||||
return self.recipes.get(recipe_id)
|
return self.recipes.get(recipe_id)
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||||
|
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
||||||
|
return str(candidate) if candidate.exists() else None
|
||||||
|
|
||||||
async def remove_recipe(self, recipe_id: str) -> None:
|
async def remove_recipe(self, recipe_id: str) -> None:
|
||||||
self.removed.append(recipe_id)
|
self.removed.append(recipe_id)
|
||||||
self.recipes.pop(recipe_id, None)
|
self.recipes.pop(recipe_id, None)
|
||||||
@@ -86,6 +91,7 @@ class StubAnalysisService:
|
|||||||
self.remote_calls: List[Optional[str]] = []
|
self.remote_calls: List[Optional[str]] = []
|
||||||
self.local_calls: List[Optional[str]] = []
|
self.local_calls: List[Optional[str]] = []
|
||||||
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
||||||
|
self._recipe_parser_factory = None
|
||||||
StubAnalysisService.instances.append(self)
|
StubAnalysisService.instances.append(self)
|
||||||
|
|
||||||
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature
|
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature
|
||||||
@@ -118,11 +124,12 @@ class StubPersistenceService:
|
|||||||
def __init__(self, **_: Any) -> None:
|
def __init__(self, **_: Any) -> None:
|
||||||
self.save_calls: List[Dict[str, Any]] = []
|
self.save_calls: List[Dict[str, Any]] = []
|
||||||
self.delete_calls: List[str] = []
|
self.delete_calls: List[str] = []
|
||||||
|
self.move_calls: List[Dict[str, str]] = []
|
||||||
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
|
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
|
||||||
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
||||||
StubPersistenceService.instances.append(self)
|
StubPersistenceService.instances.append(self)
|
||||||
|
|
||||||
async def save_recipe(self, *, recipe_scanner, image_bytes, image_base64, name, tags, metadata) -> SimpleNamespace: # noqa: D401
|
async def save_recipe(self, *, recipe_scanner, image_bytes, image_base64, name, tags, metadata, extension=None) -> SimpleNamespace: # noqa: D401
|
||||||
self.save_calls.append(
|
self.save_calls.append(
|
||||||
{
|
{
|
||||||
"recipe_scanner": recipe_scanner,
|
"recipe_scanner": recipe_scanner,
|
||||||
@@ -131,6 +138,7 @@ class StubPersistenceService:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"tags": list(tags),
|
"tags": list(tags),
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
"extension": extension,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.save_result
|
return self.save_result
|
||||||
@@ -140,6 +148,12 @@ class StubPersistenceService:
|
|||||||
await recipe_scanner.remove_recipe(recipe_id)
|
await recipe_scanner.remove_recipe(recipe_id)
|
||||||
return self.delete_result
|
return self.delete_result
|
||||||
|
|
||||||
|
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> SimpleNamespace: # noqa: D401
|
||||||
|
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
|
||||||
|
return SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": recipe_id, "new_file_path": target_path}, status=200
|
||||||
|
)
|
||||||
|
|
||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
||||||
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
|
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
|
||||||
|
|
||||||
@@ -189,6 +203,16 @@ class StubDownloader:
|
|||||||
return True, destination
|
return True, destination
|
||||||
|
|
||||||
|
|
||||||
|
class StubCivitaiClient:
|
||||||
|
"""Stub for Civitai API client."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.image_info: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def get_image_info(self, image_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
return self.image_info.get(image_id)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRouteHarness]:
|
async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRouteHarness]:
|
||||||
"""Context manager that yields a fully wired recipe route harness."""
|
"""Context manager that yields a fully wired recipe route harness."""
|
||||||
@@ -198,12 +222,13 @@ async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRou
|
|||||||
StubSharingService.instances.clear()
|
StubSharingService.instances.clear()
|
||||||
|
|
||||||
scanner = StubRecipeScanner(tmp_path)
|
scanner = StubRecipeScanner(tmp_path)
|
||||||
|
civitai_client = StubCivitaiClient()
|
||||||
|
|
||||||
async def fake_get_recipe_scanner():
|
async def fake_get_recipe_scanner():
|
||||||
return scanner
|
return scanner
|
||||||
|
|
||||||
async def fake_get_civitai_client():
|
async def fake_get_civitai_client():
|
||||||
return object()
|
return civitai_client
|
||||||
|
|
||||||
downloader = StubDownloader()
|
downloader = StubDownloader()
|
||||||
|
|
||||||
@@ -232,6 +257,7 @@ async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRou
|
|||||||
persistence=StubPersistenceService.instances[-1],
|
persistence=StubPersistenceService.instances[-1],
|
||||||
sharing=StubSharingService.instances[-1],
|
sharing=StubSharingService.instances[-1],
|
||||||
downloader=downloader,
|
downloader=downloader,
|
||||||
|
civitai=civitai_client,
|
||||||
tmp_dir=tmp_path,
|
tmp_dir=tmp_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -296,8 +322,23 @@ async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) ->
|
|||||||
assert harness.persistence.delete_calls == ["saved-id"]
|
assert harness.persistence.delete_calls == ["saved-id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
response = await harness.client.post(
|
||||||
|
"/api/lm/recipe/move",
|
||||||
|
json={"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["recipe_id"] == "move-me"
|
||||||
|
assert harness.persistence.move_calls == [
|
||||||
|
{"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||||
provider_calls: list[int] = []
|
provider_calls: list[str | int] = []
|
||||||
|
|
||||||
class Provider:
|
class Provider:
|
||||||
async def get_model_version_info(self, model_version_id):
|
async def get_model_version_info(self, model_version_id):
|
||||||
@@ -307,7 +348,7 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
|||||||
async def fake_get_default_metadata_provider():
|
async def fake_get_default_metadata_provider():
|
||||||
return Provider()
|
return Provider()
|
||||||
|
|
||||||
monkeypatch.setattr(recipe_handlers, "get_default_metadata_provider", fake_get_default_metadata_provider)
|
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider)
|
||||||
|
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
resources = [
|
resources = [
|
||||||
@@ -349,16 +390,15 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
|||||||
assert call["tags"] == ["foo", "bar"]
|
assert call["tags"] == ["foo", "bar"]
|
||||||
metadata = call["metadata"]
|
metadata = call["metadata"]
|
||||||
assert metadata["base_model"] == "Flux Provider"
|
assert metadata["base_model"] == "Flux Provider"
|
||||||
assert provider_calls == [33]
|
assert provider_calls == ["33"]
|
||||||
assert metadata["checkpoint"]["modelVersionId"] == 33
|
assert metadata["checkpoint"]["modelVersionId"] == 33
|
||||||
assert metadata["loras"][0]["weight"] == 0.25
|
assert metadata["loras"][0]["weight"] == 0.25
|
||||||
assert metadata["gen_params"]["prompt"] == "hello world"
|
assert metadata["gen_params"]["prompt"] == "hello world"
|
||||||
assert metadata["gen_params"]["checkpoint"]["modelVersionId"] == 33
|
|
||||||
assert harness.downloader.urls == ["https://example.com/images/1"]
|
assert harness.downloader.urls == ["https://example.com/images/1"]
|
||||||
|
|
||||||
|
|
||||||
async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch, tmp_path: Path) -> None:
|
async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch, tmp_path: Path) -> None:
|
||||||
provider_calls: list[int] = []
|
provider_calls: list[str | int] = []
|
||||||
|
|
||||||
class Provider:
|
class Provider:
|
||||||
async def get_model_version_info(self, model_version_id):
|
async def get_model_version_info(self, model_version_id):
|
||||||
@@ -368,7 +408,7 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch
|
|||||||
async def fake_get_default_metadata_provider():
|
async def fake_get_default_metadata_provider():
|
||||||
return Provider()
|
return Provider()
|
||||||
|
|
||||||
monkeypatch.setattr(recipe_handlers, "get_default_metadata_provider", fake_get_default_metadata_provider)
|
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider)
|
||||||
|
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
resources = [
|
resources = [
|
||||||
@@ -397,7 +437,42 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch
|
|||||||
|
|
||||||
metadata = harness.persistence.save_calls[-1]["metadata"]
|
metadata = harness.persistence.save_calls[-1]["metadata"]
|
||||||
assert metadata["base_model"] == "Flux"
|
assert metadata["base_model"] == "Flux"
|
||||||
assert provider_calls == [77]
|
assert provider_calls == ["77"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider)
|
||||||
|
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.civitai.image_info["12345"] = {
|
||||||
|
"id": 12345,
|
||||||
|
"url": "https://image.civitai.com/x/y/original=true/video.mp4",
|
||||||
|
"type": "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/import-remote",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.com/images/12345",
|
||||||
|
"name": "Video Recipe",
|
||||||
|
"resources": json.dumps([]),
|
||||||
|
"base_model": "Flux",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
|
||||||
|
# Verify downloader was called with rewritten URL
|
||||||
|
assert "transcode=true" in harness.downloader.urls[0]
|
||||||
|
|
||||||
|
# Verify persistence was called with correct extension
|
||||||
|
call = harness.persistence.save_calls[-1]
|
||||||
|
assert call["extension"] == ".mp4"
|
||||||
|
|
||||||
|
|
||||||
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
|
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
|
||||||
@@ -452,3 +527,69 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
|
|||||||
assert body == b"stub"
|
assert body == b"stub"
|
||||||
|
|
||||||
download_path.unlink(missing_ok=True)
|
download_path.unlink(missing_ok=True)
|
||||||
|
async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
# 1. Mock Metadata Provider
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, model_version_id):
|
||||||
|
return {"baseModel": "Flux Provider"}, None
|
||||||
|
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider)
|
||||||
|
|
||||||
|
# 2. Mock ExifUtils to return some embedded metadata
|
||||||
|
class MockExifUtils:
|
||||||
|
@staticmethod
|
||||||
|
def extract_image_metadata(path):
|
||||||
|
return "Recipe metadata: " + json.dumps({
|
||||||
|
"gen_params": {"prompt": "from embedded", "seed": 123}
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
||||||
|
|
||||||
|
# 3. Mock Parser Factory for StubAnalysisService
|
||||||
|
class MockParser:
|
||||||
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||||
|
return json.loads(raw[len("Recipe metadata: "):])
|
||||||
|
|
||||||
|
class MockFactory:
|
||||||
|
def create_parser(self, raw):
|
||||||
|
if raw.startswith("Recipe metadata: "):
|
||||||
|
return MockParser()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 4. Setup Harness and run test
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.analysis._recipe_parser_factory = MockFactory()
|
||||||
|
|
||||||
|
# Civitai meta via image_info
|
||||||
|
harness.civitai.image_info["1"] = {
|
||||||
|
"id": 1,
|
||||||
|
"url": "https://example.com/images/1.jpg",
|
||||||
|
"meta": {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
}
|
||||||
|
|
||||||
|
resources = []
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/import-remote",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.com/images/1",
|
||||||
|
"name": "Merged Recipe",
|
||||||
|
"resources": json.dumps(resources),
|
||||||
|
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
assert response.status == 200
|
||||||
|
|
||||||
|
call = harness.persistence.save_calls[-1]
|
||||||
|
metadata = call["metadata"]
|
||||||
|
gen_params = metadata["gen_params"]
|
||||||
|
|
||||||
|
# Priority: request (prompt=request, steps=25) > civitai (prompt=civitai, cfg=7.0) > embedded (prompt=embedded, seed=123)
|
||||||
|
assert gen_params["prompt"] == "from request"
|
||||||
|
assert gen_params["steps"] == 25
|
||||||
|
assert gen_params["cfg"] == 7.0
|
||||||
|
assert gen_params["seed"] == 123
|
||||||
|
|||||||
113
tests/services/test_comfy_metadata_parser.py
Normal file
113
tests/services/test_comfy_metadata_parser.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from py.recipes.parsers.comfy import ComfyMetadataParser
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_without_loras(monkeypatch):
|
||||||
|
checkpoint_info = {
|
||||||
|
"id": 2224012,
|
||||||
|
"modelId": 1908679,
|
||||||
|
"model": {"name": "SDXL Checkpoint", "type": "checkpoint"},
|
||||||
|
"name": "v1.0",
|
||||||
|
"images": [{"url": "https://image.civitai.com/checkpoints/original=true"}],
|
||||||
|
"baseModel": "sdxl",
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/checkpoint",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
assert version_id == "2224012"
|
||||||
|
return checkpoint_info, None
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = ComfyMetadataParser()
|
||||||
|
|
||||||
|
# User provided metadata
|
||||||
|
metadata_json = {
|
||||||
|
"resource-stack": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:1908679@2224012"}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"class_type": "smZ CLIPTextEncode",
|
||||||
|
"inputs": {"text": "Positive prompt content"},
|
||||||
|
"_meta": {"title": "Positive"}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"class_type": "smZ CLIPTextEncode",
|
||||||
|
"inputs": {"text": "Negative prompt content"},
|
||||||
|
"_meta": {"title": "Negative"}
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler_ancestral",
|
||||||
|
"scheduler": "normal",
|
||||||
|
"seed": 904124997,
|
||||||
|
"steps": 35,
|
||||||
|
"cfg": 6,
|
||||||
|
"denoise": 0.1,
|
||||||
|
"model": ["resource-stack", 0],
|
||||||
|
"positive": ["6", 0],
|
||||||
|
"negative": ["7", 0],
|
||||||
|
"latent_image": ["21", 0]
|
||||||
|
},
|
||||||
|
"_meta": {"title": "KSampler"}
|
||||||
|
},
|
||||||
|
"extraMetadata": json.dumps({
|
||||||
|
"prompt": "One woman, (solo:1.3), ...",
|
||||||
|
"negativePrompt": "lowres, worst quality, ...",
|
||||||
|
"steps": 35,
|
||||||
|
"cfgScale": 6,
|
||||||
|
"sampler": "euler_ancestral",
|
||||||
|
"seed": 904124997,
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(json.dumps(metadata_json))
|
||||||
|
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["loras"] == []
|
||||||
|
assert result["checkpoint"] is not None
|
||||||
|
assert int(result["checkpoint"]["modelId"]) == 1908679
|
||||||
|
assert int(result["checkpoint"]["id"]) == 2224012
|
||||||
|
assert result["gen_params"]["prompt"] == "One woman, (solo:1.3), ..."
|
||||||
|
assert result["gen_params"]["steps"] == 35
|
||||||
|
assert result["gen_params"]["size"] == "1024x1024"
|
||||||
|
assert result["from_comfy_metadata"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_without_extra_metadata(monkeypatch):
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
return {"model": {"name": "Test"}, "id": version_id}, None
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = ComfyMetadataParser()
|
||||||
|
|
||||||
|
metadata_json = {
|
||||||
|
"node_1": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:123@456"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(json.dumps(metadata_json))
|
||||||
|
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["loras"] == []
|
||||||
|
assert result["checkpoint"]["id"] == "456"
|
||||||
95
tests/services/test_gen_params_merger.py
Normal file
95
tests/services/test_gen_params_merger.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import pytest
|
||||||
|
from py.recipes.merger import GenParamsMerger
|
||||||
|
|
||||||
|
def test_merge_priority():
|
||||||
|
request_params = {"prompt": "from request", "steps": 20}
|
||||||
|
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from request"
|
||||||
|
assert merged["steps"] == 20
|
||||||
|
assert merged["cfg"] == 7.0
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_no_request_params():
|
||||||
|
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from civitai"
|
||||||
|
assert merged["cfg"] == 7.0
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_only_embedded():
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from embedded"
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_raw_embedded():
|
||||||
|
# Test when embedded metadata is just the gen_params themselves
|
||||||
|
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from raw embedded"
|
||||||
|
assert merged["seed"] == 456
|
||||||
|
|
||||||
|
def test_merge_none_values():
|
||||||
|
merged = GenParamsMerger.merge(None, None, None)
|
||||||
|
assert merged == {}
|
||||||
|
|
||||||
|
def test_merge_filters_blacklisted_keys():
|
||||||
|
request_params = {"prompt": "test", "id": "should-be-removed", "checkpoint": "should-not-be-here"}
|
||||||
|
civitai_meta = {"cfg": 7, "url": "remove-me"}
|
||||||
|
embedded_metadata = {"seed": 123, "hash": "remove-also"}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert "prompt" in merged
|
||||||
|
assert "cfg" in merged
|
||||||
|
assert "seed" in merged
|
||||||
|
assert "id" not in merged
|
||||||
|
assert "url" not in merged
|
||||||
|
assert "hash" not in merged
|
||||||
|
assert "checkpoint" not in merged
|
||||||
|
|
||||||
|
def test_merge_filters_meta_and_normalizes_keys():
|
||||||
|
civitai_meta = {
|
||||||
|
"prompt": "masterpiece",
|
||||||
|
"cfgScale": 5,
|
||||||
|
"clipSkip": 2,
|
||||||
|
"negativePrompt": "low quality",
|
||||||
|
"meta": {"irrelevant": "data"},
|
||||||
|
"Size": "1024x1024",
|
||||||
|
"draft": False,
|
||||||
|
"workflow": "txt2img",
|
||||||
|
"civitaiResources": [{"type": "checkpoint"}]
|
||||||
|
}
|
||||||
|
request_params = {
|
||||||
|
"cfg_scale": 5.0,
|
||||||
|
"clip_skip": "2",
|
||||||
|
"Steps": 30
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(request_params, civitai_meta)
|
||||||
|
|
||||||
|
assert "meta" not in merged
|
||||||
|
assert "cfgScale" not in merged
|
||||||
|
assert "clipSkip" not in merged
|
||||||
|
assert "negativePrompt" not in merged
|
||||||
|
assert "Size" not in merged
|
||||||
|
assert "draft" not in merged
|
||||||
|
assert "workflow" not in merged
|
||||||
|
assert "civitaiResources" not in merged
|
||||||
|
|
||||||
|
assert merged["cfg_scale"] == 5.0 # From request_params
|
||||||
|
assert merged["clip_skip"] == "2" # From request_params
|
||||||
|
assert merged["negative_prompt"] == "low quality" # Normalized from civitai_meta
|
||||||
|
assert merged["size"] == "1024x1024" # Normalized from civitai_meta
|
||||||
|
assert merged["steps"] == 30 # Normalized from request_params
|
||||||
104
tests/services/test_no_tags_filter.py
Normal file
104
tests/services/test_no_tags_filter.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import pytest
|
||||||
|
from py.services.model_query import ModelFilterSet, FilterCriteria
|
||||||
|
from py.services.recipe_scanner import RecipeScanner
|
||||||
|
from pathlib import Path
|
||||||
|
from py.config import config
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
class StubSettings:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
# --- Model Filtering Tests ---
|
||||||
|
|
||||||
|
def test_model_filter_set_no_tags_include():
|
||||||
|
filter_set = ModelFilterSet(StubSettings())
|
||||||
|
data = [
|
||||||
|
{"name": "m1", "tags": ["tag1"]},
|
||||||
|
{"name": "m2", "tags": []},
|
||||||
|
{"name": "m3", "tags": None},
|
||||||
|
{"name": "m4", "tags": ["tag2"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Include __no_tags__
|
||||||
|
criteria = FilterCriteria(tags={"__no_tags__": "include"})
|
||||||
|
result = filter_set.apply(data, criteria)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert {item["name"] for item in result} == {"m2", "m3"}
|
||||||
|
|
||||||
|
def test_model_filter_set_no_tags_exclude():
|
||||||
|
filter_set = ModelFilterSet(StubSettings())
|
||||||
|
data = [
|
||||||
|
{"name": "m1", "tags": ["tag1"]},
|
||||||
|
{"name": "m2", "tags": []},
|
||||||
|
{"name": "m3", "tags": None},
|
||||||
|
{"name": "m4", "tags": ["tag2"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Exclude __no_tags__
|
||||||
|
criteria = FilterCriteria(tags={"__no_tags__": "exclude"})
|
||||||
|
result = filter_set.apply(data, criteria)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert {item["name"] for item in result} == {"m1", "m4"}
|
||||||
|
|
||||||
|
def test_model_filter_set_no_tags_mixed():
|
||||||
|
filter_set = ModelFilterSet(StubSettings())
|
||||||
|
data = [
|
||||||
|
{"name": "m1", "tags": ["tag1"]},
|
||||||
|
{"name": "m2", "tags": []},
|
||||||
|
{"name": "m3", "tags": None},
|
||||||
|
{"name": "m4", "tags": ["tag1", "tag2"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Include tag1 AND __no_tags__
|
||||||
|
criteria = FilterCriteria(tags={"tag1": "include", "__no_tags__": "include"})
|
||||||
|
result = filter_set.apply(data, criteria)
|
||||||
|
# m1 (tag1), m2 (no tags), m3 (no tags), m4 (tag1)
|
||||||
|
assert len(result) == 4
|
||||||
|
|
||||||
|
# --- Recipe Filtering Tests ---
|
||||||
|
|
||||||
|
class StubLoraScanner:
|
||||||
|
def __init__(self):
|
||||||
|
self._cache = SimpleNamespace(raw_data=[], version_index={})
|
||||||
|
async def get_cached_data(self):
|
||||||
|
return self._cache
|
||||||
|
async def refresh_cache(self, force=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recipe_scanner(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||||
|
stub = StubLoraScanner()
|
||||||
|
scanner = RecipeScanner(lora_scanner=stub)
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recipe_scanner_no_tags_filter(recipe_scanner):
|
||||||
|
scanner = recipe_scanner
|
||||||
|
|
||||||
|
# Mock some recipe data
|
||||||
|
recipes = [
|
||||||
|
{"id": "r1", "tags": ["tag1"], "title": "R1"},
|
||||||
|
{"id": "r2", "tags": [], "title": "R2"},
|
||||||
|
{"id": "r3", "tags": None, "title": "R3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# We need to inject these into the scanner's cache
|
||||||
|
# Since get_paginated_data calls get_cached_data() which we stubbed
|
||||||
|
scanner._cache = SimpleNamespace(
|
||||||
|
raw_data=recipes,
|
||||||
|
sorted_by_date=recipes,
|
||||||
|
sorted_by_name=recipes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test Include __no_tags__
|
||||||
|
result = await scanner.get_paginated_data(page=1, page_size=10, filters={"tags": {"__no_tags__": "include"}})
|
||||||
|
assert len(result["items"]) == 2
|
||||||
|
assert {item["id"] for item in result["items"]} == {"r2", "r3"}
|
||||||
|
|
||||||
|
# Test Exclude __no_tags__
|
||||||
|
result = await scanner.get_paginated_data(page=1, page_size=10, filters={"tags": {"__no_tags__": "exclude"}})
|
||||||
|
assert len(result["items"]) == 1
|
||||||
|
assert result["items"][0]["id"] == "r1"
|
||||||
282
tests/services/test_recipe_repair.py
Normal file
282
tests/services/test_recipe_repair.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from py.services.recipe_scanner import RecipeScanner
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
# We define these here to help with spec= if needed
|
||||||
|
class MockCivitaiClient:
|
||||||
|
async def get_image_info(self, image_id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MockPersistenceService:
|
||||||
|
async def save_recipe(self, recipe):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_civitai_client():
|
||||||
|
client = MagicMock(spec=MockCivitaiClient)
|
||||||
|
client.get_image_info = AsyncMock()
|
||||||
|
return client
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_metadata_provider():
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_model_version_info = AsyncMock(return_value=(None, None))
|
||||||
|
provider.get_model_by_hash = AsyncMock(return_value=(None, None))
|
||||||
|
return provider
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recipe_scanner():
|
||||||
|
lora_scanner = MagicMock()
|
||||||
|
lora_scanner.get_cached_data = AsyncMock(return_value=SimpleNamespace(raw_data=[]))
|
||||||
|
|
||||||
|
scanner = RecipeScanner(lora_scanner=lora_scanner)
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_scanner(recipe_scanner, mock_civitai_client, mock_metadata_provider, monkeypatch):
|
||||||
|
monkeypatch.setattr(recipe_scanner, "_get_civitai_client", AsyncMock(return_value=mock_civitai_client))
|
||||||
|
|
||||||
|
# Wrap the real method with a mock so we can check calls but still execute it
|
||||||
|
real_save = recipe_scanner._save_recipe_persistently
|
||||||
|
mock_save = AsyncMock(side_effect=real_save)
|
||||||
|
monkeypatch.setattr(recipe_scanner, "_save_recipe_persistently", mock_save)
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", AsyncMock(return_value=mock_metadata_provider))
|
||||||
|
|
||||||
|
# Mock get_recipe_json_path to avoid file system issues in tests
|
||||||
|
recipe_scanner.get_recipe_json_path = AsyncMock(return_value="/tmp/test_recipe.json")
|
||||||
|
# Mock open to avoid actual file writing
|
||||||
|
monkeypatch.setattr("builtins.open", MagicMock())
|
||||||
|
monkeypatch.setattr("json.dump", MagicMock())
|
||||||
|
monkeypatch.setattr("os.path.exists", MagicMock(return_value=False)) # avoid EXIF logic
|
||||||
|
|
||||||
|
return recipe_scanner, mock_civitai_client, mock_metadata_provider
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_skip_up_to_date(setup_scanner):
|
||||||
|
recipe_scanner, _, _ = setup_scanner
|
||||||
|
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[
|
||||||
|
{"id": "r1", "repair_version": RecipeScanner.REPAIR_VERSION, "title": "Up to date"}
|
||||||
|
])
|
||||||
|
|
||||||
|
# Run
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert results["repaired"] == 0
|
||||||
|
assert results["skipped"] == 1
|
||||||
|
recipe_scanner._save_recipe_persistently.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "Old Recipe",
|
||||||
|
"source_url": "https://civitai.com/images/12345",
|
||||||
|
"checkpoint": None,
|
||||||
|
"gen_params": {"prompt": ""}
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
|
||||||
|
# Mock image info returning modelVersionId
|
||||||
|
mock_civitai_client.get_image_info.return_value = {
|
||||||
|
"modelVersionId": 5678,
|
||||||
|
"meta": {"prompt": "a beautiful forest", "Checkpoint": "basic_name.safetensors"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock metadata provider returning full info
|
||||||
|
mock_metadata_provider.get_model_version_info.return_value = ({
|
||||||
|
"id": 5678,
|
||||||
|
"modelId": 1234,
|
||||||
|
"name": "v1.0",
|
||||||
|
"model": {"name": "Full Model Name"},
|
||||||
|
"baseModel": "SDXL 1.0",
|
||||||
|
"images": [{"url": "https://image.url/thumb.jpg"}],
|
||||||
|
"files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}]
|
||||||
|
}, None)
|
||||||
|
|
||||||
|
# Run
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert results["repaired"] == 1
|
||||||
|
mock_metadata_provider.get_model_version_info.assert_called_with("5678")
|
||||||
|
|
||||||
|
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
||||||
|
checkpoint = saved_recipe["checkpoint"]
|
||||||
|
assert checkpoint["modelName"] == "Full Model Name"
|
||||||
|
assert checkpoint["modelVersionName"] == "v1.0"
|
||||||
|
assert checkpoint["modelId"] == 1234
|
||||||
|
assert checkpoint["modelVersionId"] == 5678
|
||||||
|
assert checkpoint["type"] == "checkpoint"
|
||||||
|
assert "name" not in checkpoint
|
||||||
|
assert "version" not in checkpoint
|
||||||
|
assert "hash" not in checkpoint
|
||||||
|
assert "file_name" not in checkpoint
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "Embedded Only",
|
||||||
|
"checkpoint": None,
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "",
|
||||||
|
"Model hash": "hash123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
|
||||||
|
# Mock metadata provider lookup by hash
|
||||||
|
mock_metadata_provider.get_model_by_hash.return_value = ({
|
||||||
|
"id": 999,
|
||||||
|
"modelId": 888,
|
||||||
|
"name": "v2.0",
|
||||||
|
"model": {"name": "Hashed Model"},
|
||||||
|
"baseModel": "SD 1.5",
|
||||||
|
"files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}]
|
||||||
|
}, None)
|
||||||
|
|
||||||
|
# Run
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert results["repaired"] == 1
|
||||||
|
mock_metadata_provider.get_model_by_hash.assert_called_with("hash123")
|
||||||
|
|
||||||
|
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
||||||
|
checkpoint = saved_recipe["checkpoint"]
|
||||||
|
assert checkpoint["modelName"] == "Hashed Model"
|
||||||
|
assert checkpoint["modelVersionName"] == "v2.0"
|
||||||
|
assert checkpoint["modelId"] == 888
|
||||||
|
assert checkpoint["type"] == "checkpoint"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_fallback_to_basic(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "No Meta Lookup",
|
||||||
|
"checkpoint": None,
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "",
|
||||||
|
"Checkpoint": "just_a_name.safetensors"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
|
||||||
|
# Mock metadata provider returning nothing
|
||||||
|
mock_metadata_provider.get_model_by_hash.return_value = (None, "Model not found")
|
||||||
|
|
||||||
|
# Run
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert results["repaired"] == 1
|
||||||
|
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
||||||
|
assert saved_recipe["checkpoint"]["modelName"] == "just_a_name.safetensors"
|
||||||
|
assert saved_recipe["checkpoint"]["type"] == "checkpoint"
|
||||||
|
assert "modelId" not in saved_recipe["checkpoint"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_progress_callback(setup_scanner):
|
||||||
|
recipe_scanner, _, _ = setup_scanner
|
||||||
|
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[
|
||||||
|
{"id": "r1", "title": "R1", "checkpoint": None},
|
||||||
|
{"id": "r2", "title": "R2", "checkpoint": None}
|
||||||
|
])
|
||||||
|
|
||||||
|
progress_calls = []
|
||||||
|
async def progress_callback(data):
|
||||||
|
progress_calls.append(data)
|
||||||
|
|
||||||
|
# Run
|
||||||
|
await recipe_scanner.repair_all_recipes(
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert len(progress_calls) >= 2
|
||||||
|
assert progress_calls[-1]["status"] == "completed"
|
||||||
|
assert progress_calls[-1]["total"] == 2
|
||||||
|
assert progress_calls[-1]["repaired"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_strips_runtime_fields(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
# Recipe with runtime fields
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "Cleanup Test",
|
||||||
|
"checkpoint": {
|
||||||
|
"name": "CP",
|
||||||
|
"inLibrary": True,
|
||||||
|
"localPath": "/path/to/cp",
|
||||||
|
"thumbnailUrl": "thumb.jpg"
|
||||||
|
},
|
||||||
|
"loras": [
|
||||||
|
{
|
||||||
|
"name": "L1",
|
||||||
|
"weight": 0.8,
|
||||||
|
"inLibrary": True,
|
||||||
|
"localPath": "/path/to/l1",
|
||||||
|
"preview_url": "p.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gen_params": {"prompt": ""}
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
# Set high version to trigger repair if needed (or just ensure it processes)
|
||||||
|
recipe["repair_version"] = 0
|
||||||
|
|
||||||
|
# Run
|
||||||
|
await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify sanitation
|
||||||
|
assert recipe_scanner._save_recipe_persistently.called
|
||||||
|
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
||||||
|
|
||||||
|
# 1. Check LORA
|
||||||
|
lora = saved_recipe["loras"][0]
|
||||||
|
assert "inLibrary" not in lora
|
||||||
|
assert "localPath" not in lora
|
||||||
|
assert "preview_url" not in lora
|
||||||
|
assert "strength" in lora # weight renamed to strength
|
||||||
|
assert lora["strength"] == 0.8
|
||||||
|
|
||||||
|
# 2. Check Checkpoint
|
||||||
|
cp = saved_recipe["checkpoint"]
|
||||||
|
assert "inLibrary" not in cp
|
||||||
|
assert "localPath" not in cp
|
||||||
|
assert "thumbnailUrl" not in cp
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sanitize_recipe_for_storage(recipe_scanner):
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"loras": [{"name": "L1", "inLibrary": True, "weight": 0.5}],
|
||||||
|
"checkpoint": {"name": "CP", "localPath": "/tmp/cp"}
|
||||||
|
}
|
||||||
|
|
||||||
|
clean = recipe_scanner._sanitize_recipe_for_storage(recipe)
|
||||||
|
|
||||||
|
assert "inLibrary" not in clean["loras"][0]
|
||||||
|
assert "strength" in clean["loras"][0]
|
||||||
|
assert clean["loras"][0]["strength"] == 0.5
|
||||||
|
assert "localPath" not in clean["checkpoint"]
|
||||||
|
# Testing based on what enricher would produce if it ran,
|
||||||
|
# but here we are just testing the sanitizer which handles what is ALREADY there.
|
||||||
|
# However, the sanitizer doesn't rename fields, it just removes runtime ones.
|
||||||
|
# Since we changed the enricher to NOT put 'name' anymore, this test case
|
||||||
|
# should probably reflect the new fields if it's simulating a real recipe.
|
||||||
|
assert clean["checkpoint"]["name"] == "CP"
|
||||||
@@ -349,3 +349,295 @@ def test_enrich_formats_absolute_preview_paths(recipe_scanner, tmp_path):
|
|||||||
enriched = scanner._enrich_lora_entry(dict(lora))
|
enriched = scanner._enrich_lora_entry(dict(lora))
|
||||||
|
|
||||||
assert enriched["preview_url"] == config.get_preview_static_url(str(preview_path))
|
assert enriched["preview_url"] == config.get_preview_static_url(str(preview_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_waits_for_lora_scanner(monkeypatch):
|
||||||
|
ready_flag = asyncio.Event()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
class StubLoraScanner:
|
||||||
|
def __init__(self):
|
||||||
|
self._cache = None
|
||||||
|
self._is_initializing = True
|
||||||
|
|
||||||
|
async def initialize_in_background(self):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
self._cache = SimpleNamespace(raw_data=[])
|
||||||
|
self._is_initializing = False
|
||||||
|
ready_flag.set()
|
||||||
|
|
||||||
|
lora_scanner = StubLoraScanner()
|
||||||
|
scanner = RecipeScanner(lora_scanner=lora_scanner)
|
||||||
|
|
||||||
|
await scanner.initialize_in_background()
|
||||||
|
|
||||||
|
assert ready_flag.is_set()
|
||||||
|
assert call_count == 1
|
||||||
|
assert scanner._cache is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_model_version_marked_deleted_and_not_retried(monkeypatch, recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"id": "invalid-version",
|
||||||
|
"file_path": str(recipes_dir / "invalid-version.webp"),
|
||||||
|
"title": "Invalid",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [{"modelVersionId": 999, "file_name": "", "hash": ""}],
|
||||||
|
}
|
||||||
|
await scanner.add_recipe(dict(recipe))
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def fake_get_hash(model_version_id):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(scanner, "_get_hash_from_civitai", fake_get_hash)
|
||||||
|
|
||||||
|
metadata_updated = await scanner._update_lora_information(recipe)
|
||||||
|
|
||||||
|
assert metadata_updated is True
|
||||||
|
assert recipe["loras"][0]["isDeleted"] is True
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
# Subsequent calls should skip remote lookup once marked deleted
|
||||||
|
metadata_updated_again = await scanner._update_lora_information(recipe)
|
||||||
|
assert metadata_updated_again is False
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_recipe_persists_deleted_flag_on_invalid_version(monkeypatch, recipe_scanner, tmp_path):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recipe_id = "persist-invalid"
|
||||||
|
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_data = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(recipes_dir / f"{recipe_id}.webp"),
|
||||||
|
"title": "Invalid",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [{"modelVersionId": 1234, "file_name": "", "hash": ""}],
|
||||||
|
}
|
||||||
|
recipe_path.write_text(json.dumps(recipe_data))
|
||||||
|
|
||||||
|
async def fake_get_hash(model_version_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(scanner, "_get_hash_from_civitai", fake_get_hash)
|
||||||
|
|
||||||
|
loaded = await scanner._load_recipe_file(str(recipe_path))
|
||||||
|
|
||||||
|
assert loaded["loras"][0]["isDeleted"] is True
|
||||||
|
|
||||||
|
persisted = json.loads(recipe_path.read_text())
|
||||||
|
assert persisted["loras"][0]["isDeleted"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: Path, recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Recipe 1: Contains the LoRA with hash "hash1"
|
||||||
|
recipe1_id = "recipe1"
|
||||||
|
recipe1_path = recipes_dir / f"{recipe1_id}.recipe.json"
|
||||||
|
recipe1_data = {
|
||||||
|
"id": recipe1_id,
|
||||||
|
"file_path": str(tmp_path / "img1.png"),
|
||||||
|
"title": "Recipe 1",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [
|
||||||
|
{"file_name": "old_name", "hash": "hash1"},
|
||||||
|
{"file_name": "other_lora", "hash": "hash2"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
recipe1_path.write_text(json.dumps(recipe1_data))
|
||||||
|
await scanner.add_recipe(dict(recipe1_data))
|
||||||
|
|
||||||
|
# Recipe 2: Does NOT contain the LoRA
|
||||||
|
recipe2_id = "recipe2"
|
||||||
|
recipe2_path = recipes_dir / f"{recipe2_id}.recipe.json"
|
||||||
|
recipe2_data = {
|
||||||
|
"id": recipe2_id,
|
||||||
|
"file_path": str(tmp_path / "img2.png"),
|
||||||
|
"title": "Recipe 2",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [
|
||||||
|
{"file_name": "other_lora", "hash": "hash2"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
recipe2_path.write_text(json.dumps(recipe2_data))
|
||||||
|
await scanner.add_recipe(dict(recipe2_data))
|
||||||
|
|
||||||
|
# Update LoRA name for "hash1" (using different case to test normalization)
|
||||||
|
new_name = "new_name"
|
||||||
|
file_count, cache_count = await scanner.update_lora_filename_by_hash("HASH1", new_name)
|
||||||
|
|
||||||
|
assert file_count == 1
|
||||||
|
assert cache_count == 1
|
||||||
|
|
||||||
|
# Check file on disk
|
||||||
|
persisted1 = json.loads(recipe1_path.read_text())
|
||||||
|
assert persisted1["loras"][0]["file_name"] == new_name
|
||||||
|
assert persisted1["loras"][1]["file_name"] == "other_lora"
|
||||||
|
|
||||||
|
# Verify Recipe 2 unchanged
|
||||||
|
persisted2 = json.loads(recipe2_path.read_text())
|
||||||
|
assert persisted2["loras"][0]["file_name"] == "other_lora"
|
||||||
|
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
|
||||||
|
assert cached1["loras"][0]["file_name"] == new_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add a normal recipe
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "regular",
|
||||||
|
"file_path": "path/regular.png",
|
||||||
|
"title": "Regular Recipe",
|
||||||
|
"modified": 1.0,
|
||||||
|
"created_date": 1.0,
|
||||||
|
"loras": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add a favorite recipe
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "favorite",
|
||||||
|
"file_path": "path/favorite.png",
|
||||||
|
"title": "Favorite Recipe",
|
||||||
|
"modified": 2.0,
|
||||||
|
"created_date": 2.0,
|
||||||
|
"loras": [],
|
||||||
|
"favorite": True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test without filter (should return both)
|
||||||
|
result_all = await scanner.get_paginated_data(page=1, page_size=10)
|
||||||
|
assert len(result_all["items"]) == 2
|
||||||
|
|
||||||
|
# Test with favorite filter
|
||||||
|
result_fav = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": True})
|
||||||
|
assert len(result_fav["items"]) == 1
|
||||||
|
assert result_fav["items"][0]["id"] == "favorite"
|
||||||
|
|
||||||
|
# Test with favorite filter set to False (should return both or at least not filter if it's the default)
|
||||||
|
# Actually our implementation checks if 'favorite' in filters and filters['favorite']
|
||||||
|
result_fav_false = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": False})
|
||||||
|
assert len(result_fav_false["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add a recipe with a specific prompt
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "prompt-recipe",
|
||||||
|
"file_path": "path/prompt.png",
|
||||||
|
"title": "Prompt Recipe",
|
||||||
|
"modified": 1.0,
|
||||||
|
"created_date": 1.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "a beautiful forest landscape"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add a recipe with a specific negative prompt
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "neg-prompt-recipe",
|
||||||
|
"file_path": "path/neg.png",
|
||||||
|
"title": "Negative Prompt Recipe",
|
||||||
|
"modified": 2.0,
|
||||||
|
"created_date": 2.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"negative_prompt": "ugly, blurry mountains"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test search in prompt
|
||||||
|
result_prompt = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="forest", search_options={"prompt": True}
|
||||||
|
)
|
||||||
|
assert len(result_prompt["items"]) == 1
|
||||||
|
assert result_prompt["items"][0]["id"] == "prompt-recipe"
|
||||||
|
|
||||||
|
# Test search in negative prompt
|
||||||
|
result_neg = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="mountains", search_options={"prompt": True}
|
||||||
|
)
|
||||||
|
assert len(result_neg["items"]) == 1
|
||||||
|
assert result_neg["items"][0]["id"] == "neg-prompt-recipe"
|
||||||
|
|
||||||
|
# Test search disabled (should not find by prompt)
|
||||||
|
result_disabled = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="forest", search_options={"prompt": False}
|
||||||
|
)
|
||||||
|
assert len(result_disabled["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_sorting(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add test recipes
|
||||||
|
# Recipe A: Name "Alpha", Date 10, LoRAs 2
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "A", "title": "Alpha", "created_date": 10.0,
|
||||||
|
"loras": [{}, {}], "file_path": "a.png"
|
||||||
|
})
|
||||||
|
# Recipe B: Name "Beta", Date 20, LoRAs 1
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "B", "title": "Beta", "created_date": 20.0,
|
||||||
|
"loras": [{}], "file_path": "b.png"
|
||||||
|
})
|
||||||
|
# Recipe C: Name "Gamma", Date 5, LoRAs 3
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "C", "title": "Gamma", "created_date": 5.0,
|
||||||
|
"loras": [{}, {}, {}], "file_path": "c.png"
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test Name DESC: Gamma, Beta, Alpha
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="name:desc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "B", "A"]
|
||||||
|
|
||||||
|
# Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:desc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||||
|
|
||||||
|
# Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:asc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["B", "A", "C"]
|
||||||
|
|
||||||
|
# Test Date ASC: Gamma (5), Alpha (10), Beta (20)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ from py.services.recipes.persistence_service import RecipePersistenceService
|
|||||||
|
|
||||||
|
|
||||||
class DummyExifUtils:
|
class DummyExifUtils:
|
||||||
|
def __init__(self):
|
||||||
|
self.appended = None
|
||||||
|
self.optimized_calls = 0
|
||||||
|
|
||||||
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
|
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
|
||||||
|
self.optimized_calls += 1
|
||||||
return image_data, ".webp"
|
return image_data, ".webp"
|
||||||
|
|
||||||
def append_recipe_metadata(self, image_path, recipe_data):
|
def append_recipe_metadata(self, image_path, recipe_data):
|
||||||
@@ -22,6 +27,46 @@ class DummyExifUtils:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_recipe_video_bypasses_optimization(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def add_recipe(self, recipe_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = {"base_model": "Flux", "loras": []}
|
||||||
|
video_bytes = b"mp4-content"
|
||||||
|
|
||||||
|
result = await service.save_recipe(
|
||||||
|
recipe_scanner=scanner,
|
||||||
|
image_bytes=video_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name="Video Recipe",
|
||||||
|
tags=[],
|
||||||
|
metadata=metadata,
|
||||||
|
extension=".mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["image_path"].endswith(".mp4")
|
||||||
|
assert Path(result.payload["image_path"]).read_bytes() == video_bytes
|
||||||
|
assert exif_utils.optimized_calls == 0, "Optimization should be bypassed for video"
|
||||||
|
assert exif_utils.appended is None, "Metadata embedding should be bypassed for video"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch):
|
async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
@@ -50,7 +95,7 @@ async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monke
|
|||||||
|
|
||||||
temp_path = tmp_path / "temp.jpg"
|
temp_path = tmp_path / "temp.jpg"
|
||||||
|
|
||||||
def create_temp_path():
|
def create_temp_path(suffix=".jpg"):
|
||||||
temp_path.write_bytes(b"")
|
temp_path.write_bytes(b"")
|
||||||
return str(temp_path)
|
return str(temp_path)
|
||||||
|
|
||||||
@@ -356,3 +401,138 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
|||||||
assert stored["loras"] == []
|
assert stored["loras"] == []
|
||||||
assert stored["title"] == "recipe"
|
assert stored["title"] == "recipe"
|
||||||
assert scanner.added and scanner.added[0]["loras"] == []
|
assert scanner.added and scanner.added[0]["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_move_recipe_updates_paths(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recipe_id = "move-me"
|
||||||
|
image_path = recipes_dir / f"{recipe_id}.webp"
|
||||||
|
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
image_path.write_bytes(b"img")
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Recipe",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
"created_date": 0,
|
||||||
|
"modified": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class MoveScanner:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
self.recipe = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Recipe",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
"created_date": 0,
|
||||||
|
"modified": 0,
|
||||||
|
"folder": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_recipe_by_id(self, target_id: str):
|
||||||
|
return self.recipe if target_id == recipe_id else None
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, target_id: str):
|
||||||
|
matches = list(Path(self.recipes_dir).rglob(f"{target_id}.recipe.json"))
|
||||||
|
return str(matches[0]) if matches else None
|
||||||
|
|
||||||
|
async def update_recipe_metadata(self, target_id: str, metadata: dict):
|
||||||
|
if target_id != recipe_id:
|
||||||
|
return False
|
||||||
|
self.recipe.update(metadata)
|
||||||
|
target_path = await self.get_recipe_json_path(target_id)
|
||||||
|
if not target_path:
|
||||||
|
return False
|
||||||
|
existing = json.loads(Path(target_path).read_text())
|
||||||
|
existing.update(metadata)
|
||||||
|
Path(target_path).write_text(json.dumps(existing))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_cached_data(self, force_refresh: bool = False): # noqa: ARG002 - signature parity
|
||||||
|
return SimpleNamespace(raw_data=[self.recipe])
|
||||||
|
|
||||||
|
scanner = MoveScanner(recipes_dir)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
target_folder = recipes_dir / "nested"
|
||||||
|
result = await service.move_recipe(
|
||||||
|
recipe_scanner=scanner, recipe_id=recipe_id, target_path=str(target_folder)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["folder"] == "nested"
|
||||||
|
assert Path(result.payload["json_path"]).parent == target_folder
|
||||||
|
assert Path(result.payload["new_file_path"]).parent == target_folder
|
||||||
|
assert not json_path.exists()
|
||||||
|
|
||||||
|
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||||
|
assert stored["folder"] == "nested"
|
||||||
|
assert stored["file_path"] == result.payload["new_file_path"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_remote_video(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
|
||||||
|
class DummyFactory:
|
||||||
|
def create_parser(self, metadata):
|
||||||
|
async def parse_metadata(m, recipe_scanner):
|
||||||
|
return {"loras": []}
|
||||||
|
return SimpleNamespace(parse_metadata=parse_metadata)
|
||||||
|
|
||||||
|
async def downloader_factory():
|
||||||
|
class Downloader:
|
||||||
|
async def download_file(self, url, path, use_auth=False):
|
||||||
|
Path(path).write_bytes(b"video-content")
|
||||||
|
return True, "success"
|
||||||
|
|
||||||
|
return Downloader()
|
||||||
|
|
||||||
|
service = RecipeAnalysisService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
recipe_parser_factory=DummyFactory(),
|
||||||
|
downloader_factory=downloader_factory,
|
||||||
|
metadata_collector=None,
|
||||||
|
metadata_processor_cls=None,
|
||||||
|
metadata_registry_cls=None,
|
||||||
|
standalone_mode=False,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class DummyClient:
|
||||||
|
async def get_image_info(self, image_id):
|
||||||
|
return {
|
||||||
|
"url": "https://civitai.com/video.mp4",
|
||||||
|
"type": "video",
|
||||||
|
"meta": {"prompt": "video prompt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = await service.analyze_remote_image(
|
||||||
|
url="https://civitai.com/images/123",
|
||||||
|
recipe_scanner=DummyScanner(),
|
||||||
|
civitai_client=DummyClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["is_video"] is True
|
||||||
|
assert result.payload["extension"] == ".mp4"
|
||||||
|
assert result.payload["image_base64"] is not None
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.services.base_model_service import BaseModelService
|
from py.services.base_model_service import BaseModelService
|
||||||
@@ -42,8 +43,8 @@ async def test_search_relative_paths_supports_multiple_tokens():
|
|||||||
matching = await service.search_relative_paths("flux detail")
|
matching = await service.search_relative_paths("flux detail")
|
||||||
|
|
||||||
assert matching == [
|
assert matching == [
|
||||||
"flux/detail-model.safetensors",
|
f"flux{os.sep}detail-model.safetensors",
|
||||||
"detail/flux-trained.safetensors",
|
f"detail{os.sep}flux-trained.safetensors",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -60,4 +61,4 @@ async def test_search_relative_paths_excludes_tokens():
|
|||||||
|
|
||||||
matching = await service.search_relative_paths("flux -detail")
|
matching = await service.search_relative_paths("flux -detail")
|
||||||
|
|
||||||
assert matching == ["flux/keep-me.safetensors"]
|
assert matching == [f"flux{os.sep}keep-me.safetensors"]
|
||||||
|
|||||||
92
tests/services/test_root_folder_recursive.py
Normal file
92
tests/services/test_root_folder_recursive.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import pytest
|
||||||
|
from py.services.model_query import ModelFilterSet, FilterCriteria
|
||||||
|
from py.services.recipe_scanner import RecipeScanner
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
# Mock settings
|
||||||
|
class MockSettings:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
# --- Model Filtering Tests ---
|
||||||
|
|
||||||
|
def test_model_filter_set_root_recursive_true():
|
||||||
|
filter_set = ModelFilterSet(MockSettings())
|
||||||
|
items = [
|
||||||
|
{"model_name": "root_item", "folder": ""},
|
||||||
|
{"model_name": "sub_item", "folder": "sub"},
|
||||||
|
]
|
||||||
|
criteria = FilterCriteria(folder="", search_options={"recursive": True})
|
||||||
|
|
||||||
|
result = filter_set.apply(items, criteria)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert any(i["model_name"] == "root_item" for i in result)
|
||||||
|
assert any(i["model_name"] == "sub_item" for i in result)
|
||||||
|
|
||||||
|
def test_model_filter_set_root_recursive_false():
|
||||||
|
filter_set = ModelFilterSet(MockSettings())
|
||||||
|
items = [
|
||||||
|
{"model_name": "root_item", "folder": ""},
|
||||||
|
{"model_name": "sub_item", "folder": "sub"},
|
||||||
|
]
|
||||||
|
criteria = FilterCriteria(folder="", search_options={"recursive": False})
|
||||||
|
|
||||||
|
result = filter_set.apply(items, criteria)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["model_name"] == "root_item"
|
||||||
|
|
||||||
|
# --- Recipe Filtering Tests ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recipe_scanner_root_recursive_true():
|
||||||
|
# Mock LoraScanner
|
||||||
|
class StubLoraScanner:
|
||||||
|
async def get_cached_data(self):
|
||||||
|
return SimpleNamespace(raw_data=[])
|
||||||
|
|
||||||
|
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
|
||||||
|
# Manually populate cache for testing get_paginated_data logic
|
||||||
|
scanner._cache = SimpleNamespace(
|
||||||
|
raw_data=[
|
||||||
|
{"id": "r1", "title": "root_recipe", "folder": "", "modified": 1.0, "created_date": 1.0, "loras": []},
|
||||||
|
{"id": "r2", "title": "sub_recipe", "folder": "sub", "modified": 2.0, "created_date": 2.0, "loras": []},
|
||||||
|
],
|
||||||
|
sorted_by_date=[
|
||||||
|
{"id": "r2", "title": "sub_recipe", "folder": "sub", "modified": 2.0, "created_date": 2.0, "loras": []},
|
||||||
|
{"id": "r1", "title": "root_recipe", "folder": "", "modified": 1.0, "created_date": 1.0, "loras": []},
|
||||||
|
],
|
||||||
|
sorted_by_name=[],
|
||||||
|
version_index={}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await scanner.get_paginated_data(page=1, page_size=10, folder="", recursive=True)
|
||||||
|
|
||||||
|
assert len(result["items"]) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recipe_scanner_root_recursive_false():
|
||||||
|
# Mock LoraScanner
|
||||||
|
class StubLoraScanner:
|
||||||
|
async def get_cached_data(self):
|
||||||
|
return SimpleNamespace(raw_data=[])
|
||||||
|
|
||||||
|
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
|
||||||
|
scanner._cache = SimpleNamespace(
|
||||||
|
raw_data=[
|
||||||
|
{"id": "r1", "title": "root_recipe", "folder": "", "modified": 1.0, "created_date": 1.0, "loras": []},
|
||||||
|
{"id": "r2", "title": "sub_recipe", "folder": "sub", "modified": 2.0, "created_date": 2.0, "loras": []},
|
||||||
|
],
|
||||||
|
sorted_by_date=[
|
||||||
|
{"id": "r2", "title": "sub_recipe", "folder": "sub", "modified": 2.0, "created_date": 2.0, "loras": []},
|
||||||
|
{"id": "r1", "title": "root_recipe", "folder": "", "modified": 1.0, "created_date": 1.0, "loras": []},
|
||||||
|
],
|
||||||
|
sorted_by_name=[],
|
||||||
|
version_index={}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await scanner.get_paginated_data(page=1, page_size=10, folder="", recursive=False)
|
||||||
|
|
||||||
|
assert len(result["items"]) == 1
|
||||||
|
assert result["items"][0]["id"] == "r1"
|
||||||
@@ -311,7 +311,7 @@ app.registerExtension({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// If no ',,' delimiter, treat the entire message as one group
|
// If no ',,' delimiter, treat the entire message as one group
|
||||||
const existing = existingTagMap[message.trim()];
|
const existing = consumeExistingState(message.trim());
|
||||||
tagArray = [{
|
tagArray = [{
|
||||||
text: message.trim(),
|
text: message.trim(),
|
||||||
// Use existing values if available, otherwise use defaults
|
// Use existing values if available, otherwise use defaults
|
||||||
|
|||||||
Reference in New Issue
Block a user