diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index a41fc1e7..62608f44 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -2114,8 +2114,18 @@ class RecipeScanner: if not recipe: return None + # Prefer the on-disk recipe JSON for fields that are not persisted in the + # SQLite cache yet, such as source_path. + merged_recipe = {**recipe} + recipe_json = await self._load_recipe_json(recipe_id) + if recipe_json: + for field in ("source_path", "checkpoint", "loras", "gen_params"): + if field not in recipe_json: + merged_recipe.pop(field, None) + merged_recipe.update(recipe_json) + # Format the recipe with all needed information - formatted_recipe = {**recipe} # Copy all fields + formatted_recipe = {**merged_recipe} # Format file path to URL if "file_path" in formatted_recipe: @@ -2149,6 +2159,30 @@ class RecipeScanner: return formatted_recipe + async def _load_recipe_json(self, recipe_id: str) -> Optional[Dict[str, Any]]: + """Load the raw recipe JSON payload for a recipe ID if it exists.""" + + recipe_json_path = await self.get_recipe_json_path(recipe_id) + if not recipe_json_path or not os.path.exists(recipe_json_path): + return None + + try: + with open(recipe_json_path, "r", encoding="utf-8") as f: + recipe_data = json.load(f) + except Exception as exc: + logger.debug( + "Failed to load recipe JSON for %s from %s: %s", + recipe_id, + recipe_json_path, + exc, + ) + return None + + if not isinstance(recipe_data, dict): + return None + + return recipe_data + def _format_file_url(self, file_path: str) -> str: """Format file path as URL for serving in web UI""" if not file_path: diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index cc579e7c..8efcd916 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -31,6 +31,20 @@ export function extractRecipeId(filePath) { return dotIndex > 0 ? basename.substring(0, dotIndex) : basename; } +export async function fetchRecipeDetails(recipeId) { + if (!recipeId) { + throw new Error('Unable to determine recipe ID'); + } + + const encodedRecipeId = encodeURIComponent(recipeId); + const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}`); + if (!response.ok) { + throw new Error(`Failed to load recipe: ${response.statusText}`); + } + + return response.json(); +} + /** * Fetch recipes with pagination for virtual scrolling * @param {number} page - Page number to fetch @@ -61,7 +75,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { // If we have a specific recipe ID to load if (pageState.customFilter?.active && pageState.customFilter?.recipeId) { // Special case: load specific recipe - const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`); + const response = await fetch( + `${RECIPE_ENDPOINTS.detail}/${encodeURIComponent(pageState.customFilter.recipeId)}` + ); if (!response.ok) { throw new Error(`Failed to load recipe: ${response.statusText}`); @@ -349,9 +365,10 @@ export function createRecipeCard(recipe) { * @param {Object} updates - The metadata updates to apply * @returns {Promise} The updated recipe data */ -export async function updateRecipeMetadata(filePath, updates) { +export async function updateRecipeMetadata(filePath, updates, options = {}) { try { state.loadingManager.showSimpleLoading('Saving metadata...'); + const listFilePath = options.listFilePath || filePath; // Extract recipeId from filePath (basename without extension) const recipeId = extractRecipeId(filePath); @@ -359,7 +376,7 @@ export async function updateRecipeMetadata(filePath, updates) { throw new Error('Unable to determine recipe ID'); } - const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, { + const response = await fetch(`${RECIPE_ENDPOINTS.update}/${encodeURIComponent(recipeId)}/update`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -374,7 +391,7 @@ export async function updateRecipeMetadata(filePath, updates) { throw new Error(data.error || 'Failed to update recipe'); } - state.virtualScroller.updateSingleItem(filePath, updates); + state.virtualScroller.updateSingleItem(listFilePath, updates); return data; } catch (error) { diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 25bc1f58..59fb83b7 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -3,16 +3,75 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow import { translate } from '../utils/i18nHelpers.js'; import { state } from '../state/index.js'; import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js'; -import { updateRecipeMetadata } from '../api/recipeApi.js'; +import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js'; import { downloadManager } from '../managers/DownloadManager.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; class RecipeModal { constructor() { this.promptEditorState = {}; + this.recipeHydrationRequestId = 0; + this.resetLocalEditState(); this.init(); } + createLocalEditState() { + return { + title: { commitVersion: 0, isDirty: false }, + tags: { commitVersion: 0, isDirty: false }, + prompt: { commitVersion: 0, isDirty: false }, + negative_prompt: { commitVersion: 0, isDirty: false }, + source_path: { commitVersion: 0, isDirty: false }, + }; + } + + resetLocalEditState() { + this.localEditState = this.createLocalEditState(); + this.sourceUrlEditState = this.localEditState.source_path; + } + + getLocalEditState(field) { + if (!this.localEditState[field]) { + this.localEditState[field] = { commitVersion: 0, isDirty: false }; + } + return this.localEditState[field]; + } + + markFieldDirty(field) { + this.getLocalEditState(field).isDirty = true; + } + + clearFieldDirty(field) { + this.getLocalEditState(field).isDirty = false; + } + + commitField(field) { + const fieldState = this.getLocalEditState(field); + fieldState.isDirty = false; + fieldState.commitVersion += 1; + } + + captureLocalEditVersions() { + return Object.fromEntries( + Object.entries(this.localEditState).map(([field, state]) => [ + field, + state.commitVersion, + ]) + ); + } + + shouldPreserveField(field, requestVersions) { + const fieldState = this.getLocalEditState(field); + const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion; + return fieldState.isDirty || fieldState.commitVersion !== requestVersion; + } + + hasFieldCommittedSinceRequest(field, requestVersions) { + const fieldState = this.getLocalEditState(field); + const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion; + return fieldState.commitVersion !== requestVersion; + } + init() { this.setupCopyButtons(); this.setupPromptEditors(); @@ -87,8 +146,10 @@ class RecipeModal { } showRecipeDetails(recipe) { + const hydratedRecipe = recipe || {}; + this.resetLocalEditState(); // Store the full recipe for editing - this.currentRecipe = recipe; + this.currentRecipe = hydratedRecipe; this.resetPromptEditors(); // Set modal title with edit icon @@ -96,11 +157,11 @@ class RecipeModal { if (modalTitle) { modalTitle.innerHTML = `
- ${recipe.title || 'Recipe Details'} + ${hydratedRecipe.title || 'Recipe Details'}
- +
`; @@ -122,8 +183,9 @@ class RecipeModal { } // Store the recipe ID for copy syntax API call - this.recipeId = recipe.id; - this.filePath = recipe.file_path; + this.recipeId = hydratedRecipe.id; + this.filePath = hydratedRecipe.file_path; + this.listFilePath = hydratedRecipe.file_path; // Set recipe tags if they exist const tagsCompactElement = document.getElementById('recipeTagsCompact'); @@ -143,11 +205,11 @@ class RecipeModal { const tagsDisplay = tagsCompactElement.querySelector('.tags-display'); - if (recipe.tags && recipe.tags.length > 0) { + if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) { // Limit displayed tags to 5, show a "+X more" button if needed const maxVisibleTags = 5; - const visibleTags = recipe.tags.slice(0, maxVisibleTags); - const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : []; + const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags); + const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : []; // Add visible tags visibleTags.forEach(tag => { @@ -184,7 +246,7 @@ class RecipeModal { // Add all tags to tooltip if (tagsTooltipContent) { tagsTooltipContent.innerHTML = ''; - recipe.tags.forEach(tag => { + hydratedRecipe.tags.forEach(tag => { const tooltipTag = document.createElement('div'); tooltipTag.className = 'tooltip-tag'; tooltipTag.textContent = tag; @@ -201,8 +263,8 @@ class RecipeModal { const tagsInput = tagsCompactElement.querySelector('.tags-input'); // Set current tags in the input - if (recipe.tags && recipe.tags.length > 0) { - tagsInput.value = recipe.tags.join(', '); + if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) { + tagsInput.value = hydratedRecipe.tags.join(', '); } editTagsIcon.addEventListener('click', () => this.showTagsEditor()); @@ -222,49 +284,15 @@ class RecipeModal { // Set recipe image const mediaContainer = document.getElementById('recipePreviewContainer'); if (mediaContainer) { - // Stop any playing video before replacing content - const existingVideo = mediaContainer.querySelector('video'); - if (existingVideo) { - existingVideo.pause(); - existingVideo.currentTime = 0; - } - - // Clear the container - mediaContainer.innerHTML = ''; - - // Ensure file_url exists, fallback to file_path if needed - const imageUrl = recipe.file_url || - (recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` : - '/loras_static/images/no-preview.png'); - - // Check if the file is a video (mp4) - const isVideo = imageUrl.toLowerCase().endsWith('.mp4'); - - if (isVideo) { - const videoElement = document.createElement('video'); - videoElement.id = 'recipeModalVideo'; - videoElement.src = imageUrl; - videoElement.controls = true; - videoElement.autoplay = false; - videoElement.loop = true; - videoElement.muted = true; - videoElement.className = 'recipe-preview-media'; - videoElement.alt = recipe.title || 'Recipe Preview'; - mediaContainer.appendChild(videoElement); - } else { - const imgElement = document.createElement('img'); - imgElement.id = 'recipeModalImage'; - imgElement.src = imageUrl; - imgElement.className = 'recipe-preview-media'; - imgElement.alt = recipe.title || 'Recipe Preview'; - mediaContainer.appendChild(imgElement); - } + this.syncPreviewMedia(hydratedRecipe); + mediaContainer.querySelector('.source-url-container')?.remove(); + mediaContainer.querySelector('.source-url-editor')?.remove(); // Add source URL container if the recipe has a source_path const sourceUrlContainer = document.createElement('div'); sourceUrlContainer.className = 'source-url-container'; - const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0; - const sourceUrl = hasSourceUrl ? recipe.source_path : ''; + const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0; + const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : ''; const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://')); sourceUrlContainer.innerHTML = ` @@ -299,34 +327,261 @@ class RecipeModal { }, 50); } - // Set generation parameters + this.syncGenerationParams(hydratedRecipe.gen_params); + this.syncResourcesSection(hydratedRecipe); + + // Show the modal + modalManager.showModal('recipeModal'); + + if (this.recipeId) { + const hydrationRequestId = ++this.recipeHydrationRequestId; + const requestEditVersions = this.captureLocalEditVersions(); + this.hydrateRecipeDetails( + this.recipeId, + hydrationRequestId, + requestEditVersions + ); + } + } + + async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) { + try { + const fullRecipe = await fetchRecipeDetails(recipeId); + if (requestId !== this.recipeHydrationRequestId || !fullRecipe) { + return; + } + + const nextRecipe = { ...this.currentRecipe }; + + if (!this.hasFieldCommittedSinceRequest('title', requestEditVersions) && fullRecipe.title !== undefined) { + nextRecipe.title = fullRecipe.title; + } + + if (!this.hasFieldCommittedSinceRequest('tags', requestEditVersions) && fullRecipe.tags !== undefined) { + nextRecipe.tags = Array.isArray(fullRecipe.tags) ? [...fullRecipe.tags] : fullRecipe.tags; + } + + if (!this.hasFieldCommittedSinceRequest('source_path', requestEditVersions)) { + nextRecipe.source_path = fullRecipe.source_path || ''; + } + + const previousFilePath = nextRecipe.file_path; + if (fullRecipe.file_path !== undefined) { + nextRecipe.file_path = fullRecipe.file_path; + } + if (fullRecipe.file_url !== undefined) { + nextRecipe.file_url = fullRecipe.file_url; + } + if (fullRecipe.preview_url !== undefined) { + nextRecipe.preview_url = fullRecipe.preview_url; + } + if ( + fullRecipe.file_path !== undefined && + fullRecipe.file_path !== previousFilePath && + fullRecipe.file_url === undefined && + fullRecipe.preview_url === undefined + ) { + delete nextRecipe.file_url; + delete nextRecipe.preview_url; + } + + if (fullRecipe.gen_params !== undefined) { + const previousGenParams = nextRecipe.gen_params || {}; + const incomingGenParams = { ...(fullRecipe.gen_params || {}) }; + for (const [key, value] of Object.entries(previousGenParams)) { + if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) { + incomingGenParams[key] = value; + } + } + nextRecipe.gen_params = incomingGenParams; + } else { + const previousGenParams = nextRecipe.gen_params || {}; + const preservedGenParams = {}; + for (const [key, value] of Object.entries(previousGenParams)) { + if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) { + preservedGenParams[key] = value; + } + } + nextRecipe.gen_params = preservedGenParams; + } + + if (fullRecipe.checkpoint !== undefined) { + nextRecipe.checkpoint = fullRecipe.checkpoint; + } else { + delete nextRecipe.checkpoint; + } + if (fullRecipe.loras !== undefined) { + nextRecipe.loras = Array.isArray(fullRecipe.loras) ? [...fullRecipe.loras] : fullRecipe.loras; + } else { + delete nextRecipe.loras; + } + + this.currentRecipe = nextRecipe; + this.filePath = this.currentRecipe.file_path || this.filePath; + + this.syncHydratedRecipeFields(requestEditVersions); + } catch (error) { + // Keep the cached recipe visible if hydration fails. + console.warn('Failed to hydrate recipe details:', error); + } + } + + syncHydratedRecipeFields(requestEditVersions = {}) { + this.syncPreviewMedia(this.currentRecipe); + + if (!this.shouldPreserveField('title', requestEditVersions)) { + this.syncTitleDisplay(this.currentRecipe?.title || ''); + } + + if (!this.shouldPreserveField('tags', requestEditVersions)) { + this.syncTagsDisplay(this.currentRecipe?.tags || []); + } + + if (!this.shouldPreserveField('prompt', requestEditVersions)) { + this.syncPromptField( + 'prompt', + this.currentRecipe?.gen_params?.prompt || '', + 'No prompt information available' + ); + } + + if (!this.shouldPreserveField('negative_prompt', requestEditVersions)) { + this.syncPromptField( + 'negative_prompt', + this.currentRecipe?.gen_params?.negative_prompt || '', + 'No negative prompt information available' + ); + } + + this.syncGenerationParams(this.currentRecipe?.gen_params, { promptFieldsOnly: true }); + this.syncResourcesSection(this.currentRecipe); + + if (!this.shouldPreserveField('source_path', requestEditVersions)) { + this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true }); + } else { + this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); + } + } + + getPreviewMediaUrl(recipe = {}) { + return recipe.file_url || + recipe.preview_url || + (recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` : + '/loras_static/images/no-preview.png'); + } + + syncPreviewMedia(recipe = {}) { + const mediaContainer = document.getElementById('recipePreviewContainer'); + if (!mediaContainer) { + return; + } + + const previewUrl = this.getPreviewMediaUrl(recipe); + const isVideo = previewUrl.toLowerCase().endsWith('.mp4'); + const expectedElementId = isVideo ? 'recipeModalVideo' : 'recipeModalImage'; + let previewElement = mediaContainer.querySelector(`#${expectedElementId}`); + const existingPreviewElement = mediaContainer.querySelector('.recipe-preview-media'); + + if (!previewElement || (existingPreviewElement && existingPreviewElement !== previewElement)) { + if (existingPreviewElement?.tagName === 'VIDEO') { + const existingVideo = existingPreviewElement; + existingVideo.pause(); + existingVideo.currentTime = 0; + } + + existingPreviewElement?.remove(); + previewElement = document.createElement(isVideo ? 'video' : 'img'); + previewElement.id = expectedElementId; + previewElement.className = 'recipe-preview-media'; + mediaContainer.prepend(previewElement); + } + + previewElement.src = previewUrl; + previewElement.alt = recipe.title || 'Recipe Preview'; + + if (isVideo) { + previewElement.controls = true; + previewElement.autoplay = false; + previewElement.loop = true; + previewElement.muted = true; + } + } + + getMetadataUpdateOptions() { + return this.listFilePath ? { listFilePath: this.listFilePath } : {}; + } + + syncTitleDisplay(title) { + const titleContainer = document.getElementById('recipeModalTitle'); + if (!titleContainer) { + return; + } + + const contentText = titleContainer.querySelector('.content-text'); + if (contentText) { + contentText.textContent = title || 'Recipe Details'; + } + + const titleInput = titleContainer.querySelector('.title-input'); + if (titleInput) { + titleInput.value = title || ''; + } + } + + syncTagsDisplay(tags) { + const tagsContainer = document.getElementById('recipeTagsCompact'); + if (!tagsContainer) { + return; + } + + this.updateTagsDisplay(tagsContainer, tags || []); + + const tagsInput = tagsContainer.querySelector('.tags-input'); + if (tagsInput) { + tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : ''; + } + } + + syncPromptField(field, value, placeholder) { + const contentId = field === 'prompt' ? 'recipePrompt' : 'recipeNegativePrompt'; + const editorId = field === 'prompt' ? 'recipePromptEditor' : 'recipeNegativePromptEditor'; + const inputId = field === 'prompt' ? 'recipePromptInput' : 'recipeNegativePromptInput'; + + this.renderPromptContent(document.getElementById(contentId), value, placeholder); + + const input = document.getElementById(inputId); + if (input) { + input.value = value || ''; + } + } + + syncGenerationParams(genParams, options = {}) { const promptElement = document.getElementById('recipePrompt'); const negativePromptElement = document.getElementById('recipeNegativePrompt'); const otherParamsElement = document.getElementById('recipeOtherParams'); const promptInput = document.getElementById('recipePromptInput'); const negativePromptInput = document.getElementById('recipeNegativePromptInput'); + const promptFieldsOnly = options.promptFieldsOnly === true; - if (recipe.gen_params) { - this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available'); - this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available'); + if (genParams) { + if (!promptFieldsOnly) { + this.renderPromptContent(promptElement, genParams.prompt, 'No prompt information available'); + this.renderPromptContent(negativePromptElement, genParams.negative_prompt, 'No negative prompt information available'); - if (promptInput) { - promptInput.value = recipe.gen_params.prompt || ''; + if (promptInput) { + promptInput.value = genParams.prompt || ''; + } + + if (negativePromptInput) { + negativePromptInput.value = genParams.negative_prompt || ''; + } } - if (negativePromptInput) { - negativePromptInput.value = recipe.gen_params.negative_prompt || ''; - } - - // Set other parameters if (otherParamsElement) { - // Clear previous params otherParamsElement.innerHTML = ''; - - // Add all other parameters except prompt and negative_prompt const excludedParams = ['prompt', 'negative_prompt']; - for (const [key, value] of Object.entries(recipe.gen_params)) { + for (const [key, value] of Object.entries(genParams)) { if (!excludedParams.includes(key) && value !== undefined && value !== null) { const paramTag = document.createElement('div'); paramTag.className = 'param-tag'; @@ -338,22 +593,31 @@ class RecipeModal { } } - // If no other params, show a message if (otherParamsElement.children.length === 0) { otherParamsElement.innerHTML = '
No additional parameters available
'; } } - } else { - // No generation parameters available + return; + } + + if (!promptFieldsOnly) { this.renderPromptContent(promptElement, '', 'No prompt information available'); this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available'); if (promptInput) promptInput.value = ''; if (negativePromptInput) negativePromptInput.value = ''; - if (otherParamsElement) otherParamsElement.innerHTML = '
No parameters available
'; } + if (otherParamsElement) { + otherParamsElement.innerHTML = '
No parameters available
'; + } + } + + syncResourcesSection(recipe = {}) { const checkpointContainer = document.getElementById('recipeCheckpoint'); const resourceDivider = document.getElementById('recipeResourceDivider'); + const lorasListElement = document.getElementById('recipeLorasList'); + const lorasCountElement = document.getElementById('recipeLorasCount'); + const loras = Array.isArray(recipe.loras) ? recipe.loras : []; if (checkpointContainer) { checkpointContainer.innerHTML = ''; @@ -364,59 +628,43 @@ class RecipeModal { } } - // Set LoRAs list and count - const lorasListElement = document.getElementById('recipeLorasList'); - const lorasCountElement = document.getElementById('recipeLorasCount'); - - // Check all LoRAs status let allLorasAvailable = true; let missingLorasCount = 0; let deletedLorasCount = 0; - if (recipe.loras && recipe.loras.length > 0) { - recipe.loras.forEach(lora => { - if (lora.isDeleted) { - deletedLorasCount++; - } else if (!lora.inLibrary) { - allLorasAvailable = false; - missingLorasCount++; - } - }); - } + loras.forEach(lora => { + if (lora.isDeleted) { + deletedLorasCount++; + } else if (!lora.inLibrary) { + allLorasAvailable = false; + missingLorasCount++; + } + }); - // Set LoRAs count and status - if (lorasCountElement && recipe.loras) { - const totalCount = recipe.loras.length; - - // Create status indicator based on LoRA states + if (lorasCountElement) { + const totalCount = loras.length; let statusHTML = ''; if (totalCount > 0) { if (allLorasAvailable && deletedLorasCount === 0) { - // All LoRAs are available statusHTML = `
Ready to use
`; } else if (missingLorasCount > 0) { - // Some LoRAs are missing (prioritize showing missing over deleted) statusHTML = `
${missingLorasCount} missing
Click to download missing LoRAs
`; } else if (deletedLorasCount > 0 && missingLorasCount === 0) { - // Some LoRAs are deleted but none are missing statusHTML = `
${deletedLorasCount} deleted
`; } } lorasCountElement.innerHTML = ` ${totalCount} LoRAs ${statusHTML}`; - // Add event listeners for buttons and status indicators setTimeout(() => { - // Set up click handler for View LoRAs button const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); if (viewRecipeLorasBtn) { viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage()); } - // Add click handler for missing LoRAs status const missingStatus = document.querySelector('.recipe-status.missing'); if (missingStatus && missingLorasCount > 0) { missingStatus.classList.add('clickable'); @@ -425,13 +673,12 @@ class RecipeModal { }, 100); } - if (lorasListElement && recipe.loras && recipe.loras.length > 0) { - lorasListElement.innerHTML = recipe.loras.map(lora => { + if (lorasListElement && loras.length > 0) { + lorasListElement.innerHTML = loras.map(lora => { const existsLocally = lora.inLibrary; const isDeleted = lora.isDeleted; const localPath = lora.localPath || ''; - // Create status badge based on LoRA state let localStatus; if (existsLocally) { localStatus = ` @@ -441,7 +688,7 @@ class RecipeModal { `; } else if (isDeleted) { localStatus = ` -
+
Deleted
Click to reconnect with a local LoRA
`; @@ -452,7 +699,6 @@ class RecipeModal {
`; } - // Check if preview is a video const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4'); const previewMedia = isPreviewVideo ? `` : `LoRA preview`; - // Determine CSS class based on LoRA state let loraItemClass = 'recipe-lora-item'; if (existsLocally) { loraItemClass += ' exists-locally'; @@ -471,7 +716,7 @@ class RecipeModal { } return ` -
+
${previewMedia}
@@ -485,7 +730,7 @@ class RecipeModal {
Weight: ${lora.strength || 1.0}
${lora.baseModel ? `
${lora.baseModel}
` : ''}
-
+

Enter LoRA Syntax or Name to Reconnect:

Example: <lora:Boris_Vallejo_BV_flux_D:1> or just Boris_Vallejo_BV_flux_D @@ -503,15 +748,12 @@ class RecipeModal { `; }).join(''); - // Add event listeners for reconnect functionality setTimeout(() => { this.setupReconnectButtons(); this.setupLoraItemsClickable(); }, 100); - // Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API) this.recipeLorasSyntax = ''; - } else if (lorasListElement) { lorasListElement.innerHTML = '
No LoRAs associated with this recipe
'; this.recipeLorasSyntax = ''; @@ -522,9 +764,31 @@ class RecipeModal { const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item'); resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none'; } + } - // Show the modal - modalManager.showModal('recipeModal'); + updateSourceUrlDisplay(sourcePath, options = {}) { + const sourceUrlContainer = document.querySelector('.source-url-container'); + const sourceUrlEditor = document.querySelector('.source-url-editor'); + if (!sourceUrlContainer || !sourceUrlEditor) { + return; + } + + const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text'); + const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input'); + if (!sourceUrlText || !sourceUrlInput) { + return; + } + + const normalizedSourcePath = typeof sourcePath === 'string' ? sourcePath.trim() : ''; + const isValidUrl = normalizedSourcePath.startsWith('http://') || normalizedSourcePath.startsWith('https://'); + + sourceUrlText.textContent = normalizedSourcePath || 'No source URL'; + sourceUrlText.title = normalizedSourcePath + ? (isValidUrl ? 'Click to open source URL' : 'No valid URL') + : 'No valid URL'; + if (options.forceInputSync || !sourceUrlEditor.classList.contains('active') || !this.sourceUrlEditState.isDirty) { + sourceUrlInput.value = normalizedSourcePath; + } } // Title editing methods @@ -535,6 +799,7 @@ class RecipeModal { const editor = titleContainer.querySelector('#recipeTitleEditor'); editor.classList.add('active'); const input = editor.querySelector('input'); + input.oninput = () => this.markFieldDirty('title'); input.focus(); input.select(); } @@ -553,19 +818,23 @@ class RecipeModal { titleContainer.querySelector('.content-text').textContent = newTitle; // Update the recipe on the server - updateRecipeMetadata(this.filePath, { title: newTitle }) + updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions()) .then(data => { // Show success toast showToast('toast.recipes.nameUpdated', {}, 'success'); // Update the current recipe object this.currentRecipe.title = newTitle; + this.commitField('title'); }) .catch(error => { // Error is handled in the API function // Reset the UI if needed titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || ''; + this.clearFieldDirty('title'); }); + } else { + this.clearFieldDirty('title'); } // Hide editor @@ -581,6 +850,7 @@ class RecipeModal { const editor = titleContainer.querySelector('#recipeTitleEditor'); const input = editor.querySelector('input'); input.value = this.currentRecipe.title || ''; + this.clearFieldDirty('title'); // Hide editor editor.classList.remove('active'); @@ -596,6 +866,7 @@ class RecipeModal { const editor = tagsContainer.querySelector('#recipeTagsEditor'); editor.classList.add('active'); const input = editor.querySelector('input'); + input.oninput = () => this.markFieldDirty('tags'); input.focus(); } } @@ -623,20 +894,24 @@ class RecipeModal { if (tagsChanged) { // Update the recipe on the server - updateRecipeMetadata(this.filePath, { tags: newTags }) + updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions()) .then(data => { // Show success toast showToast('toast.recipes.tagsUpdated', {}, 'success'); // Update the current recipe object this.currentRecipe.tags = newTags; + this.commitField('tags'); // Update tags in the UI this.updateTagsDisplay(tagsContainer, newTags); }) .catch(error => { // Error is handled in the API function + this.clearFieldDirty('tags'); }); + } else { + this.clearFieldDirty('tags'); } // Hide editor @@ -708,6 +983,7 @@ class RecipeModal { const editor = tagsContainer.querySelector('#recipeTagsEditor'); const input = editor.querySelector('input'); input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : ''; + this.clearFieldDirty('tags'); // Hide editor editor.classList.remove('active'); @@ -748,6 +1024,7 @@ class RecipeModal { } if (input) { + input.addEventListener('input', () => this.markFieldDirty(config.field)); input.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); @@ -843,6 +1120,7 @@ class RecipeModal { const currentValue = currentGenParams[config.field] || ''; if (nextValue === currentValue) { + this.clearFieldDirty(config.field); this.hidePromptEditor(config); return; } @@ -857,14 +1135,17 @@ class RecipeModal { ...promptState, isSaving: true, }; - await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }); + await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions()); this.currentRecipe.gen_params = nextGenParams; this.renderPromptContent(content, nextValue, config.placeholder); showToast(config.successKey, {}, 'success', config.successFallback); + this.commitField(config.field); } catch (error) { this.renderPromptContent(content, currentValue, config.placeholder); input.value = currentValue; + this.clearFieldDirty(config.field); } finally { + this.clearFieldDirty(config.field); this.hidePromptEditor(config); } } @@ -872,10 +1153,10 @@ class RecipeModal { cancelPromptEdit(config) { const input = document.getElementById(config.inputId); if (input) { - const initialValue = this.promptEditorState[config.field]?.initialValue; - input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || ''); + input.value = this.currentRecipe?.gen_params?.[config.field] || ''; } + this.clearFieldDirty(config.field); this.hidePromptEditor(config); } @@ -918,11 +1199,16 @@ class RecipeModal { sourceUrlInput.focus(); }); + sourceUrlInput.addEventListener('input', () => { + this.sourceUrlEditState.isDirty = true; + }); + // Cancel editing sourceUrlCancelBtn.addEventListener('click', () => { sourceUrlEditor.classList.remove('active'); sourceUrlContainer.classList.remove('hide'); - sourceUrlInput.value = this.currentRecipe.source_path || ''; + this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true }); + this.clearFieldDirty('source_path'); }); // Save new source URL @@ -930,23 +1216,24 @@ class RecipeModal { const newSourceUrl = sourceUrlInput.value.trim(); if (newSourceUrl !== this.currentRecipe.source_path) { // Update the recipe on the server - updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }) + updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions()) .then(data => { // Show success toast showToast('toast.recipes.sourceUrlUpdated', {}, 'success'); // Update source URL in the UI - sourceUrlText.textContent = newSourceUrl || 'No source URL'; - sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') || - newSourceUrl.startsWith('https://')) ? - 'Click to open source URL' : 'No valid URL'; + this.commitField('source_path'); + this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true }); // Update the current recipe object this.currentRecipe.source_path = newSourceUrl; }) .catch(error => { // Error is handled in the API function + this.clearFieldDirty('source_path'); }); + } else { + this.clearFieldDirty('source_path'); } // Hide editor @@ -1286,7 +1573,7 @@ class RecipeModal { this.showRecipeDetails(this.currentRecipe); }, 500); - state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, { + state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, { loras: this.currentRecipe.loras }); } else { diff --git a/tests/frontend/api/recipeApi.bulk.test.js b/tests/frontend/api/recipeApi.bulk.test.js index c73732f8..02217c57 100644 --- a/tests/frontend/api/recipeApi.bulk.test.js +++ b/tests/frontend/api/recipeApi.bulk.test.js @@ -5,6 +5,9 @@ const loadingManagerMock = vi.hoisted(() => ({ showSimpleLoading: vi.fn(), hide: vi.fn(), })); +const virtualScrollerMock = vi.hoisted(() => ({ + updateSingleItem: vi.fn(), +})); vi.mock('../../../static/js/utils/uiHelpers.js', () => { return { @@ -20,12 +23,13 @@ vi.mock('../../../static/js/state/index.js', () => { return { state: { loadingManager: loadingManagerMock, + virtualScroller: virtualScrollerMock, }, getCurrentPageState: vi.fn(), }; }); -import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js'; +import { RecipeSidebarApiClient, fetchRecipeDetails, updateRecipeMetadata } from '../../../static/js/api/recipeApi.js'; describe('RecipeSidebarApiClient bulk operations', () => { beforeEach(() => { @@ -111,4 +115,37 @@ describe('RecipeSidebarApiClient bulk operations', () => { }); expect(loadingManagerMock.hide).toHaveBeenCalled(); }); + + it('encodes recipe IDs when fetching recipe details', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ id: 'abc' }), + }); + + await fetchRecipeDetails('recipe#1?name=foo%bar'); + + expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe%231%3Fname%3Dfoo%25bar'); + }); + + it('updates the virtual scroller using the original list path when provided', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + await updateRecipeMetadata( + '/recipes/new-folder/recipe#1.webp', + { title: 'Updated Title' }, + { listFilePath: '/recipes/old-folder/recipe#1.webp' } + ); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/recipe/recipe%231/update', + expect.objectContaining({ method: 'PUT' }) + ); + expect(virtualScrollerMock.updateSingleItem).toHaveBeenCalledWith( + '/recipes/old-folder/recipe#1.webp', + { title: 'Updated Title' } + ); + }); }); diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index aa0c2525..4d351772 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -59,6 +59,7 @@ const getModelApiClientMock = vi.fn(() => ({ })); const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true })); +const fetchRecipeDetailsMock = vi.fn(); vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ showToast: showToastMock, @@ -129,6 +130,7 @@ vi.mock('../../../static/js/managers/MoveManager.js', () => ({ })); vi.mock('../../../static/js/api/recipeApi.js', () => ({ + fetchRecipeDetails: fetchRecipeDetailsMock, updateRecipeMetadata: updateRecipeMetadataMock, })); @@ -141,6 +143,17 @@ async function flushAsyncTasks() { await new Promise((resolve) => setTimeout(resolve, 0)); } +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + describe('Interaction-level regression coverage', () => { beforeEach(() => { vi.clearAllMocks(); @@ -150,6 +163,7 @@ describe('Interaction-level regression coverage', () => { saveModelMetadataMock.mockResolvedValue(undefined); downloadExampleImagesApiMock.mockResolvedValue(undefined); updateRecipeMetadataMock.mockResolvedValue({ success: true }); + fetchRecipeDetailsMock.mockResolvedValue(null); resetAndReloadMock.mockResolvedValue(undefined); getCompleteApiConfigMock.mockReturnValue({ config: { displayName: 'LoRA' }, @@ -161,6 +175,10 @@ describe('Interaction-level regression coverage', () => { getCurrentModelTypeMock.mockReturnValue('loras'); translateMock.mockImplementation((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); global.modalManager = modalManagerMock; + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({}), + })); }); afterEach(() => { @@ -325,7 +343,11 @@ describe('Interaction-level regression coverage', () => { recipeModal.saveTitleEdit(); - expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/test.json', { title: 'Updated Title' }); + expect(updateRecipeMetadataMock).toHaveBeenCalledWith( + '/recipes/test.json', + { title: 'Updated Title' }, + { listFilePath: '/recipes/test.json' } + ); expect(updateRecipeMetadataMock).toHaveBeenCalledTimes(1); await updateRecipeMetadataMock.mock.results[0].value; await flushAsyncTasks(); @@ -336,6 +358,1413 @@ describe('Interaction-level regression coverage', () => { expect(recipeModal.currentRecipe.title).toBe('Updated Title'); }); + it('hydrates recipe source URL from the backend when opening the modal', async () => { + fetchRecipeDetailsMock.mockResolvedValueOnce({ + id: 'recipe-4', + file_path: '/recipes/source.json', + title: 'Hydrated Recipe', + source_path: 'https://example.com/source-url', + gen_params: { + prompt: 'hydrated prompt', + }, + loras: [], + }); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-4', + file_path: '/recipes/source.json', + title: 'Cached Title', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { + prompt: 'cached prompt', + }, + loras: [], + }); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + expect(fetchRecipeDetailsMock).toHaveBeenCalledWith('recipe-4'); + expect(document.querySelector('.source-url-text').textContent).toBe('https://example.com/source-url'); + expect(recipeModal.currentRecipe.source_path).toBe('https://example.com/source-url'); + expect(recipeModal.filePath).toBe('/recipes/source.json'); + }); + + it('drops stale cached preview URLs when hydration corrects only the recipe file path', async () => { + const deferred = createDeferred(); + fetchRecipeDetailsMock.mockReturnValueOnce(deferred.promise); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-preview', + file_path: '/recipes/original.webp', + title: 'Preview Recipe', + tags: [], + file_url: '/loras_static/root1/preview/stale.webp', + preview_url: '', + source_path: '', + gen_params: { prompt: 'cached prompt' }, + loras: [], + }); + + const previewBefore = document.getElementById('recipeModalImage'); + expect(previewBefore.getAttribute('src')).toContain('/loras_static/root1/preview/stale.webp'); + + deferred.resolve({ + id: 'recipe-preview', + file_path: '/recipes/moved.webp', + title: 'Preview Recipe', + source_path: '', + gen_params: { prompt: 'cached prompt' }, + loras: [], + }); + + await flushAsyncTasks(); + + const previewAfter = document.getElementById('recipeModalImage'); + expect(previewAfter.getAttribute('src')).toContain('/loras_static/root1/preview/moved.webp'); + expect(recipeModal.filePath).toBe('/recipes/moved.webp'); + expect(recipeModal.listFilePath).toBe('/recipes/original.webp'); + }); + + it('keeps source URL controls when hydration switches preview media type', async () => { + fetchRecipeDetailsMock.mockResolvedValueOnce({ + id: 'recipe-video', + file_path: '/recipes/clip.mp4', + title: 'Video Recipe', + source_path: 'https://example.com/video-source', + gen_params: { prompt: 'video prompt' }, + loras: [], + }); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-video', + file_path: '/recipes/still.webp', + title: 'Video Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: 'https://example.com/video-source', + gen_params: { prompt: 'cached prompt' }, + loras: [], + }); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + expect(document.getElementById('recipeModalVideo')).not.toBeNull(); + expect(document.querySelector('.source-url-container')).not.toBeNull(); + expect(document.querySelector('.source-url-editor')).not.toBeNull(); + expect(document.querySelector('.source-url-text').textContent).toBe('https://example.com/video-source'); + }); + + it('replaces source URL controls when reopening the modal', async () => { + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-reopen-1', + file_path: '/recipes/reopen-1.webp', + title: 'First Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: 'https://example.com/first', + gen_params: { prompt: 'first prompt' }, + loras: [], + }); + + recipeModal.showRecipeDetails({ + id: 'recipe-reopen-2', + file_path: '/recipes/reopen-2.webp', + title: 'Second Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: 'https://example.com/second', + gen_params: { prompt: 'second prompt' }, + loras: [], + }); + + expect(document.querySelectorAll('.source-url-container')).toHaveLength(1); + expect(document.querySelectorAll('.source-url-editor')).toHaveLength(1); + expect(document.querySelector('.source-url-text').textContent).toBe('https://example.com/second'); + + document.querySelector('.source-url-edit-btn').click(); + expect(document.querySelector('.source-url-input').value).toBe('https://example.com/second'); + }); + + it('preserves local title tags and prompt edits when hydration resolves later', async () => { + const deferred = createDeferred(); + fetchRecipeDetailsMock.mockReturnValueOnce(deferred.promise); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-5', + file_path: '/recipes/editing.json', + title: 'Cached Title', + tags: ['cached-tag'], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { + prompt: 'cached prompt', + negative_prompt: 'cached negative', + }, + loras: [], + }); + + recipeModal.markFieldDirty('title'); + recipeModal.markFieldDirty('tags'); + recipeModal.markFieldDirty('prompt'); + recipeModal.markFieldDirty('negative_prompt'); + + document.querySelector('#recipeTitleEditor .title-input').value = 'Local Title'; + document.querySelector('#recipeTagsEditor .tags-input').value = 'local-tag-1, local-tag-2'; + document.getElementById('recipePromptInput').value = 'local prompt'; + document.getElementById('recipeNegativePromptInput').value = 'local negative'; + + deferred.resolve({ + id: 'recipe-5', + file_path: '/recipes/editing.json', + title: 'Hydrated Title', + tags: ['hydrated-tag'], + source_path: 'https://example.com/hydrated', + gen_params: { + prompt: 'hydrated prompt', + negative_prompt: 'hydrated negative', + }, + loras: [], + }); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + expect(document.querySelector('#recipeTitleEditor .title-input').value).toBe('Local Title'); + expect(document.querySelector('#recipeTagsEditor .tags-input').value).toBe('local-tag-1, local-tag-2'); + expect(document.getElementById('recipePromptInput').value).toBe('local prompt'); + expect(document.getElementById('recipeNegativePromptInput').value).toBe('local negative'); + expect(recipeModal.currentRecipe.title).toBe('Hydrated Title'); + expect(recipeModal.currentRecipe.tags).toEqual(['hydrated-tag']); + expect(recipeModal.currentRecipe.gen_params.prompt).toBe('hydrated prompt'); + expect(recipeModal.currentRecipe.gen_params.negative_prompt).toBe('hydrated negative'); + expect(recipeModal.currentRecipe.source_path).toBe('https://example.com/hydrated'); + }); + + it('cancels dirty edits back to hydrated values after hydration resolves', async () => { + const deferred = createDeferred(); + fetchRecipeDetailsMock.mockReturnValueOnce(deferred.promise); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-cancel-hydrated', + file_path: '/recipes/cancel-hydrated.json', + title: 'Cached Title', + tags: [], + file_url: '', + preview_url: '', + source_path: 'https://example.com/cached-source', + gen_params: { + prompt: 'cached prompt', + negative_prompt: 'cached negative', + }, + loras: [], + }); + + document.querySelector('#recipeModalTitle .edit-icon').click(); + const titleInput = document.querySelector('#recipeTitleEditor .title-input'); + titleInput.value = 'Local Title'; + titleInput.dispatchEvent(new Event('input', { bubbles: true })); + + document.getElementById('editPromptBtn').click(); + const promptInput = document.getElementById('recipePromptInput'); + promptInput.value = 'local prompt'; + promptInput.dispatchEvent(new Event('input', { bubbles: true })); + + document.querySelector('.source-url-edit-btn').click(); + const sourceInput = document.querySelector('.source-url-input'); + sourceInput.value = 'https://example.com/local-source'; + sourceInput.dispatchEvent(new Event('input', { bubbles: true })); + + deferred.resolve({ + id: 'recipe-cancel-hydrated', + file_path: '/recipes/cancel-hydrated.json', + title: 'Hydrated Title', + source_path: 'https://example.com/hydrated-source', + gen_params: { + prompt: 'hydrated prompt', + negative_prompt: 'hydrated negative', + }, + loras: [], + }); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + expect(recipeModal.currentRecipe.title).toBe('Hydrated Title'); + expect(recipeModal.currentRecipe.source_path).toBe('https://example.com/hydrated-source'); + expect(recipeModal.currentRecipe.gen_params.prompt).toBe('hydrated prompt'); + + recipeModal.cancelTitleEdit(); + recipeModal.cancelPromptEdit({ + contentId: 'recipePrompt', + editorId: 'recipePromptEditor', + inputId: 'recipePromptInput', + field: 'prompt', + }); + document.querySelector('.source-url-cancel-btn').click(); + + expect(document.querySelector('#recipeTitleEditor .title-input').value).toBe('Hydrated Title'); + expect(document.getElementById('recipePromptInput').value).toBe('hydrated prompt'); + expect(document.querySelector('.source-url-input').value).toBe('https://example.com/hydrated-source'); + }); + + it('replaces removed gen_params keys when hydration returns a smaller parameter set', async () => { + const deferred = createDeferred(); + fetchRecipeDetailsMock.mockReturnValueOnce(deferred.promise); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-gen-params', + file_path: '/recipes/gen-params.json', + title: 'Gen Params Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { + prompt: 'old prompt', + negative_prompt: 'old negative', + sampler: 'euler', + cfg_scale: 7, + }, + loras: [], + }); + + deferred.resolve({ + id: 'recipe-gen-params', + file_path: '/recipes/gen-params.json', + title: 'Gen Params Recipe', + gen_params: { + sampler: 'dpmpp_2m', + }, + loras: [], + }); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + expect(recipeModal.currentRecipe.gen_params).toEqual({ sampler: 'dpmpp_2m' }); + expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available'); + expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available'); + const otherParamsText = document.getElementById('recipeOtherParams').textContent; + expect(otherParamsText).toContain('sampler:'); + expect(otherParamsText).toContain('dpmpp_2m'); + expect(otherParamsText).not.toContain('cfg_scale'); + }); + + it('replaces cached checkpoint and loras with hydrated resources', async () => { + fetchRecipeDetailsMock.mockResolvedValueOnce({ + id: 'recipe-resources', + file_path: '/recipes/resources.json', + title: 'Resources Recipe', + gen_params: { prompt: 'hydrated prompt' }, + checkpoint: { + name: 'New Checkpoint', + modelName: 'New Checkpoint', + preview_url: '/previews/checkpoint-new.png', + inLibrary: true, + }, + loras: [ + { + modelName: 'Hydrated LoRA', + modelVersionName: 'v2', + preview_url: '/previews/lora-new.png', + inLibrary: true, + strength: 0.8, + }, + ], + }); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-resources', + file_path: '/recipes/resources.json', + title: 'Resources Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { prompt: 'cached prompt' }, + checkpoint: { + name: 'Old Checkpoint', + modelName: 'Old Checkpoint', + preview_url: '/previews/checkpoint-old.png', + inLibrary: true, + }, + loras: [ + { + modelName: 'Cached LoRA', + modelVersionName: 'v1', + preview_url: '/previews/lora-old.png', + inLibrary: true, + strength: 1.0, + }, + ], + }); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + expect(recipeModal.currentRecipe.checkpoint.modelName).toBe('New Checkpoint'); + expect(recipeModal.currentRecipe.loras).toHaveLength(1); + expect(recipeModal.currentRecipe.loras[0].modelName).toBe('Hydrated LoRA'); + expect(document.getElementById('recipeCheckpoint').textContent).toContain('New Checkpoint'); + expect(document.getElementById('recipeLorasList').textContent).toContain('Hydrated LoRA'); + expect(document.getElementById('recipeLorasList').textContent).not.toContain('Cached LoRA'); + }); + + it('clears optional recipe fields when hydration omits them', async () => { + fetchRecipeDetailsMock.mockResolvedValueOnce({ + id: 'recipe-clear-optional', + file_path: '/recipes/clear-optional.json', + title: 'Cleared Recipe', + }); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-clear-optional', + file_path: '/recipes/clear-optional.json', + title: 'Cached Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: 'https://example.com/stale-source', + gen_params: { + prompt: 'stale prompt', + negative_prompt: 'stale negative', + sampler: 'euler', + }, + checkpoint: { + name: 'Stale Checkpoint', + modelName: 'Stale Checkpoint', + preview_url: '/previews/stale-checkpoint.png', + inLibrary: true, + }, + loras: [ + { + modelName: 'Stale LoRA', + modelVersionName: 'v1', + preview_url: '/previews/stale-lora.png', + inLibrary: true, + strength: 1.0, + }, + ], + }); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + expect(recipeModal.currentRecipe.source_path).toBe(''); + expect(recipeModal.currentRecipe.gen_params).toEqual({}); + expect(recipeModal.currentRecipe.checkpoint).toBeUndefined(); + expect(recipeModal.currentRecipe.loras).toBeUndefined(); + expect(document.querySelector('.source-url-text').textContent).toBe('No source URL'); + expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available'); + expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available'); + expect(document.getElementById('recipeOtherParams').textContent).toContain('No additional parameters available'); + expect(document.getElementById('recipeCheckpoint').textContent).toBe(''); + expect(document.getElementById('recipeLorasList').textContent).toContain('No LoRAs associated with this recipe'); + }); + + it('refreshes the source URL input when hydration completes while editing', async () => { + const deferred = createDeferred(); + fetchRecipeDetailsMock.mockReturnValueOnce(deferred.promise); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-5', + file_path: '/recipes/editing.json', + title: 'Editing Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { prompt: 'cached' }, + loras: [], + }); + + await new Promise((resolve) => setTimeout(resolve, 60)); + + const editButton = document.querySelector('.source-url-edit-btn'); + editButton.click(); + + const sourceInput = document.querySelector('.source-url-input'); + sourceInput.value = 'https://example.com/local-edit'; + sourceInput.dispatchEvent(new Event('input', { bubbles: true })); + + deferred.resolve({ + id: 'recipe-5', + file_path: '/recipes/editing.json', + title: 'Editing Recipe', + source_path: 'https://example.com/hydrated-edit', + gen_params: { prompt: 'hydrated' }, + loras: [], + }); + + await flushAsyncTasks(); + + expect(sourceInput.value).toBe('https://example.com/local-edit'); + expect(document.querySelector('.source-url-text').textContent).toBe('https://example.com/hydrated-edit'); + expect(recipeModal.currentRecipe.source_path).toBe('https://example.com/hydrated-edit'); + }); + + it('keeps a freshly saved source URL when hydration resolves later', async () => { + const deferred = createDeferred(); + fetchRecipeDetailsMock.mockReturnValueOnce(deferred.promise); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-6', + file_path: '/recipes/saved.json', + title: 'Saved Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { prompt: 'cached' }, + loras: [], + }); + + await new Promise((resolve) => setTimeout(resolve, 60)); + + const editButton = document.querySelector('.source-url-edit-btn'); + editButton.click(); + const sourceInput = document.querySelector('.source-url-input'); + sourceInput.value = 'https://example.com/new-source'; + sourceInput.dispatchEvent(new Event('input', { bubbles: true })); + + document.querySelector('.source-url-save-btn').click(); + await updateRecipeMetadataMock.mock.results[0].value; + await flushAsyncTasks(); + + deferred.resolve({ + id: 'recipe-6', + file_path: '/recipes/saved.json', + title: 'Saved Recipe', + source_path: 'https://example.com/stale-source', + gen_params: { prompt: 'hydrated' }, + loras: [], + }); + + await flushAsyncTasks(); + + expect(recipeModal.currentRecipe.source_path).toBe('https://example.com/new-source'); + expect(document.querySelector('.source-url-text').textContent).toBe('https://example.com/new-source'); + expect(recipeModal.filePath).toBe('/recipes/saved.json'); + }); + + it('writes metadata using the hydrated path while keeping list updates keyed to the original card path', async () => { + fetchRecipeDetailsMock.mockResolvedValueOnce({ + id: 'recipe-moved', + file_path: '/recipes/new-folder/moved.json', + title: 'Moved Recipe', + source_path: '', + gen_params: { prompt: 'hydrated prompt' }, + loras: [], + }); + + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-moved', + file_path: '/recipes/original-folder/moved.json', + title: 'Moved Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { prompt: 'cached prompt' }, + loras: [], + }); + + await flushAsyncTasks(); + await flushAsyncTasks(); + + const editIcon = document.querySelector('#recipeModalTitle .edit-icon'); + editIcon.dispatchEvent(new Event('click', { bubbles: true })); + + const titleInput = document.querySelector('#recipeTitleEditor .title-input'); + titleInput.value = 'Updated After Move'; + recipeModal.saveTitleEdit(); + + expect(updateRecipeMetadataMock).toHaveBeenCalledWith( + '/recipes/new-folder/moved.json', + { title: 'Updated After Move' }, + { listFilePath: '/recipes/original-folder/moved.json' } + ); + }); + it('saves prompt edits on Enter while preserving Shift+Enter for new lines', async () => { document.body.innerHTML = `