Add metadata transfer undo

This commit is contained in:
Dariusz L
2026-09-14 01:25:10 +02:00
parent baaedb9425
commit aa6c1fdc3d
2 changed files with 275 additions and 4 deletions
+124
View File
@@ -163,6 +163,130 @@ test('metadata transfer persists only mode and activity scope preferences', () =
});
});
test('metadata transfer exposes Undo transfer while the post-transfer workflow is unchanged', () => {
const context = createTransferContext();
const state = createTransferState([
{ category: 'text_encoders', original_path: 'imported.safetensors' },
]);
state.lastTransfer = {
workflow: { signature: 'before' },
afterSignature: 'after',
undoing: false,
};
context.imageInspectorState = state;
context.getCurrentWorkflow = () => ({ signature: 'after' });
context.getWorkflowSignature = workflow => workflow.signature;
const html = context.renderMetadataTransferPanel(state);
assert.match(html, /data-image-inspector-action="undo-transfer"/);
assert.match(html, />\s*Undo transfer\s*<\/button>/);
context.getCurrentWorkflow = () => ({ signature: 'changed' });
const changedHtml = context.renderMetadataTransferPanel(state);
assert.match(changedHtml, /data-image-inspector-action="undo-transfer" disabled/);
assert.match(changedHtml, /title="The current workflow changed after the last transfer\."/);
assert.match(changedHtml, />\s*Undo transfer\s*<\/button>/);
});
test('metadata transfer undo restores the previous workflow and consumes its history', async () => {
const previousWorkflow = {
signature: 'before',
nodes: [{ id: 1, widgets_values: ['old-model.safetensors'] }],
};
let currentWorkflow = {
signature: 'after',
nodes: [{ id: 1, widgets_values: ['new-model.safetensors'] }],
};
let restoredWorkflow = null;
const notifications = [];
const state = {
transfer: { undoing: false },
lastTransfer: {
workflow: previousWorkflow,
beforeSignature: 'before',
afterSignature: 'after',
undoing: false,
},
};
const context = {
...imageMetadataMethods,
imageInspectorState: state,
getCurrentWorkflow: () => currentWorkflow,
getWorkflowSignature: workflow => workflow.signature,
updateWorkflowInComfyUI: async workflow => {
restoredWorkflow = workflow;
currentWorkflow = workflow;
return true;
},
renderImageMetadataResult() {},
openMetadataTransfer() {},
showNotification(message, type) {
notifications.push({ message, type });
},
cachedLoadedModelsSignature: 'after',
cachedLoadedModelsData: { loaded_models: [] },
activeWorkflowSignature: 'after',
};
const previousWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
try {
await context.undoMetadataTransfer();
} finally {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
assert.deepEqual(restoredWorkflow, previousWorkflow);
assert.notEqual(restoredWorkflow, previousWorkflow);
assert.equal(context.imageInspectorState.lastTransfer, null);
assert.equal(context.imageInspectorState.transfer, null);
assert.equal(context.activeWorkflowSignature, 'before');
assert.equal(context.cachedLoadedModelsSignature, null);
assert.equal(context.cachedLoadedModelsData, null);
assert.deepEqual(notifications, [{
message: 'Undid the last model transfer.',
type: 'success',
}]);
});
test('metadata transfer undo refuses to restore a changed workflow', async () => {
const state = {
lastTransfer: {
workflow: { signature: 'before' },
afterSignature: 'after',
undoing: false,
},
};
let updateCalled = false;
const notifications = [];
const context = {
...imageMetadataMethods,
imageInspectorState: state,
getCurrentWorkflow: () => ({ signature: 'changed' }),
getWorkflowSignature: workflow => workflow.signature,
updateWorkflowInComfyUI: async () => {
updateCalled = true;
return true;
},
renderImageMetadataResult() {},
showNotification(message, type) {
notifications.push({ message, type });
},
};
const result = await context.undoMetadataTransfer();
assert.equal(result, null);
assert.equal(updateCalled, false);
assert.equal(state.lastTransfer.undoing, false);
assert.deepEqual(notifications, [{
message: 'The current workflow changed after the last transfer.',
type: 'warning',
}]);
});
test('metadata tab reuses transfer state for the unchanged workflow', () => {
const context = createTransferContext();
context.getWorkflowSignature = workflow => workflow.signature;
@@ -73,11 +73,30 @@ function getImageInspectorState(dialog) {
loading: '',
requestToken: null,
transfer: null,
lastTransfer: null,
};
}
return dialog.imageInspectorState;
}
function cloneWorkflowSnapshot(workflow) {
if (!workflow || typeof workflow !== 'object') return null;
if (typeof structuredClone === 'function') {
try {
return structuredClone(workflow);
} catch {
// Fall back to JSON for plain serialized workflow values.
}
}
try {
return JSON.parse(JSON.stringify(workflow));
} catch {
return null;
}
}
function isImageFile(file) {
const name = String(file?.name || '').toLowerCase();
return String(file?.type || '').startsWith('image/')
@@ -242,6 +261,7 @@ export const imageMetadataMethods = {
if (action === 'clear') this.clearImageMetadata();
if (action === 'clear-transfer-targets') this.clearMetadataTransferTargets();
if (action === 'retry-transfer-targets') void this.openMetadataTransfer({ force: true });
if (action === 'undo-transfer') void this.undoMetadataTransfer();
if (action === 'apply-transfer') void this.applyMetadataTransfer();
if (action === 'analyze-paste') {
const textarea = root.querySelector('.mr-image-inspector-textarea');
@@ -724,6 +744,53 @@ export const imageMetadataMethods = {
return activityScope;
},
getMetadataTransferUndoState() {
const state = getImageInspectorState(this);
const lastTransfer = state.lastTransfer;
if (!lastTransfer?.workflow) {
return {
hasHistory: false,
available: false,
reason: '',
};
}
if (lastTransfer.undoing) {
return {
hasHistory: true,
available: false,
reason: 'Undoing the last model transfer...',
};
}
const workflow = this.getCurrentWorkflow?.();
if (!workflow) {
return {
hasHistory: true,
available: false,
reason: 'No current workflow is available.',
};
}
const currentSignature = this.getWorkflowSignature?.(workflow) || '';
if (
lastTransfer.afterSignature
&& currentSignature
&& lastTransfer.afterSignature !== currentSignature
) {
return {
hasHistory: true,
available: false,
reason: 'The current workflow changed after the last transfer.',
};
}
return {
hasHistory: true,
available: true,
reason: 'Undo the last model transfer.',
};
},
getMetadataTransferSourceSelectionKey(ref = {}) {
return String(
ref.transferKey
@@ -1151,12 +1218,19 @@ export const imageMetadataMethods = {
const transfer = state?.transfer || {
loading: false,
applying: false,
undoing: false,
error: '',
mode: 'replace',
activityScope: 'all',
targetGroups: [],
selectedNodeKeys: new Set(),
};
const undoState = this.getMetadataTransferUndoState();
const undoButton = undoState.hasHistory ? `
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="undo-transfer"${undoState.available ? '' : ' disabled'} title="${this.escapeHtml(undoState.reason)}">
${state.lastTransfer?.undoing ? 'Undoing...' : 'Undo transfer'}
</button>
` : '';
const targetGroups = Array.isArray(transfer.targetGroups) ? transfer.targetGroups : [];
const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set
? transfer.selectedNodeKeys
@@ -1196,7 +1270,10 @@ export const imageMetadataMethods = {
<h3 class="mr-loaded-models-title">Transfer models to current workflow</h3>
<p class="mr-error-text">${this.escapeHtml(transfer.error)}</p>
</div>
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="retry-transfer-targets">Retry</button>
<div class="mr-image-inspector-header-actions">
${undoButton}
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="retry-transfer-targets">Retry</button>
</div>
</div>
<p class="mr-image-transfer-note">${this.escapeHtml(sourceSummary)}</p>
</section>
@@ -1346,6 +1423,7 @@ export const imageMetadataMethods = {
const hasTransferableTarget = previewRows.length > 0;
const applyDisabled = (
transfer.applying
|| transfer.undoing
|| transfer.refreshing
|| !sourceEntries.length
|| !selectedTargetCount
@@ -1364,7 +1442,10 @@ export const imageMetadataMethods = {
<h3 class="mr-loaded-models-title">Transfer models to current workflow <span class="mr-loaded-total">${selectedTargetCount}/${allTargetNodeKeys.size}</span></h3>
<p class="mr-loaded-models-subtitle">Select the target nodes. ${this.escapeHtml(sourceSummary)}</p>
</div>
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="clear-transfer-targets">Clear selection</button>
<div class="mr-image-inspector-header-actions">
${undoButton}
<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm" data-image-inspector-action="clear-transfer-targets">Clear selection</button>
</div>
</div>
<div class="mr-image-transfer-mode" role="group" aria-label="Transfer mode">
<label class="mr-image-transfer-mode-option">
@@ -1394,7 +1475,7 @@ export const imageMetadataMethods = {
<div class="mr-image-transfer-actions">
<span class="mr-image-transfer-selection-count">${this.escapeHtml(selectionSourceText)} · ${selectedTargetCount} selected node${selectedTargetCount === 1 ? '' : 's'}</span>
<button type="button" class="mr-btn mr-btn-primary mr-btn-sm" data-image-inspector-action="apply-transfer" ${applyDisabled ? 'disabled' : ''}>
${transfer.applying ? 'Applying...' : 'Apply transfer'}
${transfer.applying ? 'Applying...' : transfer.undoing ? 'Undoing...' : 'Apply transfer'}
</button>
</div>
</section>
@@ -1437,6 +1518,7 @@ export const imageMetadataMethods = {
loading: Boolean(workflow) && !keepCurrentTransferContent,
refreshing: Boolean(workflow) && keepCurrentTransferContent,
applying: false,
undoing: false,
error: '',
mode: normalizeTransferMode(previousTransfer?.mode ?? savedPreferences.mode),
activityScope: normalizeTransferActivityScope(
@@ -1577,6 +1659,58 @@ export const imageMetadataMethods = {
this.renderImageMetadataResult();
},
async undoMetadataTransfer() {
const state = getImageInspectorState(this);
const lastTransfer = state.lastTransfer;
if (!lastTransfer?.workflow || lastTransfer.undoing) return null;
const undoState = this.getMetadataTransferUndoState();
if (!undoState.available) {
if (undoState.reason) this.showNotification(undoState.reason, 'warning');
this.renderImageMetadataResult();
return null;
}
const restoreWorkflow = cloneWorkflowSnapshot(lastTransfer.workflow);
if (!restoreWorkflow) {
this.showNotification('The previous workflow snapshot could not be restored.', 'error');
return null;
}
const transfer = state.transfer;
lastTransfer.undoing = true;
if (transfer) transfer.undoing = true;
this.renderImageMetadataResult();
try {
const restored = await this.updateWorkflowInComfyUI(restoreWorkflow, []);
if (!restored) {
throw new Error('ComfyUI could not restore the previous workflow.');
}
this.cachedLoadedModelsSignature = null;
this.cachedLoadedModelsData = null;
this.activeWorkflowSignature = lastTransfer.beforeSignature
|| this.getWorkflowSignature?.(restoreWorkflow)
|| null;
state.lastTransfer = null;
state.transfer = null;
this.renderImageMetadataResult();
void this.openMetadataTransfer();
this.showNotification('Undid the last model transfer.', 'success');
window.dispatchEvent?.(new Event('model-resolver-active-workflowchange'));
return restoreWorkflow;
} catch (error) {
if (state.lastTransfer === lastTransfer) lastTransfer.undoing = false;
if (state.transfer === transfer && transfer) {
transfer.undoing = false;
transfer.error = error?.message || 'The previous workflow could not be restored.';
}
this.renderImageMetadataResult();
return null;
}
},
async applyMetadataTransfer() {
const state = getImageInspectorState(this);
const transfer = state.transfer;
@@ -1679,6 +1813,12 @@ export const imageMetadataMethods = {
this.showNotification('No current workflow is available.', 'warning');
return null;
}
const previousWorkflow = cloneWorkflowSnapshot(workflow);
if (!previousWorkflow) {
this.showNotification('Could not prepare an undo snapshot for this transfer.', 'error');
return null;
}
const previousWorkflowSignature = this.getWorkflowSignature?.(workflow) || null;
transfer.applying = true;
this.renderImageMetadataResult();
@@ -1710,7 +1850,14 @@ export const imageMetadataMethods = {
this.cachedLoadedModelsSignature = null;
this.cachedLoadedModelsData = null;
this.activeWorkflowSignature = this.getWorkflowSignature?.(data.workflow) || null;
const updatedWorkflow = this.getCurrentWorkflow?.() || data.workflow;
state.lastTransfer = {
workflow: previousWorkflow,
beforeSignature: previousWorkflowSignature,
afterSignature: this.getWorkflowSignature?.(updatedWorkflow) || null,
undoing: false,
};
this.activeWorkflowSignature = this.getWorkflowSignature?.(updatedWorkflow) || null;
state.transfer = null;
this.renderImageMetadataResult();
void this.openMetadataTransfer();