mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: implement duplicate recipe detection and management; add UI for marking duplicates for deletion
This commit is contained in:
@@ -733,3 +733,205 @@
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Duplicate Recipes Styles */
|
||||
.duplicate-recipes-container {
|
||||
margin-bottom: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.duplicate-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
border: 1px solid var(--lora-accent);
|
||||
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.duplicate-warning .warning-icon {
|
||||
color: var(--lora-accent);
|
||||
font-size: 1.2em;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.duplicate-warning .warning-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.duplicate-warning .warning-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.duplicate-warning .warning-text {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.duplicate-recipes-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
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);
|
||||
}
|
||||
|
||||
.duplicate-recipe-item {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.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-preview {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.duplicate-recipe-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.duplicate-recipe-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Modal buttons layout to accommodate multiple buttons */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,48 @@ export class ImportManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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 = '<i class="fas fa-trash"></i> Delete';
|
||||
} else {
|
||||
// Mark the recipe for deletion
|
||||
this.recipesToDelete.push(recipeId);
|
||||
recipeItem.classList.add('marked-for-deletion');
|
||||
buttonElement.innerHTML = '<i class="fas fa-undo"></i> Keep';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the recipe as new, ignoring duplicates
|
||||
*/
|
||||
importRecipeAnyway() {
|
||||
// Set flag to indicate we're importing as a new recipe
|
||||
this.importAsNew = true;
|
||||
|
||||
// Proceed with normal flow but skip duplicate replacement
|
||||
this.proceedFromDetails();
|
||||
}
|
||||
|
||||
downloadMissingLoras(recipeData, recipeId) {
|
||||
// Store the recipe data and ID
|
||||
this.recipeData = recipeData;
|
||||
|
||||
@@ -94,6 +94,9 @@ export class ImageProcessor {
|
||||
lora => !lora.existsLocally
|
||||
);
|
||||
|
||||
// Reset import as new flag
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
@@ -139,6 +142,9 @@ export class ImageProcessor {
|
||||
lora => !lora.existsLocally
|
||||
);
|
||||
|
||||
// Reset import as new flag
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
@@ -187,6 +193,9 @@ export class ImageProcessor {
|
||||
lora => !lora.existsLocally
|
||||
);
|
||||
|
||||
// Reset import as new flag
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
|
||||
@@ -196,24 +196,134 @@ export class RecipeDataManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Update Next button state based on missing LoRAs
|
||||
// Check for duplicate recipes and display warning if found
|
||||
this.checkAndDisplayDuplicates();
|
||||
|
||||
// Update Next button state based on missing LoRAs and duplicates
|
||||
this.updateNextButtonState();
|
||||
}
|
||||
|
||||
checkAndDisplayDuplicates() {
|
||||
// Check if we have duplicate recipes
|
||||
if (this.importManager.recipeData &&
|
||||
this.importManager.recipeData.matching_recipes &&
|
||||
this.importManager.recipeData.matching_recipes.length > 0) {
|
||||
|
||||
// Store duplicates in the importManager for later use
|
||||
this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes;
|
||||
|
||||
// Create duplicate warning container
|
||||
const duplicateContainer = document.getElementById('duplicateRecipesContainer') ||
|
||||
this.createDuplicateContainer();
|
||||
|
||||
// Format date helper function
|
||||
const formatDate = (timestamp) => {
|
||||
try {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
} catch (e) {
|
||||
return 'Unknown date';
|
||||
}
|
||||
};
|
||||
|
||||
// Generate the HTML for duplicate recipes
|
||||
duplicateContainer.innerHTML = `
|
||||
<div class="duplicate-warning">
|
||||
<div class="warning-icon"><i class="fas fa-clone"></i></div>
|
||||
<div class="warning-content">
|
||||
<div class="warning-title">
|
||||
${this.importManager.duplicateRecipes.length} similar ${this.importManager.duplicateRecipes.length === 1 ? 'recipe' : 'recipes'} found in your library
|
||||
</div>
|
||||
<div class="warning-text">
|
||||
These recipes contain the same LoRAs with similar weights.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="duplicate-recipes-list">
|
||||
${this.importManager.duplicateRecipes.map((recipe, index) => `
|
||||
<div class="duplicate-recipe-item" data-recipe-id="${recipe.id}">
|
||||
<div class="duplicate-recipe-preview">
|
||||
<img src="${recipe.file_url}" alt="Recipe preview">
|
||||
</div>
|
||||
<div class="duplicate-recipe-content">
|
||||
<div class="duplicate-recipe-title">${recipe.title}</div>
|
||||
<div class="duplicate-recipe-info">
|
||||
<div class="duplicate-recipe-date">
|
||||
<i class="fas fa-calendar-alt"></i> ${formatDate(recipe.modified)}
|
||||
</div>
|
||||
<div class="duplicate-recipe-lora-count">
|
||||
<i class="fas fa-layer-group"></i> ${recipe.lora_count} LoRAs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="duplicate-recipe-actions">
|
||||
<button class="danger-btn delete-recipe-btn" onclick="importManager.markDuplicateForDeletion('${recipe.id}', this)">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = '<i class="fas fa-plus"></i> 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 = '<i class="fas fa-plus"></i> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container for duplicate recipes warning -->
|
||||
<div id="duplicateRecipesContainer" class="duplicate-recipes-container" style="display: none;">
|
||||
<!-- Duplicate recipes will be populated here -->
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>LoRAs in this Recipe <span id="loraCountInfo" class="lora-count-info">(0/0 in library)</span></label>
|
||||
<div id="lorasList" class="loras-list">
|
||||
|
||||
Reference in New Issue
Block a user