mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): show a summary modal after rematch runs
Replace the post-run toast cascade and the standalone L4 results modal with a summary modal modeled on the batch download summary: 3-state header, stat cards (matched / needs review / unresolved / errors), an L4 review table with per-entry undo, and a copyable report. Wired into the global, bulk and single-recipe rematch entries; complete no-op runs keep the lightweight toast. Obsolete results-modal code, styles and i18n keys are removed.
This commit is contained in:
@@ -697,78 +697,40 @@ button:disabled,
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Recipe Rematch L4 Results Modal */
|
||||
#rematchResultsModal .modal-body {
|
||||
padding: var(--space-3);
|
||||
/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js;
|
||||
stat cards / failure table / summary header come from
|
||||
metadata-refresh-result.css and download-batch-summary.css). */
|
||||
.rematch-summary-modal {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
#rematchResultsModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-preview {
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-row {
|
||||
.rematch-cancelled-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-row:last-child {
|
||||
border-bottom: none;
|
||||
.rematch-cancelled-note i {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
/* Review section heading uses the accent (review, not failure) instead of
|
||||
the failure-section error color. */
|
||||
.rematch-review-section h4 {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-entry {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
overflow-wrap: anywhere;
|
||||
#rematchSummaryModal .rematch-undo-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-file {
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-recipe {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-row.undone .rematch-results-info {
|
||||
#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#rematchResultsModal .rematch-results-undo {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { state } from '../../state/index.js';
|
||||
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||
|
||||
export class GlobalContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -425,58 +426,37 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
||||
const failures = p.errors || 0;
|
||||
const unresolved = p.unresolved_entries ?? 0;
|
||||
if (entries > 0) {
|
||||
const successKey = failures > 0
|
||||
? 'globalContextMenu.rematchRecipes.successErrors'
|
||||
: 'globalContextMenu.rematchRecipes.success';
|
||||
const successText = failures > 0
|
||||
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
|
||||
: `Matched ${entries} entries across ${recipes} recipes.`;
|
||||
progressUI?.complete(translate(
|
||||
successKey,
|
||||
{ count: recipes, recipes, entries, failures },
|
||||
successText
|
||||
));
|
||||
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
|
||||
} else if (failures > 0) {
|
||||
// Nothing matched and at least one recipe
|
||||
// errored — "no rematch needed" would be
|
||||
// actively misleading here.
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.allFailed',
|
||||
{ total: p.total, recipes, entries, failures },
|
||||
`Rematch failed for ${failures} of ${p.total} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
|
||||
} else if (unresolved > 0) {
|
||||
// Entries existed but have no local model —
|
||||
// expected for models deleted from Civitai;
|
||||
// informational, not an error.
|
||||
const unresolvedRecipes = p.unresolved_recipes ?? 0;
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.noMatch',
|
||||
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
|
||||
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
|
||||
} else {
|
||||
// Everything was skipped (nothing to do).
|
||||
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
|
||||
// Complete no-op (nothing matched, nothing
|
||||
// unresolved, no errors) keeps the lightweight
|
||||
// toast; anything else opens the post-run summary
|
||||
// modal.
|
||||
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
|
||||
if (isNoop) {
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.success',
|
||||
{ count: recipes, recipes, entries, failures },
|
||||
`Matched ${entries} entries across ${recipes} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
|
||||
} else {
|
||||
progressUI?.complete();
|
||||
showRematchSummary({
|
||||
scope: 'global',
|
||||
total: p.total || 0,
|
||||
matchedRecipes: recipes,
|
||||
matchedEntries: entries,
|
||||
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||
unresolvedEntries: unresolved,
|
||||
skipped: p.skipped || 0,
|
||||
errors: failures,
|
||||
l4Matches,
|
||||
});
|
||||
}
|
||||
// Refresh recipes page if active
|
||||
if (window.recipesPage) {
|
||||
window.recipesPage.refresh();
|
||||
}
|
||||
// Filename-level (L4) matches are imprecise —
|
||||
// always surface them for review/undo.
|
||||
if (Array.isArray(p.l4_matches) && p.l4_matches.length > 0) {
|
||||
rematchModalManager.showResultsModal(p.l4_matches);
|
||||
}
|
||||
} else if (p.status === 'error') {
|
||||
throw new Error(p.error || 'Rematch failed');
|
||||
} else if (p.status === 'cancelled') {
|
||||
@@ -488,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
||||
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
|
||||
// A cancelled run still reports partial results
|
||||
// via the summary modal (marked as cancelled).
|
||||
showRematchSummary({
|
||||
scope: 'global',
|
||||
cancelled: true,
|
||||
total: p.total || 0,
|
||||
matchedRecipes: cancelledRecipes,
|
||||
matchedEntries: cancelledEntries,
|
||||
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||
unresolvedEntries: p.unresolved_entries ?? 0,
|
||||
skipped: p.skipped || 0,
|
||||
errors: p.errors || 0,
|
||||
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
|
||||
});
|
||||
if (window.recipesPage) {
|
||||
window.recipesPage.refresh();
|
||||
}
|
||||
}
|
||||
} else if (progressResponse.status === 404) {
|
||||
// Progress might have finished quickly and been cleaned up
|
||||
|
||||
@@ -7,6 +7,7 @@ import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
||||
|
||||
export class RecipeContextMenu extends BaseContextMenu {
|
||||
@@ -326,15 +327,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
if (result.success) {
|
||||
const matchedEntries = result.matched_entries || result.rematched || 0;
|
||||
const failures = result.errors || 0;
|
||||
const unresolvedEntries = result.unresolved_entries || 0;
|
||||
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||
// Complete no-op (nothing matched, nothing unresolved, no
|
||||
// errors) keeps the lightweight toast; anything else opens
|
||||
// the post-run summary modal.
|
||||
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||
|
||||
if (matchedEntries > 0) {
|
||||
const toastKey = failures > 0
|
||||
? 'toast.recipes.rematchCompleteErrors'
|
||||
: 'toast.recipes.rematchComplete';
|
||||
showToast(
|
||||
toastKey,
|
||||
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
|
||||
failures > 0 ? 'warning' : 'success'
|
||||
);
|
||||
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||
if (detailResponse.ok) {
|
||||
const updatedRecipe = await detailResponse.json();
|
||||
@@ -342,21 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||
}
|
||||
}
|
||||
// Filename-level (L4) matches are imprecise — always
|
||||
// surface them for review/undo.
|
||||
if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) {
|
||||
rematchModalManager.showResultsModal(result.l4_matches);
|
||||
}
|
||||
} else if (result.unresolved_entries > 0) {
|
||||
// Entries existed but have no local model — expected for
|
||||
// models deleted from Civitai; informational, not an error.
|
||||
showToast(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
}
|
||||
|
||||
if (isNoop) {
|
||||
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
||||
} else {
|
||||
showRematchSummary({
|
||||
scope: 'single',
|
||||
total: 1,
|
||||
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
|
||||
matchedEntries,
|
||||
unresolvedRecipes: result.unresolved_recipes || 0,
|
||||
unresolvedEntries,
|
||||
skipped: result.skipped || 0,
|
||||
errors: failures,
|
||||
l4Matches,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Rematch failed');
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Escape HTML entities in a string to prevent injection when interpolating
|
||||
* into innerHTML (same approach as DownloadBatchSummaryModal).
|
||||
* @param {string} str - The string to escape
|
||||
* @returns {string} - The escaped string
|
||||
*/
|
||||
function _escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the 3-state summary header (mirrors the batch download/import
|
||||
* summary semantics).
|
||||
*
|
||||
* - error: nothing matched and at least one recipe errored
|
||||
* - warning: errors, unresolved entries, filename-level (L4) matches to
|
||||
* review, or a cancelled run
|
||||
* - success: otherwise
|
||||
*/
|
||||
function _resolveHeader({ matchedEntries, errors, unresolvedEntries, l4Count, cancelled }) {
|
||||
if (matchedEntries === 0 && errors > 0) {
|
||||
return {
|
||||
state: 'error',
|
||||
icon: 'fa-times-circle',
|
||||
text: translate('modals.rematchSummary.failed', {}, 'Rematch failed'),
|
||||
};
|
||||
}
|
||||
if (errors > 0 || unresolvedEntries > 0 || l4Count > 0 || cancelled) {
|
||||
return {
|
||||
state: 'warning',
|
||||
icon: 'fa-exclamation-circle',
|
||||
text: translate('modals.rematchSummary.completedWithWarnings', {}, 'Rematch completed — review recommended'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: 'success',
|
||||
icon: 'fa-check-circle',
|
||||
text: translate('modals.rematchSummary.successMessage', { entries: matchedEntries }, `Matched ${matchedEntries} entries`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plain-text report of the rematch run. `undoneIndexes` carries the
|
||||
* L4 rows undone so far, so the report reflects the undo status at copy time.
|
||||
*/
|
||||
function _buildReportText({ scope, cancelled, total, matchedRecipes, matchedEntries, unresolvedRecipes, unresolvedEntries, skipped, errors, l4Matches, undoneIndexes }) {
|
||||
const scopeFallbacks = {
|
||||
global: 'All recipes',
|
||||
bulk: 'Selected recipes',
|
||||
single: 'Single recipe',
|
||||
};
|
||||
const scopeLabel = translate(
|
||||
`modals.rematchSummary.scope_${scope}`,
|
||||
{},
|
||||
scopeFallbacks[scope] || scope
|
||||
);
|
||||
const lines = [
|
||||
'=== Recipe Rematch Report ===',
|
||||
`Date: ${new Date().toLocaleString()}`,
|
||||
`Scope: ${scopeLabel}`,
|
||||
`Cancelled: ${cancelled ? 'yes' : 'no'}`,
|
||||
`Total recipes: ${total}`,
|
||||
`Matched recipes: ${matchedRecipes}`,
|
||||
`Matched entries: ${matchedEntries}`,
|
||||
`Needs review (filename matches): ${l4Matches.length}`,
|
||||
`Unresolved entries: ${unresolvedEntries} (in ${unresolvedRecipes} recipes)`,
|
||||
`Skipped: ${skipped}`,
|
||||
`Errors: ${errors}`,
|
||||
'',
|
||||
];
|
||||
if (l4Matches.length > 0) {
|
||||
lines.push('--- Filename matches (L4) ---');
|
||||
l4Matches.forEach((match, i) => {
|
||||
const undone = undoneIndexes.has(i) ? ' [undone]' : '';
|
||||
lines.push(`${i + 1}. [${match.recipe_id}] ${match.entry} -> ${match.file_name}${undone}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('====================');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a successful clipboard write: confirm via toast and briefly swap the
|
||||
* trigger button to a "Copied!" state (mirrors the batch summary modal).
|
||||
*/
|
||||
function _onCopyReportSuccess(btn) {
|
||||
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||
if (btn) {
|
||||
const origHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for environments without the async Clipboard API (e.g. insecure
|
||||
* contexts over LAN http): copy via a hidden textarea and execCommand.
|
||||
*/
|
||||
function _copyReportWithExecCommand(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||
}
|
||||
|
||||
function _copyReport(btn, reportArgs) {
|
||||
const text = _buildReportText(reportArgs);
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => _onCopyReportSuccess(btn))
|
||||
.catch(() => _copyReportWithExecCommand(text));
|
||||
} else {
|
||||
_copyReportWithExecCommand(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a single L4 match via the existing restore endpoints (moved from
|
||||
* RematchModalManager). Checkpoint restore needs only recipe_id; lora
|
||||
* restore additionally takes lora_index.
|
||||
*/
|
||||
async function _undoMatch(match) {
|
||||
const isCheckpoint = match.type === 'checkpoint';
|
||||
const body = isCheckpoint
|
||||
? { recipe_id: match.recipe_id }
|
||||
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
|
||||
const response = await fetch(
|
||||
isCheckpoint
|
||||
? '/api/lm/recipe/checkpoint/restore'
|
||||
: '/api/lm/recipe/lora/restore',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Restore failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the post-run rematch summary modal. Mirrors the batch download
|
||||
* summary lifecycle: the modal element is appended directly to
|
||||
* document.body and removed on close; it is not registered with
|
||||
* ModalManager.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {'global'|'bulk'|'single'} options.scope - Which entry point ran
|
||||
* @param {boolean} options.cancelled - Whether the run was cancelled
|
||||
* @param {number} options.total - Recipes scanned
|
||||
* @param {number} options.matchedRecipes - Recipes updated
|
||||
* @param {number} options.matchedEntries - Entries reconnected
|
||||
* @param {number} options.unresolvedRecipes - Recipes with unresolved entries
|
||||
* @param {number} options.unresolvedEntries - Candidate entries with no local match
|
||||
* @param {number} options.skipped - Recipes left untouched
|
||||
* @param {number} options.errors - Per-recipe errors
|
||||
* @param {Array} options.l4Matches - Filename-level matches for review/undo
|
||||
* ({ recipe_id, type, entry, file_name, lora_index? })
|
||||
*/
|
||||
export function showRematchSummary({
|
||||
scope = 'global',
|
||||
cancelled = false,
|
||||
total = 0,
|
||||
matchedRecipes = 0,
|
||||
matchedEntries = 0,
|
||||
unresolvedRecipes = 0,
|
||||
unresolvedEntries = 0,
|
||||
skipped = 0,
|
||||
errors = 0,
|
||||
l4Matches = [],
|
||||
} = {}) {
|
||||
const matches = Array.isArray(l4Matches) ? l4Matches : [];
|
||||
const undoneIndexes = new Set();
|
||||
const header = _resolveHeader({
|
||||
matchedEntries,
|
||||
errors,
|
||||
unresolvedEntries,
|
||||
l4Count: matches.length,
|
||||
cancelled,
|
||||
});
|
||||
|
||||
const matchRows = matches.map((match, i) => `
|
||||
<tr data-l4-index="${i}">
|
||||
<td class="failure-index">${i + 1}</td>
|
||||
<td class="failure-name" title="${_escapeHtml(match.recipe_id)}">${_escapeHtml(match.recipe_id)}</td>
|
||||
<td class="failure-name" title="${_escapeHtml(match.entry)}">${_escapeHtml(match.entry)}</td>
|
||||
<td class="failure-name" title="${_escapeHtml(match.file_name)}">${_escapeHtml(match.file_name)}</td>
|
||||
<td class="rematch-undo-cell">
|
||||
<button class="secondary-btn rematch-undo-btn" data-action="undo-match" data-index="${i}">
|
||||
${translate('modals.rematchResults.undo', {}, 'Undo')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const modalHtml = `
|
||||
<div id="rematchSummaryModal" class="modal" style="display: block;">
|
||||
<div class="modal-content rematch-summary-modal">
|
||||
<button class="close" data-action="close-modal">×</button>
|
||||
|
||||
<h2>${translate('modals.rematchSummary.title', {}, 'Rematch Summary')}</h2>
|
||||
|
||||
<div class="summary-header ${header.state}">
|
||||
<i class="fas ${header.icon}"></i>
|
||||
<span class="summary-title">${header.text}</span>
|
||||
<span class="summary-hint">${matchedRecipes}/${total}</span>
|
||||
</div>
|
||||
${cancelled ? `
|
||||
<p class="rematch-cancelled-note">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
${translate('modals.rematchSummary.cancelledNote', {}, 'Run cancelled before completion — counts are partial.')}
|
||||
</p>` : ''}
|
||||
|
||||
<div class="refresh-summary-stats">
|
||||
<div class="stat-card stat-card-success">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.rematchSummary.statMatched', {}, 'Matched entries')}</span>
|
||||
<span class="stat-card-value">${matchedEntries}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-skipped">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.rematchSummary.statReview', {}, 'Needs review')}</span>
|
||||
<span class="stat-card-value">${matches.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-total">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.rematchSummary.statUnresolved', {}, 'Unresolved')}</span>
|
||||
<span class="stat-card-value">${unresolvedEntries}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-failure">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.rematchSummary.statErrors', {}, 'Errors')}</span>
|
||||
<span class="stat-card-value">${errors}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${matches.length > 0 ? `
|
||||
<div class="refresh-failures-section rematch-review-section">
|
||||
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.rematchSummary.reviewSection', { count: matches.length }, `Filename matches to review (${matches.length})`)}</h4>
|
||||
<div class="failure-table-wrapper">
|
||||
<table class="failure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>${translate('modals.rematchSummary.columnRecipe', {}, 'Recipe')}</th>
|
||||
<th>${translate('modals.rematchSummary.columnEntry', {}, 'Entry')}</th>
|
||||
<th>${translate('modals.rematchSummary.columnFile', {}, 'Matched file')}</th>
|
||||
<th>${translate('modals.rematchSummary.columnUndo', {}, 'Undo')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${matchRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.rematchSummary.copyReport', {}, 'Copy Report')}</button>
|
||||
<button class="cancel-btn" data-action="close-modal">${translate('modals.rematchSummary.close', {}, 'Close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const existing = document.getElementById('rematchSummaryModal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = modalHtml;
|
||||
const modal = container.firstElementChild;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const reportArgs = {
|
||||
scope,
|
||||
cancelled,
|
||||
total,
|
||||
matchedRecipes,
|
||||
matchedEntries,
|
||||
unresolvedRecipes,
|
||||
unresolvedEntries,
|
||||
skipped,
|
||||
errors,
|
||||
l4Matches: matches,
|
||||
undoneIndexes,
|
||||
};
|
||||
|
||||
modal.addEventListener('click', async (e) => {
|
||||
const actionEl = e.target.closest('[data-action]');
|
||||
const action = actionEl?.dataset.action;
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
|
||||
switch (action) {
|
||||
case 'close-modal':
|
||||
modal.remove();
|
||||
break;
|
||||
case 'copy-report':
|
||||
_copyReport(actionEl, reportArgs);
|
||||
break;
|
||||
case 'undo-match': {
|
||||
const index = Number(actionEl.dataset.index);
|
||||
const match = matches[index];
|
||||
if (!match || actionEl.disabled) break;
|
||||
const row = modal.querySelector(`tr[data-l4-index="${index}"]`);
|
||||
try {
|
||||
await _undoMatch(match);
|
||||
undoneIndexes.add(index);
|
||||
row?.classList.add('undone');
|
||||
actionEl.disabled = true;
|
||||
actionEl.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
|
||||
} catch (error) {
|
||||
console.error('Failed to undo rematch match:', error);
|
||||
showToast(
|
||||
'modals.rematchResults.undoFailed',
|
||||
{ message: error.message },
|
||||
'error'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { rematchModalManager } from './RematchModalManager.js';
|
||||
import { showRematchSummary } from '../components/RematchSummaryModal.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
@@ -1025,47 +1026,32 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedEntries > 0) {
|
||||
const hasFailures = failures > 0;
|
||||
const toastKey = hasFailures
|
||||
? 'toast.recipes.rematchCompleteErrors'
|
||||
: 'toast.recipes.rematchComplete';
|
||||
showToast(
|
||||
toastKey,
|
||||
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
|
||||
hasFailures ? 'warning' : 'success'
|
||||
);
|
||||
} else if (failures > 0) {
|
||||
// Nothing matched and at least one recipe errored —
|
||||
// "no rematch needed" would be actively misleading here.
|
||||
showToast(
|
||||
'toast.recipes.rematchAllFailed',
|
||||
{ total, failures },
|
||||
'error'
|
||||
);
|
||||
} else if (unresolvedEntries > 0) {
|
||||
// Entries existed but have no local model — expected for
|
||||
// models deleted from Civitai; informational, not an error.
|
||||
showToast(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
// Complete no-op (nothing matched, nothing unresolved, no
|
||||
// errors) keeps the lightweight toast; anything else opens
|
||||
// the post-run summary modal.
|
||||
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||
if (isNoop) {
|
||||
showToast(
|
||||
'toast.recipes.rematchSkipped',
|
||||
{ total },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showRematchSummary({
|
||||
scope: 'bulk',
|
||||
total,
|
||||
matchedRecipes,
|
||||
matchedEntries,
|
||||
unresolvedRecipes,
|
||||
unresolvedEntries,
|
||||
skipped,
|
||||
errors: failures,
|
||||
l4Matches,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.bulkMode) this.toggleBulkMode();
|
||||
|
||||
// Filename-level (L4) matches are imprecise — always surface
|
||||
// them for review/undo.
|
||||
if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) {
|
||||
rematchModalManager.showResultsModal(result.l4_matches);
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Bulk rematch failed');
|
||||
}
|
||||
|
||||
@@ -360,19 +360,6 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Register rematchResultsModal
|
||||
const rematchResultsModal = document.getElementById('rematchResultsModal');
|
||||
if (rematchResultsModal) {
|
||||
this.registerModal('rematchResultsModal', {
|
||||
element: rematchResultsModal,
|
||||
onClose: () => {
|
||||
this.getModal('rematchResultsModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Owns the two recipe-rematch modals:
|
||||
*
|
||||
* - rematchOptionsModal — shown BEFORE a global/bulk/single rematch run;
|
||||
* collects the "relaxed matching" opt-in and only then invokes the run
|
||||
* callback.
|
||||
* - rematchResultsModal — shown AFTER a run that produced L4 (filename
|
||||
* level) matches; lists them for review with a per-row Undo that calls
|
||||
* the existing restore endpoints.
|
||||
* Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE
|
||||
* a global/bulk/single rematch run; collects the "relaxed matching" opt-in
|
||||
* and only then invokes the run callback. Post-run reporting lives in
|
||||
* static/js/components/RematchSummaryModal.js.
|
||||
*/
|
||||
export class RematchModalManager {
|
||||
constructor() {
|
||||
this._optionsConfirmCallback = null;
|
||||
this._resultsMatches = [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,99 +67,6 @@ export class RematchModalManager {
|
||||
this._optionsConfirmCallback = null;
|
||||
modalManager.closeModal('rematchOptionsModal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the results modal listing L4 (filename-level) matches.
|
||||
*
|
||||
* @param {Array<{recipe_id: string, type: string, entry: string, file_name: string, lora_index?: number}>} l4Matches
|
||||
*/
|
||||
showResultsModal(l4Matches) {
|
||||
if (!Array.isArray(l4Matches) || l4Matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
const list = document.getElementById('rematchResultsList');
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
this._resultsMatches = l4Matches;
|
||||
list.innerHTML = '';
|
||||
|
||||
l4Matches.forEach((match, index) => {
|
||||
const row = document.createElement('li');
|
||||
row.className = 'rematch-results-row';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'rematch-results-info';
|
||||
|
||||
const entryName = document.createElement('span');
|
||||
entryName.className = 'rematch-results-entry';
|
||||
entryName.textContent = match.entry || '';
|
||||
|
||||
const matchedFile = document.createElement('span');
|
||||
matchedFile.className = 'rematch-results-file';
|
||||
matchedFile.textContent = `→ ${match.file_name || ''}`;
|
||||
|
||||
const recipeRef = document.createElement('span');
|
||||
recipeRef.className = 'rematch-results-recipe';
|
||||
recipeRef.textContent = match.recipe_id || '';
|
||||
|
||||
info.appendChild(entryName);
|
||||
info.appendChild(matchedFile);
|
||||
info.appendChild(recipeRef);
|
||||
|
||||
const undoButton = document.createElement('button');
|
||||
undoButton.className = 'secondary-btn rematch-results-undo';
|
||||
undoButton.textContent = translate('modals.rematchResults.undo', {}, 'Undo');
|
||||
undoButton.addEventListener('click', () => this.undoMatch(index, row, undoButton));
|
||||
|
||||
row.appendChild(info);
|
||||
row.appendChild(undoButton);
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
modalManager.showModal('rematchResultsModal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a single L4 match via the existing restore endpoints. On success
|
||||
* the row is struck through and its button disabled.
|
||||
*/
|
||||
async undoMatch(index, row, button) {
|
||||
const match = this._resultsMatches[index];
|
||||
if (!match || button.disabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isCheckpoint = match.type === 'checkpoint';
|
||||
const body = isCheckpoint
|
||||
? { recipe_id: match.recipe_id }
|
||||
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
|
||||
const response = await fetch(
|
||||
isCheckpoint
|
||||
? '/api/lm/recipe/checkpoint/restore'
|
||||
: '/api/lm/recipe/lora/restore',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Restore failed');
|
||||
}
|
||||
row.classList.add('undone');
|
||||
button.disabled = true;
|
||||
button.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
|
||||
} catch (error) {
|
||||
console.error('Failed to undo rematch match:', error);
|
||||
showToast(
|
||||
'modals.rematchResults.undoFailed',
|
||||
{ message: error.message },
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const rematchModalManager = new RematchModalManager();
|
||||
|
||||
Reference in New Issue
Block a user