mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-11 13:19:24 -03:00
fix(recipe): reimport data loss, local file support, and scroll bugs
- Add local file reimport support via _do_reimport_from_local - Validate source_path BEFORE deleting old recipe (prevent data loss) - Move delete_recipe after save_recipe (safe ordering) - Preserve folder location, NSFW level, and carry over user edits - Remove old timestamp preservation (use current time) - Add scrollTop reset in resetAndReloadWithVirtualScroll - Only reload on successful bulk reimport (avoid empty grid) - Disable preserveScroll for both single and bulk reimport
This commit is contained in:
@@ -833,22 +833,50 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_edits: dict[str, Any] = {}
|
user_edits: dict[str, Any] = {}
|
||||||
for key in ("title", "tags", "favorite"):
|
for key in ("title", "tags", "favorite", "preview_nsfw_level"):
|
||||||
if key in old_recipe and old_recipe[key]:
|
if key in old_recipe and old_recipe[key] is not None:
|
||||||
user_edits[key] = old_recipe[key]
|
user_edits[key] = old_recipe[key]
|
||||||
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
|
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
|
||||||
del user_edits["tags"]
|
del user_edits["tags"]
|
||||||
|
|
||||||
old_created = old_recipe.get("created_date")
|
old_file_path = old_recipe.get("file_path", "")
|
||||||
old_modified = old_recipe.get("modified")
|
old_folder = os.path.dirname(old_file_path) if old_file_path else None
|
||||||
|
|
||||||
await self._persistence_service.delete_recipe(
|
image_id = extract_civitai_image_id(source_path)
|
||||||
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
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:
|
async with self._import_semaphore:
|
||||||
import_response = await self._do_import_from_url(
|
import_response = await self._do_import_from_url(
|
||||||
source_path, recipe_scanner
|
source_path,
|
||||||
|
recipe_scanner,
|
||||||
|
target_dir=old_folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._persistence_service.delete_recipe(
|
||||||
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||||
)
|
)
|
||||||
|
|
||||||
body_bytes = import_response.body
|
body_bytes = import_response.body
|
||||||
@@ -872,24 +900,6 @@ class RecipeManagementHandler:
|
|||||||
exc,
|
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(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -1662,6 +1672,9 @@ class RecipeManagementHandler:
|
|||||||
self,
|
self,
|
||||||
image_url: str,
|
image_url: str,
|
||||||
recipe_scanner: Any,
|
recipe_scanner: Any,
|
||||||
|
*,
|
||||||
|
recipe_id: str | None = None,
|
||||||
|
target_dir: str | None = None,
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
image_id = extract_civitai_image_id(image_url)
|
image_id = extract_civitai_image_id(image_url)
|
||||||
if not image_id:
|
if not image_id:
|
||||||
@@ -1835,9 +1848,104 @@ class RecipeManagementHandler:
|
|||||||
tags=[],
|
tags=[],
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
extension=extension,
|
extension=extension,
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
target_dir=target_dir,
|
||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
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:
|
async def create_from_example(self, request: web.Request) -> web.Response:
|
||||||
"""Create a recipe from a model's example image using cached metadata.
|
"""Create a recipe from a model's example image using cached metadata.
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,18 @@ class RecipePersistenceService:
|
|||||||
tags: Iterable[str],
|
tags: Iterable[str],
|
||||||
metadata: Optional[dict[str, Any]],
|
metadata: Optional[dict[str, Any]],
|
||||||
extension: str | None = None,
|
extension: str | None = None,
|
||||||
|
recipe_id: str | None = None,
|
||||||
|
target_dir: str | None = None,
|
||||||
) -> PersistenceResult:
|
) -> 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 = []
|
missing_fields = []
|
||||||
if not name:
|
if not name:
|
||||||
@@ -63,10 +73,10 @@ class RecipePersistenceService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64)
|
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)
|
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
|
# Handle video formats by bypassing optimization and metadata embedding
|
||||||
is_video = extension in [".mp4", ".webm"]
|
is_video = extension in [".mp4", ".webm"]
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
|
|
||||||
if (scrollSnapshot) {
|
if (scrollSnapshot) {
|
||||||
await restoreScrollPosition(scrollSnapshot);
|
await restoreScrollPosition(scrollSnapshot);
|
||||||
|
} else if (state.virtualScroller?.scrollContainer) {
|
||||||
|
state.virtualScroller.scrollContainer.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
showToast('toast.recipes.reimportSuccess', {}, 'success');
|
showToast('toast.recipes.reimportSuccess', {}, 'success');
|
||||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||||
resetAndReload(false, { preserveScroll: true });
|
resetAndReload(false, { preserveScroll: false });
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Re-import failed');
|
throw new Error(result.error || 'Re-import failed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -724,14 +724,13 @@ export class BulkManager {
|
|||||||
await progressUI.complete(
|
await progressUI.complete(
|
||||||
`Re-import complete: ${completed} re-imported, ${failed} failed`
|
`Re-import complete: ${completed} re-imported, ${failed} failed`
|
||||||
);
|
);
|
||||||
|
const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
|
||||||
|
recipeResetAndReload(false, { preserveScroll: false });
|
||||||
|
this.clearSelection();
|
||||||
} else {
|
} else {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
showToast('toast.recipes.reimportBulkFailed', {}, 'error');
|
showToast('toast.recipes.reimportBulkFailed', {}, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
|
|
||||||
recipeResetAndReload(false, { preserveScroll: true });
|
|
||||||
this.clearSelection();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[reimportSelectedRecipes] outer catch:', error);
|
console.error('[reimportSelectedRecipes] outer catch:', error);
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
|
|||||||
Reference in New Issue
Block a user