fix(recipe): hydrate stale modal data from recipe json

This commit is contained in:
Will Miao
2026-04-12 19:22:58 +08:00
parent 9998da3241
commit 0253d001e6
6 changed files with 2084 additions and 136 deletions

View File

@@ -2114,8 +2114,18 @@ class RecipeScanner:
if not recipe: if not recipe:
return None 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 # Format the recipe with all needed information
formatted_recipe = {**recipe} # Copy all fields formatted_recipe = {**merged_recipe}
# Format file path to URL # Format file path to URL
if "file_path" in formatted_recipe: if "file_path" in formatted_recipe:
@@ -2149,6 +2159,30 @@ class RecipeScanner:
return formatted_recipe 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: def _format_file_url(self, file_path: str) -> str:
"""Format file path as URL for serving in web UI""" """Format file path as URL for serving in web UI"""
if not file_path: if not file_path:

View File

@@ -31,6 +31,20 @@ export function extractRecipeId(filePath) {
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename; 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 * Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch * @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 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(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`); const response = await fetch(
`${RECIPE_ENDPOINTS.detail}/${encodeURIComponent(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}`);
@@ -349,9 +365,10 @@ export function createRecipeCard(recipe) {
* @param {Object} updates - The metadata updates to apply * @param {Object} updates - The metadata updates to apply
* @returns {Promise<Object>} The updated recipe data * @returns {Promise<Object>} The updated recipe data
*/ */
export async function updateRecipeMetadata(filePath, updates) { export async function updateRecipeMetadata(filePath, updates, options = {}) {
try { try {
state.loadingManager.showSimpleLoading('Saving metadata...'); state.loadingManager.showSimpleLoading('Saving metadata...');
const listFilePath = options.listFilePath || filePath;
// Extract recipeId from filePath (basename without extension) // Extract recipeId from filePath (basename without extension)
const recipeId = extractRecipeId(filePath); const recipeId = extractRecipeId(filePath);
@@ -359,7 +376,7 @@ export async function updateRecipeMetadata(filePath, updates) {
throw new Error('Unable to determine recipe ID'); 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', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -374,7 +391,7 @@ export async function updateRecipeMetadata(filePath, updates) {
throw new Error(data.error || 'Failed to update recipe'); throw new Error(data.error || 'Failed to update recipe');
} }
state.virtualScroller.updateSingleItem(filePath, updates); state.virtualScroller.updateSingleItem(listFilePath, updates);
return data; return data;
} catch (error) { } catch (error) {

View File

@@ -3,16 +3,75 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.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 { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
class RecipeModal { class RecipeModal {
constructor() { constructor() {
this.promptEditorState = {}; this.promptEditorState = {};
this.recipeHydrationRequestId = 0;
this.resetLocalEditState();
this.init(); 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() { init() {
this.setupCopyButtons(); this.setupCopyButtons();
this.setupPromptEditors(); this.setupPromptEditors();
@@ -87,8 +146,10 @@ class RecipeModal {
} }
showRecipeDetails(recipe) { showRecipeDetails(recipe) {
const hydratedRecipe = recipe || {};
this.resetLocalEditState();
// Store the full recipe for editing // Store the full recipe for editing
this.currentRecipe = recipe; this.currentRecipe = hydratedRecipe;
this.resetPromptEditors(); this.resetPromptEditors();
// Set modal title with edit icon // Set modal title with edit icon
@@ -96,11 +157,11 @@ class RecipeModal {
if (modalTitle) { if (modalTitle) {
modalTitle.innerHTML = ` modalTitle.innerHTML = `
<div class="editable-content"> <div class="editable-content">
<span class="content-text">${recipe.title || 'Recipe Details'}</span> <span class="content-text">${hydratedRecipe.title || 'Recipe Details'}</span>
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button> <button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
</div> </div>
<div id="recipeTitleEditor" class="content-editor"> <div id="recipeTitleEditor" class="content-editor">
<input type="text" class="title-input" value="${recipe.title || ''}"> <input type="text" class="title-input" value="${hydratedRecipe.title || ''}">
</div> </div>
`; `;
@@ -122,8 +183,9 @@ class RecipeModal {
} }
// Store the recipe ID for copy syntax API call // Store the recipe ID for copy syntax API call
this.recipeId = recipe.id; this.recipeId = hydratedRecipe.id;
this.filePath = recipe.file_path; this.filePath = hydratedRecipe.file_path;
this.listFilePath = hydratedRecipe.file_path;
// Set recipe tags if they exist // Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact'); const tagsCompactElement = document.getElementById('recipeTagsCompact');
@@ -143,11 +205,11 @@ class RecipeModal {
const tagsDisplay = tagsCompactElement.querySelector('.tags-display'); 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 // Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5; const maxVisibleTags = 5;
const visibleTags = recipe.tags.slice(0, maxVisibleTags); const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : []; const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
// Add visible tags // Add visible tags
visibleTags.forEach(tag => { visibleTags.forEach(tag => {
@@ -184,7 +246,7 @@ class RecipeModal {
// Add all tags to tooltip // Add all tags to tooltip
if (tagsTooltipContent) { if (tagsTooltipContent) {
tagsTooltipContent.innerHTML = ''; tagsTooltipContent.innerHTML = '';
recipe.tags.forEach(tag => { hydratedRecipe.tags.forEach(tag => {
const tooltipTag = document.createElement('div'); const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag'; tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag; tooltipTag.textContent = tag;
@@ -201,8 +263,8 @@ class RecipeModal {
const tagsInput = tagsCompactElement.querySelector('.tags-input'); const tagsInput = tagsCompactElement.querySelector('.tags-input');
// Set current tags in the input // Set current tags in the input
if (recipe.tags && recipe.tags.length > 0) { if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
tagsInput.value = recipe.tags.join(', '); tagsInput.value = hydratedRecipe.tags.join(', ');
} }
editTagsIcon.addEventListener('click', () => this.showTagsEditor()); editTagsIcon.addEventListener('click', () => this.showTagsEditor());
@@ -222,49 +284,15 @@ class RecipeModal {
// Set recipe image // Set recipe image
const mediaContainer = document.getElementById('recipePreviewContainer'); const mediaContainer = document.getElementById('recipePreviewContainer');
if (mediaContainer) { if (mediaContainer) {
// Stop any playing video before replacing content this.syncPreviewMedia(hydratedRecipe);
const existingVideo = mediaContainer.querySelector('video'); mediaContainer.querySelector('.source-url-container')?.remove();
if (existingVideo) { mediaContainer.querySelector('.source-url-editor')?.remove();
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);
}
// Add source URL container if the recipe has a source_path // Add source URL container if the recipe has a source_path
const sourceUrlContainer = document.createElement('div'); const sourceUrlContainer = document.createElement('div');
sourceUrlContainer.className = 'source-url-container'; sourceUrlContainer.className = 'source-url-container';
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0; const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0;
const sourceUrl = hasSourceUrl ? recipe.source_path : ''; const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : '';
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://')); const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
sourceUrlContainer.innerHTML = ` sourceUrlContainer.innerHTML = `
@@ -299,34 +327,261 @@ class RecipeModal {
}, 50); }, 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 promptElement = document.getElementById('recipePrompt');
const negativePromptElement = document.getElementById('recipeNegativePrompt'); const negativePromptElement = document.getElementById('recipeNegativePrompt');
const otherParamsElement = document.getElementById('recipeOtherParams'); const otherParamsElement = document.getElementById('recipeOtherParams');
const promptInput = document.getElementById('recipePromptInput'); const promptInput = document.getElementById('recipePromptInput');
const negativePromptInput = document.getElementById('recipeNegativePromptInput'); const negativePromptInput = document.getElementById('recipeNegativePromptInput');
const promptFieldsOnly = options.promptFieldsOnly === true;
if (recipe.gen_params) { if (genParams) {
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available'); if (!promptFieldsOnly) {
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available'); this.renderPromptContent(promptElement, genParams.prompt, 'No prompt information available');
this.renderPromptContent(negativePromptElement, genParams.negative_prompt, 'No negative prompt information available');
if (promptInput) { if (promptInput) {
promptInput.value = recipe.gen_params.prompt || ''; 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) { if (otherParamsElement) {
// Clear previous params
otherParamsElement.innerHTML = ''; otherParamsElement.innerHTML = '';
// Add all other parameters except prompt and negative_prompt
const excludedParams = ['prompt', '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) { if (!excludedParams.includes(key) && value !== undefined && value !== null) {
const paramTag = document.createElement('div'); const paramTag = document.createElement('div');
paramTag.className = 'param-tag'; paramTag.className = 'param-tag';
@@ -338,22 +593,31 @@ class RecipeModal {
} }
} }
// If no other params, show a message
if (otherParamsElement.children.length === 0) { if (otherParamsElement.children.length === 0) {
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>'; otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
} }
} }
} else { return;
// No generation parameters available }
if (!promptFieldsOnly) {
this.renderPromptContent(promptElement, '', 'No prompt information available'); this.renderPromptContent(promptElement, '', 'No prompt information available');
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available'); this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
if (promptInput) promptInput.value = ''; if (promptInput) promptInput.value = '';
if (negativePromptInput) negativePromptInput.value = ''; if (negativePromptInput) negativePromptInput.value = '';
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
} }
if (otherParamsElement) {
otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
}
}
syncResourcesSection(recipe = {}) {
const checkpointContainer = document.getElementById('recipeCheckpoint'); const checkpointContainer = document.getElementById('recipeCheckpoint');
const resourceDivider = document.getElementById('recipeResourceDivider'); 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) { if (checkpointContainer) {
checkpointContainer.innerHTML = ''; 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 allLorasAvailable = true;
let missingLorasCount = 0; let missingLorasCount = 0;
let deletedLorasCount = 0; let deletedLorasCount = 0;
if (recipe.loras && recipe.loras.length > 0) { loras.forEach(lora => {
recipe.loras.forEach(lora => { if (lora.isDeleted) {
if (lora.isDeleted) { deletedLorasCount++;
deletedLorasCount++; } else if (!lora.inLibrary) {
} else if (!lora.inLibrary) { allLorasAvailable = false;
allLorasAvailable = false; missingLorasCount++;
missingLorasCount++; }
} });
});
}
// Set LoRAs count and status if (lorasCountElement) {
if (lorasCountElement && recipe.loras) { const totalCount = loras.length;
const totalCount = recipe.loras.length;
// Create status indicator based on LoRA states
let statusHTML = ''; let statusHTML = '';
if (totalCount > 0) { if (totalCount > 0) {
if (allLorasAvailable && deletedLorasCount === 0) { if (allLorasAvailable && deletedLorasCount === 0) {
// All LoRAs are available
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`; statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
} else if (missingLorasCount > 0) { } else if (missingLorasCount > 0) {
// Some LoRAs are missing (prioritize showing missing over deleted)
statusHTML = `<div class="recipe-status missing"> statusHTML = `<div class="recipe-status missing">
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing <i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
<div class="missing-tooltip">Click to download missing LoRAs</div> <div class="missing-tooltip">Click to download missing LoRAs</div>
</div>`; </div>`;
} else if (deletedLorasCount > 0 && missingLorasCount === 0) { } else if (deletedLorasCount > 0 && missingLorasCount === 0) {
// Some LoRAs are deleted but none are missing
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`; statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
} }
} }
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`; lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
// Add event listeners for buttons and status indicators
setTimeout(() => { setTimeout(() => {
// Set up click handler for View LoRAs button
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) { if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage()); viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
} }
// Add click handler for missing LoRAs status
const missingStatus = document.querySelector('.recipe-status.missing'); const missingStatus = document.querySelector('.recipe-status.missing');
if (missingStatus && missingLorasCount > 0) { if (missingStatus && missingLorasCount > 0) {
missingStatus.classList.add('clickable'); missingStatus.classList.add('clickable');
@@ -425,13 +673,12 @@ class RecipeModal {
}, 100); }, 100);
} }
if (lorasListElement && recipe.loras && recipe.loras.length > 0) { if (lorasListElement && loras.length > 0) {
lorasListElement.innerHTML = recipe.loras.map(lora => { lorasListElement.innerHTML = loras.map(lora => {
const existsLocally = lora.inLibrary; const existsLocally = lora.inLibrary;
const isDeleted = lora.isDeleted; const isDeleted = lora.isDeleted;
const localPath = lora.localPath || ''; const localPath = lora.localPath || '';
// Create status badge based on LoRA state
let localStatus; let localStatus;
if (existsLocally) { if (existsLocally) {
localStatus = ` localStatus = `
@@ -441,7 +688,7 @@ class RecipeModal {
</div>`; </div>`;
} else if (isDeleted) { } else if (isDeleted) {
localStatus = ` localStatus = `
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}"> <div class="deleted-badge reconnectable" data-lora-index="${loras.indexOf(lora)}">
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span> <span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div> <div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
</div>`; </div>`;
@@ -452,7 +699,6 @@ class RecipeModal {
</div>`; </div>`;
} }
// Check if preview is a video
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4'); const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
const previewMedia = isPreviewVideo ? const previewMedia = isPreviewVideo ?
`<video class="thumbnail-video" autoplay loop muted playsinline> `<video class="thumbnail-video" autoplay loop muted playsinline>
@@ -460,7 +706,6 @@ class RecipeModal {
</video>` : </video>` :
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`; `<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
// Determine CSS class based on LoRA state
let loraItemClass = 'recipe-lora-item'; let loraItemClass = 'recipe-lora-item';
if (existsLocally) { if (existsLocally) {
loraItemClass += ' exists-locally'; loraItemClass += ' exists-locally';
@@ -471,7 +716,7 @@ class RecipeModal {
} }
return ` return `
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}"> <div class="${loraItemClass}" data-lora-index="${loras.indexOf(lora)}">
<div class="recipe-lora-thumbnail"> <div class="recipe-lora-thumbnail">
${previewMedia} ${previewMedia}
</div> </div>
@@ -485,7 +730,7 @@ class RecipeModal {
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div> <div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''} ${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
</div> </div>
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}"> <div class="lora-reconnect-container" data-lora-index="${loras.indexOf(lora)}">
<div class="reconnect-instructions"> <div class="reconnect-instructions">
<p>Enter LoRA Syntax or Name to Reconnect:</p> <p>Enter LoRA Syntax or Name to Reconnect:</p>
<small>Example: <code>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</code> or just <code>Boris_Vallejo_BV_flux_D</code></small> <small>Example: <code>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
@@ -503,15 +748,12 @@ class RecipeModal {
`; `;
}).join(''); }).join('');
// Add event listeners for reconnect functionality
setTimeout(() => { setTimeout(() => {
this.setupReconnectButtons(); this.setupReconnectButtons();
this.setupLoraItemsClickable(); this.setupLoraItemsClickable();
}, 100); }, 100);
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
this.recipeLorasSyntax = ''; this.recipeLorasSyntax = '';
} else if (lorasListElement) { } else if (lorasListElement) {
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>'; lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
this.recipeLorasSyntax = ''; this.recipeLorasSyntax = '';
@@ -522,9 +764,31 @@ class RecipeModal {
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item'); const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none'; resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
} }
}
// Show the modal updateSourceUrlDisplay(sourcePath, options = {}) {
modalManager.showModal('recipeModal'); 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 // Title editing methods
@@ -535,6 +799,7 @@ class RecipeModal {
const editor = titleContainer.querySelector('#recipeTitleEditor'); const editor = titleContainer.querySelector('#recipeTitleEditor');
editor.classList.add('active'); editor.classList.add('active');
const input = editor.querySelector('input'); const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('title');
input.focus(); input.focus();
input.select(); input.select();
} }
@@ -553,19 +818,23 @@ class RecipeModal {
titleContainer.querySelector('.content-text').textContent = newTitle; titleContainer.querySelector('.content-text').textContent = newTitle;
// Update the recipe on the server // Update the recipe on the server
updateRecipeMetadata(this.filePath, { title: newTitle }) updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions())
.then(data => { .then(data => {
// Show success toast // Show success toast
showToast('toast.recipes.nameUpdated', {}, 'success'); showToast('toast.recipes.nameUpdated', {}, 'success');
// Update the current recipe object // Update the current recipe object
this.currentRecipe.title = newTitle; this.currentRecipe.title = newTitle;
this.commitField('title');
}) })
.catch(error => { .catch(error => {
// Error is handled in the API function // Error is handled in the API function
// Reset the UI if needed // Reset the UI if needed
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || ''; titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
this.clearFieldDirty('title');
}); });
} else {
this.clearFieldDirty('title');
} }
// Hide editor // Hide editor
@@ -581,6 +850,7 @@ class RecipeModal {
const editor = titleContainer.querySelector('#recipeTitleEditor'); const editor = titleContainer.querySelector('#recipeTitleEditor');
const input = editor.querySelector('input'); const input = editor.querySelector('input');
input.value = this.currentRecipe.title || ''; input.value = this.currentRecipe.title || '';
this.clearFieldDirty('title');
// Hide editor // Hide editor
editor.classList.remove('active'); editor.classList.remove('active');
@@ -596,6 +866,7 @@ class RecipeModal {
const editor = tagsContainer.querySelector('#recipeTagsEditor'); const editor = tagsContainer.querySelector('#recipeTagsEditor');
editor.classList.add('active'); editor.classList.add('active');
const input = editor.querySelector('input'); const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('tags');
input.focus(); input.focus();
} }
} }
@@ -623,20 +894,24 @@ class RecipeModal {
if (tagsChanged) { if (tagsChanged) {
// Update the recipe on the server // Update the recipe on the server
updateRecipeMetadata(this.filePath, { tags: newTags }) updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
.then(data => { .then(data => {
// Show success toast // Show success toast
showToast('toast.recipes.tagsUpdated', {}, 'success'); showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object // Update the current recipe object
this.currentRecipe.tags = newTags; this.currentRecipe.tags = newTags;
this.commitField('tags');
// Update tags in the UI // Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags); this.updateTagsDisplay(tagsContainer, newTags);
}) })
.catch(error => { .catch(error => {
// Error is handled in the API function // Error is handled in the API function
this.clearFieldDirty('tags');
}); });
} else {
this.clearFieldDirty('tags');
} }
// Hide editor // Hide editor
@@ -708,6 +983,7 @@ class RecipeModal {
const editor = tagsContainer.querySelector('#recipeTagsEditor'); const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input'); const input = editor.querySelector('input');
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : ''; input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
this.clearFieldDirty('tags');
// Hide editor // Hide editor
editor.classList.remove('active'); editor.classList.remove('active');
@@ -748,6 +1024,7 @@ class RecipeModal {
} }
if (input) { if (input) {
input.addEventListener('input', () => this.markFieldDirty(config.field));
input.addEventListener('keydown', (event) => { input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
@@ -843,6 +1120,7 @@ class RecipeModal {
const currentValue = currentGenParams[config.field] || ''; const currentValue = currentGenParams[config.field] || '';
if (nextValue === currentValue) { if (nextValue === currentValue) {
this.clearFieldDirty(config.field);
this.hidePromptEditor(config); this.hidePromptEditor(config);
return; return;
} }
@@ -857,14 +1135,17 @@ class RecipeModal {
...promptState, ...promptState,
isSaving: true, isSaving: true,
}; };
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }); await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions());
this.currentRecipe.gen_params = nextGenParams; this.currentRecipe.gen_params = nextGenParams;
this.renderPromptContent(content, nextValue, config.placeholder); this.renderPromptContent(content, nextValue, config.placeholder);
showToast(config.successKey, {}, 'success', config.successFallback); showToast(config.successKey, {}, 'success', config.successFallback);
this.commitField(config.field);
} catch (error) { } catch (error) {
this.renderPromptContent(content, currentValue, config.placeholder); this.renderPromptContent(content, currentValue, config.placeholder);
input.value = currentValue; input.value = currentValue;
this.clearFieldDirty(config.field);
} finally { } finally {
this.clearFieldDirty(config.field);
this.hidePromptEditor(config); this.hidePromptEditor(config);
} }
} }
@@ -872,10 +1153,10 @@ class RecipeModal {
cancelPromptEdit(config) { cancelPromptEdit(config) {
const input = document.getElementById(config.inputId); const input = document.getElementById(config.inputId);
if (input) { if (input) {
const initialValue = this.promptEditorState[config.field]?.initialValue; input.value = this.currentRecipe?.gen_params?.[config.field] || '';
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
} }
this.clearFieldDirty(config.field);
this.hidePromptEditor(config); this.hidePromptEditor(config);
} }
@@ -918,11 +1199,16 @@ class RecipeModal {
sourceUrlInput.focus(); sourceUrlInput.focus();
}); });
sourceUrlInput.addEventListener('input', () => {
this.sourceUrlEditState.isDirty = true;
});
// Cancel editing // Cancel editing
sourceUrlCancelBtn.addEventListener('click', () => { sourceUrlCancelBtn.addEventListener('click', () => {
sourceUrlEditor.classList.remove('active'); sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide'); 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 // Save new source URL
@@ -930,23 +1216,24 @@ class RecipeModal {
const newSourceUrl = sourceUrlInput.value.trim(); const newSourceUrl = sourceUrlInput.value.trim();
if (newSourceUrl !== this.currentRecipe.source_path) { if (newSourceUrl !== this.currentRecipe.source_path) {
// Update the recipe on the server // Update the recipe on the server
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }) updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions())
.then(data => { .then(data => {
// Show success toast // Show success toast
showToast('toast.recipes.sourceUrlUpdated', {}, 'success'); showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
// Update source URL in the UI // Update source URL in the UI
sourceUrlText.textContent = newSourceUrl || 'No source URL'; this.commitField('source_path');
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') || this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
newSourceUrl.startsWith('https://')) ?
'Click to open source URL' : 'No valid URL';
// Update the current recipe object // Update the current recipe object
this.currentRecipe.source_path = newSourceUrl; this.currentRecipe.source_path = newSourceUrl;
}) })
.catch(error => { .catch(error => {
// Error is handled in the API function // Error is handled in the API function
this.clearFieldDirty('source_path');
}); });
} else {
this.clearFieldDirty('source_path');
} }
// Hide editor // Hide editor
@@ -1286,7 +1573,7 @@ class RecipeModal {
this.showRecipeDetails(this.currentRecipe); this.showRecipeDetails(this.currentRecipe);
}, 500); }, 500);
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, { state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
loras: this.currentRecipe.loras loras: this.currentRecipe.loras
}); });
} else { } else {

View File

@@ -5,6 +5,9 @@ const loadingManagerMock = vi.hoisted(() => ({
showSimpleLoading: vi.fn(), showSimpleLoading: vi.fn(),
hide: vi.fn(), hide: vi.fn(),
})); }));
const virtualScrollerMock = vi.hoisted(() => ({
updateSingleItem: vi.fn(),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => { vi.mock('../../../static/js/utils/uiHelpers.js', () => {
return { return {
@@ -20,12 +23,13 @@ vi.mock('../../../static/js/state/index.js', () => {
return { return {
state: { state: {
loadingManager: loadingManagerMock, loadingManager: loadingManagerMock,
virtualScroller: virtualScrollerMock,
}, },
getCurrentPageState: vi.fn(), 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', () => { describe('RecipeSidebarApiClient bulk operations', () => {
beforeEach(() => { beforeEach(() => {
@@ -111,4 +115,37 @@ describe('RecipeSidebarApiClient bulk operations', () => {
}); });
expect(loadingManagerMock.hide).toHaveBeenCalled(); 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' }
);
});
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -358,6 +358,133 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
assert recipe["checkpoint"]["file_name"] == "by-id" assert recipe["checkpoint"]["file_name"] == "by-id"
@pytest.mark.asyncio
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "hydrate-me"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/hydrate-me.png",
"title": "Hydrated Recipe",
"source_path": "https://example.com/source",
"gen_params": {
"prompt": "prompt from json",
"negative_prompt": "negative from json",
},
"loras": [],
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/hydrate-me.png",
"title": "Cached Recipe",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["title"] == "Hydrated Recipe"
assert recipe["source_path"] == "https://example.com/source"
assert recipe["gen_params"]["prompt"] == "prompt from json"
@pytest.mark.asyncio
async def test_get_recipe_by_id_prefers_json_file_path(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "move-me"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/new-location.png",
"title": "Moved Recipe",
"source_path": "https://example.com/moved",
"gen_params": {},
"loras": [],
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/old-location.png",
"title": "Cached Title",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"gen_params": {},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["file_path"] == "/tmp/new-location.png"
assert recipe["title"] == "Moved Recipe"
assert recipe["source_path"] == "https://example.com/moved"
@pytest.mark.asyncio
async def test_get_recipe_by_id_drops_deleted_optional_json_fields(recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(scanner.recipes_dir)
recipe_id = "drop-optional-fields"
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": "/tmp/drop-optional-fields.png",
"title": "Trimmed Recipe",
}
),
encoding="utf-8",
)
scanner._cache.raw_data = [
{
"id": recipe_id,
"file_path": "/tmp/drop-optional-fields.png",
"title": "Cached Recipe",
"folder": "",
"modified": 0.0,
"created_date": 0.0,
"source_path": "https://example.com/stale-source",
"checkpoint": {"name": "stale-checkpoint.safetensors"},
"loras": [{"modelName": "stale-lora"}],
"gen_params": {"prompt": "stale prompt"},
}
]
recipe = await scanner.get_recipe_by_id(recipe_id)
assert recipe is not None
assert recipe["title"] == "Trimmed Recipe"
assert "source_path" not in recipe
assert "checkpoint" not in recipe
assert "gen_params" not in recipe
assert "loras" not in recipe
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner): async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner