Add removable model search results and restore re-added links
This commit is contained in:
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
test('image URL adds every linked version and deduplicates repeated additions', async () => {
|
||||
test('image URL restores removed linked versions without duplicates or restoring unrelated results', async () => {
|
||||
const methodStart = resolveDownloadMethodsSource.indexOf('async addCustomUrlResult(');
|
||||
const methodEnd = resolveDownloadMethodsSource.indexOf('\n },', methodStart);
|
||||
const addCustomUrlResult = eval(`(${resolveDownloadMethodsSource.slice(methodStart, methodEnd + 6).replace('async addCustomUrlResult', 'async function addCustomUrlResult')})`);
|
||||
@@ -19,6 +19,10 @@ test('image URL adds every linked version and deduplicates repeated additions',
|
||||
getFilenameFromPath: value => value,
|
||||
fetchJson: async () => ({ result: models[0], custom: models }),
|
||||
getSearchResultSignature: result => String(result.version_id),
|
||||
getCustomUrlResultTableRow: (_missing, result) => ({
|
||||
sourceKey: result.source, downloadUrl: `https://civitai.com/api/download/models/${result.version_id}`,
|
||||
}),
|
||||
prepareSearchResultRow: (_missing, row) => row,
|
||||
persistSearchStateForWorkflow() {},
|
||||
refreshSearchUiForMissing() {},
|
||||
};
|
||||
@@ -28,6 +32,15 @@ test('image URL adds every linked version and deduplicates repeated additions',
|
||||
input.value = 'https://civitai.com/images/144201545';
|
||||
await addCustomUrlResult.call(dialog, missing, input, null);
|
||||
assert.deepEqual(state.results.custom.map(result => result.version_id), [3352534, 3356151]);
|
||||
state.removedSearchResultKeys = [
|
||||
'civitai:https://civitai.com/api/download/models/3352534',
|
||||
'civitai:https://civitai.com/api/download/models/3356151',
|
||||
'civitai:https://civitai.com/api/download/models/999',
|
||||
];
|
||||
input.value = 'https://civitai.com/images/144201545';
|
||||
await addCustomUrlResult.call(dialog, missing, input, null);
|
||||
assert.deepEqual(state.results.custom.map(result => result.version_id), [3352534, 3356151]);
|
||||
assert.deepEqual(state.removedSearchResultKeys, ['civitai:https://civitai.com/api/download/models/999']);
|
||||
assert.equal(input.disabled, false);
|
||||
});
|
||||
import { Window } from 'happy-dom';
|
||||
@@ -7940,6 +7953,66 @@ test('search result highlight toggles linked hash result row and badge', () => {
|
||||
assert.deepEqual(rowToggles, [['mr-search-result-hash-highlight', true]]);
|
||||
});
|
||||
|
||||
test('result removal is opt-in, removes only its row, and survives rerendering', () => {
|
||||
const window = new Window();
|
||||
const container = window.document.createElement('div');
|
||||
const render = eval(`(${extractMethod(searchPanelMethodsSource, 'renderSearchResultsTable')})`);
|
||||
const display = eval(`(${extractMethod(resolveDownloadMethodsSource, 'displaySearchResults')})`);
|
||||
const wire = new Function('bindEventOnce', `return (${extractMethod(resolveDownloadMethodsSource, 'wireSearchDownloadButtons')});`)(
|
||||
(element, type, handler) => {
|
||||
if (element.bound) return;
|
||||
element.bound = true;
|
||||
element.addEventListener(type, handler);
|
||||
}
|
||||
);
|
||||
const state = { results: { custom: [
|
||||
{ sourceKey: 'civitai', model: 'First', downloadUrl: 'first' },
|
||||
{ sourceKey: 'civitai', model: 'Second', downloadUrl: 'second' },
|
||||
] } };
|
||||
let saved;
|
||||
const dialog = {
|
||||
getWorkflowScopedQueueKey: () => 'workflow',
|
||||
getSearchStateForWorkflow: () => state,
|
||||
persistSearchStateForWorkflow(_key, _missing, value) { saved = structuredClone(value); },
|
||||
displaySearchResults: display,
|
||||
renderSearchResultsTable: render,
|
||||
wireSearchDownloadButtons: wire,
|
||||
shouldDisplayKnownDownloadSource: () => false,
|
||||
renderSearchProgress: () => '',
|
||||
hasActiveSearchProgress: () => false,
|
||||
prepareSearchResultRow: (_missing, row) => row,
|
||||
getCustomUrlResultTableRow: (_missing, result) => result,
|
||||
patchSearchResultsContainer(element, html) { element.innerHTML = html; },
|
||||
renderStatusMessage: message => `<p>${message}</p>`,
|
||||
getSearchResultsTableLayout: () => ({ sourcePx: 100, matchPx: 60, sizePx: 60, actionsPx: 100, tableMinPx: 500 }),
|
||||
renderSearchSourcePill: () => 'CivitAI',
|
||||
escapeHtml: value => String(value),
|
||||
getVersionedModelName: name => name,
|
||||
renderVersionedModelNameHtml: name => name,
|
||||
getContextMenuAttrs: () => '',
|
||||
};
|
||||
const missing = {};
|
||||
display.call(dialog, missing, state, container);
|
||||
assert.equal(container.querySelectorAll('.search-remove-result-btn').length, 0);
|
||||
container.querySelector('.search-toggle-remove-btn').click();
|
||||
assert.equal(container.querySelectorAll('.search-remove-result-btn').length, 2);
|
||||
const actions = container.querySelector('.mr-search-result-actions');
|
||||
assert.equal(actions.lastElementChild.classList.contains('search-remove-result-btn'), true);
|
||||
actions.lastElementChild.click();
|
||||
assert.equal(container.querySelectorAll('tbody tr').length, 1);
|
||||
assert.match(container.textContent, /Second/);
|
||||
assert.deepEqual(saved.removedSearchResultKeys, ['civitai:first']);
|
||||
display.call(dialog, missing, structuredClone(saved), container);
|
||||
assert.equal(container.querySelectorAll('tbody tr').length, 1);
|
||||
container.querySelector('.search-toggle-remove-btn').click();
|
||||
assert.equal(container.querySelectorAll('.search-remove-result-btn').length, 0);
|
||||
container.querySelector('.search-toggle-remove-btn').click();
|
||||
container.querySelector('.search-remove-result-btn').click();
|
||||
assert.equal(container.querySelector('table'), null);
|
||||
assert.match(container.textContent, /No model results remain/);
|
||||
window.happyDOM.close();
|
||||
});
|
||||
|
||||
test('search result table exposes linked local hash targets for exact matches', () => {
|
||||
const renderSearchResultsTable = eval(`(${extractMethod(searchPanelMethodsSource, 'renderSearchResultsTable')})`);
|
||||
const dialog = {
|
||||
|
||||
@@ -3308,6 +3308,33 @@
|
||||
color: var(--mr-color-error-lighter);
|
||||
}
|
||||
|
||||
.mr-search-actions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#model-resolver-modal .mr-missing-detail-pane .search-toggle-remove-btn[aria-pressed="true"],
|
||||
#model-resolver-modal .search-toggle-remove-btn[aria-pressed="true"] {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
#model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.search-remove-result-btn,
|
||||
#model-resolver-modal .mr-search-result-action-btn.search-remove-result-btn {
|
||||
background: #b91c1c;
|
||||
border-color: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.search-remove-result-btn:hover:not(:disabled),
|
||||
#model-resolver-modal .mr-search-result-action-btn.search-remove-result-btn:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
border-color: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#model-resolver-modal .mr-search-result-action-btn.mr-search-progress-retry,
|
||||
#model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.mr-search-progress-retry {
|
||||
width: 16px;
|
||||
|
||||
@@ -2560,6 +2560,15 @@ export const resolveDownloadMethods = {
|
||||
custom: nextCustom,
|
||||
local_hash_matches: localHashMatches
|
||||
};
|
||||
if (state.removedSearchResultKeys?.length) {
|
||||
const restoredKeys = new Set(customResults.map(item => {
|
||||
const customRow = this.getCustomUrlResultTableRow(missing, item);
|
||||
if (!customRow) return '';
|
||||
const row = this.prepareSearchResultRow(missing, customRow);
|
||||
return `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`;
|
||||
}));
|
||||
state.removedSearchResultKeys = state.removedSearchResultKeys.filter(key => !restoredKeys.has(key));
|
||||
}
|
||||
state.lastAttemptSources = Array.from(new Set([
|
||||
...(Array.isArray(state.lastAttemptSources) ? state.lastAttemptSources : []),
|
||||
'custom'
|
||||
@@ -3178,10 +3187,29 @@ export const resolveDownloadMethods = {
|
||||
this.wireSearchHashMatchHighlights?.(container);
|
||||
bindEventOnce(container, 'click', (event) => {
|
||||
const btn = event.target?.closest?.(
|
||||
'.search-download-btn, .search-open-page-btn, .search-show-details-btn'
|
||||
'.search-download-btn, .search-open-page-btn, .search-show-details-btn, .search-toggle-remove-btn, .search-remove-result-btn'
|
||||
);
|
||||
if (!btn || !container.contains(btn)) return;
|
||||
|
||||
if (btn.matches('.search-toggle-remove-btn, .search-remove-result-btn')) {
|
||||
const workflowKey = this.getWorkflowScopedQueueKey();
|
||||
const state = this.getSearchStateForWorkflow(workflowKey, missing);
|
||||
if (btn.classList.contains('search-toggle-remove-btn')) {
|
||||
state.showSearchResultRemoval = !state.showSearchResultRemoval;
|
||||
} else {
|
||||
if (!state.showSearchResultRemoval) return;
|
||||
const key = btn.closest('tr')?.dataset.searchResultKey;
|
||||
if (!key) return;
|
||||
state.removedSearchResultKeys = Array.from(new Set([
|
||||
...(state.removedSearchResultKeys || []), decodeURIComponent(key)
|
||||
]));
|
||||
}
|
||||
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
||||
const resultsContainer = btn.closest('.mr-search-results-table-wrap')?.parentElement;
|
||||
this.displaySearchResults(missing, state, resultsContainer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn.classList.contains('search-download-btn')) {
|
||||
const url = btn.dataset.url;
|
||||
const filename = btn.dataset.filename;
|
||||
@@ -3587,6 +3615,11 @@ export const resolveDownloadMethods = {
|
||||
|
||||
this.syncSearchElementAttributes(current, next);
|
||||
this.syncSearchElementAttributes(currentTable, nextTable);
|
||||
const currentHead = currentTable.querySelector('thead');
|
||||
const nextHead = nextTable.querySelector('thead');
|
||||
if (currentHead && nextHead && currentHead.innerHTML !== nextHead.innerHTML) {
|
||||
currentHead.innerHTML = nextHead.innerHTML;
|
||||
}
|
||||
for (const nextRow of nextRows) {
|
||||
const key = nextRow.dataset.searchResultKey;
|
||||
let currentRow = currentByKey.get(key);
|
||||
@@ -3742,10 +3775,12 @@ export const resolveDownloadMethods = {
|
||||
|
||||
const rows = [];
|
||||
const rowKeys = new Set();
|
||||
const removedRowKeys = new Set(state?.removedSearchResultKeys || []);
|
||||
const addRow = (row) => {
|
||||
if (!row) return;
|
||||
row = this.prepareSearchResultRow(missing, row);
|
||||
const rowKey = `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`;
|
||||
if (removedRowKeys.has(rowKey)) return;
|
||||
if (rowKeys.has(rowKey)) return;
|
||||
rowKeys.add(rowKey);
|
||||
rows.push({ ...row, __searchResultKey: rowKey });
|
||||
@@ -3974,7 +4009,10 @@ export const resolveDownloadMethods = {
|
||||
));
|
||||
});
|
||||
|
||||
const html = `${progressHtml}${statusHtml}${this.renderSearchResultsTable(rows)}`;
|
||||
const tableHtml = rows.length
|
||||
? this.renderSearchResultsTable(rows, { removalEnabled: true, showRemoval: Boolean(state?.showSearchResultRemoval) })
|
||||
: this.renderStatusMessage('No model results remain in this table.', 'info');
|
||||
const html = `${progressHtml}${statusHtml}${tableHtml}`;
|
||||
this.patchSearchResultsContainer(container, html);
|
||||
|
||||
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
||||
|
||||
@@ -1965,15 +1965,26 @@ export const searchPanelMethods = {
|
||||
html += `</div>`;
|
||||
|
||||
const downloadSourceRow = this.getDownloadSourceTableRow(missing, downloadSource);
|
||||
const searchState = this.getSearchState(missing);
|
||||
const resultKey = downloadSourceRow
|
||||
? `${downloadSourceRow.sourceKey}:${downloadSourceRow.downloadUrl || downloadSourceRow.openUrl || `${downloadSourceRow.model}:${downloadSourceRow.filename}`}`
|
||||
: '';
|
||||
const visibleRow = downloadSourceRow && !(searchState.removedSearchResultKeys || []).includes(resultKey);
|
||||
html += `<div id="search-results-${this.getMissingModelDomKey(missing)}" class="mr-search-results mr-is-visible">`;
|
||||
html += this.renderSearchResultsTable(downloadSourceRow ? [downloadSourceRow] : []);
|
||||
html += this.renderSearchResultsTable(visibleRow ? [downloadSourceRow] : [], {
|
||||
removalEnabled: true, showRemoval: Boolean(searchState.showSearchResultRemoval)
|
||||
});
|
||||
html += `</div>`;
|
||||
return html;
|
||||
},
|
||||
|
||||
renderSearchResultsTable(rows = []) {
|
||||
renderSearchResultsTable(rows = [], { removalEnabled = false, showRemoval = false } = {}) {
|
||||
if (!rows.length) return '';
|
||||
const layout = this.getSearchResultsTableLayout(rows);
|
||||
if (removalEnabled && showRemoval) {
|
||||
layout.actionsPx += 36;
|
||||
layout.tableMinPx += 36;
|
||||
}
|
||||
const tableStyle = [
|
||||
`--mr-source-col:${layout.sourcePx}px`,
|
||||
`--mr-match-col:${layout.matchPx}px`,
|
||||
@@ -1998,7 +2009,11 @@ export const searchPanelMethods = {
|
||||
<th>Model</th>
|
||||
<th>Match</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
<th><div class="mr-search-actions-header">Actions${removalEnabled ? `
|
||||
<button type="button" class="search-toggle-remove-btn mr-search-result-action-btn"
|
||||
aria-label="Toggle result removal" aria-pressed="${showRemoval}"
|
||||
data-tooltip="${showRemoval ? 'Hide remove buttons' : 'Show remove buttons'}">${getSvgIcon('pencil')}</button>
|
||||
` : ''}</div></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -2087,6 +2102,10 @@ export const searchPanelMethods = {
|
||||
if (!actions) {
|
||||
actions = '<span class="mr-search-result-empty">-</span>';
|
||||
}
|
||||
if (removalEnabled && showRemoval) {
|
||||
actions += `<button type="button" class="search-remove-result-btn mr-search-result-action-btn"
|
||||
data-tooltip="Remove from results" aria-label="Remove ${modelTitle} from results">${getSvgIcon('trash2')}</button>`;
|
||||
}
|
||||
|
||||
const resultKey = row.__searchResultKey || `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`;
|
||||
html += `
|
||||
|
||||
@@ -632,6 +632,7 @@ export const workflowStateMethods = {
|
||||
},
|
||||
|
||||
isPersistableSearchState(state = {}) {
|
||||
if (state?.removedSearchResultKeys?.length || state?.showSearchResultRemoval) return true;
|
||||
if ((state?.selectedSource || 'all') !== 'all') return true;
|
||||
const defaultBaseModel = this.getDefaultSearchBaseModel?.() || 'auto';
|
||||
if ((state?.selectedBaseModel || defaultBaseModel) !== defaultBaseModel) return true;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const SVG_ICONS = {
|
||||
trash2: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M5 6l1 14a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-14M10 10v7M14 10v7"></path></svg>`,
|
||||
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>`,
|
||||
circleCheckBig: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.801 10A10 10 0 1 1 17 3.335"></path><path d="m9 11 3 3L22 4"></path></svg>`,
|
||||
circlePlus: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M8 12h8"></path><path d="M12 8v8"></path></svg>`,
|
||||
|
||||
Reference in New Issue
Block a user