diff --git a/tests/test_image_metadata_transfer.mjs b/tests/test_image_metadata_transfer.mjs index c23cbcd..0d070c4 100644 --- a/tests/test_image_metadata_transfer.mjs +++ b/tests/test_image_metadata_transfer.mjs @@ -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; diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index b84e69f..8c9557e 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -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 ? ` + + ` : ''; const targetGroups = Array.isArray(transfer.targetGroups) ? transfer.targetGroups : []; const selectedNodeKeys = transfer.selectedNodeKeys instanceof Set ? transfer.selectedNodeKeys @@ -1196,7 +1270,10 @@ export const imageMetadataMethods = {

Transfer models to current workflow

${this.escapeHtml(transfer.error)}

- +
+ ${undoButton} + +

${this.escapeHtml(sourceSummary)}

@@ -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 = {

Transfer models to current workflow ${selectedTargetCount}/${allTargetNodeKeys.size}

Select the target nodes. ${this.escapeHtml(sourceSummary)}

- +
+ ${undoButton} + +