From e3bf1f763c962487ce2be50f027959e2ea7d4fa7 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 7 May 2025 17:13:30 +0800 Subject: [PATCH 1/8] refactor: remove workflow parsing module and associated files for cleanup --- py/server_routes.py | 26 --- py/workflow/__init__.py | 3 - py/workflow/cli.py | 58 ------- py/workflow/ext/__init__.py | 3 - py/workflow/ext/comfyui_core.py | 285 -------------------------------- py/workflow/ext/kjnodes.py | 74 --------- py/workflow/main.py | 37 ----- py/workflow/mappers.py | 282 ------------------------------- py/workflow/parser.py | 181 -------------------- py/workflow/test.py | 63 ------- py/workflow/utils.py | 120 -------------- 11 files changed, 1132 deletions(-) delete mode 100644 py/server_routes.py delete mode 100644 py/workflow/__init__.py delete mode 100644 py/workflow/cli.py delete mode 100644 py/workflow/ext/__init__.py delete mode 100644 py/workflow/ext/comfyui_core.py delete mode 100644 py/workflow/ext/kjnodes.py delete mode 100644 py/workflow/main.py delete mode 100644 py/workflow/mappers.py delete mode 100644 py/workflow/parser.py delete mode 100644 py/workflow/test.py delete mode 100644 py/workflow/utils.py diff --git a/py/server_routes.py b/py/server_routes.py deleted file mode 100644 index 68ee9749..00000000 --- a/py/server_routes.py +++ /dev/null @@ -1,26 +0,0 @@ -from aiohttp import web -from server import PromptServer -from .nodes.utils import get_lora_info - -@PromptServer.instance.routes.post("/loramanager/get_trigger_words") -async def get_trigger_words(request): - json_data = await request.json() - lora_names = json_data.get("lora_names", []) - node_ids = json_data.get("node_ids", []) - - all_trigger_words = [] - for lora_name in lora_names: - _, trigger_words = await get_lora_info(lora_name) - all_trigger_words.extend(trigger_words) - - # Format the trigger words - trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else "" - - # Send update to all connected trigger word toggle nodes - for node_id in node_ids: - PromptServer.instance.send_sync("trigger_word_update", { - "id": node_id, - "message": trigger_words_text - }) - - return web.json_response({"success": True}) diff --git a/py/workflow/__init__.py b/py/workflow/__init__.py deleted file mode 100644 index 5bb0929b..00000000 --- a/py/workflow/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -ComfyUI workflow parsing module to extract generation parameters -""" \ No newline at end of file diff --git a/py/workflow/cli.py b/py/workflow/cli.py deleted file mode 100644 index ab39ed4a..00000000 --- a/py/workflow/cli.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Command-line interface for the ComfyUI workflow parser -""" -import argparse -import json -import os -import logging -import sys -from .parser import parse_workflow - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[logging.StreamHandler()] -) -logger = logging.getLogger(__name__) - -def main(): - """Entry point for the CLI""" - parser = argparse.ArgumentParser(description='Parse ComfyUI workflow files') - parser.add_argument('input', help='Input workflow JSON file path') - parser.add_argument('-o', '--output', help='Output JSON file path') - parser.add_argument('-p', '--pretty', action='store_true', help='Pretty print JSON output') - parser.add_argument('--debug', action='store_true', help='Enable debug logging') - - args = parser.parse_args() - - # Set logging level - if args.debug: - logging.getLogger().setLevel(logging.DEBUG) - - # Validate input file - if not os.path.isfile(args.input): - logger.error(f"Input file not found: {args.input}") - sys.exit(1) - - # Parse workflow - try: - result = parse_workflow(args.input, args.output) - - # Print result to console if output file not specified - if not args.output: - if args.pretty: - print(json.dumps(result, indent=4)) - else: - print(json.dumps(result)) - else: - logger.info(f"Output saved to: {args.output}") - - except Exception as e: - logger.error(f"Error parsing workflow: {e}") - if args.debug: - import traceback - traceback.print_exc() - sys.exit(1) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/py/workflow/ext/__init__.py b/py/workflow/ext/__init__.py deleted file mode 100644 index 86e11ab6..00000000 --- a/py/workflow/ext/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Extension directory for custom node mappers -""" \ No newline at end of file diff --git a/py/workflow/ext/comfyui_core.py b/py/workflow/ext/comfyui_core.py deleted file mode 100644 index 5a116d59..00000000 --- a/py/workflow/ext/comfyui_core.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -ComfyUI Core nodes mappers extension for workflow parsing -""" -import logging -from typing import Dict, Any, List - -logger = logging.getLogger(__name__) - -# ============================================================================= -# Transform Functions -# ============================================================================= - -def transform_random_noise(inputs: Dict) -> Dict: - """Transform function for RandomNoise node""" - return {"seed": str(inputs.get("noise_seed", ""))} - -def transform_ksampler_select(inputs: Dict) -> Dict: - """Transform function for KSamplerSelect node""" - return {"sampler": inputs.get("sampler_name", "")} - -def transform_basic_scheduler(inputs: Dict) -> Dict: - """Transform function for BasicScheduler node""" - result = { - "scheduler": inputs.get("scheduler", ""), - "denoise": str(inputs.get("denoise", "1.0")) - } - - # Get steps from inputs or steps input - if "steps" in inputs: - if isinstance(inputs["steps"], str): - result["steps"] = inputs["steps"] - elif isinstance(inputs["steps"], dict) and "value" in inputs["steps"]: - result["steps"] = str(inputs["steps"]["value"]) - else: - result["steps"] = str(inputs["steps"]) - - return result - -def transform_basic_guider(inputs: Dict) -> Dict: - """Transform function for BasicGuider node""" - result = {} - - # Process conditioning - if "conditioning" in inputs: - if isinstance(inputs["conditioning"], str): - result["prompt"] = inputs["conditioning"] - elif isinstance(inputs["conditioning"], dict): - result["conditioning"] = inputs["conditioning"] - - # Get model information if needed - if "model" in inputs and isinstance(inputs["model"], dict): - result["model"] = inputs["model"] - - return result - -def transform_model_sampling_flux(inputs: Dict) -> Dict: - """Transform function for ModelSamplingFlux - mostly a pass-through node""" - # This node is primarily used for routing, so we mostly pass through values - - return inputs["model"] - -def transform_sampler_custom_advanced(inputs: Dict) -> Dict: - """Transform function for SamplerCustomAdvanced node""" - result = {} - - # Extract seed from noise - if "noise" in inputs and isinstance(inputs["noise"], dict): - result["seed"] = str(inputs["noise"].get("seed", "")) - - # Extract sampler info - if "sampler" in inputs and isinstance(inputs["sampler"], dict): - sampler = inputs["sampler"].get("sampler", "") - if sampler: - result["sampler"] = sampler - - # Extract scheduler, steps, denoise from sigmas - if "sigmas" in inputs and isinstance(inputs["sigmas"], dict): - sigmas = inputs["sigmas"] - result["scheduler"] = sigmas.get("scheduler", "") - result["steps"] = str(sigmas.get("steps", "")) - result["denoise"] = str(sigmas.get("denoise", "1.0")) - - # Extract prompt and guidance from guider - if "guider" in inputs and isinstance(inputs["guider"], dict): - guider = inputs["guider"] - - # Get prompt from conditioning - if "conditioning" in guider and isinstance(guider["conditioning"], str): - result["prompt"] = guider["conditioning"] - elif "conditioning" in guider and isinstance(guider["conditioning"], dict): - result["guidance"] = guider["conditioning"].get("guidance", "") - result["prompt"] = guider["conditioning"].get("prompt", "") - - if "model" in guider and isinstance(guider["model"], dict): - result["checkpoint"] = guider["model"].get("checkpoint", "") - result["loras"] = guider["model"].get("loras", "") - result["clip_skip"] = str(int(guider["model"].get("clip_skip", "-1")) * -1) - - # Extract dimensions from latent_image - if "latent_image" in inputs and isinstance(inputs["latent_image"], dict): - latent = inputs["latent_image"] - width = latent.get("width", 0) - height = latent.get("height", 0) - if width and height: - result["width"] = width - result["height"] = height - result["size"] = f"{width}x{height}" - - return result - -def transform_ksampler(inputs: Dict) -> Dict: - """Transform function for KSampler nodes""" - result = { - "seed": str(inputs.get("seed", "")), - "steps": str(inputs.get("steps", "")), - "cfg": str(inputs.get("cfg", "")), - "sampler": inputs.get("sampler_name", ""), - "scheduler": inputs.get("scheduler", ""), - } - - # Process positive prompt - if "positive" in inputs: - result["prompt"] = inputs["positive"] - - # Process negative prompt - if "negative" in inputs: - result["negative_prompt"] = inputs["negative"] - - # Get dimensions from latent image - if "latent_image" in inputs and isinstance(inputs["latent_image"], dict): - width = inputs["latent_image"].get("width", 0) - height = inputs["latent_image"].get("height", 0) - if width and height: - result["size"] = f"{width}x{height}" - - # Add clip_skip if present - if "clip_skip" in inputs: - result["clip_skip"] = str(inputs.get("clip_skip", "")) - - # Add guidance if present - if "guidance" in inputs: - result["guidance"] = str(inputs.get("guidance", "")) - - # Add model if present - if "model" in inputs: - result["checkpoint"] = inputs.get("model", {}).get("checkpoint", "") - result["loras"] = inputs.get("model", {}).get("loras", "") - result["clip_skip"] = str(inputs.get("model", {}).get("clip_skip", -1) * -1) - - return result - -def transform_empty_latent(inputs: Dict) -> Dict: - """Transform function for EmptyLatentImage nodes""" - width = inputs.get("width", 0) - height = inputs.get("height", 0) - return {"width": width, "height": height, "size": f"{width}x{height}"} - -def transform_clip_text(inputs: Dict) -> Any: - """Transform function for CLIPTextEncode nodes""" - return inputs.get("text", "") - -def transform_flux_guidance(inputs: Dict) -> Dict: - """Transform function for FluxGuidance nodes""" - result = {} - - if "guidance" in inputs: - result["guidance"] = inputs["guidance"] - - if "conditioning" in inputs: - conditioning = inputs["conditioning"] - if isinstance(conditioning, str): - result["prompt"] = conditioning - else: - result["prompt"] = "Unknown prompt" - - return result - -def transform_unet_loader(inputs: Dict) -> Dict: - """Transform function for UNETLoader node""" - unet_name = inputs.get("unet_name", "") - return {"checkpoint": unet_name} if unet_name else {} - -def transform_checkpoint_loader(inputs: Dict) -> Dict: - """Transform function for CheckpointLoaderSimple node""" - ckpt_name = inputs.get("ckpt_name", "") - return {"checkpoint": ckpt_name} if ckpt_name else {} - -def transform_latent_upscale_by(inputs: Dict) -> Dict: - """Transform function for LatentUpscaleBy node""" - result = {} - - width = inputs["samples"].get("width", 0) * inputs["scale_by"] - height = inputs["samples"].get("height", 0) * inputs["scale_by"] - result["width"] = width - result["height"] = height - result["size"] = f"{width}x{height}" - - return result - -def transform_clip_set_last_layer(inputs: Dict) -> Dict: - """Transform function for CLIPSetLastLayer node""" - result = {} - - if "stop_at_clip_layer" in inputs: - result["clip_skip"] = inputs["stop_at_clip_layer"] - - return result - -# ============================================================================= -# Node Mapper Definitions -# ============================================================================= - -# Define the mappers for ComfyUI core nodes not in main mapper -NODE_MAPPERS_EXT = { - # KSamplers - "SamplerCustomAdvanced": { - "inputs_to_track": ["noise", "guider", "sampler", "sigmas", "latent_image"], - "transform_func": transform_sampler_custom_advanced - }, - "KSampler": { - "inputs_to_track": [ - "seed", "steps", "cfg", "sampler_name", "scheduler", - "denoise", "positive", "negative", "latent_image", - "model", "clip_skip" - ], - "transform_func": transform_ksampler - }, - # ComfyUI core nodes - "EmptyLatentImage": { - "inputs_to_track": ["width", "height", "batch_size"], - "transform_func": transform_empty_latent - }, - "EmptySD3LatentImage": { - "inputs_to_track": ["width", "height", "batch_size"], - "transform_func": transform_empty_latent - }, - "CLIPTextEncode": { - "inputs_to_track": ["text", "clip"], - "transform_func": transform_clip_text - }, - "FluxGuidance": { - "inputs_to_track": ["guidance", "conditioning"], - "transform_func": transform_flux_guidance - }, - "RandomNoise": { - "inputs_to_track": ["noise_seed"], - "transform_func": transform_random_noise - }, - "KSamplerSelect": { - "inputs_to_track": ["sampler_name"], - "transform_func": transform_ksampler_select - }, - "BasicScheduler": { - "inputs_to_track": ["scheduler", "steps", "denoise", "model"], - "transform_func": transform_basic_scheduler - }, - "BasicGuider": { - "inputs_to_track": ["model", "conditioning"], - "transform_func": transform_basic_guider - }, - "ModelSamplingFlux": { - "inputs_to_track": ["max_shift", "base_shift", "width", "height", "model"], - "transform_func": transform_model_sampling_flux - }, - "UNETLoader": { - "inputs_to_track": ["unet_name"], - "transform_func": transform_unet_loader - }, - "CheckpointLoaderSimple": { - "inputs_to_track": ["ckpt_name"], - "transform_func": transform_checkpoint_loader - }, - "LatentUpscale": { - "inputs_to_track": ["width", "height"], - "transform_func": transform_empty_latent - }, - "LatentUpscaleBy": { - "inputs_to_track": ["samples", "scale_by"], - "transform_func": transform_latent_upscale_by - }, - "CLIPSetLastLayer": { - "inputs_to_track": ["clip", "stop_at_clip_layer"], - "transform_func": transform_clip_set_last_layer - } -} \ No newline at end of file diff --git a/py/workflow/ext/kjnodes.py b/py/workflow/ext/kjnodes.py deleted file mode 100644 index 8ea99d2c..00000000 --- a/py/workflow/ext/kjnodes.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -KJNodes mappers extension for ComfyUI workflow parsing -""" -import logging -import re -from typing import Dict, Any - -logger = logging.getLogger(__name__) - -# ============================================================================= -# Transform Functions -# ============================================================================= - -def transform_join_strings(inputs: Dict) -> str: - """Transform function for JoinStrings nodes""" - string1 = inputs.get("string1", "") - string2 = inputs.get("string2", "") - delimiter = inputs.get("delimiter", "") - return f"{string1}{delimiter}{string2}" - -def transform_string_constant(inputs: Dict) -> str: - """Transform function for StringConstant nodes""" - return inputs.get("string", "") - -def transform_empty_latent_presets(inputs: Dict) -> Dict: - """Transform function for EmptyLatentImagePresets nodes""" - dimensions = inputs.get("dimensions", "") - invert = inputs.get("invert", False) - - # Extract width and height from dimensions string - # Expected format: "width x height (ratio)" or similar - width = 0 - height = 0 - - if dimensions: - # Try to extract dimensions using regex - match = re.search(r'(\d+)\s*x\s*(\d+)', dimensions) - if match: - width = int(match.group(1)) - height = int(match.group(2)) - - # If invert is True, swap width and height - if invert and width and height: - width, height = height, width - - return {"width": width, "height": height, "size": f"{width}x{height}"} - -def transform_int_constant(inputs: Dict) -> int: - """Transform function for INTConstant nodes""" - return inputs.get("value", 0) - -# ============================================================================= -# Node Mapper Definitions -# ============================================================================= - -# Define the mappers for KJNodes -NODE_MAPPERS_EXT = { - "JoinStrings": { - "inputs_to_track": ["string1", "string2", "delimiter"], - "transform_func": transform_join_strings - }, - "StringConstantMultiline": { - "inputs_to_track": ["string"], - "transform_func": transform_string_constant - }, - "EmptyLatentImagePresets": { - "inputs_to_track": ["dimensions", "invert", "batch_size"], - "transform_func": transform_empty_latent_presets - }, - "INTConstant": { - "inputs_to_track": ["value"], - "transform_func": transform_int_constant - } -} \ No newline at end of file diff --git a/py/workflow/main.py b/py/workflow/main.py deleted file mode 100644 index 2f46591d..00000000 --- a/py/workflow/main.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Main entry point for the workflow parser module -""" -import os -import sys -import logging -from typing import Dict, Optional, Union - -# Add the parent directory to sys.path to enable imports -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..')) -sys.path.insert(0, os.path.dirname(SCRIPT_DIR)) - -from .parser import parse_workflow - -logger = logging.getLogger(__name__) - -def parse_comfyui_workflow( - workflow_path: str, - output_path: Optional[str] = None -) -> Dict: - """ - Parse a ComfyUI workflow file and extract generation parameters - - Args: - workflow_path: Path to the workflow JSON file - output_path: Optional path to save the output JSON - - Returns: - Dictionary containing extracted parameters - """ - return parse_workflow(workflow_path, output_path) - -if __name__ == "__main__": - # If run directly, use the CLI - from .cli import main - main() \ No newline at end of file diff --git a/py/workflow/mappers.py b/py/workflow/mappers.py deleted file mode 100644 index 528aeefb..00000000 --- a/py/workflow/mappers.py +++ /dev/null @@ -1,282 +0,0 @@ -""" -Node mappers for ComfyUI workflow parsing -""" -import logging -import os -import importlib.util -import inspect -from typing import Dict, List, Any, Optional, Union, Type, Callable, Tuple - -logger = logging.getLogger(__name__) - -# Global mapper registry -_MAPPER_REGISTRY: Dict[str, Dict] = {} - -# ============================================================================= -# Mapper Definition Functions -# ============================================================================= - -def create_mapper( - node_type: str, - inputs_to_track: List[str], - transform_func: Callable[[Dict], Any] = None -) -> Dict: - """Create a mapper definition for a node type""" - mapper = { - "node_type": node_type, - "inputs_to_track": inputs_to_track, - "transform": transform_func or (lambda inputs: inputs) - } - return mapper - -def register_mapper(mapper: Dict) -> None: - """Register a node mapper in the global registry""" - _MAPPER_REGISTRY[mapper["node_type"]] = mapper - logger.debug(f"Registered mapper for node type: {mapper['node_type']}") - -def get_mapper(node_type: str) -> Optional[Dict]: - """Get a mapper for the specified node type""" - return _MAPPER_REGISTRY.get(node_type) - -def get_all_mappers() -> Dict[str, Dict]: - """Get all registered mappers""" - return _MAPPER_REGISTRY.copy() - -# ============================================================================= -# Node Processing Function -# ============================================================================= - -def process_node(node_id: str, node_data: Dict, workflow: Dict, parser: 'WorkflowParser') -> Any: # type: ignore - """Process a node using its mapper and extract relevant information""" - node_type = node_data.get("class_type") - mapper = get_mapper(node_type) - - if not mapper: - logger.warning(f"No mapper found for node type: {node_type}") - return None - - result = {} - - # Extract inputs based on the mapper's tracked inputs - for input_name in mapper["inputs_to_track"]: - if input_name in node_data.get("inputs", {}): - input_value = node_data["inputs"][input_name] - - # Check if input is a reference to another node's output - if isinstance(input_value, list) and len(input_value) == 2: - try: - # Format is [node_id, output_slot] - ref_node_id, output_slot = input_value - # Convert node_id to string if it's an integer - if isinstance(ref_node_id, int): - ref_node_id = str(ref_node_id) - - # Recursively process the referenced node - ref_value = parser.process_node(ref_node_id, workflow) - - if ref_value is not None: - result[input_name] = ref_value - else: - # If we couldn't get a value from the reference, store the raw value - result[input_name] = input_value - except Exception as e: - logger.error(f"Error processing reference in node {node_id}, input {input_name}: {e}") - result[input_name] = input_value - else: - # Direct value - result[input_name] = input_value - - # Apply the transform function - try: - return mapper["transform"](result) - except Exception as e: - logger.error(f"Error in transform function for node {node_id} of type {node_type}: {e}") - return result - -# ============================================================================= -# Transform Functions -# ============================================================================= - - - -def transform_lora_loader(inputs: Dict) -> Dict: - """Transform function for LoraLoader nodes""" - loras_data = inputs.get("loras", []) - lora_stack = inputs.get("lora_stack", {}).get("lora_stack", []) - - lora_texts = [] - - # Process loras array - if isinstance(loras_data, dict) and "__value__" in loras_data: - loras_list = loras_data["__value__"] - elif isinstance(loras_data, list): - loras_list = loras_data - else: - loras_list = [] - - # Process each active lora entry - for lora in loras_list: - if isinstance(lora, dict) and lora.get("active", False): - lora_name = lora.get("name", "") - strength = lora.get("strength", 1.0) - lora_texts.append(f"") - - # Process lora_stack if valid - if lora_stack and isinstance(lora_stack, list): - if not (len(lora_stack) == 2 and isinstance(lora_stack[0], (str, int)) and isinstance(lora_stack[1], int)): - for stack_entry in lora_stack: - lora_name = stack_entry[0] - strength = stack_entry[1] - lora_texts.append(f"") - - result = { - "checkpoint": inputs.get("model", {}).get("checkpoint", ""), - "loras": " ".join(lora_texts) - } - - if "clip" in inputs and isinstance(inputs["clip"], dict): - result["clip_skip"] = inputs["clip"].get("clip_skip", "-1") - - return result - -def transform_lora_stacker(inputs: Dict) -> Dict: - """Transform function for LoraStacker nodes""" - loras_data = inputs.get("loras", []) - result_stack = [] - - # Handle existing stack entries - existing_stack = [] - lora_stack_input = inputs.get("lora_stack", []) - - if isinstance(lora_stack_input, dict) and "lora_stack" in lora_stack_input: - existing_stack = lora_stack_input["lora_stack"] - elif isinstance(lora_stack_input, list): - if not (len(lora_stack_input) == 2 and isinstance(lora_stack_input[0], (str, int)) and - isinstance(lora_stack_input[1], int)): - existing_stack = lora_stack_input - - # Add existing entries - if existing_stack: - result_stack.extend(existing_stack) - - # Process new loras - if isinstance(loras_data, dict) and "__value__" in loras_data: - loras_list = loras_data["__value__"] - elif isinstance(loras_data, list): - loras_list = loras_data - else: - loras_list = [] - - for lora in loras_list: - if isinstance(lora, dict) and lora.get("active", False): - lora_name = lora.get("name", "") - strength = float(lora.get("strength", 1.0)) - result_stack.append((lora_name, strength)) - - return {"lora_stack": result_stack} - -def transform_trigger_word_toggle(inputs: Dict) -> str: - """Transform function for TriggerWordToggle nodes""" - toggle_data = inputs.get("toggle_trigger_words", []) - - if isinstance(toggle_data, dict) and "__value__" in toggle_data: - toggle_words = toggle_data["__value__"] - elif isinstance(toggle_data, list): - toggle_words = toggle_data - else: - toggle_words = [] - - # Filter active trigger words - active_words = [] - for item in toggle_words: - if isinstance(item, dict) and item.get("active", False): - word = item.get("text", "") - if word and not word.startswith("__dummy"): - active_words.append(word) - - return ", ".join(active_words) - -# ============================================================================= -# Node Mapper Definitions -# ============================================================================= - -# Central definition of all supported node types and their configurations -NODE_MAPPERS = { - - # LoraManager nodes - "Lora Loader (LoraManager)": { - "inputs_to_track": ["model", "clip", "loras", "lora_stack"], - "transform_func": transform_lora_loader - }, - "Lora Stacker (LoraManager)": { - "inputs_to_track": ["loras", "lora_stack"], - "transform_func": transform_lora_stacker - }, - "TriggerWord Toggle (LoraManager)": { - "inputs_to_track": ["toggle_trigger_words"], - "transform_func": transform_trigger_word_toggle - } -} - -def register_all_mappers() -> None: - """Register all mappers from the NODE_MAPPERS dictionary""" - for node_type, config in NODE_MAPPERS.items(): - mapper = create_mapper( - node_type=node_type, - inputs_to_track=config["inputs_to_track"], - transform_func=config["transform_func"] - ) - register_mapper(mapper) - logger.info(f"Registered {len(NODE_MAPPERS)} node mappers") - -# ============================================================================= -# Extension Loading -# ============================================================================= - -def load_extensions(ext_dir: str = None) -> None: - """ - Load mapper extensions from the specified directory - - Extension files should define a NODE_MAPPERS_EXT dictionary containing mapper configurations. - These will be added to the global NODE_MAPPERS dictionary and registered automatically. - """ - # Use default path if none provided - if ext_dir is None: - # Get the directory of this file - current_dir = os.path.dirname(os.path.abspath(__file__)) - ext_dir = os.path.join(current_dir, 'ext') - - # Ensure the extension directory exists - if not os.path.exists(ext_dir): - os.makedirs(ext_dir, exist_ok=True) - logger.info(f"Created extension directory: {ext_dir}") - return - - # Load each Python file in the extension directory - for filename in os.listdir(ext_dir): - if filename.endswith('.py') and not filename.startswith('_'): - module_path = os.path.join(ext_dir, filename) - module_name = f"workflow.ext.{filename[:-3]}" # Remove .py - - try: - # Load the module - spec = importlib.util.spec_from_file_location(module_name, module_path) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - # Check if the module defines NODE_MAPPERS_EXT - if hasattr(module, 'NODE_MAPPERS_EXT'): - # Add the extension mappers to the global NODE_MAPPERS dictionary - NODE_MAPPERS.update(module.NODE_MAPPERS_EXT) - logger.info(f"Added {len(module.NODE_MAPPERS_EXT)} mappers from extension: {filename}") - else: - logger.warning(f"Extension {filename} does not define NODE_MAPPERS_EXT dictionary") - except Exception as e: - logger.warning(f"Error loading extension {filename}: {e}") - - # Re-register all mappers after loading extensions - register_all_mappers() - -# Initialize the registry with default mappers -# register_default_mappers() \ No newline at end of file diff --git a/py/workflow/parser.py b/py/workflow/parser.py deleted file mode 100644 index 0a5a02ef..00000000 --- a/py/workflow/parser.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -Main workflow parser implementation for ComfyUI -""" -import json -import logging -from typing import Dict, List, Any, Optional, Union, Set -from .mappers import get_mapper, get_all_mappers, load_extensions, process_node -from .utils import ( - load_workflow, save_output, find_node_by_type, - trace_model_path -) - -logger = logging.getLogger(__name__) - -class WorkflowParser: - """Parser for ComfyUI workflows""" - - def __init__(self): - """Initialize the parser with mappers""" - self.processed_nodes: Set[str] = set() # Track processed nodes to avoid cycles - self.node_results_cache: Dict[str, Any] = {} # Cache for processed node results - - # Load extensions - load_extensions() - - def process_node(self, node_id: str, workflow: Dict) -> Any: - """Process a single node and extract relevant information""" - # Return cached result if available - if node_id in self.node_results_cache: - return self.node_results_cache[node_id] - - # Check if we're in a cycle - if node_id in self.processed_nodes: - return None - - # Mark this node as being processed (to detect cycles) - self.processed_nodes.add(node_id) - - if node_id not in workflow: - self.processed_nodes.remove(node_id) - return None - - node_data = workflow[node_id] - node_type = node_data.get("class_type") - - result = None - if get_mapper(node_type): - try: - result = process_node(node_id, node_data, workflow, self) - # Cache the result - self.node_results_cache[node_id] = result - except Exception as e: - logger.error(f"Error processing node {node_id} of type {node_type}: {e}", exc_info=True) - # Return a partial result or None depending on how we want to handle errors - result = {} - - # Remove node from processed set to allow it to be processed again in a different context - self.processed_nodes.remove(node_id) - return result - - def find_primary_sampler_node(self, workflow: Dict) -> Optional[str]: - """ - Find the primary sampler node in the workflow. - - Priority: - 1. First try to find a SamplerCustomAdvanced node - 2. If not found, look for KSampler nodes with denoise=1.0 - 3. If still not found, use the first KSampler node - - Args: - workflow: The workflow data as a dictionary - - Returns: - The node ID of the primary sampler node, or None if not found - """ - # First check for SamplerCustomAdvanced nodes - sampler_advanced_nodes = [] - ksampler_nodes = [] - - # Scan workflow for sampler nodes - for node_id, node_data in workflow.items(): - node_type = node_data.get("class_type") - - if node_type == "SamplerCustomAdvanced": - sampler_advanced_nodes.append(node_id) - elif node_type == "KSampler": - ksampler_nodes.append(node_id) - - # If we found SamplerCustomAdvanced nodes, return the first one - if sampler_advanced_nodes: - logger.debug(f"Found SamplerCustomAdvanced node: {sampler_advanced_nodes[0]}") - return sampler_advanced_nodes[0] - - # If we have KSampler nodes, look for one with denoise=1.0 - if ksampler_nodes: - for node_id in ksampler_nodes: - node_data = workflow[node_id] - inputs = node_data.get("inputs", {}) - denoise = inputs.get("denoise", 0) - - # Check if denoise is 1.0 (allowing for small floating point differences) - if abs(float(denoise) - 1.0) < 0.001: - logger.debug(f"Found KSampler node with denoise=1.0: {node_id}") - return node_id - - # If no KSampler with denoise=1.0 found, use the first one - logger.debug(f"No KSampler with denoise=1.0 found, using first KSampler: {ksampler_nodes[0]}") - return ksampler_nodes[0] - - # No sampler nodes found - logger.warning("No sampler nodes found in workflow") - return None - - def parse_workflow(self, workflow_data: Union[str, Dict], output_path: Optional[str] = None) -> Dict: - """ - Parse the workflow and extract generation parameters - - Args: - workflow_data: The workflow data as a dictionary or a file path - output_path: Optional path to save the output JSON - - Returns: - Dictionary containing extracted parameters - """ - # Load workflow from file if needed - if isinstance(workflow_data, str): - workflow = load_workflow(workflow_data) - else: - workflow = workflow_data - - # Reset the processed nodes tracker and cache - self.processed_nodes = set() - self.node_results_cache = {} - - # Find the primary sampler node - sampler_node_id = self.find_primary_sampler_node(workflow) - if not sampler_node_id: - logger.warning("No suitable sampler node found in workflow") - return {} - - # Process sampler node to extract parameters - sampler_result = self.process_node(sampler_node_id, workflow) - if not sampler_result: - return {} - - # Return the sampler result directly - it's already in the format we need - # This simplifies the structure and makes it easier to use in recipe_routes.py - - # Handle standard ComfyUI names vs our output format - if "cfg" in sampler_result: - sampler_result["cfg_scale"] = sampler_result.pop("cfg") - - # Add clip_skip = 1 to match reference output if not already present - if "clip_skip" not in sampler_result: - sampler_result["clip_skip"] = "1" - - # Ensure the prompt is a string and not a nested dictionary - if "prompt" in sampler_result and isinstance(sampler_result["prompt"], dict): - if "prompt" in sampler_result["prompt"]: - sampler_result["prompt"] = sampler_result["prompt"]["prompt"] - - # Save the result if requested - if output_path: - save_output(sampler_result, output_path) - - return sampler_result - - -def parse_workflow(workflow_path: str, output_path: Optional[str] = None) -> Dict: - """ - Parse a ComfyUI workflow file and extract generation parameters - - Args: - workflow_path: Path to the workflow JSON file - output_path: Optional path to save the output JSON - - Returns: - Dictionary containing extracted parameters - """ - parser = WorkflowParser() - return parser.parse_workflow(workflow_path, output_path) \ No newline at end of file diff --git a/py/workflow/test.py b/py/workflow/test.py deleted file mode 100644 index 0b14673e..00000000 --- a/py/workflow/test.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Test script for the ComfyUI workflow parser -""" -import os -import json -import logging -from .parser import parse_workflow - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[logging.StreamHandler()] -) -logger = logging.getLogger(__name__) - -# Configure paths -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..')) -REFS_DIR = os.path.join(ROOT_DIR, 'refs') -OUTPUT_DIR = os.path.join(ROOT_DIR, 'output') - -def test_parse_flux_workflow(): - """Test parsing the flux example workflow""" - # Ensure output directory exists - os.makedirs(OUTPUT_DIR, exist_ok=True) - - # Define input and output paths - input_path = os.path.join(REFS_DIR, 'flux_prompt.json') - output_path = os.path.join(OUTPUT_DIR, 'parsed_flux_output.json') - - # Parse workflow - logger.info(f"Parsing workflow: {input_path}") - result = parse_workflow(input_path, output_path) - - # Print result summary - logger.info(f"Output saved to: {output_path}") - logger.info(f"Parsing completed. Result summary:") - logger.info(f" LoRAs: {result.get('loras', '')}") - - gen_params = result.get('gen_params', {}) - logger.info(f" Prompt: {gen_params.get('prompt', '')[:50]}...") - logger.info(f" Steps: {gen_params.get('steps', '')}") - logger.info(f" Sampler: {gen_params.get('sampler', '')}") - logger.info(f" Size: {gen_params.get('size', '')}") - - # Compare with reference output - ref_output_path = os.path.join(REFS_DIR, 'flux_output.json') - try: - with open(ref_output_path, 'r') as f: - ref_output = json.load(f) - - # Simple validation - loras_match = result.get('loras', '') == ref_output.get('loras', '') - prompt_match = gen_params.get('prompt', '') == ref_output.get('gen_params', {}).get('prompt', '') - - logger.info(f"Validation against reference:") - logger.info(f" LoRAs match: {loras_match}") - logger.info(f" Prompt match: {prompt_match}") - except Exception as e: - logger.warning(f"Failed to compare with reference output: {e}") - -if __name__ == "__main__": - test_parse_flux_workflow() \ No newline at end of file diff --git a/py/workflow/utils.py b/py/workflow/utils.py deleted file mode 100644 index aaa333ea..00000000 --- a/py/workflow/utils.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Utility functions for ComfyUI workflow parsing -""" -import json -import os -import logging -from typing import Dict, List, Any, Optional, Union, Set, Tuple - -logger = logging.getLogger(__name__) - -def load_workflow(workflow_path: str) -> Dict: - """Load a workflow from a JSON file""" - try: - with open(workflow_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - logger.error(f"Error loading workflow from {workflow_path}: {e}") - raise - -def save_output(output: Dict, output_path: str) -> None: - """Save the parsed output to a JSON file""" - os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) - try: - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(output, f, indent=4) - except Exception as e: - logger.error(f"Error saving output to {output_path}: {e}") - raise - -def find_node_by_type(workflow: Dict, node_type: str) -> Optional[str]: - """Find a node of the specified type in the workflow""" - for node_id, node_data in workflow.items(): - if node_data.get("class_type") == node_type: - return node_id - return None - -def find_nodes_by_type(workflow: Dict, node_type: str) -> List[str]: - """Find all nodes of the specified type in the workflow""" - return [node_id for node_id, node_data in workflow.items() - if node_data.get("class_type") == node_type] - -def get_input_node_ids(workflow: Dict, node_id: str) -> Dict[str, Tuple[str, int]]: - """ - Get the node IDs for all inputs of the given node - - Returns a dictionary mapping input names to (node_id, output_slot) tuples - """ - result = {} - if node_id not in workflow: - return result - - node_data = workflow[node_id] - for input_name, input_value in node_data.get("inputs", {}).items(): - # Check if this input is connected to another node - if isinstance(input_value, list) and len(input_value) == 2: - # Input is connected to another node's output - # Format: [node_id, output_slot] - ref_node_id, output_slot = input_value - result[input_name] = (str(ref_node_id), output_slot) - - return result - -def trace_model_path(workflow: Dict, start_node_id: str) -> List[str]: - """ - Trace the model path backward from KSampler to find all LoRA nodes - - Args: - workflow: The workflow data - start_node_id: The starting node ID (usually KSampler) - - Returns: - List of node IDs in the model path - """ - model_path_nodes = [] - - # Get the model input from the start node - if start_node_id not in workflow: - return model_path_nodes - - # Track visited nodes to avoid cycles - visited = set() - - # Stack for depth-first search - stack = [] - - # Get model input reference if available - start_node = workflow[start_node_id] - if "inputs" in start_node and "model" in start_node["inputs"] and isinstance(start_node["inputs"]["model"], list): - model_ref = start_node["inputs"]["model"] - stack.append(str(model_ref[0])) - - # Perform depth-first search - while stack: - node_id = stack.pop() - - # Skip if already visited - if node_id in visited: - continue - - # Mark as visited - visited.add(node_id) - - # Skip if node doesn't exist - if node_id not in workflow: - continue - - node = workflow[node_id] - node_type = node.get("class_type", "") - - # Add current node to result list if it's a LoRA node - if "Lora" in node_type: - model_path_nodes.append(node_id) - - # Add all input nodes that have a "model" or "lora_stack" output to the stack - if "inputs" in node: - for input_name, input_value in node["inputs"].items(): - if input_name in ["model", "lora_stack"] and isinstance(input_value, list) and len(input_value) == 2: - stack.append(str(input_value[0])) - - return model_path_nodes \ No newline at end of file From e92ab9e3cc2d709a88e6e90ac8e4ae3a1bf69ad3 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 7 May 2025 19:34:27 +0800 Subject: [PATCH 2/8] refactor: add endpoints for finding duplicates and bulk deletion of recipes; enhance fingerprint calculation and handling --- py/routes/recipe_routes.py | 220 +++++++++++++++++++++++++++++++++- py/services/recipe_scanner.py | 63 ++++++++++ py/utils/utils.py | 46 +++++++ 3 files changed, 325 insertions(+), 4 deletions(-) diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index aaefa1df..955689e3 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -72,12 +72,18 @@ class RecipeRoutes: # Add new endpoint for getting recipe syntax app.router.add_get('/api/recipe/{recipe_id}/syntax', routes.get_recipe_syntax) - # Add new endpoint for updating recipe metadata (name and tags) + # Add new endpoint for updating recipe metadata (name, tags and source_path) app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe) # Add new endpoint for reconnecting deleted LoRAs app.router.add_post('/api/recipe/lora/reconnect', routes.reconnect_lora) + # Add new endpoint for finding duplicate recipes + app.router.add_get('/api/recipes/find-duplicates', routes.find_duplicates) + + # Add new endpoint for bulk deletion of recipes + app.router.add_post('/api/recipes/bulk-delete', routes.bulk_delete) + # Start cache initialization app.on_startup.append(routes._init_cache) @@ -339,6 +345,21 @@ class RecipeRoutes: if "error" in result and not result.get("loras"): return web.json_response(result, status=200) + # Calculate fingerprint from parsed loras + from ..utils.utils import calculate_recipe_fingerprint + fingerprint = calculate_recipe_fingerprint(result.get("loras", [])) + + # Add fingerprint to result + result["fingerprint"] = fingerprint + + # Find matching recipes with the same fingerprint + matching_recipes = [] + if fingerprint: + matching_recipes = await self.recipe_scanner.find_recipes_by_fingerprint(fingerprint) + + # Add matching recipes to result + result["matching_recipes"] = matching_recipes + return web.json_response(result) except Exception as e: @@ -425,6 +446,21 @@ class RecipeRoutes: if "error" in result and not result.get("loras"): return web.json_response(result, status=200) + # Calculate fingerprint from parsed loras + from ..utils.utils import calculate_recipe_fingerprint + fingerprint = calculate_recipe_fingerprint(result.get("loras", [])) + + # Add fingerprint to result + result["fingerprint"] = fingerprint + + # Find matching recipes with the same fingerprint + matching_recipes = [] + if fingerprint: + matching_recipes = await self.recipe_scanner.find_recipes_by_fingerprint(fingerprint) + + # Add matching recipes to result + result["matching_recipes"] = matching_recipes + return web.json_response(result) except Exception as e: @@ -590,6 +626,10 @@ class RecipeRoutes: "clip_skip": raw_metadata.get("clip_skip", "") } + # Calculate recipe fingerprint + from ..utils.utils import calculate_recipe_fingerprint + fingerprint = calculate_recipe_fingerprint(loras_data) + # Create the recipe data structure recipe_data = { "id": recipe_id, @@ -599,7 +639,8 @@ class RecipeRoutes: "created_date": current_time, "base_model": metadata.get("base_model", ""), "loras": loras_data, - "gen_params": gen_params + "gen_params": gen_params, + "fingerprint": fingerprint } # Add tags if provided @@ -619,6 +660,14 @@ class RecipeRoutes: # Add recipe metadata to the image ExifUtils.append_recipe_metadata(image_path, recipe_data) + # Check for duplicates + matching_recipes = [] + if fingerprint: + matching_recipes = await self.recipe_scanner.find_recipes_by_fingerprint(fingerprint) + # Remove current recipe from matches + if recipe_id in matching_recipes: + matching_recipes.remove(recipe_id) + # Simplified cache update approach # Instead of trying to update the cache directly, just set it to None # to force a refresh on the next get_cached_data call @@ -634,7 +683,8 @@ class RecipeRoutes: 'success': True, 'recipe_id': recipe_id, 'image_path': image_path, - 'json_path': json_path + 'json_path': json_path, + 'matching_recipes': matching_recipes }) except Exception as e: @@ -1266,6 +1316,10 @@ class RecipeRoutes: if not found: return web.json_response({"error": "Could not find matching deleted LoRA in recipe"}, status=404) + + # Recalculate recipe fingerprint after updating LoRA + from ..utils.utils import calculate_recipe_fingerprint + recipe_data['fingerprint'] = calculate_recipe_fingerprint(recipe_data.get('loras', [])) # Save updated recipe with open(recipe_path, 'w', encoding='utf-8') as f: @@ -1281,6 +1335,8 @@ class RecipeRoutes: if cache_item.get('id') == recipe_id: # Replace loras array with updated version cache_item['loras'] = recipe_data['loras'] + # Update fingerprint in cache + cache_item['fingerprint'] = recipe_data['fingerprint'] # Resort the cache asyncio.create_task(scanner._cache.resort()) @@ -1291,11 +1347,20 @@ class RecipeRoutes: if image_path and os.path.exists(image_path): from ..utils.exif_utils import ExifUtils ExifUtils.append_recipe_metadata(image_path, recipe_data) + + # Find other recipes with the same fingerprint + matching_recipes = [] + if 'fingerprint' in recipe_data: + matching_recipes = await scanner.find_recipes_by_fingerprint(recipe_data['fingerprint']) + # Remove current recipe from matches + if recipe_id in matching_recipes: + matching_recipes.remove(recipe_id) return web.json_response({ "success": True, "recipe_id": recipe_id, - "updated_lora": updated_lora + "updated_lora": updated_lora, + "matching_recipes": matching_recipes }) except Exception as e: @@ -1371,3 +1436,150 @@ class RecipeRoutes: 'success': False, 'error': str(e) }, status=500) + + async def find_duplicates(self, request: web.Request) -> web.Response: + """Find all duplicate recipes based on fingerprints""" + try: + # Ensure services are initialized + await self.init_services() + + # Get all duplicate recipes + duplicate_groups = await self.recipe_scanner.find_all_duplicate_recipes() + + # Create response data with additional recipe information + response_data = [] + + for fingerprint, recipe_ids in duplicate_groups.items(): + # Skip groups with only one recipe (not duplicates) + if len(recipe_ids) <= 1: + continue + + # Get recipe details for each recipe in the group + recipes = [] + for recipe_id in recipe_ids: + recipe = await self.recipe_scanner.get_recipe_by_id(recipe_id) + if recipe: + # Add only needed fields to keep response size manageable + recipes.append({ + 'id': recipe.get('id'), + 'title': recipe.get('title'), + 'file_url': recipe.get('file_url') or self._format_recipe_file_url(recipe.get('file_path', '')), + 'modified': recipe.get('modified'), + 'created_date': recipe.get('created_date'), + 'lora_count': len(recipe.get('loras', [])), + }) + + # Only include groups with at least 2 valid recipes + if len(recipes) >= 2: + # Sort recipes by modified date (newest first) + recipes.sort(key=lambda x: x.get('modified', 0), reverse=True) + + response_data.append({ + 'fingerprint': fingerprint, + 'count': len(recipes), + 'recipes': recipes + }) + + # Sort groups by count (highest first) + response_data.sort(key=lambda x: x['count'], reverse=True) + + return web.json_response({ + 'success': True, + 'duplicate_groups': response_data + }) + + except Exception as e: + logger.error(f"Error finding duplicate recipes: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + + async def bulk_delete(self, request: web.Request) -> web.Response: + """Delete multiple recipes by ID""" + try: + # Ensure services are initialized + await self.init_services() + + # Parse request data + data = await request.json() + recipe_ids = data.get('recipe_ids', []) + + if not recipe_ids: + return web.json_response({ + 'success': False, + 'error': 'No recipe IDs provided' + }, status=400) + + # Get recipes directory + recipes_dir = self.recipe_scanner.recipes_dir + if not recipes_dir or not os.path.exists(recipes_dir): + return web.json_response({ + 'success': False, + 'error': 'Recipes directory not found' + }, status=404) + + # Track deleted and failed recipes + deleted_recipes = [] + failed_recipes = [] + + # Process each recipe ID + for recipe_id in recipe_ids: + # Find recipe JSON file + recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json") + + if not os.path.exists(recipe_json_path): + failed_recipes.append({ + 'id': recipe_id, + 'reason': 'Recipe not found' + }) + continue + + try: + # Load recipe data to get image path + with open(recipe_json_path, 'r', encoding='utf-8') as f: + recipe_data = json.load(f) + + # Get image path + image_path = recipe_data.get('file_path') + + # Delete recipe JSON file + os.remove(recipe_json_path) + + # Delete recipe image if it exists + if image_path and os.path.exists(image_path): + os.remove(image_path) + + deleted_recipes.append(recipe_id) + + except Exception as e: + failed_recipes.append({ + 'id': recipe_id, + 'reason': str(e) + }) + + # Update cache if any recipes were deleted + if deleted_recipes and self.recipe_scanner._cache is not None: + # Remove deleted recipes from raw_data + self.recipe_scanner._cache.raw_data = [ + r for r in self.recipe_scanner._cache.raw_data + if r.get('id') not in deleted_recipes + ] + # Resort the cache + asyncio.create_task(self.recipe_scanner._cache.resort()) + logger.info(f"Removed {len(deleted_recipes)} recipes from cache") + + return web.json_response({ + 'success': True, + 'deleted': deleted_recipes, + 'failed': failed_recipes, + 'total_deleted': len(deleted_recipes), + 'total_failed': len(failed_recipes) + }) + + except Exception as e: + logger.error(f"Error performing bulk delete: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 1987fa41..a27ff074 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -322,6 +322,20 @@ class RecipeScanner: # Update lora information with local paths and availability await self._update_lora_information(recipe_data) + + # Calculate and update fingerprint if missing + if 'loras' in recipe_data and 'fingerprint' not in recipe_data: + from ..utils.utils import calculate_recipe_fingerprint + fingerprint = calculate_recipe_fingerprint(recipe_data['loras']) + recipe_data['fingerprint'] = fingerprint + + # Write updated recipe data back to file + try: + with open(recipe_path, 'w', encoding='utf-8') as f: + json.dump(recipe_data, f, indent=4, ensure_ascii=False) + logger.info(f"Added fingerprint to recipe: {recipe_path}") + except Exception as e: + logger.error(f"Error writing updated recipe with fingerprint: {e}") return recipe_data except Exception as e: @@ -802,3 +816,52 @@ class RecipeScanner: logger.info(f"Resorted recipe cache after updating {cache_updated_count} items") return file_updated_count, cache_updated_count + + async def find_recipes_by_fingerprint(self, fingerprint: str) -> list: + """Find recipes with a matching fingerprint + + Args: + fingerprint: The recipe fingerprint to search for + + Returns: + List of recipe IDs that match the fingerprint + """ + if not fingerprint: + return [] + + # Get all recipes from cache + cache = await self.get_cached_data() + + # Find recipes with matching fingerprint + matching_recipes = [] + for recipe in cache.raw_data: + if recipe.get('fingerprint') == fingerprint: + matching_recipes.append(recipe.get('id')) + + return matching_recipes + + async def find_all_duplicate_recipes(self) -> dict: + """Find all recipe duplicates based on fingerprints + + Returns: + Dictionary where keys are fingerprints and values are lists of recipe IDs + """ + # Get all recipes from cache + cache = await self.get_cached_data() + + # Group recipes by fingerprint + fingerprint_groups = {} + for recipe in cache.raw_data: + fingerprint = recipe.get('fingerprint') + if not fingerprint: + continue + + if fingerprint not in fingerprint_groups: + fingerprint_groups[fingerprint] = [] + + fingerprint_groups[fingerprint].append(recipe.get('id')) + + # Filter to only include groups with more than one recipe + duplicate_groups = {k: v for k, v in fingerprint_groups.items() if len(v) > 1} + + return duplicate_groups diff --git a/py/utils/utils.py b/py/utils/utils.py index 8bfeba77..4ab6b3ba 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -114,3 +114,49 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> bool: # All words found either as substrings or fuzzy matches return True + +def calculate_recipe_fingerprint(loras): + """ + Calculate a unique fingerprint for a recipe based on its LoRAs. + + The fingerprint is created by sorting LoRA hashes, filtering invalid entries, + normalizing strength values to 2 decimal places, and joining in format: + hash1:strength1|hash2:strength2|... + + Args: + loras (list): List of LoRA dictionaries with hash and strength values + + Returns: + str: The calculated fingerprint + """ + if not loras: + return "" + + # Filter valid entries and extract hash and strength + valid_loras = [] + for lora in loras: + # Skip excluded loras + if lora.get("exclude", False): + continue + + # Get the hash - use modelVersionId as fallback if hash is empty + hash_value = lora.get("hash", "").lower() + if not hash_value and lora.get("isDeleted", False) and lora.get("modelVersionId"): + hash_value = lora.get("modelVersionId") + + # Skip entries without a valid hash + if not hash_value: + continue + + # Normalize strength to 2 decimal places + strength = round(float(lora.get("strength", 1.0)), 2) + + valid_loras.append((hash_value, strength)) + + # Sort by hash + valid_loras.sort() + + # Join in format hash1:strength1|hash2:strength2|... + fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras]) + + return fingerprint From 59aefdff77ec36a7ecc958f9da033eec6054e817 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 8 May 2025 13:05:12 +0800 Subject: [PATCH 3/8] feat: implement duplicate detection and management features; add UI components and styles for duplicates --- static/css/base.css | 4 +- static/css/components/duplicates.css | 259 +++++++++++++++ static/css/style.css | 1 + static/js/components/DuplicatesManager.js | 380 ++++++++++++++++++++++ static/js/components/RecipeCard.js | 61 ++-- static/js/recipes.js | 29 ++ static/js/state/index.js | 1 + static/js/utils/infiniteScroll.js | 5 + templates/recipes.html | 23 ++ 9 files changed, 736 insertions(+), 27 deletions(-) create mode 100644 static/css/components/duplicates.css create mode 100644 static/js/components/DuplicatesManager.js diff --git a/static/css/base.css b/static/css/base.css index ddc8352b..5dbf15f0 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -38,7 +38,7 @@ html, body { --lora-border: oklch(90% 0.02 256 / 0.15); --lora-text: oklch(95% 0.02 256); --lora-error: oklch(75% 0.32 29); - --lora-warning: oklch(75% 0.25 80); /* Add warning color for deleted LoRAs */ + --lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */ /* Spacing Scale */ --space-1: calc(8px * 1); @@ -79,7 +79,7 @@ html[data-theme="light"] { --lora-surface: oklch(25% 0.02 256 / 0.98); --lora-border: oklch(90% 0.02 256 / 0.15); --lora-text: oklch(98% 0.02 256); - --lora-warning: oklch(75% 0.25 80); /* Add warning color for dark theme too */ + --lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */ } body { diff --git a/static/css/components/duplicates.css b/static/css/components/duplicates.css new file mode 100644 index 00000000..302f0bc6 --- /dev/null +++ b/static/css/components/duplicates.css @@ -0,0 +1,259 @@ +/* Duplicates Management Styles */ + +/* Duplicates banner */ +.duplicates-banner { + position: sticky; + top: 48px; /* Match header height */ + left: 0; + width: 100%; + background-color: var(--card-bg); + color: var(--text-color); + border-bottom: 1px solid var(--border-color); + z-index: var(--z-overlay); + padding: 12px 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: all 0.3s ease; +} + +.duplicates-banner .banner-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 12px; +} + +.duplicates-banner i.fa-exclamation-triangle { + font-size: 18px; + color: oklch(var(--lora-warning)); +} + +.duplicates-banner .banner-actions { + margin-left: auto; + display: flex; + gap: 8px; + align-items: center; +} + +.duplicates-banner button { + min-width: 100px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: var(--border-radius-xs); + padding: 4px 10px; + border: 1px solid var(--border-color); + background: var(--card-bg); + color: var(--text-color); + font-size: 0.85em; + transition: all 0.2s ease; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.duplicates-banner button:hover { + border-color: var(--lora-accent); + background: var(--bg-color); + transform: translateY(-1px); + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); +} + +.duplicates-banner button.btn-exit { + min-width: unset; + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.duplicates-banner button.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Duplicate groups */ +.duplicate-group { + position: relative; + border: 2px solid oklch(var(--lora-warning)); + border-radius: var(--border-radius-base); + padding: 16px; + margin-bottom: 24px; + background: var(--card-bg); +} + +.duplicate-group-header { + background-color: var(--bg-color); + color: var(--text-color); + border: 1px solid var(--border-color); + padding: 8px 16px; + border-radius: var(--border-radius-xs); + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.duplicate-group-header span:last-child { + display: flex; + gap: 8px; + align-items: center; +} + +.duplicate-group-header button { + min-width: 80px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: var(--border-radius-xs); + padding: 4px 8px; + border: 1px solid var(--border-color); + background: var(--card-bg); + color: var(--text-color); + font-size: 0.85em; + transition: all 0.2s ease; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + margin-left: 8px; +} + +.duplicate-group-header button:hover { + border-color: var(--lora-accent); + background: var(--bg-color); + transform: translateY(-1px); + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); +} + +.card-group-container { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: flex-start; + align-items: flex-start; +} + +/* Make cards in duplicate groups have consistent width */ +.card-group-container .lora-card { + flex: 0 0 auto; + width: 240px; + margin: 0; + cursor: pointer; /* Indicate the card is clickable */ +} + +/* Ensure the grid layout is only applied to the main recipe grid, not duplicate groups */ +.duplicate-mode .card-grid { + display: block; +} + +/* Scrollable container for large duplicate groups */ +.card-group-container.scrollable { + max-height: 450px; + overflow-y: auto; + padding-right: 8px; +} + +/* Add a toggle button to expand/collapse large duplicate groups */ +.group-toggle-btn { + position: absolute; + right: 16px; + bottom: -12px; + background: var(--card-bg); + color: var(--text-color); + border: 1px solid var(--border-color); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.group-toggle-btn:hover { + border-color: var(--lora-accent); + transform: translateY(-1px); + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); +} + +/* Duplicate card styling */ +.lora-card.duplicate { + position: relative; + transition: all 0.2s ease; +} + +.lora-card.duplicate:hover { + border-color: var(--lora-accent); +} + +.lora-card.duplicate.latest { + border-style: solid; + border-color: oklch(var(--lora-warning)); +} + +.lora-card.duplicate-selected { + border: 2px solid oklch(var(--lora-accent)); + box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); +} + +.lora-card .selector-checkbox { + position: absolute; + top: 10px; + right: 10px; + z-index: 10; + width: 20px; + height: 20px; + cursor: pointer; +} + +/* Latest indicator */ +.lora-card.duplicate.latest::after { + content: "Latest"; + position: absolute; + top: 10px; + left: 10px; + background: oklch(var(--lora-accent)); + color: white; + font-size: 12px; + padding: 2px 6px; + border-radius: var(--border-radius-xs); + z-index: 5; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .duplicates-banner .banner-content { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .duplicates-banner .banner-actions { + width: 100%; + margin-left: 0; + justify-content: space-between; + } + + .duplicate-group-header { + flex-direction: column; + gap: 8px; + align-items: flex-start; + } + + .duplicate-group-header span:last-child { + display: flex; + gap: 8px; + width: 100%; + } + + .duplicate-group-header button { + margin-left: 0; + flex: 1; + } +} diff --git a/static/css/style.css b/static/css/style.css index 08cb7a0d..64158a7f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -22,6 +22,7 @@ @import 'components/initialization.css'; @import 'components/progress-panel.css'; @import 'components/alphabet-bar.css'; /* Add alphabet bar component */ +@import 'components/duplicates.css'; /* Add duplicates component */ .initialization-notice { display: flex; diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js new file mode 100644 index 00000000..63713e4b --- /dev/null +++ b/static/js/components/DuplicatesManager.js @@ -0,0 +1,380 @@ +// Duplicates Manager Component +import { showToast } from '../utils/uiHelpers.js'; +import { RecipeCard } from './RecipeCard.js'; +import { getCurrentPageState } from '../state/index.js'; +import { initializeInfiniteScroll } from '../utils/infiniteScroll.js'; + +export class DuplicatesManager { + constructor(recipeManager) { + this.recipeManager = recipeManager; + this.duplicateGroups = []; + this.inDuplicateMode = false; + this.selectedForDeletion = new Set(); + } + + async findDuplicates() { + try { + document.body.classList.add('loading'); + + const response = await fetch('/api/recipes/find-duplicates'); + if (!response.ok) { + throw new Error('Failed to find duplicates'); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Unknown error finding duplicates'); + } + + this.duplicateGroups = data.duplicate_groups || []; + + if (this.duplicateGroups.length === 0) { + showToast('No duplicate recipes found', 'info'); + return false; + } + + this.enterDuplicateMode(); + return true; + } catch (error) { + console.error('Error finding duplicates:', error); + showToast('Failed to find duplicates: ' + error.message, 'error'); + return false; + } finally { + document.body.classList.remove('loading'); + } + } + + enterDuplicateMode() { + this.inDuplicateMode = true; + this.selectedForDeletion.clear(); + + // Update state + const pageState = getCurrentPageState(); + pageState.duplicatesMode = true; + + // Show duplicates banner + const banner = document.getElementById('duplicatesBanner'); + const countSpan = document.getElementById('duplicatesCount'); + + if (banner && countSpan) { + countSpan.textContent = `Found ${this.duplicateGroups.length} duplicate group${this.duplicateGroups.length !== 1 ? 's' : ''}`; + banner.style.display = 'block'; + } + + // Disable infinite scroll + if (this.recipeManager.observer) { + this.recipeManager.observer.disconnect(); + this.recipeManager.observer = null; + } + + // Add duplicate-mode class to the body + document.body.classList.add('duplicate-mode'); + + // Render duplicate groups + this.renderDuplicateGroups(); + + // Update selected count + this.updateSelectedCount(); + } + + exitDuplicateMode() { + this.inDuplicateMode = false; + this.selectedForDeletion.clear(); + + // Update state + const pageState = getCurrentPageState(); + pageState.duplicatesMode = false; + + // Hide duplicates banner + const banner = document.getElementById('duplicatesBanner'); + if (banner) { + banner.style.display = 'none'; + } + + // Remove duplicate-mode class from the body + document.body.classList.remove('duplicate-mode'); + + // Reload normal recipes view + this.recipeManager.loadRecipes(); + + // Reinitialize infinite scroll + setTimeout(() => { + initializeInfiniteScroll('recipes'); + }, 500); + } + + renderDuplicateGroups() { + const recipeGrid = document.getElementById('recipeGrid'); + if (!recipeGrid) return; + + // Clear existing content + recipeGrid.innerHTML = ''; + + // Render each duplicate group + this.duplicateGroups.forEach((group, groupIndex) => { + const groupDiv = document.createElement('div'); + groupDiv.className = 'duplicate-group'; + groupDiv.dataset.fingerprint = group.fingerprint; + + // Create group header + const header = document.createElement('div'); + header.className = 'duplicate-group-header'; + header.innerHTML = ` + Duplicate Group #${groupIndex + 1} (${group.recipes.length} recipes) + + + + + `; + groupDiv.appendChild(header); + + // Create cards container + const cardsDiv = document.createElement('div'); + cardsDiv.className = 'card-group-container'; + + // Add scrollable class if there are many recipes in the group + if (group.recipes.length > 6) { + cardsDiv.classList.add('scrollable'); + + // Add expand/collapse toggle button + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'group-toggle-btn'; + toggleBtn.innerHTML = ''; + toggleBtn.title = "Expand/Collapse"; + toggleBtn.onclick = function() { + cardsDiv.classList.toggle('scrollable'); + this.innerHTML = cardsDiv.classList.contains('scrollable') ? + '' : + ''; + }; + groupDiv.appendChild(toggleBtn); + } + + // Sort recipes by date (newest first) + const sortedRecipes = [...group.recipes].sort((a, b) => b.modified - a.modified); + + // Add all recipe cards in this group + sortedRecipes.forEach((recipe, index) => { + // Create recipe card + const recipeCard = new RecipeCard(recipe, (recipe) => { + this.recipeManager.showRecipeDetails(recipe); + }); + const card = recipeCard.element; + + // Add duplicate class + card.classList.add('duplicate'); + + // Mark the latest one + if (index === 0) { + card.classList.add('latest'); + } + + // Add selection checkbox + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'selector-checkbox'; + checkbox.dataset.recipeId = recipe.id; + checkbox.dataset.groupFingerprint = group.fingerprint; + + // Check if already selected + if (this.selectedForDeletion.has(recipe.id)) { + checkbox.checked = true; + card.classList.add('duplicate-selected'); + } + + // Add change event to checkbox + checkbox.addEventListener('change', (e) => { + e.stopPropagation(); + this.toggleCardSelection(recipe.id, card, checkbox); + }); + + // Make the entire card clickable for selection + card.addEventListener('click', (e) => { + // Don't toggle if clicking on the checkbox directly or card actions + if (e.target === checkbox || e.target.closest('.card-actions')) { + return; + } + + // Toggle checkbox state + checkbox.checked = !checkbox.checked; + this.toggleCardSelection(recipe.id, card, checkbox); + }); + + card.appendChild(checkbox); + cardsDiv.appendChild(card); + }); + + groupDiv.appendChild(cardsDiv); + recipeGrid.appendChild(groupDiv); + }); + } + + // Helper method to toggle card selection state + toggleCardSelection(recipeId, card, checkbox) { + if (checkbox.checked) { + this.selectedForDeletion.add(recipeId); + card.classList.add('duplicate-selected'); + } else { + this.selectedForDeletion.delete(recipeId); + card.classList.remove('duplicate-selected'); + } + + this.updateSelectedCount(); + } + + updateSelectedCount() { + const selectedCountEl = document.getElementById('selectedCount'); + if (selectedCountEl) { + selectedCountEl.textContent = this.selectedForDeletion.size; + } + + // Update delete button state + const deleteBtn = document.querySelector('.btn-delete-selected'); + if (deleteBtn) { + deleteBtn.disabled = this.selectedForDeletion.size === 0; + deleteBtn.classList.toggle('disabled', this.selectedForDeletion.size === 0); + } + } + + toggleSelectAllInGroup(fingerprint) { + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked); + + // If all are selected, deselect all; otherwise select all + checkboxes.forEach(checkbox => { + checkbox.checked = !allSelected; + const recipeId = checkbox.dataset.recipeId; + const card = checkbox.closest('.lora-card'); + + if (!allSelected) { + this.selectedForDeletion.add(recipeId); + card.classList.add('duplicate-selected'); + } else { + this.selectedForDeletion.delete(recipeId); + card.classList.remove('duplicate-selected'); + } + }); + + // Update the button text + const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`); + if (button) { + button.textContent = !allSelected ? "Deselect All" : "Select All"; + } + + this.updateSelectedCount(); + } + + selectAllInGroup(fingerprint) { + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + checkboxes.forEach(checkbox => { + checkbox.checked = true; + this.selectedForDeletion.add(checkbox.dataset.recipeId); + checkbox.closest('.lora-card').classList.add('duplicate-selected'); + }); + + // Update the button text + const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`); + if (button) { + button.textContent = "Deselect All"; + } + + this.updateSelectedCount(); + } + + selectLatestInGroup(fingerprint) { + // Find all checkboxes in this group + const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`); + + // Get all the recipes in this group + const group = this.duplicateGroups.find(g => g.fingerprint === fingerprint); + if (!group) return; + + // Sort recipes by date (newest first) + const sortedRecipes = [...group.recipes].sort((a, b) => b.modified - a.modified); + + // Skip the first (latest) one and select the rest for deletion + for (let i = 1; i < sortedRecipes.length; i++) { + const recipeId = sortedRecipes[i].id; + const checkbox = document.querySelector(`.selector-checkbox[data-recipe-id="${recipeId}"]`); + + if (checkbox) { + checkbox.checked = true; + this.selectedForDeletion.add(recipeId); + checkbox.closest('.lora-card').classList.add('duplicate-selected'); + } + } + + // Make sure the latest one is not selected + const latestId = sortedRecipes[0].id; + const latestCheckbox = document.querySelector(`.selector-checkbox[data-recipe-id="${latestId}"]`); + + if (latestCheckbox) { + latestCheckbox.checked = false; + this.selectedForDeletion.delete(latestId); + latestCheckbox.closest('.lora-card').classList.remove('duplicate-selected'); + } + + this.updateSelectedCount(); + } + + selectLatestDuplicates() { + // For each duplicate group, select all but the latest recipe + this.duplicateGroups.forEach(group => { + this.selectLatestInGroup(group.fingerprint); + }); + } + + async deleteSelectedDuplicates() { + if (this.selectedForDeletion.size === 0) { + showToast('No recipes selected for deletion', 'info'); + return; + } + + try { + // Show confirmation dialog + if (!confirm(`Are you sure you want to delete ${this.selectedForDeletion.size} selected recipes?`)) { + return; + } + + document.body.classList.add('loading'); + + // Prepare recipe IDs for deletion + const recipeIds = Array.from(this.selectedForDeletion); + + // Call API to bulk delete + const response = await fetch('/api/recipes/bulk-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ recipe_ids: recipeIds }) + }); + + if (!response.ok) { + throw new Error('Failed to delete selected recipes'); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Unknown error deleting recipes'); + } + + showToast(`Successfully deleted ${data.total_deleted} recipes`, 'success'); + + // Exit duplicate mode if deletions were successful + if (data.total_deleted > 0) { + this.exitDuplicateMode(); + } + + } catch (error) { + console.error('Error deleting recipes:', error); + showToast('Failed to delete recipes: ' + error.message, 'error'); + } finally { + document.body.classList.remove('loading'); + } + } +} diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 29ea95c4..c120444b 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -1,6 +1,7 @@ // Recipe Card Component import { showToast, copyToClipboard } from '../utils/uiHelpers.js'; import { modalManager } from '../managers/ModalManager.js'; +import { getCurrentPageState } from '../state/index.js'; class RecipeCard { constructor(recipe, clickHandler) { @@ -36,10 +37,15 @@ class RecipeCard { (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : '/loras_static/images/no-preview.png'); + // Check if in duplicates mode + const pageState = getCurrentPageState(); + const isDuplicatesMode = pageState.duplicatesMode; + card.innerHTML = ` -
R
+ ${!isDuplicatesMode ? `
R
` : ''}
${this.recipe.title} + ${!isDuplicatesMode ? `
${baseModel ? `${baseModel}` : ''} @@ -50,19 +56,22 @@ class RecipeCard {
+ ` : ''}
`; - this.attachEventListeners(card); + this.attachEventListeners(card, isDuplicatesMode); return card; } @@ -72,29 +81,31 @@ class RecipeCard { return `${missingCount} of ${totalCount} LoRAs missing`; } - attachEventListeners(card) { - // Recipe card click event - card.addEventListener('click', () => { - this.clickHandler(this.recipe); - }); - - // Share button click event - prevent propagation to card - card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { - e.stopPropagation(); - this.shareRecipe(); - }); - - // Copy button click event - prevent propagation to card - card.querySelector('.fa-copy')?.addEventListener('click', (e) => { - e.stopPropagation(); - this.copyRecipeSyntax(); - }); - - // Delete button click event - prevent propagation to card - card.querySelector('.fa-trash')?.addEventListener('click', (e) => { - e.stopPropagation(); - this.showDeleteConfirmation(); - }); + attachEventListeners(card, isDuplicatesMode) { + // Recipe card click event - only attach if not in duplicates mode + if (!isDuplicatesMode) { + card.addEventListener('click', () => { + this.clickHandler(this.recipe); + }); + + // Share button click event - prevent propagation to card + card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.shareRecipe(); + }); + + // Copy button click event - prevent propagation to card + card.querySelector('.fa-copy')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.copyRecipeSyntax(); + }); + + // Delete button click event - prevent propagation to card + card.querySelector('.fa-trash')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.showDeleteConfirmation(); + }); + } } copyRecipeSyntax() { diff --git a/static/js/recipes.js b/static/js/recipes.js index 012478b9..0bca2aca 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -6,6 +6,8 @@ import { RecipeModal } from './components/RecipeModal.js'; import { getCurrentPageState } from './state/index.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js'; +import { DuplicatesManager } from './components/DuplicatesManager.js'; +import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; class RecipeManager { constructor() { @@ -18,6 +20,9 @@ class RecipeManager { // Initialize RecipeModal this.recipeModal = new RecipeModal(); + // Initialize DuplicatesManager + this.duplicatesManager = new DuplicatesManager(this); + // Add state tracking for infinite scroll this.pageState.isLoading = false; this.pageState.hasMore = true; @@ -179,6 +184,12 @@ class RecipeManager { async loadRecipes(resetPage = true) { try { + // Skip loading if in duplicates mode + const pageState = getCurrentPageState(); + if (pageState.duplicatesMode) { + return; + } + // Show loading indicator document.body.classList.add('loading'); this.pageState.isLoading = true; @@ -366,6 +377,24 @@ class RecipeManager { showRecipeDetails(recipe) { this.recipeModal.showRecipeDetails(recipe); } + + // Duplicate detection and management methods + async findDuplicateRecipes() { + return await this.duplicatesManager.findDuplicates(); + } + + selectLatestDuplicates() { + this.duplicatesManager.selectLatestDuplicates(); + } + + deleteSelectedDuplicates() { + this.duplicatesManager.deleteSelectedDuplicates(); + } + + exitDuplicateMode() { + this.duplicatesManager.exitDuplicateMode(); + initializeInfiniteScroll(); + } } // Initialize components diff --git a/static/js/state/index.js b/static/js/state/index.js index bbfa3ea9..6851baff 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -65,6 +65,7 @@ export const state = { }, pageSize: 20, showFavoritesOnly: false, + duplicatesMode: false, // Add flag for duplicates mode }, checkpoints: { diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 57290ebd..40ca85af 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -14,6 +14,11 @@ export function initializeInfiniteScroll(pageType = 'loras') { // Get the current page state const pageState = getCurrentPageState(); + + // Skip initializing if in duplicates mode (for recipes page) + if (pageType === 'recipes' && pageState.duplicatesMode) { + return; + } // Determine the load more function and grid ID based on page type let loadMoreFunction; diff --git a/templates/recipes.html b/templates/recipes.html index 1c97d78b..7701d3e1 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -42,6 +42,10 @@
+ +
+ +
+ + +
From 23fa2995c830b620ae5e7022e89f0197b298e81b Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 8 May 2025 15:41:13 +0800 Subject: [PATCH 4/8] refactor(import): Implement DownloadManager, FolderBrowser, ImageProcessor, and RecipeDataManager for enhanced recipe import functionality - Added DownloadManager to handle saving recipes and downloading missing LoRAs. - Introduced FolderBrowser for selecting LoRA root directories and managing folder navigation. - Created ImageProcessor for handling image uploads and URL inputs for recipe analysis. - Developed RecipeDataManager to manage recipe details, including metadata and LoRA information. - Implemented ImportStepManager to control the flow of the import process and manage UI steps. - Added utility function for formatting file sizes for better user experience. --- static/js/managers/ImportManager.js | 1190 +---------------- static/js/managers/import/DownloadManager.js | 257 ++++ static/js/managers/import/FolderBrowser.js | 220 +++ static/js/managers/import/ImageProcessor.js | 206 +++ .../js/managers/import/ImportStepManager.js | 57 + .../js/managers/import/RecipeDataManager.js | 349 +++++ static/js/utils/formatters.js | 12 + 7 files changed, 1168 insertions(+), 1123 deletions(-) create mode 100644 static/js/managers/import/DownloadManager.js create mode 100644 static/js/managers/import/FolderBrowser.js create mode 100644 static/js/managers/import/ImageProcessor.js create mode 100644 static/js/managers/import/ImportStepManager.js create mode 100644 static/js/managers/import/RecipeDataManager.js create mode 100644 static/js/utils/formatters.js diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index ae39a141..bec00d73 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -2,34 +2,41 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { LoadingManager } from './LoadingManager.js'; import { getStorageItem } from '../utils/storageHelpers.js'; +import { ImportStepManager } from './import/ImportStepManager.js'; +import { ImageProcessor } from './import/ImageProcessor.js'; +import { RecipeDataManager } from './import/RecipeDataManager.js'; +import { DownloadManager } from './import/DownloadManager.js'; +import { FolderBrowser } from './import/FolderBrowser.js'; +import { formatFileSize } from '../utils/formatters.js'; export class ImportManager { constructor() { + // Core state properties this.recipeImage = null; this.recipeData = null; this.recipeName = ''; this.recipeTags = []; this.missingLoras = []; - - // Add initialization check this.initialized = false; this.selectedFolder = ''; - - // Add LoadingManager instance + this.downloadableLoRAs = []; + this.recipeId = null; + this.importMode = 'url'; // Default mode: 'url' or 'upload' + + // Initialize sub-managers this.loadingManager = new LoadingManager(); - this.folderClickHandler = null; - this.updateTargetPath = this.updateTargetPath.bind(this); + this.stepManager = new ImportStepManager(); + this.imageProcessor = new ImageProcessor(this); + this.recipeDataManager = new RecipeDataManager(this); + this.downloadManager = new DownloadManager(this); + this.folderBrowser = new FolderBrowser(this); - // 添加对注入样式的引用 - this.injectedStyles = null; - - // Change default mode to url/path - this.importMode = 'url'; // Default mode changed to: 'url' or 'upload' + // Bind methods + this.formatFileSize = formatFileSize; } showImportModal(recipeData = null, recipeId = null) { if (!this.initialized) { - // Check if modal exists const modal = document.getElementById('importModal'); if (!modal) { console.error('Import modal element not found'); @@ -38,82 +45,46 @@ export class ImportManager { this.initialized = true; } - // Always reset the state when opening the modal + // Reset state this.resetSteps(); if (recipeData) { this.downloadableLoRAs = recipeData.loras; this.recipeId = recipeId; } - // Show the modal + // Show modal modalManager.showModal('importModal', null, () => { - // Cleanup handler when modal closes - this.cleanupFolderBrowser(); - - // Remove any injected styles - this.removeInjectedStyles(); + this.folderBrowser.cleanup(); + this.stepManager.removeInjectedStyles(); }); - // Verify the modal is properly shown - setTimeout(() => { - this.ensureModalVisible(); - }, 50); - } - - // 添加移除注入样式的方法 - removeInjectedStyles() { - if (this.injectedStyles && this.injectedStyles.parentNode) { - this.injectedStyles.parentNode.removeChild(this.injectedStyles); - this.injectedStyles = null; - } - - // Also reset any inline styles that might have been set with !important - document.querySelectorAll('.import-step').forEach(step => { - step.style.cssText = ''; - }); + // Verify visibility + setTimeout(() => this.ensureModalVisible(), 50); } resetSteps() { - // Remove any existing injected styles - this.removeInjectedStyles(); + // Clear UI state + this.stepManager.removeInjectedStyles(); + this.stepManager.showStep('uploadStep'); - // Show the first step - this.showStep('uploadStep'); - - // Reset file input + // Reset form inputs const fileInput = document.getElementById('recipeImageUpload'); - if (fileInput) { - fileInput.value = ''; - } + if (fileInput) fileInput.value = ''; - // Reset URL input const urlInput = document.getElementById('imageUrlInput'); - if (urlInput) { - urlInput.value = ''; - } + if (urlInput) urlInput.value = ''; - // Reset error messages const uploadError = document.getElementById('uploadError'); - if (uploadError) { - uploadError.textContent = ''; - } + if (uploadError) uploadError.textContent = ''; const urlError = document.getElementById('urlError'); - if (urlError) { - urlError.textContent = ''; - } + if (urlError) urlError.textContent = ''; - // Reset recipe name input const recipeName = document.getElementById('recipeName'); - if (recipeName) { - recipeName.value = ''; - } + if (recipeName) recipeName.value = ''; - // Reset tags container const tagsContainer = document.getElementById('tagsContainer'); - if (tagsContainer) { - tagsContainer.innerHTML = '
No tags added
'; - } + if (tagsContainer) tagsContainer.innerHTML = '
No tags added
'; // Reset state variables this.recipeImage = null; @@ -123,11 +94,11 @@ export class ImportManager { this.missingLoras = []; this.downloadableLoRAs = []; - // Reset import mode to url/path instead of upload + // Reset import mode this.importMode = 'url'; this.toggleImportMode('url'); - // Clear selected folder and remove selection from UI + // Reset folder browser this.selectedFolder = ''; const folderBrowser = document.getElementById('importFolderBrowser'); if (folderBrowser) { @@ -135,29 +106,20 @@ export class ImportManager { f.classList.remove('selected')); } - // Clear missing LoRAs list if it exists + // Clear missing LoRAs list const missingLorasList = document.getElementById('missingLorasList'); - if (missingLorasList) { - missingLorasList.innerHTML = ''; - } + if (missingLorasList) missingLorasList.innerHTML = ''; // Reset total download size const totalSizeDisplay = document.getElementById('totalDownloadSize'); - if (totalSizeDisplay) { - totalSizeDisplay.textContent = 'Calculating...'; - } + if (totalSizeDisplay) totalSizeDisplay.textContent = 'Calculating...'; - // Remove any existing deleted LoRAs warning + // Remove warnings const deletedLorasWarning = document.getElementById('deletedLorasWarning'); - if (deletedLorasWarning) { - deletedLorasWarning.remove(); - } + if (deletedLorasWarning) deletedLorasWarning.remove(); - // Remove any existing early access warning const earlyAccessWarning = document.getElementById('earlyAccessWarning'); - if (earlyAccessWarning) { - earlyAccessWarning.remove(); - } + if (earlyAccessWarning) earlyAccessWarning.remove(); } toggleImportMode(mode) { @@ -200,433 +162,19 @@ export class ImportManager { } handleImageUpload(event) { - const file = event.target.files[0]; - const errorElement = document.getElementById('uploadError'); - - if (!file) { - return; - } - - // Validate file type - if (!file.type.match('image.*')) { - errorElement.textContent = 'Please select an image file'; - return; - } - - // Reset error - errorElement.textContent = ''; - this.recipeImage = file; - - // Auto-proceed to next step if file is selected - this.uploadAndAnalyzeImage(); + this.imageProcessor.handleFileUpload(event); } async handleUrlInput() { - const urlInput = document.getElementById('imageUrlInput'); - const errorElement = document.getElementById('urlError'); - const input = urlInput.value.trim(); - - // Validate input - if (!input) { - errorElement.textContent = 'Please enter a URL or file path'; - return; - } - - // Reset error - errorElement.textContent = ''; - - // Show loading indicator - this.loadingManager.showSimpleLoading('Processing input...'); - - try { - // Check if it's a URL or a local file path - if (input.startsWith('http://') || input.startsWith('https://')) { - // Handle as URL - await this.analyzeImageFromUrl(input); - } else { - // Handle as local file path - await this.analyzeImageFromLocalPath(input); - } - } catch (error) { - errorElement.textContent = error.message || 'Failed to process input'; - } finally { - this.loadingManager.hide(); - } - } - - async analyzeImageFromUrl(url) { - try { - // Call the API with URL data - const response = await fetch('/api/recipes/analyze-image', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ url: url }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to analyze image from URL'); - } - - // Get recipe data from response - this.recipeData = await response.json(); - - // Check if we have an error message - if (this.recipeData.error) { - throw new Error(this.recipeData.error); - } - - // Check if we have valid recipe data - if (!this.recipeData || !this.recipeData.loras || this.recipeData.loras.length === 0) { - throw new Error('No LoRA information found in this image'); - } - - // Find missing LoRAs - this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); - - // Proceed to recipe details step - this.showRecipeDetailsStep(); - - } catch (error) { - console.error('Error analyzing URL:', error); - throw error; - } - } - - // Add new method to handle local file paths - async analyzeImageFromLocalPath(path) { - try { - // Call the API with local path data - const response = await fetch('/api/recipes/analyze-local-image', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ path: path }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to load image from local path'); - } - - // Get recipe data from response - this.recipeData = await response.json(); - - // Check if we have an error message - if (this.recipeData.error) { - throw new Error(this.recipeData.error); - } - - // Check if we have valid recipe data - if (!this.recipeData || !this.recipeData.loras || this.recipeData.loras.length === 0) { - throw new Error('No LoRA information found in this image'); - } - - // Find missing LoRAs - this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); - - // Proceed to recipe details step - this.showRecipeDetailsStep(); - - } catch (error) { - console.error('Error analyzing local path:', error); - throw error; - } + await this.imageProcessor.handleUrlInput(); } async uploadAndAnalyzeImage() { - if (!this.recipeImage) { - showToast('Please select an image first', 'error'); - return; - } - - try { - this.loadingManager.showSimpleLoading('Analyzing image metadata...'); - - // Create form data for upload - const formData = new FormData(); - formData.append('image', this.recipeImage); - - // Upload image for analysis - const response = await fetch('/api/recipes/analyze-image', { - method: 'POST', - body: formData - }); - - // Get recipe data from response - this.recipeData = await response.json(); - - console.log('Recipe data:', this.recipeData); - - // Check if we have an error message - if (this.recipeData.error) { - throw new Error(this.recipeData.error); - } - - // Check if we have valid recipe data - if (!this.recipeData || !this.recipeData.loras || this.recipeData.loras.length === 0) { - throw new Error('No LoRA information found in this image'); - } - - // Store generation parameters if available - if (this.recipeData.gen_params) { - console.log('Generation parameters found:', this.recipeData.gen_params); - } - - // Find missing LoRAs - this.missingLoras = this.recipeData.loras.filter(lora => !lora.existsLocally); - - // Proceed to recipe details step - this.showRecipeDetailsStep(); - - } catch (error) { - document.getElementById('uploadError').textContent = error.message; - } finally { - this.loadingManager.hide(); - } + await this.imageProcessor.uploadAndAnalyzeImage(); } showRecipeDetailsStep() { - this.showStep('detailsStep'); - - // Set default recipe name from prompt or image filename - const recipeName = document.getElementById('recipeName'); - - // Check if we have recipe metadata from a shared recipe - if (this.recipeData && this.recipeData.from_recipe_metadata) { - // Use title from recipe metadata - if (this.recipeData.title) { - recipeName.value = this.recipeData.title; - this.recipeName = this.recipeData.title; - } - - // Use tags from recipe metadata - if (this.recipeData.tags && Array.isArray(this.recipeData.tags)) { - this.recipeTags = [...this.recipeData.tags]; - this.updateTagsDisplay(); - } - } else if (this.recipeData && this.recipeData.gen_params && this.recipeData.gen_params.prompt) { - // Use the first 10 words from the prompt as the default recipe name - const promptWords = this.recipeData.gen_params.prompt.split(' '); - const truncatedPrompt = promptWords.slice(0, 10).join(' '); - recipeName.value = truncatedPrompt; - this.recipeName = truncatedPrompt; - - // Set up click handler to select all text for easy editing - if (!recipeName.hasSelectAllHandler) { - recipeName.addEventListener('click', function() { - this.select(); - }); - recipeName.hasSelectAllHandler = true; - } - } else if (this.recipeImage && !recipeName.value) { - // Fallback to image filename if no prompt is available - const fileName = this.recipeImage.name.split('.')[0]; - recipeName.value = fileName; - this.recipeName = fileName; - } - - // Always set up click handler for easy editing if not already set - if (!recipeName.hasSelectAllHandler) { - recipeName.addEventListener('click', function() { - this.select(); - }); - recipeName.hasSelectAllHandler = true; - } - - // Display the uploaded image in the preview - const imagePreview = document.getElementById('recipeImagePreview'); - if (imagePreview) { - if (this.recipeImage) { - // For file upload mode - const reader = new FileReader(); - reader.onload = (e) => { - imagePreview.innerHTML = `Recipe preview`; - }; - reader.readAsDataURL(this.recipeImage); - } else if (this.recipeData && this.recipeData.image_base64) { - // For URL mode - use the base64 image data returned from the backend - imagePreview.innerHTML = `Recipe preview`; - } else if (this.importMode === 'url') { - // Fallback for URL mode if no base64 data - const urlInput = document.getElementById('imageUrlInput'); - if (urlInput && urlInput.value) { - imagePreview.innerHTML = `Recipe preview`; - } - } - } - - // Update LoRA count information - const totalLoras = this.recipeData.loras.length; - const existingLoras = this.recipeData.loras.filter(lora => lora.existsLocally).length; - const loraCountInfo = document.getElementById('loraCountInfo'); - if (loraCountInfo) { - loraCountInfo.textContent = `(${existingLoras}/${totalLoras} in library)`; - } - - // Display LoRAs list - const lorasList = document.getElementById('lorasList'); - if (lorasList) { - lorasList.innerHTML = this.recipeData.loras.map(lora => { - const existsLocally = lora.existsLocally; - const isDeleted = lora.isDeleted; - const isEarlyAccess = lora.isEarlyAccess; - const localPath = lora.localPath || ''; - - // Create status badge based on LoRA status - let statusBadge; - if (isDeleted) { - statusBadge = `
- Deleted from Civitai -
`; - } else { - statusBadge = existsLocally ? - `
- In Library -
${localPath}
-
` : - `
- Not in Library -
`; - } - - // Early access badge (shown additionally with other badges) - let earlyAccessBadge = ''; - if (isEarlyAccess) { - // Format the early access end date if available - let earlyAccessInfo = 'This LoRA requires early access payment to download.'; - if (lora.earlyAccessEndsAt) { - try { - const endDate = new Date(lora.earlyAccessEndsAt); - const formattedDate = endDate.toLocaleDateString(); - earlyAccessInfo += ` Early access ends on ${formattedDate}.`; - } catch (e) { - console.warn('Failed to format early access date', e); - } - } - - earlyAccessBadge = `
- Early Access -
${earlyAccessInfo} Verify that you have purchased early access before downloading.
-
`; - } - - // Format size if available - const sizeDisplay = lora.size ? - `
${this.formatFileSize(lora.size)}
` : ''; - - return ` -
-
- LoRA preview -
-
-
-

${lora.name}

-
- ${statusBadge} - ${earlyAccessBadge} -
-
- ${lora.version ? `
${lora.version}
` : ''} -
- ${lora.baseModel ? `
${lora.baseModel}
` : ''} - ${sizeDisplay} -
Weight: ${lora.weight || 1.0}
-
-
-
- `; - }).join(''); - } - - // Check for early access loras and show warning if any exist - const earlyAccessLoras = this.recipeData.loras.filter(lora => - lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted); - if (earlyAccessLoras.length > 0) { - // Show a warning about early access loras - const warningMessage = ` -
-
-
-
${earlyAccessLoras.length} LoRA(s) require Early Access
-
- These LoRAs require a payment to access. Download will fail if you haven't purchased access. - You may need to log in to your Civitai account in browser settings. -
-
-
- `; - - // Show the warning message - const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); - if (buttonsContainer) { - // Remove existing warning if any - const existingWarning = document.getElementById('earlyAccessWarning'); - if (existingWarning) { - existingWarning.remove(); - } - - // Add new warning - const warningContainer = document.createElement('div'); - warningContainer.id = 'earlyAccessWarning'; - warningContainer.innerHTML = warningMessage; - buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); - } - } - - // Update Next button state based on missing LoRAs - this.updateNextButtonState(); - } - - updateNextButtonState() { - const nextButton = document.querySelector('#detailsStep .primary-btn'); - if (!nextButton) return; - - // Always clean up previous warnings first - const existingWarning = document.getElementById('deletedLorasWarning'); - if (existingWarning) { - existingWarning.remove(); - } - - // Count deleted LoRAs - const deletedLoras = this.recipeData.loras.filter(lora => lora.isDeleted).length; - - // If we have deleted LoRAs, show a warning and update button text - if (deletedLoras > 0) { - // Create a new warning container above the buttons - const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode; - const warningContainer = document.createElement('div'); - warningContainer.id = 'deletedLorasWarning'; - warningContainer.className = 'deleted-loras-warning'; - - // Create warning message - warningContainer.innerHTML = ` -
-
-
${deletedLoras} LoRA(s) have been deleted from Civitai
-
These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.
-
- `; - - // Insert before the buttons container - buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); - } - - // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" - // Otherwise show "Save Recipe" - const missingNotDeleted = this.recipeData.loras.filter( - lora => !lora.existsLocally && !lora.isDeleted - ).length; - - if (missingNotDeleted > 0) { - nextButton.textContent = 'Download Missing LoRAs'; - } else { - nextButton.textContent = 'Save Recipe'; - } + this.recipeDataManager.showRecipeDetailsStep(); } handleRecipeNameChange(event) { @@ -634,649 +182,52 @@ export class ImportManager { } addTag() { - const tagInput = document.getElementById('tagInput'); - const tag = tagInput.value.trim(); - - if (!tag) return; - - if (!this.recipeTags.includes(tag)) { - this.recipeTags.push(tag); - this.updateTagsDisplay(); - } - - tagInput.value = ''; + this.recipeDataManager.addTag(); } removeTag(tag) { - this.recipeTags = this.recipeTags.filter(t => t !== tag); - this.updateTagsDisplay(); - } - - updateTagsDisplay() { - const tagsContainer = document.getElementById('tagsContainer'); - - if (this.recipeTags.length === 0) { - tagsContainer.innerHTML = '
No tags added
'; - return; - } - - tagsContainer.innerHTML = this.recipeTags.map(tag => ` -
- ${tag} - -
- `).join(''); + this.recipeDataManager.removeTag(tag); } proceedFromDetails() { - // Validate recipe name - if (!this.recipeName) { - showToast('Please enter a recipe name', 'error'); - return; - } - - // Automatically mark all deleted LoRAs as excluded - if (this.recipeData && this.recipeData.loras) { - this.recipeData.loras.forEach(lora => { - if (lora.isDeleted) { - lora.exclude = true; - } - }); - } - - // Update missing LoRAs list to exclude deleted LoRAs - this.missingLoras = this.recipeData.loras.filter(lora => - !lora.existsLocally && !lora.isDeleted); - - // Check for early access loras and show warning if any exist - const earlyAccessLoras = this.missingLoras.filter(lora => lora.isEarlyAccess); - if (earlyAccessLoras.length > 0) { - // Show a warning about early access loras - const warningMessage = ` -
-
-
-
${earlyAccessLoras.length} LoRA(s) require Early Access
-
- These LoRAs require a payment to access. Download will fail if you haven't purchased access. - You may need to log in to your Civitai account in browser settings. -
-
-
- `; - - // Show the warning message - const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); - if (buttonsContainer) { - // Remove existing warning if any - const existingWarning = document.getElementById('earlyAccessWarning'); - if (existingWarning) { - existingWarning.remove(); - } - - // Add new warning - const warningContainer = document.createElement('div'); - warningContainer.id = 'earlyAccessWarning'; - warningContainer.innerHTML = warningMessage; - buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); - } - } - - // If we have downloadable missing LoRAs, go to location step - if (this.missingLoras.length > 0) { - // Store only downloadable LoRAs for the download step - this.downloadableLoRAs = this.missingLoras; - this.proceedToLocation(); - } else { - // Otherwise, save the recipe directly - this.saveRecipe(); - } + this.recipeDataManager.proceedFromDetails(); } async proceedToLocation() { - - // Show the location step with special handling - this.showStep('locationStep'); - - // Double-check after a short delay to ensure the step is visible - setTimeout(() => { - const locationStep = document.getElementById('locationStep'); - if (locationStep.style.display !== 'block' || - window.getComputedStyle(locationStep).display !== 'block') { - // Force display again - locationStep.style.display = 'block'; - - // If still not visible, try with injected style - if (window.getComputedStyle(locationStep).display !== 'block') { - this.injectedStyles = document.createElement('style'); - this.injectedStyles.innerHTML = ` - #locationStep { - display: block !important; - opacity: 1 !important; - visibility: visible !important; - } - `; - document.head.appendChild(this.injectedStyles); - } - } - }, 100); - - try { - // Display missing LoRAs that will be downloaded - const missingLorasList = document.getElementById('missingLorasList'); - if (missingLorasList && this.downloadableLoRAs.length > 0) { - // Calculate total size - const totalSize = this.downloadableLoRAs.reduce((sum, lora) => { - return sum + (lora.size ? parseInt(lora.size) : 0); - }, 0); - - // Update total size display - const totalSizeDisplay = document.getElementById('totalDownloadSize'); - if (totalSizeDisplay) { - totalSizeDisplay.textContent = this.formatFileSize(totalSize); - } - - // Update header to include count of missing LoRAs - const missingLorasHeader = document.querySelector('.summary-header h3'); - if (missingLorasHeader) { - missingLorasHeader.innerHTML = `Missing LoRAs (${this.downloadableLoRAs.length}) ${this.formatFileSize(totalSize)}`; - } - - // Generate missing LoRAs list - missingLorasList.innerHTML = this.downloadableLoRAs.map(lora => { - const sizeDisplay = lora.size ? this.formatFileSize(lora.size) : 'Unknown size'; - const baseModel = lora.baseModel ? `${lora.baseModel}` : ''; - const isEarlyAccess = lora.isEarlyAccess; - - // Early access badge - let earlyAccessBadge = ''; - if (isEarlyAccess) { - earlyAccessBadge = ` - Early Access - `; - } - - return ` -
-
-
${lora.name}
- ${baseModel} - ${earlyAccessBadge} -
-
${sizeDisplay}
-
- `; - }).join(''); - - // Set up toggle for missing LoRAs list - const toggleBtn = document.getElementById('toggleMissingLorasList'); - if (toggleBtn) { - toggleBtn.addEventListener('click', () => { - missingLorasList.classList.toggle('collapsed'); - const icon = toggleBtn.querySelector('i'); - if (icon) { - icon.classList.toggle('fa-chevron-down'); - icon.classList.toggle('fa-chevron-up'); - } - }); - } - } - - // Fetch LoRA roots - const rootsResponse = await fetch('/api/lora-roots'); - if (!rootsResponse.ok) { - throw new Error(`Failed to fetch LoRA roots: ${rootsResponse.status}`); - } - - const rootsData = await rootsResponse.json(); - const loraRoot = document.getElementById('importLoraRoot'); - if (loraRoot) { - loraRoot.innerHTML = rootsData.roots.map(root => - `` - ).join(''); - - // Set default lora root if available - const defaultRoot = getStorageItem('settings', {}).default_loras_root; - if (defaultRoot && rootsData.roots.includes(defaultRoot)) { - loraRoot.value = defaultRoot; - } - } - - // Fetch folders - const foldersResponse = await fetch('/api/folders'); - if (!foldersResponse.ok) { - throw new Error(`Failed to fetch folders: ${foldersResponse.status}`); - } - - const foldersData = await foldersResponse.json(); - const folderBrowser = document.getElementById('importFolderBrowser'); - if (folderBrowser) { - folderBrowser.innerHTML = foldersData.folders.map(folder => - folder ? `
${folder}
` : '' - ).join(''); - } - - // Initialize folder browser after loading data - this.initializeFolderBrowser(); - } catch (error) { - console.error('Error in API calls:', error); - showToast(error.message, 'error'); - } + await this.folderBrowser.proceedToLocation(); } backToUpload() { - this.showStep('uploadStep'); + this.stepManager.showStep('uploadStep'); - // Reset file input to ensure it can trigger change events again + // Reset file input const fileInput = document.getElementById('recipeImageUpload'); - if (fileInput) { - fileInput.value = ''; - } + if (fileInput) fileInput.value = ''; // Reset URL input const urlInput = document.getElementById('imageUrlInput'); - if (urlInput) { - urlInput.value = ''; - } + if (urlInput) urlInput.value = ''; - // Clear any previous error messages + // Clear error messages const uploadError = document.getElementById('uploadError'); - if (uploadError) { - uploadError.textContent = ''; - } + if (uploadError) uploadError.textContent = ''; const urlError = document.getElementById('urlError'); - if (urlError) { - urlError.textContent = ''; - } + if (urlError) urlError.textContent = ''; } backToDetails() { - this.showStep('detailsStep'); + this.stepManager.showStep('detailsStep'); } async saveRecipe() { - // Check if we're in download-only mode (for existing recipe) - const isDownloadOnly = !!this.recipeId; - - console.log("isDownloadOnly", isDownloadOnly); - - if (!isDownloadOnly && !this.recipeName) { - showToast('Please enter a recipe name', 'error'); - return; - } - - try { - // Show progress indicator - this.loadingManager.showSimpleLoading(isDownloadOnly ? 'Downloading LoRAs...' : 'Saving recipe...'); - - // Only send the complete recipe to save if not in download-only mode - if (!isDownloadOnly) { - // Create FormData object for saving recipe - const formData = new FormData(); - - // Add image data - depends on import mode - if (this.recipeImage) { - // Direct upload - formData.append('image', this.recipeImage); - } else if (this.recipeData && this.recipeData.image_base64) { - // URL mode with base64 data - formData.append('image_base64', this.recipeData.image_base64); - } else if (this.importMode === 'url') { - // Fallback for URL mode - tell backend to fetch the image again - const urlInput = document.getElementById('imageUrlInput'); - if (urlInput && urlInput.value) { - formData.append('image_url', urlInput.value); - } else { - throw new Error('No image data available'); - } - } else { - throw new Error('No image data available'); - } - - formData.append('name', this.recipeName); - formData.append('tags', JSON.stringify(this.recipeTags)); - - // Prepare complete metadata including generation parameters - const completeMetadata = { - base_model: this.recipeData.base_model || "", - loras: this.recipeData.loras || [], - gen_params: this.recipeData.gen_params || {}, - raw_metadata: this.recipeData.raw_metadata || {} - }; - - // Add source_path to metadata to track where the recipe was imported from - if (this.importMode === 'url') { - const urlInput = document.getElementById('imageUrlInput'); - console.log("urlInput.value", urlInput.value); - if (urlInput && urlInput.value) { - completeMetadata.source_path = urlInput.value; - } - } - - formData.append('metadata', JSON.stringify(completeMetadata)); - - // Send save request - const response = await fetch('/api/recipes/save', { - method: 'POST', - body: formData - }); - - const result = await response.json(); - - if (!result.success) { - // Handle save error - console.error("Failed to save recipe:", result.error); - showToast(result.error, 'error'); - // Close modal - modalManager.closeModal('importModal'); - return; - } - } - - // Check if we need to download LoRAs - let failedDownloads = 0; - if (this.downloadableLoRAs && this.downloadableLoRAs.length > 0) { - // For download, we need to validate the target path - const loraRoot = document.getElementById('importLoraRoot')?.value; - if (!loraRoot) { - throw new Error('Please select a LoRA root directory'); - } - - // Build target path - let targetPath = loraRoot; - if (this.selectedFolder) { - targetPath += '/' + this.selectedFolder; - } - - const newFolder = document.getElementById('importNewFolder')?.value?.trim(); - if (newFolder) { - targetPath += '/' + newFolder; - } - - // Set up WebSocket for progress updates - const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); - - // Show enhanced loading with progress details for multiple items - const updateProgress = this.loadingManager.showDownloadProgress(this.downloadableLoRAs.length); - - let completedDownloads = 0; - let accessFailures = 0; - let currentLoraProgress = 0; - - // Set up progress tracking for current download - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.status === 'progress') { - // Update current LoRA progress - currentLoraProgress = data.progress; - - // Get current LoRA name - const currentLora = this.downloadableLoRAs[completedDownloads + failedDownloads]; - const loraName = currentLora ? currentLora.name : ''; - - // Update progress display - updateProgress(currentLoraProgress, completedDownloads, loraName); - - // Add more detailed status messages based on progress - if (currentLoraProgress < 3) { - this.loadingManager.setStatus( - `Preparing download for LoRA ${completedDownloads + failedDownloads + 1}/${this.downloadableLoRAs.length}` - ); - } else if (currentLoraProgress === 3) { - this.loadingManager.setStatus( - `Downloaded preview for LoRA ${completedDownloads + failedDownloads + 1}/${this.downloadableLoRAs.length}` - ); - } else if (currentLoraProgress > 3 && currentLoraProgress < 100) { - this.loadingManager.setStatus( - `Downloading LoRA ${completedDownloads + failedDownloads + 1}/${this.downloadableLoRAs.length}` - ); - } else { - this.loadingManager.setStatus( - `Finalizing LoRA ${completedDownloads + failedDownloads + 1}/${this.downloadableLoRAs.length}` - ); - } - } - }; - - for (let i = 0; i < this.downloadableLoRAs.length; i++) { - const lora = this.downloadableLoRAs[i]; - - // Reset current LoRA progress for new download - currentLoraProgress = 0; - - // Initial status update for new LoRA - this.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.downloadableLoRAs.length}`); - updateProgress(0, completedDownloads, lora.name); - - try { - // Download the LoRA - const response = await fetch('/api/download-lora', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - download_url: lora.downloadUrl, - model_version_id: lora.modelVersionId, - model_hash: lora.hash, - lora_root: loraRoot, - relative_path: targetPath.replace(loraRoot + '/', '') - }) - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`Failed to download LoRA ${lora.name}: ${errorText}`); - - // Check if this is an early access error (status 401 is the key indicator) - if (response.status === 401) { - accessFailures++; - this.loadingManager.setStatus( - `Failed to download ${lora.name}: Access restricted` - ); - } - - failedDownloads++; - // Continue with next download - } else { - completedDownloads++; - - // Update progress to show completion of current LoRA - updateProgress(100, completedDownloads, ''); - - if (completedDownloads + failedDownloads < this.downloadableLoRAs.length) { - this.loadingManager.setStatus( - `Completed ${completedDownloads}/${this.downloadableLoRAs.length} LoRAs. Starting next download...` - ); - } - } - } catch (downloadError) { - console.error(`Error downloading LoRA ${lora.name}:`, downloadError); - failedDownloads++; - // Continue with next download - } - } - - // Close WebSocket - ws.close(); - - // Show appropriate completion message based on results - if (failedDownloads === 0) { - showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success'); - } else { - if (accessFailures > 0) { - showToast( - `Downloaded ${completedDownloads} of ${this.downloadableLoRAs.length} LoRAs. ${accessFailures} failed due to access restrictions. Check your API key in settings or early access status.`, - 'error' - ); - } else { - showToast(`Downloaded ${completedDownloads} of ${this.downloadableLoRAs.length} LoRAs`, 'error'); - } - } - } - - // Show success message - if (isDownloadOnly) { - if (failedDownloads === 0) { - showToast('LoRAs downloaded successfully', 'success'); - } - } else { - showToast(`Recipe "${this.recipeName}" saved successfully`, 'success'); - } - - // Close modal - modalManager.closeModal('importModal'); - - // Refresh the recipe - window.recipeManager.loadRecipes(this.recipeId); - - } catch (error) { - console.error('Error:', error); - showToast(error.message, 'error'); - } finally { - this.loadingManager.hide(); - } + await this.downloadManager.saveRecipe(); } - initializeFolderBrowser() { - const folderBrowser = document.getElementById('importFolderBrowser'); - if (!folderBrowser) return; - - // Cleanup existing handler if any - this.cleanupFolderBrowser(); - - // Create new handler - this.folderClickHandler = (event) => { - const folderItem = event.target.closest('.folder-item'); - if (!folderItem) return; - - if (folderItem.classList.contains('selected')) { - folderItem.classList.remove('selected'); - this.selectedFolder = ''; - } else { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); - folderItem.classList.add('selected'); - this.selectedFolder = folderItem.dataset.folder; - } - - // Update path display after folder selection - this.updateTargetPath(); - }; - - // Add the new handler - folderBrowser.addEventListener('click', this.folderClickHandler); - - // Add event listeners for path updates - const loraRoot = document.getElementById('importLoraRoot'); - const newFolder = document.getElementById('importNewFolder'); - - if (loraRoot) loraRoot.addEventListener('change', this.updateTargetPath); - if (newFolder) newFolder.addEventListener('input', this.updateTargetPath); - - // Update initial path - this.updateTargetPath(); - } - - cleanupFolderBrowser() { - if (this.folderClickHandler) { - const folderBrowser = document.getElementById('importFolderBrowser'); - if (folderBrowser) { - folderBrowser.removeEventListener('click', this.folderClickHandler); - this.folderClickHandler = null; - } - } - - // Remove path update listeners - const loraRoot = document.getElementById('importLoraRoot'); - const newFolder = document.getElementById('importNewFolder'); - - if (loraRoot) loraRoot.removeEventListener('change', this.updateTargetPath); - if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); - } - updateTargetPath() { - const pathDisplay = document.getElementById('importTargetPathDisplay'); - if (!pathDisplay) return; - - const loraRoot = document.getElementById('importLoraRoot')?.value || ''; - const newFolder = document.getElementById('importNewFolder')?.value?.trim() || ''; - - let fullPath = loraRoot || 'Select a LoRA root directory'; - - if (loraRoot) { - if (this.selectedFolder) { - fullPath += '/' + this.selectedFolder; - } - if (newFolder) { - fullPath += '/' + newFolder; - } - } - - pathDisplay.innerHTML = `${fullPath}`; + this.folderBrowser.updateTargetPath(); } - showStep(stepId) { - - // First, remove any injected styles to prevent conflicts - this.removeInjectedStyles(); - - // Hide all steps first - document.querySelectorAll('.import-step').forEach(step => { - step.style.display = 'none'; - }); - - // Show target step with a monitoring mechanism - const targetStep = document.getElementById(stepId); - if (targetStep) { - // Use direct style setting - targetStep.style.display = 'block'; - - // For the locationStep specifically, we need additional measures - if (stepId === 'locationStep') { - // Create a more persistent style to override any potential conflicts - this.injectedStyles = document.createElement('style'); - this.injectedStyles.innerHTML = ` - #locationStep { - display: block !important; - opacity: 1 !important; - visibility: visible !important; - } - `; - document.head.appendChild(this.injectedStyles); - - // Force layout recalculation - targetStep.offsetHeight; - - // Set up a monitor to ensure the step remains visible - setTimeout(() => { - if (targetStep.style.display !== 'block') { - targetStep.style.display = 'block'; - } - - // Check dimensions again after a short delay - const newRect = targetStep.getBoundingClientRect(); - }, 50); - } - - // Scroll modal content to top - const modalContent = document.querySelector('#importModal .modal-content'); - if (modalContent) { - modalContent.scrollTop = 0; - } - } - } - - // Add a helper method to format file sizes - formatFileSize(bytes) { - if (!bytes || isNaN(bytes)) return ''; - - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; - } - - // Add this method to ensure the modal is fully visible and initialized ensureModalVisible() { const importModal = document.getElementById('importModal'); if (!importModal) { @@ -1294,32 +245,25 @@ export class ImportManager { return true; } - // Add new method to handle downloading missing LoRAs from a recipe downloadMissingLoras(recipeData, recipeId) { // Store the recipe data and ID this.recipeData = recipeData; this.recipeId = recipeId; - // Show the location step directly + // Show the modal and go to location step this.showImportModal(recipeData, recipeId); this.proceedToLocation(); - // Update the modal title to reflect we're downloading for an existing recipe + // Update the modal title const modalTitle = document.querySelector('#importModal h2'); - if (modalTitle) { - modalTitle.textContent = 'Download Missing LoRAs'; - } + if (modalTitle) modalTitle.textContent = 'Download Missing LoRAs'; // Update the save button text const saveButton = document.querySelector('#locationStep .primary-btn'); - if (saveButton) { - saveButton.textContent = 'Download Missing LoRAs'; - } + if (saveButton) saveButton.textContent = 'Download Missing LoRAs'; - // Hide the back button since we're skipping steps + // Hide the back button const backButton = document.querySelector('#locationStep .secondary-btn'); - if (backButton) { - backButton.style.display = 'none'; - } + if (backButton) backButton.style.display = 'none'; } } diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js new file mode 100644 index 00000000..058ba9cb --- /dev/null +++ b/static/js/managers/import/DownloadManager.js @@ -0,0 +1,257 @@ +import { showToast } from '../../utils/uiHelpers.js'; + +export class DownloadManager { + constructor(importManager) { + this.importManager = importManager; + } + + async saveRecipe() { + // Check if we're in download-only mode (for existing recipe) + const isDownloadOnly = !!this.importManager.recipeId; + + if (!isDownloadOnly && !this.importManager.recipeName) { + showToast('Please enter a recipe name', 'error'); + return; + } + + try { + // Show progress indicator + this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? 'Downloading LoRAs...' : 'Saving recipe...'); + + // Only send the complete recipe to save if not in download-only mode + if (!isDownloadOnly) { + // Create FormData object for saving recipe + const formData = new FormData(); + + // Add image data - depends on import mode + if (this.importManager.recipeImage) { + // Direct upload + formData.append('image', this.importManager.recipeImage); + } else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) { + // URL mode with base64 data + formData.append('image_base64', this.importManager.recipeData.image_base64); + } else if (this.importManager.importMode === 'url') { + // Fallback for URL mode - tell backend to fetch the image again + const urlInput = document.getElementById('imageUrlInput'); + if (urlInput && urlInput.value) { + formData.append('image_url', urlInput.value); + } else { + throw new Error('No image data available'); + } + } else { + throw new Error('No image data available'); + } + + formData.append('name', this.importManager.recipeName); + formData.append('tags', JSON.stringify(this.importManager.recipeTags)); + + // Prepare complete metadata including generation parameters + const completeMetadata = { + base_model: this.importManager.recipeData.base_model || "", + loras: this.importManager.recipeData.loras || [], + gen_params: this.importManager.recipeData.gen_params || {}, + raw_metadata: this.importManager.recipeData.raw_metadata || {} + }; + + // Add source_path to metadata to track where the recipe was imported from + if (this.importManager.importMode === 'url') { + const urlInput = document.getElementById('imageUrlInput'); + console.log("urlInput.value", urlInput.value); + if (urlInput && urlInput.value) { + completeMetadata.source_path = urlInput.value; + } + } + + formData.append('metadata', JSON.stringify(completeMetadata)); + + // Send save request + const response = await fetch('/api/recipes/save', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!result.success) { + // Handle save error + console.error("Failed to save recipe:", result.error); + showToast(result.error, 'error'); + // Close modal + modalManager.closeModal('importModal'); + return; + } + } + + // Check if we need to download LoRAs + let failedDownloads = 0; + if (this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) { + await this.downloadMissingLoras(); + } + + // Show success message + if (isDownloadOnly) { + if (failedDownloads === 0) { + showToast('LoRAs downloaded successfully', 'success'); + } + } else { + showToast(`Recipe "${this.importManager.recipeName}" saved successfully`, 'success'); + } + + // Close modal + modalManager.closeModal('importModal'); + + // Refresh the recipe + window.recipeManager.loadRecipes(); + + } catch (error) { + console.error('Error:', error); + showToast(error.message, 'error'); + } finally { + this.importManager.loadingManager.hide(); + } + } + + async downloadMissingLoras() { + // For download, we need to validate the target path + const loraRoot = document.getElementById('importLoraRoot')?.value; + if (!loraRoot) { + throw new Error('Please select a LoRA root directory'); + } + + // Build target path + let targetPath = loraRoot; + if (this.importManager.selectedFolder) { + targetPath += '/' + this.importManager.selectedFolder; + } + + const newFolder = document.getElementById('importNewFolder')?.value?.trim(); + if (newFolder) { + targetPath += '/' + newFolder; + } + + // Set up WebSocket for progress updates + const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); + + // Show enhanced loading with progress details for multiple items + const updateProgress = this.importManager.loadingManager.showDownloadProgress( + this.importManager.downloadableLoRAs.length + ); + + let completedDownloads = 0; + let failedDownloads = 0; + let accessFailures = 0; + let currentLoraProgress = 0; + + // Set up progress tracking for current download + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.status === 'progress') { + // Update current LoRA progress + currentLoraProgress = data.progress; + + // Get current LoRA name + const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads]; + const loraName = currentLora ? currentLora.name : ''; + + // Update progress display + updateProgress(currentLoraProgress, completedDownloads, loraName); + + // Add more detailed status messages based on progress + if (currentLoraProgress < 3) { + this.importManager.loadingManager.setStatus( + `Preparing download for LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}` + ); + } else if (currentLoraProgress === 3) { + this.importManager.loadingManager.setStatus( + `Downloaded preview for LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}` + ); + } else if (currentLoraProgress > 3 && currentLoraProgress < 100) { + this.importManager.loadingManager.setStatus( + `Downloading LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}` + ); + } else { + this.importManager.loadingManager.setStatus( + `Finalizing LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}` + ); + } + } + }; + + for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) { + const lora = this.importManager.downloadableLoRAs[i]; + + // Reset current LoRA progress for new download + currentLoraProgress = 0; + + // Initial status update for new LoRA + this.importManager.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`); + updateProgress(0, completedDownloads, lora.name); + + try { + // Download the LoRA + const response = await fetch('/api/download-lora', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + download_url: lora.downloadUrl, + model_version_id: lora.modelVersionId, + model_hash: lora.hash, + lora_root: loraRoot, + relative_path: targetPath.replace(loraRoot + '/', '') + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`Failed to download LoRA ${lora.name}: ${errorText}`); + + // Check if this is an early access error (status 401 is the key indicator) + if (response.status === 401) { + accessFailures++; + this.importManager.loadingManager.setStatus( + `Failed to download ${lora.name}: Access restricted` + ); + } + + failedDownloads++; + // Continue with next download + } else { + completedDownloads++; + + // Update progress to show completion of current LoRA + updateProgress(100, completedDownloads, ''); + + if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) { + this.importManager.loadingManager.setStatus( + `Completed ${completedDownloads}/${this.importManager.downloadableLoRAs.length} LoRAs. Starting next download...` + ); + } + } + } catch (downloadError) { + console.error(`Error downloading LoRA ${lora.name}:`, downloadError); + failedDownloads++; + // Continue with next download + } + } + + // Close WebSocket + ws.close(); + + // Show appropriate completion message based on results + if (failedDownloads === 0) { + showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success'); + } else { + if (accessFailures > 0) { + showToast( + `Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs. ${accessFailures} failed due to access restrictions. Check your API key in settings or early access status.`, + 'error' + ); + } else { + showToast(`Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs`, 'error'); + } + } + + return failedDownloads; + } +} diff --git a/static/js/managers/import/FolderBrowser.js b/static/js/managers/import/FolderBrowser.js new file mode 100644 index 00000000..33504ee8 --- /dev/null +++ b/static/js/managers/import/FolderBrowser.js @@ -0,0 +1,220 @@ +import { showToast } from '../../utils/uiHelpers.js'; +import { getStorageItem } from '../../utils/storageHelpers.js'; + +export class FolderBrowser { + constructor(importManager) { + this.importManager = importManager; + this.folderClickHandler = null; + this.updateTargetPath = this.updateTargetPath.bind(this); + } + + async proceedToLocation() { + // Show the location step with special handling + this.importManager.stepManager.showStep('locationStep'); + + // Double-check after a short delay to ensure the step is visible + setTimeout(() => { + const locationStep = document.getElementById('locationStep'); + if (locationStep.style.display !== 'block' || + window.getComputedStyle(locationStep).display !== 'block') { + // Force display again + locationStep.style.display = 'block'; + + // If still not visible, try with injected style + if (window.getComputedStyle(locationStep).display !== 'block') { + this.importManager.stepManager.injectedStyles = document.createElement('style'); + this.importManager.stepManager.injectedStyles.innerHTML = ` + #locationStep { + display: block !important; + opacity: 1 !important; + visibility: visible !important; + } + `; + document.head.appendChild(this.importManager.stepManager.injectedStyles); + } + } + }, 100); + + try { + // Display missing LoRAs that will be downloaded + const missingLorasList = document.getElementById('missingLorasList'); + if (missingLorasList && this.importManager.downloadableLoRAs.length > 0) { + // Calculate total size + const totalSize = this.importManager.downloadableLoRAs.reduce((sum, lora) => { + return sum + (lora.size ? parseInt(lora.size) : 0); + }, 0); + + // Update total size display + const totalSizeDisplay = document.getElementById('totalDownloadSize'); + if (totalSizeDisplay) { + totalSizeDisplay.textContent = this.importManager.formatFileSize(totalSize); + } + + // Update header to include count of missing LoRAs + const missingLorasHeader = document.querySelector('.summary-header h3'); + if (missingLorasHeader) { + missingLorasHeader.innerHTML = `Missing LoRAs (${this.importManager.downloadableLoRAs.length}) ${this.importManager.formatFileSize(totalSize)}`; + } + + // Generate missing LoRAs list + missingLorasList.innerHTML = this.importManager.downloadableLoRAs.map(lora => { + const sizeDisplay = lora.size ? + this.importManager.formatFileSize(lora.size) : 'Unknown size'; + const baseModel = lora.baseModel ? + `${lora.baseModel}` : ''; + const isEarlyAccess = lora.isEarlyAccess; + + // Early access badge + let earlyAccessBadge = ''; + if (isEarlyAccess) { + earlyAccessBadge = ` + Early Access + `; + } + + return ` +
+
+
${lora.name}
+ ${baseModel} + ${earlyAccessBadge} +
+
${sizeDisplay}
+
+ `; + }).join(''); + + // Set up toggle for missing LoRAs list + const toggleBtn = document.getElementById('toggleMissingLorasList'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + missingLorasList.classList.toggle('collapsed'); + const icon = toggleBtn.querySelector('i'); + if (icon) { + icon.classList.toggle('fa-chevron-down'); + icon.classList.toggle('fa-chevron-up'); + } + }); + } + } + + // Fetch LoRA roots + const rootsResponse = await fetch('/api/lora-roots'); + if (!rootsResponse.ok) { + throw new Error(`Failed to fetch LoRA roots: ${rootsResponse.status}`); + } + + const rootsData = await rootsResponse.json(); + const loraRoot = document.getElementById('importLoraRoot'); + if (loraRoot) { + loraRoot.innerHTML = rootsData.roots.map(root => + `` + ).join(''); + + // Set default lora root if available + const defaultRoot = getStorageItem('settings', {}).default_loras_root; + if (defaultRoot && rootsData.roots.includes(defaultRoot)) { + loraRoot.value = defaultRoot; + } + } + + // Fetch folders + const foldersResponse = await fetch('/api/folders'); + if (!foldersResponse.ok) { + throw new Error(`Failed to fetch folders: ${foldersResponse.status}`); + } + + const foldersData = await foldersResponse.json(); + const folderBrowser = document.getElementById('importFolderBrowser'); + if (folderBrowser) { + folderBrowser.innerHTML = foldersData.folders.map(folder => + folder ? `
${folder}
` : '' + ).join(''); + } + + // Initialize folder browser after loading data + this.initializeFolderBrowser(); + } catch (error) { + console.error('Error in API calls:', error); + showToast(error.message, 'error'); + } + } + + initializeFolderBrowser() { + const folderBrowser = document.getElementById('importFolderBrowser'); + if (!folderBrowser) return; + + // Cleanup existing handler if any + this.cleanup(); + + // Create new handler + this.folderClickHandler = (event) => { + const folderItem = event.target.closest('.folder-item'); + if (!folderItem) return; + + if (folderItem.classList.contains('selected')) { + folderItem.classList.remove('selected'); + this.importManager.selectedFolder = ''; + } else { + folderBrowser.querySelectorAll('.folder-item').forEach(f => + f.classList.remove('selected')); + folderItem.classList.add('selected'); + this.importManager.selectedFolder = folderItem.dataset.folder; + } + + // Update path display after folder selection + this.updateTargetPath(); + }; + + // Add the new handler + folderBrowser.addEventListener('click', this.folderClickHandler); + + // Add event listeners for path updates + const loraRoot = document.getElementById('importLoraRoot'); + const newFolder = document.getElementById('importNewFolder'); + + if (loraRoot) loraRoot.addEventListener('change', this.updateTargetPath); + if (newFolder) newFolder.addEventListener('input', this.updateTargetPath); + + // Update initial path + this.updateTargetPath(); + } + + cleanup() { + if (this.folderClickHandler) { + const folderBrowser = document.getElementById('importFolderBrowser'); + if (folderBrowser) { + folderBrowser.removeEventListener('click', this.folderClickHandler); + this.folderClickHandler = null; + } + } + + // Remove path update listeners + const loraRoot = document.getElementById('importLoraRoot'); + const newFolder = document.getElementById('importNewFolder'); + + if (loraRoot) loraRoot.removeEventListener('change', this.updateTargetPath); + if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath); + } + + updateTargetPath() { + const pathDisplay = document.getElementById('importTargetPathDisplay'); + if (!pathDisplay) return; + + const loraRoot = document.getElementById('importLoraRoot')?.value || ''; + const newFolder = document.getElementById('importNewFolder')?.value?.trim() || ''; + + let fullPath = loraRoot || 'Select a LoRA root directory'; + + if (loraRoot) { + if (this.importManager.selectedFolder) { + fullPath += '/' + this.importManager.selectedFolder; + } + if (newFolder) { + fullPath += '/' + newFolder; + } + } + + pathDisplay.innerHTML = `${fullPath}`; + } +} diff --git a/static/js/managers/import/ImageProcessor.js b/static/js/managers/import/ImageProcessor.js new file mode 100644 index 00000000..be568d50 --- /dev/null +++ b/static/js/managers/import/ImageProcessor.js @@ -0,0 +1,206 @@ +import { showToast } from '../../utils/uiHelpers.js'; + +export class ImageProcessor { + constructor(importManager) { + this.importManager = importManager; + } + + handleFileUpload(event) { + const file = event.target.files[0]; + const errorElement = document.getElementById('uploadError'); + + if (!file) return; + + // Validate file type + if (!file.type.match('image.*')) { + errorElement.textContent = 'Please select an image file'; + return; + } + + // Reset error + errorElement.textContent = ''; + this.importManager.recipeImage = file; + + // Auto-proceed to next step if file is selected + this.importManager.uploadAndAnalyzeImage(); + } + + async handleUrlInput() { + const urlInput = document.getElementById('imageUrlInput'); + const errorElement = document.getElementById('urlError'); + const input = urlInput.value.trim(); + + // Validate input + if (!input) { + errorElement.textContent = 'Please enter a URL or file path'; + return; + } + + // Reset error + errorElement.textContent = ''; + + // Show loading indicator + this.importManager.loadingManager.showSimpleLoading('Processing input...'); + + try { + // Check if it's a URL or a local file path + if (input.startsWith('http://') || input.startsWith('https://')) { + // Handle as URL + await this.analyzeImageFromUrl(input); + } else { + // Handle as local file path + await this.analyzeImageFromLocalPath(input); + } + } catch (error) { + errorElement.textContent = error.message || 'Failed to process input'; + } finally { + this.importManager.loadingManager.hide(); + } + } + + async analyzeImageFromUrl(url) { + try { + // Call the API with URL data + const response = await fetch('/api/recipes/analyze-image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url: url }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to analyze image from URL'); + } + + // Get recipe data from response + this.importManager.recipeData = await response.json(); + + // Check if we have an error message + if (this.importManager.recipeData.error) { + throw new Error(this.importManager.recipeData.error); + } + + // Check if we have valid recipe data + if (!this.importManager.recipeData || + !this.importManager.recipeData.loras || + this.importManager.recipeData.loras.length === 0) { + throw new Error('No LoRA information found in this image'); + } + + // Find missing LoRAs + this.importManager.missingLoras = this.importManager.recipeData.loras.filter( + lora => !lora.existsLocally + ); + + // Proceed to recipe details step + this.importManager.showRecipeDetailsStep(); + + } catch (error) { + console.error('Error analyzing URL:', error); + throw error; + } + } + + async analyzeImageFromLocalPath(path) { + try { + // Call the API with local path data + const response = await fetch('/api/recipes/analyze-local-image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ path: path }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to load image from local path'); + } + + // Get recipe data from response + this.importManager.recipeData = await response.json(); + + // Check if we have an error message + if (this.importManager.recipeData.error) { + throw new Error(this.importManager.recipeData.error); + } + + // Check if we have valid recipe data + if (!this.importManager.recipeData || + !this.importManager.recipeData.loras || + this.importManager.recipeData.loras.length === 0) { + throw new Error('No LoRA information found in this image'); + } + + // Find missing LoRAs + this.importManager.missingLoras = this.importManager.recipeData.loras.filter( + lora => !lora.existsLocally + ); + + // Proceed to recipe details step + this.importManager.showRecipeDetailsStep(); + + } catch (error) { + console.error('Error analyzing local path:', error); + throw error; + } + } + + async uploadAndAnalyzeImage() { + if (!this.importManager.recipeImage) { + showToast('Please select an image first', 'error'); + return; + } + + try { + this.importManager.loadingManager.showSimpleLoading('Analyzing image metadata...'); + + // Create form data for upload + const formData = new FormData(); + formData.append('image', this.importManager.recipeImage); + + // Upload image for analysis + const response = await fetch('/api/recipes/analyze-image', { + method: 'POST', + body: formData + }); + + // Get recipe data from response + this.importManager.recipeData = await response.json(); + + console.log('Recipe data:', this.importManager.recipeData); + + // Check if we have an error message + if (this.importManager.recipeData.error) { + throw new Error(this.importManager.recipeData.error); + } + + // Check if we have valid recipe data + if (!this.importManager.recipeData || + !this.importManager.recipeData.loras || + this.importManager.recipeData.loras.length === 0) { + throw new Error('No LoRA information found in this image'); + } + + // Store generation parameters if available + if (this.importManager.recipeData.gen_params) { + console.log('Generation parameters found:', this.importManager.recipeData.gen_params); + } + + // Find missing LoRAs + this.importManager.missingLoras = this.importManager.recipeData.loras.filter( + lora => !lora.existsLocally + ); + + // Proceed to recipe details step + this.importManager.showRecipeDetailsStep(); + + } catch (error) { + document.getElementById('uploadError').textContent = error.message; + } finally { + this.importManager.loadingManager.hide(); + } + } +} diff --git a/static/js/managers/import/ImportStepManager.js b/static/js/managers/import/ImportStepManager.js new file mode 100644 index 00000000..80ecfbb9 --- /dev/null +++ b/static/js/managers/import/ImportStepManager.js @@ -0,0 +1,57 @@ +export class ImportStepManager { + constructor() { + this.injectedStyles = null; + } + + removeInjectedStyles() { + if (this.injectedStyles && this.injectedStyles.parentNode) { + this.injectedStyles.parentNode.removeChild(this.injectedStyles); + this.injectedStyles = null; + } + + // Reset inline styles + document.querySelectorAll('.import-step').forEach(step => { + step.style.cssText = ''; + }); + } + + showStep(stepId) { + // Remove any injected styles to prevent conflicts + this.removeInjectedStyles(); + + // Hide all steps first + document.querySelectorAll('.import-step').forEach(step => { + step.style.display = 'none'; + }); + + // Show target step with a monitoring mechanism + const targetStep = document.getElementById(stepId); + if (targetStep) { + // Use direct style setting + targetStep.style.display = 'block'; + + // For the locationStep specifically, we need additional measures + if (stepId === 'locationStep') { + // Create a more persistent style to override any potential conflicts + this.injectedStyles = document.createElement('style'); + this.injectedStyles.innerHTML = ` + #locationStep { + display: block !important; + opacity: 1 !important; + visibility: visible !important; + } + `; + document.head.appendChild(this.injectedStyles); + + // Force layout recalculation + targetStep.offsetHeight; + } + + // Scroll modal content to top + const modalContent = document.querySelector('#importModal .modal-content'); + if (modalContent) { + modalContent.scrollTop = 0; + } + } + } +} diff --git a/static/js/managers/import/RecipeDataManager.js b/static/js/managers/import/RecipeDataManager.js new file mode 100644 index 00000000..da25538c --- /dev/null +++ b/static/js/managers/import/RecipeDataManager.js @@ -0,0 +1,349 @@ +import { showToast } from '../../utils/uiHelpers.js'; + +export class RecipeDataManager { + constructor(importManager) { + this.importManager = importManager; + } + + showRecipeDetailsStep() { + this.importManager.stepManager.showStep('detailsStep'); + + // Set default recipe name from prompt or image filename + const recipeName = document.getElementById('recipeName'); + + // Check if we have recipe metadata from a shared recipe + if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) { + // Use title from recipe metadata + if (this.importManager.recipeData.title) { + recipeName.value = this.importManager.recipeData.title; + this.importManager.recipeName = this.importManager.recipeData.title; + } + + // Use tags from recipe metadata + if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) { + this.importManager.recipeTags = [...this.importManager.recipeData.tags]; + this.updateTagsDisplay(); + } + } else if (this.importManager.recipeData && + this.importManager.recipeData.gen_params && + this.importManager.recipeData.gen_params.prompt) { + // Use the first 10 words from the prompt as the default recipe name + const promptWords = this.importManager.recipeData.gen_params.prompt.split(' '); + const truncatedPrompt = promptWords.slice(0, 10).join(' '); + recipeName.value = truncatedPrompt; + this.importManager.recipeName = truncatedPrompt; + + // Set up click handler to select all text for easy editing + if (!recipeName.hasSelectAllHandler) { + recipeName.addEventListener('click', function() { + this.select(); + }); + recipeName.hasSelectAllHandler = true; + } + } else if (this.importManager.recipeImage && !recipeName.value) { + // Fallback to image filename if no prompt is available + const fileName = this.importManager.recipeImage.name.split('.')[0]; + recipeName.value = fileName; + this.importManager.recipeName = fileName; + } + + // Always set up click handler for easy editing if not already set + if (!recipeName.hasSelectAllHandler) { + recipeName.addEventListener('click', function() { + this.select(); + }); + recipeName.hasSelectAllHandler = true; + } + + // Display the uploaded image in the preview + const imagePreview = document.getElementById('recipeImagePreview'); + if (imagePreview) { + if (this.importManager.recipeImage) { + // For file upload mode + const reader = new FileReader(); + reader.onload = (e) => { + imagePreview.innerHTML = `Recipe preview`; + }; + reader.readAsDataURL(this.importManager.recipeImage); + } else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) { + // For URL mode - use the base64 image data returned from the backend + imagePreview.innerHTML = `Recipe preview`; + } else if (this.importManager.importMode === 'url') { + // Fallback for URL mode if no base64 data + const urlInput = document.getElementById('imageUrlInput'); + if (urlInput && urlInput.value) { + imagePreview.innerHTML = `Recipe preview`; + } + } + } + + // Update LoRA count information + const totalLoras = this.importManager.recipeData.loras.length; + const existingLoras = this.importManager.recipeData.loras.filter(lora => lora.existsLocally).length; + const loraCountInfo = document.getElementById('loraCountInfo'); + if (loraCountInfo) { + loraCountInfo.textContent = `(${existingLoras}/${totalLoras} in library)`; + } + + // Display LoRAs list + const lorasList = document.getElementById('lorasList'); + if (lorasList) { + lorasList.innerHTML = this.importManager.recipeData.loras.map(lora => { + const existsLocally = lora.existsLocally; + const isDeleted = lora.isDeleted; + const isEarlyAccess = lora.isEarlyAccess; + const localPath = lora.localPath || ''; + + // Create status badge based on LoRA status + let statusBadge; + if (isDeleted) { + statusBadge = `
+ Deleted from Civitai +
`; + } else { + statusBadge = existsLocally ? + `
+ In Library +
${localPath}
+
` : + `
+ Not in Library +
`; + } + + // Early access badge (shown additionally with other badges) + let earlyAccessBadge = ''; + if (isEarlyAccess) { + // Format the early access end date if available + let earlyAccessInfo = 'This LoRA requires early access payment to download.'; + if (lora.earlyAccessEndsAt) { + try { + const endDate = new Date(lora.earlyAccessEndsAt); + const formattedDate = endDate.toLocaleDateString(); + earlyAccessInfo += ` Early access ends on ${formattedDate}.`; + } catch (e) { + console.warn('Failed to format early access date', e); + } + } + + earlyAccessBadge = `
+ Early Access +
${earlyAccessInfo} Verify that you have purchased early access before downloading.
+
`; + } + + // Format size if available + const sizeDisplay = lora.size ? + `
${this.importManager.formatFileSize(lora.size)}
` : ''; + + return ` +
+
+ LoRA preview +
+
+
+

${lora.name}

+
+ ${statusBadge} + ${earlyAccessBadge} +
+
+ ${lora.version ? `
${lora.version}
` : ''} +
+ ${lora.baseModel ? `
${lora.baseModel}
` : ''} + ${sizeDisplay} +
Weight: ${lora.weight || 1.0}
+
+
+
+ `; + }).join(''); + } + + // Check for early access loras and show warning if any exist + const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora => + lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted); + if (earlyAccessLoras.length > 0) { + // Show a warning about early access loras + const warningMessage = ` +
+
+
+
${earlyAccessLoras.length} LoRA(s) require Early Access
+
+ These LoRAs require a payment to access. Download will fail if you haven't purchased access. + You may need to log in to your Civitai account in browser settings. +
+
+
+ `; + + // Show the warning message + const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); + if (buttonsContainer) { + // Remove existing warning if any + const existingWarning = document.getElementById('earlyAccessWarning'); + if (existingWarning) { + existingWarning.remove(); + } + + // Add new warning + const warningContainer = document.createElement('div'); + warningContainer.id = 'earlyAccessWarning'; + warningContainer.innerHTML = warningMessage; + buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); + } + } + + // Update Next button state based on missing LoRAs + this.updateNextButtonState(); + } + + updateNextButtonState() { + const nextButton = document.querySelector('#detailsStep .primary-btn'); + if (!nextButton) return; + + // Always clean up previous warnings first + const existingWarning = document.getElementById('deletedLorasWarning'); + if (existingWarning) { + existingWarning.remove(); + } + + // Count deleted LoRAs + const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length; + + // If we have deleted LoRAs, show a warning and update button text + if (deletedLoras > 0) { + // Create a new warning container above the buttons + const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode; + const warningContainer = document.createElement('div'); + warningContainer.id = 'deletedLorasWarning'; + warningContainer.className = 'deleted-loras-warning'; + + // Create warning message + warningContainer.innerHTML = ` +
+
+
${deletedLoras} LoRA(s) have been deleted from Civitai
+
These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.
+
+ `; + + // Insert before the buttons container + buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); + } + + // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" + // Otherwise show "Save Recipe" + const missingNotDeleted = this.importManager.recipeData.loras.filter( + lora => !lora.existsLocally && !lora.isDeleted + ).length; + + if (missingNotDeleted > 0) { + nextButton.textContent = 'Download Missing LoRAs'; + } else { + nextButton.textContent = 'Save Recipe'; + } + } + + addTag() { + const tagInput = document.getElementById('tagInput'); + const tag = tagInput.value.trim(); + + if (!tag) return; + + if (!this.importManager.recipeTags.includes(tag)) { + this.importManager.recipeTags.push(tag); + this.updateTagsDisplay(); + } + + tagInput.value = ''; + } + + removeTag(tag) { + this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag); + this.updateTagsDisplay(); + } + + updateTagsDisplay() { + const tagsContainer = document.getElementById('tagsContainer'); + + if (this.importManager.recipeTags.length === 0) { + tagsContainer.innerHTML = '
No tags added
'; + return; + } + + tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => ` +
+ ${tag} + +
+ `).join(''); + } + + proceedFromDetails() { + // Validate recipe name + if (!this.importManager.recipeName) { + showToast('Please enter a recipe name', 'error'); + return; + } + + // Automatically mark all deleted LoRAs as excluded + if (this.importManager.recipeData && this.importManager.recipeData.loras) { + this.importManager.recipeData.loras.forEach(lora => { + if (lora.isDeleted) { + lora.exclude = true; + } + }); + } + + // Update missing LoRAs list to exclude deleted LoRAs + this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora => + !lora.existsLocally && !lora.isDeleted); + + // Check for early access loras and show warning if any exist + const earlyAccessLoras = this.importManager.missingLoras.filter(lora => lora.isEarlyAccess); + if (earlyAccessLoras.length > 0) { + // Show a warning about early access loras + const warningMessage = ` +
+
+
+
${earlyAccessLoras.length} LoRA(s) require Early Access
+
+ These LoRAs require a payment to access. Download will fail if you haven't purchased access. + You may need to log in to your Civitai account in browser settings. +
+
+
+ `; + + // Show the warning message + const buttonsContainer = document.querySelector('#detailsStep .modal-actions'); + if (buttonsContainer) { + // Remove existing warning if any + const existingWarning = document.getElementById('earlyAccessWarning'); + if (existingWarning) { + existingWarning.remove(); + } + + // Add new warning + const warningContainer = document.createElement('div'); + warningContainer.id = 'earlyAccessWarning'; + warningContainer.innerHTML = warningMessage; + buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); + } + } + + // If we have downloadable missing LoRAs, go to location step + if (this.importManager.missingLoras.length > 0) { + // Store only downloadable LoRAs for the download step + this.importManager.downloadableLoRAs = this.importManager.missingLoras; + this.importManager.proceedToLocation(); + } else { + // Otherwise, save the recipe directly + this.importManager.saveRecipe(); + } + } +} diff --git a/static/js/utils/formatters.js b/static/js/utils/formatters.js new file mode 100644 index 00000000..00c17213 --- /dev/null +++ b/static/js/utils/formatters.js @@ -0,0 +1,12 @@ +/** + * Format a file size in bytes to a human-readable string + * @param {number} bytes - The size in bytes + * @returns {string} Formatted size string (e.g., "1.5 MB") + */ +export function formatFileSize(bytes) { + if (!bytes || isNaN(bytes)) return ''; + + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; +} From 92fdc16fe6b45fb9826c07e9ddaa756f9efad4a8 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 8 May 2025 16:17:52 +0800 Subject: [PATCH 5/8] feat(modals): implement duplicate delete confirmation modal and enhance deletion workflow --- static/js/components/DuplicatesManager.js | 21 ++++++++++++++++++--- static/js/managers/ModalManager.js | 14 +++++++++++++- static/js/recipes.js | 4 ++++ templates/components/modals.html | 15 +++++++++++++++ templates/recipes.html | 2 +- 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js index 63713e4b..66fa1f33 100644 --- a/static/js/components/DuplicatesManager.js +++ b/static/js/components/DuplicatesManager.js @@ -335,13 +335,28 @@ export class DuplicatesManager { } try { - // Show confirmation dialog - if (!confirm(`Are you sure you want to delete ${this.selectedForDeletion.size} selected recipes?`)) { - return; + // Show the delete confirmation modal instead of a simple confirm + const duplicateDeleteCount = document.getElementById('duplicateDeleteCount'); + if (duplicateDeleteCount) { + duplicateDeleteCount.textContent = this.selectedForDeletion.size; } + // Use the modal manager to show the confirmation modal + modalManager.showModal('duplicateDeleteModal'); + } catch (error) { + console.error('Error preparing delete:', error); + showToast('Error: ' + error.message, 'error'); + } + } + + // Add new method to execute deletion after confirmation + async confirmDeleteDuplicates() { + try { document.body.classList.add('loading'); + // Close the modal + modalManager.closeModal('duplicateDeleteModal'); + // Prepare recipe IDs for deletion const recipeIds = Array.from(this.selectedForDeletion); diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 8b1d5b6d..3b21c78f 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -158,6 +158,18 @@ export class ModalManager { }); } + // Add duplicateDeleteModal registration + const duplicateDeleteModal = document.getElementById('duplicateDeleteModal'); + if (duplicateDeleteModal) { + this.registerModal('duplicateDeleteModal', { + element: duplicateDeleteModal, + onClose: () => { + this.getModal('duplicateDeleteModal').element.classList.remove('show'); + document.body.classList.remove('modal-open'); + } + }); + } + // Set up event listeners for modal toggles const supportToggle = document.getElementById('supportToggleBtn'); if (supportToggle) { @@ -221,7 +233,7 @@ export class ModalManager { // Store current scroll position before showing modal this.scrollPosition = window.scrollY; - if (id === 'deleteModal' || id === 'excludeModal') { + if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal') { modal.element.classList.add('show'); } else { modal.element.style.display = 'block'; diff --git a/static/js/recipes.js b/static/js/recipes.js index 0bca2aca..8dc6dc80 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -390,6 +390,10 @@ class RecipeManager { deleteSelectedDuplicates() { this.duplicatesManager.deleteSelectedDuplicates(); } + + confirmDeleteDuplicates() { + this.duplicatesManager.confirmDeleteDuplicates(); + } exitDuplicateMode() { this.duplicatesManager.exitDuplicateMode(); diff --git a/templates/components/modals.html b/templates/components/modals.html index 0f822086..d046a27d 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -24,6 +24,21 @@
+ + + + `; + + // Show the duplicate container + duplicateContainer.style.display = 'block'; + + // Initialize deletion tracking if not already done + if (!this.importManager.recipesToDelete) { + this.importManager.recipesToDelete = []; + } + } else { + // No duplicates, hide the container if it exists + const duplicateContainer = document.getElementById('duplicateRecipesContainer'); + if (duplicateContainer) { + duplicateContainer.style.display = 'none'; + } + + // Reset deletion tracking + this.importManager.duplicateRecipes = []; + this.importManager.recipesToDelete = []; + } + } + + createDuplicateContainer() { + // Find where to insert the duplicate container + const lorasListContainer = document.querySelector('.input-group:has(#lorasList)'); + + if (!lorasListContainer) return null; + + // Create container + const duplicateContainer = document.createElement('div'); + duplicateContainer.id = 'duplicateRecipesContainer'; + duplicateContainer.className = 'duplicate-recipes-container'; + + // Insert before the LoRA list + lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer); + + return duplicateContainer; + } + updateNextButtonState() { const nextButton = document.querySelector('#detailsStep .primary-btn'); - if (!nextButton) return; + const actionsContainer = document.querySelector('#detailsStep .modal-actions'); + if (!nextButton || !actionsContainer) return; - // Always clean up previous warnings first + // Always clean up previous warnings and buttons first const existingWarning = document.getElementById('deletedLorasWarning'); if (existingWarning) { existingWarning.remove(); } + // Remove any existing "import anyway" button + const importAnywayBtn = document.getElementById('importAnywayBtn'); + if (importAnywayBtn) { + importAnywayBtn.remove(); + } + // Count deleted LoRAs const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length; - // If we have deleted LoRAs, show a warning and update button text + // If we have deleted LoRAs, show a warning if (deletedLoras > 0) { // Create a new warning container above the buttons const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode; @@ -233,17 +343,60 @@ export class RecipeDataManager { // Insert before the buttons container buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer); } - + + // Check for duplicates to adjust button actions + const hasDuplicates = this.importManager.duplicateRecipes && + this.importManager.duplicateRecipes.length > 0; + // If we have missing LoRAs (not deleted), show "Download Missing LoRAs" - // Otherwise show "Save Recipe" + // Otherwise show appropriate action based on duplicates const missingNotDeleted = this.importManager.recipeData.loras.filter( lora => !lora.existsLocally && !lora.isDeleted ).length; - if (missingNotDeleted > 0) { - nextButton.textContent = 'Download Missing LoRAs'; + // All LoRAs exist locally + const allLorasExist = missingNotDeleted === 0; + + if (hasDuplicates) { + if (missingNotDeleted > 0) { + // We have both duplicates and missing LoRAs + nextButton.textContent = 'Download Missing LoRAs'; + + // Add "Import Anyway" as a secondary option + const importAnywayButton = document.createElement('button'); + importAnywayButton.id = 'importAnywayBtn'; + importAnywayButton.className = 'secondary-btn'; + importAnywayButton.innerHTML = ' Import as New Recipe'; + importAnywayButton.onclick = () => this.importManager.importRecipeAnyway(); + + // Insert after the back button + const backButton = document.querySelector('#detailsStep .secondary-btn'); + actionsContainer.insertBefore(importAnywayButton, backButton.nextSibling); + } else { + // All LoRAs exist locally, but we have duplicates + nextButton.textContent = 'Replace Existing Recipe'; + nextButton.classList.add('warning-btn'); + + // Add "Import as New" as a secondary option + const importAsNewButton = document.createElement('button'); + importAsNewButton.id = 'importAnywayBtn'; + importAsNewButton.className = 'secondary-btn'; + importAsNewButton.innerHTML = ' Import as New Recipe'; + importAsNewButton.onclick = () => this.importManager.importRecipeAnyway(); + + // Insert after the back button + const backButton = document.querySelector('#detailsStep .secondary-btn'); + actionsContainer.insertBefore(importAsNewButton, backButton.nextSibling); + } } else { - nextButton.textContent = 'Save Recipe'; + // No duplicates, standard behavior + nextButton.classList.remove('warning-btn'); + + if (missingNotDeleted > 0) { + nextButton.textContent = 'Download Missing LoRAs'; + } else { + nextButton.textContent = 'Save Recipe'; + } } } @@ -298,10 +451,24 @@ export class RecipeDataManager { }); } + // Process any recipes marked for deletion + if (this.importManager.recipesToDelete && this.importManager.recipesToDelete.length > 0) { + this.deleteMarkedRecipes(); + } + // Update missing LoRAs list to exclude deleted LoRAs this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora => !lora.existsLocally && !lora.isDeleted); - + + // Check if we're replacing a duplicate or proceeding normally + const hasDuplicates = this.importManager.duplicateRecipes && + this.importManager.duplicateRecipes.length > 0; + + // Store replacement flag in importManager + this.importManager.isReplacingDuplicate = hasDuplicates && + this.importManager.missingLoras.length === 0 && + !this.importManager.importAsNew; + // Check for early access loras and show warning if any exist const earlyAccessLoras = this.importManager.missingLoras.filter(lora => lora.isEarlyAccess); if (earlyAccessLoras.length > 0) { @@ -346,4 +513,46 @@ export class RecipeDataManager { this.importManager.saveRecipe(); } } + + async deleteMarkedRecipes() { + if (!this.importManager.recipesToDelete || this.importManager.recipesToDelete.length === 0) { + return; + } + + try { + // Show loading indicator + this.importManager.loadingManager.showSimpleLoading('Deleting marked recipes...'); + + // Call API to delete recipes + const response = await fetch('/api/recipes/bulk-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + recipe_ids: this.importManager.recipesToDelete + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to delete recipes'); + } + + const result = await response.json(); + + // Show success message + const deletedCount = this.importManager.recipesToDelete.length; + showToast(`Successfully deleted ${deletedCount} ${deletedCount === 1 ? 'recipe' : 'recipes'}`, 'success'); + + // Reset the delete list + this.importManager.recipesToDelete = []; + + } catch (error) { + console.error('Error deleting recipes:', error); + showToast('Failed to delete recipes: ' + error.message, 'error'); + } finally { + this.importManager.loadingManager.hide(); + } + } } diff --git a/templates/components/import_modal.html b/templates/components/import_modal.html index 699494eb..7f9024e1 100644 --- a/templates/components/import_modal.html +++ b/templates/components/import_modal.html @@ -78,6 +78,11 @@ + + +
From e33da502785caab49d08c3c4f9a402675fd2997e Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 8 May 2025 18:33:19 +0800 Subject: [PATCH 8/8] refactor: update duplicate recipe management; simplify UI and remove deprecated functions --- static/css/components/import-modal.css | 173 +++++++-------- static/js/managers/ImportManager.js | 42 +--- .../js/managers/import/RecipeDataManager.js | 202 ++++-------------- templates/components/import_modal.html | 10 +- 4 files changed, 128 insertions(+), 299 deletions(-) diff --git a/static/css/components/import-modal.css b/static/css/components/import-modal.css index 112c5d60..86bbf33a 100644 --- a/static/css/components/import-modal.css +++ b/static/css/components/import-modal.css @@ -291,7 +291,7 @@ gap: 8px; padding: var(--space-1); border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); + border-radius: var (--border-radius-sm); background: var(--lora-surface); } @@ -752,14 +752,14 @@ align-items: flex-start; gap: 12px; padding: 12px 16px; - background: oklch(var(--lora-accent) / 0.1); - border: 1px solid var(--lora-accent); + background: oklch(var(--lora-warning) / 0.1); + border: 1px solid var(--lora-warning); border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0; color: var(--text-color); } .duplicate-warning .warning-icon { - color: var(--lora-accent); + color: var(--lora-warning); font-size: 1.2em; padding-top: 2px; } @@ -776,59 +776,67 @@ .duplicate-warning .warning-text { font-size: 0.9em; line-height: 1.4; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.toggle-duplicates-btn { + background: none; + border: none; + color: var(--lora-warning); + cursor: pointer; + font-size: 0.9em; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--border-radius-xs); +} + +.toggle-duplicates-btn:hover { + background: oklch(var(--lora-warning) / 0.1); } .duplicate-recipes-list { - max-height: 250px; - overflow-y: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; + padding: 16px; border: 1px solid var(--border-color); border-top: none; border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm); background: var(--bg-color); + max-height: 300px; + overflow-y: auto; + transition: max-height 0.3s ease, padding 0.3s ease; } -.duplicate-recipe-item { - display: flex; - gap: var(--space-2); - padding: var(--space-2); - border-bottom: 1px solid var(--border-color); +.duplicate-recipes-list.collapsed { + max-height: 0; + padding: 0 16px; + overflow: hidden; +} + +.duplicate-recipe-card { position: relative; - transition: background-color 0.2s; + border-radius: var(--border-radius-sm); + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease; } -.duplicate-recipe-item:last-child { - border-bottom: none; -} - -.duplicate-recipe-item:hover { - background: var(--lora-surface); -} - -.duplicate-recipe-item.marked-for-deletion { - background: rgba(var(--lora-error-rgb), 0.1); - opacity: 0.8; -} - -.duplicate-recipe-item.marked-for-deletion::before { - content: 'Will be deleted'; - position: absolute; - right: 10px; - top: 5px; - background: var(--lora-error); - color: white; - padding: 2px 8px; - border-radius: var(--border-radius-xs); - font-size: 0.8em; +.duplicate-recipe-card:hover { + transform: translateY(-2px); } .duplicate-recipe-preview { - width: 80px; - height: 80px; - flex-shrink: 0; - border-radius: var(--border-radius-xs); - overflow: hidden; + width: 100%; + position: relative; + aspect-ratio: 2/3; background: var(--bg-color); - border: 1px solid var(--border-color); } .duplicate-recipe-preview img { @@ -837,18 +845,17 @@ object-fit: cover; } -.duplicate-recipe-content { - display: flex; - flex-direction: column; - gap: 8px; - flex: 1; - min-width: 0; -} - .duplicate-recipe-title { - font-weight: 500; - font-size: 1em; - margin-bottom: 4px; + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 8px; + background: rgba(0, 0, 0, 0.7); + color: white; + font-size: 0.85em; + line-height: 1.3; + max-height: 50%; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; @@ -856,67 +863,31 @@ -webkit-box-orient: vertical; } -.duplicate-recipe-info { +.duplicate-recipe-details { + padding: 8px; + background: var(--bg-color); + font-size: 0.75em; display: flex; - gap: 12px; - font-size: 0.85em; + justify-content: space-between; + align-items: center; color: var(--text-color); opacity: 0.8; } -.duplicate-recipe-date, +.duplicate-recipe-date, .duplicate-recipe-lora-count { display: flex; align-items: center; gap: 4px; } -.duplicate-recipe-actions { - display: flex; - justify-content: center; - align-items: center; - min-width: 80px; -} - -.duplicate-recipe-actions button { - padding: 6px 12px; - font-size: 0.85em; - display: flex; - align-items: center; - gap: 4px; - white-space: nowrap; - width: 100%; - justify-content: center; -} - -.danger-btn { - background: var(--lora-error) !important; - color: white !important; - border: none !important; -} - -.danger-btn:hover { - background: oklch(from var(--lora-error) l c h / 0.9) !important; -} - +/* Remove the old duplicate styles that are no longer needed */ +.duplicate-recipe-item, +.duplicate-recipe-content, +.duplicate-recipe-actions, +.danger-btn, .view-recipe-btn { - background: var(--lora-surface) !important; - color: var(--text-color) !important; - border: 1px solid var(--border-color) !important; -} - -.view-recipe-btn:hover { - background: var(--lora-accent) !important; - color: var(--lora-text) !important; -} - -.warning-btn { - background: var(--lora-warning) !important; - color: white !important; -} - -.warning-btn:hover { - background: oklch(from var(--lora-warning) l c h / 0.9) !important; + /* These styles are being replaced by the card layout */ } /* Modal buttons layout to accommodate multiple buttons */ diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js index 5e3d80d0..831ae148 100644 --- a/static/js/managers/ImportManager.js +++ b/static/js/managers/ImportManager.js @@ -120,6 +120,9 @@ export class ImportManager { const earlyAccessWarning = document.getElementById('earlyAccessWarning'); if (earlyAccessWarning) earlyAccessWarning.remove(); + + // Reset duplicate related properties + this.duplicateRecipes = []; } toggleImportMode(mode) { @@ -246,44 +249,21 @@ export class ImportManager { } /** - * Marks or unmarks a duplicate recipe for deletion - * @param {string} recipeId - The ID of the recipe to mark/unmark - * @param {HTMLElement} buttonElement - The button element that was clicked + * NOTE: This function is no longer needed with the simplified duplicates flow. + * We're keeping it as a no-op stub to avoid breaking existing code that might call it. */ markDuplicateForDeletion(recipeId, buttonElement) { - // Initialize recipesToDelete array if it doesn't exist - if (!this.recipesToDelete) { - this.recipesToDelete = []; - } - - // Get the recipe item container - const recipeItem = buttonElement.closest('.duplicate-recipe-item'); - if (!recipeItem) return; - - // Check if this recipe is already marked for deletion - const isMarked = this.recipesToDelete.includes(recipeId); - - if (isMarked) { - // Unmark the recipe - this.recipesToDelete = this.recipesToDelete.filter(id => id !== recipeId); - recipeItem.classList.remove('marked-for-deletion'); - buttonElement.innerHTML = ' Delete'; - } else { - // Mark the recipe for deletion - this.recipesToDelete.push(recipeId); - recipeItem.classList.add('marked-for-deletion'); - buttonElement.innerHTML = ' Keep'; - } + // This functionality has been removed + console.log('markDuplicateForDeletion is deprecated'); } /** - * Imports the recipe as new, ignoring duplicates + * NOTE: This function is no longer needed with the simplified duplicates flow. + * We're keeping it as a no-op stub to avoid breaking existing code that might call it. */ importRecipeAnyway() { - // Set flag to indicate we're importing as a new recipe - this.importAsNew = true; - - // Proceed with normal flow but skip duplicate replacement + // This functionality has been simplified + // Just proceed with normal flow this.proceedFromDetails(); } diff --git a/static/js/managers/import/RecipeDataManager.js b/static/js/managers/import/RecipeDataManager.js index 0c4938c5..6614d4d5 100644 --- a/static/js/managers/import/RecipeDataManager.js +++ b/static/js/managers/import/RecipeDataManager.js @@ -226,40 +226,36 @@ export class RecipeDataManager { } }; - // Generate the HTML for duplicate recipes + // Generate the HTML for duplicate recipes warning duplicateContainer.innerHTML = `
- ${this.importManager.duplicateRecipes.length} similar ${this.importManager.duplicateRecipes.length === 1 ? 'recipe' : 'recipes'} found in your library + ${this.importManager.duplicateRecipes.length} identical ${this.importManager.duplicateRecipes.length === 1 ? 'recipe' : 'recipes'} found in your library
- These recipes contain the same LoRAs with similar weights. + These recipes contain the same LoRAs with identical weights. +
-
- ${this.importManager.duplicateRecipes.map((recipe, index) => ` -
+ - - -
+ + +