diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 08b1a2e5..d2c202c0 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -833,24 +833,52 @@ class RecipeManagementHandler: ) user_edits: dict[str, Any] = {} - for key in ("title", "tags", "favorite"): - if key in old_recipe and old_recipe[key]: + for key in ("title", "tags", "favorite", "preview_nsfw_level"): + if key in old_recipe and old_recipe[key] is not None: user_edits[key] = old_recipe[key] if "tags" in user_edits and not isinstance(user_edits["tags"], list): del user_edits["tags"] - old_created = old_recipe.get("created_date") - old_modified = old_recipe.get("modified") + old_file_path = old_recipe.get("file_path", "") + old_folder = os.path.dirname(old_file_path) if old_file_path else None + + image_id = extract_civitai_image_id(source_path) + is_local_file = not image_id and os.path.isfile(source_path) + + if not image_id and not is_local_file: + return web.json_response( + { + "success": False, + "error": ( + "Recipe source is neither a valid CivitAI image URL " + "nor an accessible local file. " + "Use repair or manual import instead." + ), + }, + status=400, + ) + + if is_local_file: + return await self._do_reimport_from_local( + source_path, + recipe_scanner, + recipe_id=recipe_id, + target_dir=old_folder, + user_edits=user_edits, + old_title=old_recipe.get("title", ""), + ) + + async with self._import_semaphore: + import_response = await self._do_import_from_url( + source_path, + recipe_scanner, + target_dir=old_folder, + ) await self._persistence_service.delete_recipe( recipe_scanner=recipe_scanner, recipe_id=recipe_id ) - async with self._import_semaphore: - import_response = await self._do_import_from_url( - source_path, recipe_scanner - ) - body_bytes = import_response.body if not body_bytes: raise RuntimeError("Re-import returned an empty response") @@ -872,24 +900,6 @@ class RecipeManagementHandler: exc, ) - timestamp_updates: dict[str, Any] = {} - if old_created is not None: - timestamp_updates["created_date"] = old_created - if old_modified is not None: - timestamp_updates["modified"] = old_modified - if new_recipe_id and timestamp_updates: - try: - await recipe_scanner.update_recipe_metadata( - new_recipe_id, timestamp_updates - ) - except Exception as exc: - self._logger.warning( - "Re-import succeeded but failed to preserve " - "timestamps for new recipe %s: %s", - new_recipe_id, - exc, - ) - return web.json_response( { "success": True, @@ -1662,6 +1672,9 @@ class RecipeManagementHandler: self, image_url: str, recipe_scanner: Any, + *, + recipe_id: str | None = None, + target_dir: str | None = None, ) -> web.Response: image_id = extract_civitai_image_id(image_url) if not image_id: @@ -1835,9 +1848,104 @@ class RecipeManagementHandler: tags=[], metadata=metadata, extension=extension, + recipe_id=recipe_id, + target_dir=target_dir, ) return web.json_response(result.payload, status=result.status) + async def _do_reimport_from_local( + self, + file_path: str, + recipe_scanner: Any, + *, + recipe_id: str, + target_dir: str | None, + user_edits: dict[str, Any], + old_title: str, + ) -> web.Response: + """Re-import a recipe from a local image file. + + Reads the original source file, re-parses its EXIF metadata, saves a + fresh recipe, then deletes the old one. + """ + normalized = os.path.normpath(file_path) + if not os.path.isfile(normalized): + raise RecipeNotFoundError( + f"Source file no longer accessible: {normalized}" + ) + + with open(normalized, "rb") as fh: + image_bytes = fh.read() + + extension = os.path.splitext(normalized)[1].lower() or ".png" + + analysis_result = await self._analysis_service.analyze_local_image( + file_path=normalized, + recipe_scanner=recipe_scanner, + ) + analysis_payload: dict[str, Any] = analysis_result.payload + + gen_params = analysis_payload.get("gen_params") or {} + loras = analysis_payload.get("loras") or [] + checkpoint = analysis_payload.get("checkpoint") + base_model = analysis_payload.get("base_model", "") + + metadata: dict[str, Any] = { + "base_model": base_model, + "loras": loras, + "gen_params": gen_params, + "source_path": normalized, + } + if checkpoint: + metadata["checkpoint"] = checkpoint + + prompt = ( + gen_params.get("prompt") + or gen_params.get("positivePrompt") + or "" + ) + name = " ".join(str(prompt).split()[:10]) if prompt else old_title + + result = await self._persistence_service.save_recipe( + recipe_scanner=recipe_scanner, + image_bytes=image_bytes, + image_base64=analysis_payload.get("image_base64"), + name=name, + tags=[], + metadata=metadata, + extension=extension, + target_dir=target_dir, + ) + + await self._persistence_service.delete_recipe( + recipe_scanner=recipe_scanner, recipe_id=recipe_id + ) + + new_recipe_id = result.payload.get("recipe_id") + if new_recipe_id and user_edits: + try: + await self._persistence_service.update_recipe( + recipe_scanner=recipe_scanner, + recipe_id=new_recipe_id, + updates=user_edits, + ) + except Exception as exc: + self._logger.warning( + "Re-import (local) succeeded but failed to carry over " + "user edits for recipe %s: %s", + new_recipe_id, + exc, + ) + + return web.json_response( + { + "success": True, + "old_recipe_id": recipe_id, + "recipe_id": new_recipe_id, + "source_path": normalized, + } + ) + async def create_from_example(self, request: web.Request) -> web.Response: """Create a recipe from a model's example image using cached metadata. diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index bf795f4a..49bf7dfb 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -49,8 +49,18 @@ class RecipePersistenceService: tags: Iterable[str], metadata: Optional[dict[str, Any]], extension: str | None = None, + recipe_id: str | None = None, + target_dir: str | None = None, ) -> PersistenceResult: - """Persist a user uploaded recipe.""" + """Persist a user uploaded recipe. + + Args: + recipe_id: If provided, reuse this ID instead of generating a new + UUID. Used by re-import to preserve the original recipe identity. + target_dir: If provided, save recipe files to this directory instead + of the default recipes_dir. Used by re-import to preserve the + original folder location. + """ missing_fields = [] if not name: @@ -63,10 +73,10 @@ class RecipePersistenceService: ) resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64) - recipes_dir = recipe_scanner.recipes_dir + recipes_dir = target_dir or recipe_scanner.recipes_dir os.makedirs(recipes_dir, exist_ok=True) - recipe_id = str(uuid.uuid4()) + recipe_id = recipe_id or str(uuid.uuid4()) # Handle video formats by bypassing optimization and metadata embedding is_video = extension in [".mp4", ".webm"] diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 3ef0c3aa..f0e09d45 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -213,6 +213,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) { if (scrollSnapshot) { await restoreScrollPosition(scrollSnapshot); + } else if (state.virtualScroller?.scrollContainer) { + state.virtualScroller.scrollContainer.scrollTop = 0; } return result; diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index 79832587..dbb949be 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -347,7 +347,7 @@ export class RecipeContextMenu extends BaseContextMenu { state.loadingManager.hide(); showToast('toast.recipes.reimportSuccess', {}, 'success'); const { resetAndReload } = await import('../../api/recipeApi.js'); - resetAndReload(false, { preserveScroll: true }); + resetAndReload(false, { preserveScroll: false }); } else { throw new Error(result.error || 'Re-import failed'); } diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 0e9434ee..ddcfa665 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -724,14 +724,13 @@ export class BulkManager { await progressUI.complete( `Re-import complete: ${completed} re-imported, ${failed} failed` ); + const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js'); + recipeResetAndReload(false, { preserveScroll: false }); + this.clearSelection(); } else { state.loadingManager.hide(); showToast('toast.recipes.reimportBulkFailed', {}, 'error'); } - - const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js'); - recipeResetAndReload(false, { preserveScroll: true }); - this.clearSelection(); } catch (error) { console.error('[reimportSelectedRecipes] outer catch:', error); state.loadingManager.hide();