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] 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 = ``;
- };
- 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 = ``;
- } else if (this.importMode === 'url') {
- // Fallback for URL mode if no base64 data
- const urlInput = document.getElementById('imageUrlInput');
- if (urlInput && urlInput.value) {
- imagePreview.innerHTML = ``;
- }
- }
- }
-
- // 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.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.
- `).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 `
-
` : ''
+ ).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 = ``;
+ };
+ 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 = ``;
+ } 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 = ``;
+ }
+ }
+ }
+
+ // 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.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.
+ `).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];
+}