feat: sync search hash matches with local matches and auto-remove on row deletion
This commit is contained in:
@@ -8075,6 +8075,101 @@ test('result removal is opt-in, removes only its row, and survives rerendering',
|
||||
window.happyDOM.close();
|
||||
});
|
||||
|
||||
test('removing a search result row automatically removes its local hash match from missing matches', () => {
|
||||
const window = new Window();
|
||||
const container = window.document.createElement('div');
|
||||
const render = eval(`(${extractMethod(searchPanelMethodsSource, 'renderSearchResultsTable')})`);
|
||||
const display = eval(`(${extractMethod(resolveDownloadMethodsSource, 'displaySearchResults')})`);
|
||||
const sync = eval(`(${extractMethod(resolveDownloadMethodsSource, 'syncVisibleSearchResultsLocalMatches')})`);
|
||||
const wire = new Function('bindEventOnce', `return (${extractMethod(resolveDownloadMethodsSource, 'wireSearchDownloadButtons')});`)(
|
||||
(element, type, handler) => {
|
||||
if (element.bound) return;
|
||||
element.bound = true;
|
||||
element.addEventListener(type, handler);
|
||||
}
|
||||
);
|
||||
const sha1 = '1'.repeat(64);
|
||||
const sha2 = '2'.repeat(64);
|
||||
const hashMatch1 = { filename: 'local1.safetensors', sha256: sha1, match_type: 'hash', hash_lookup_source: 'civitai', confidence: 100 };
|
||||
const hashMatch2 = { filename: 'local2.safetensors', sha256: sha2, match_type: 'hash', hash_lookup_source: 'civitai', confidence: 100 };
|
||||
const exactMatch = { filename: 'original.safetensors', confidence: 100, match_type: 'exact' };
|
||||
|
||||
const state = {
|
||||
results: {
|
||||
custom: [
|
||||
{ sourceKey: 'civitai', model: 'First', downloadUrl: 'url1', sha256: sha1 },
|
||||
{ sourceKey: 'civitai', model: 'Second', downloadUrl: 'url2', sha256: sha2 },
|
||||
],
|
||||
local_hash_matches: [hashMatch1, hashMatch2],
|
||||
},
|
||||
};
|
||||
const missing = {
|
||||
original_path: 'original.safetensors',
|
||||
matches: [exactMatch, hashMatch1, hashMatch2],
|
||||
};
|
||||
|
||||
let refreshedUiCount = 0;
|
||||
const dialog = {
|
||||
getWorkflowScopedQueueKey: () => 'workflow',
|
||||
getSearchStateForWorkflow: () => state,
|
||||
persistSearchStateForWorkflow(_key, _missing, value) { Object.assign(state, value); },
|
||||
displaySearchResults: display,
|
||||
renderSearchResultsTable: render,
|
||||
wireSearchDownloadButtons: wire,
|
||||
syncVisibleSearchResultsLocalMatches: sync,
|
||||
shouldDisplayKnownDownloadSource: () => false,
|
||||
renderSearchProgress: () => '',
|
||||
hasActiveSearchProgress: () => false,
|
||||
prepareSearchResultRow: (_missing, row) => row,
|
||||
getCustomUrlResultTableRow: (_missing, result) => ({
|
||||
sourceKey: result.sourceKey,
|
||||
model: result.model,
|
||||
downloadUrl: result.downloadUrl,
|
||||
sha256: result.sha256,
|
||||
result,
|
||||
localHashMatchIdentities: [result.sha256],
|
||||
}),
|
||||
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: () => '',
|
||||
getSearchResultSha256: result => result?.sha256 || '',
|
||||
getLocalMatchHash: match => match?.sha256 || '',
|
||||
getLocalMatchIdentity: match => match?.sha256 || match?.filename || '',
|
||||
getMissingModelKey: () => 'missing-1',
|
||||
mergeLocalMatches: (a, b) => {
|
||||
const seen = new Set();
|
||||
const res = [];
|
||||
[...a, ...b].forEach(m => {
|
||||
const id = m.sha256 || m.filename;
|
||||
if (!seen.has(id)) { seen.add(id); res.push(m); }
|
||||
});
|
||||
return res;
|
||||
},
|
||||
refreshLocalMatchesUiForMissing() { refreshedUiCount++; },
|
||||
setLocalMatchesInAnalysisCache() {},
|
||||
};
|
||||
|
||||
display.call(dialog, missing, state, container);
|
||||
assert.equal(missing.matches.length, 3);
|
||||
|
||||
container.querySelector('.search-toggle-remove-btn').click();
|
||||
const removeButtons = container.querySelectorAll('.search-remove-result-btn');
|
||||
assert.equal(removeButtons.length, 2);
|
||||
|
||||
removeButtons[1].click();
|
||||
|
||||
assert.equal(missing.matches.length, 2);
|
||||
assert.deepEqual(missing.matches.map(m => m.filename), ['original.safetensors', 'local1.safetensors']);
|
||||
assert.equal(refreshedUiCount > 0, true);
|
||||
|
||||
window.happyDOM.close();
|
||||
});
|
||||
|
||||
test('search result table exposes linked local hash targets for exact matches', () => {
|
||||
const renderSearchResultsTable = eval(`(${extractMethod(searchPanelMethodsSource, 'renderSearchResultsTable')})`);
|
||||
const dialog = {
|
||||
|
||||
@@ -842,15 +842,51 @@ export const resolveDownloadMethods = {
|
||||
restoreSearchLocalHashMatchesForMissing(missing) {
|
||||
if (!missing) return missing;
|
||||
|
||||
const state = this.searchResultCache?.get(this.getMissingSearchKey(missing));
|
||||
const hashMatches = Array.isArray(state?.results?.local_hash_matches)
|
||||
const workflowKey = this.getWorkflowScopedQueueKey?.();
|
||||
const state = (workflowKey ? this.getSearchStateForWorkflow?.(workflowKey, missing) : null)
|
||||
|| this.searchResultCache?.get?.(this.getMissingSearchKey?.(missing))
|
||||
|| null;
|
||||
const allHashMatches = Array.isArray(state?.results?.local_hash_matches)
|
||||
? state.results.local_hash_matches
|
||||
: [];
|
||||
if (!hashMatches.length) return missing;
|
||||
if (!allHashMatches.length) return missing;
|
||||
|
||||
const removedKeys = new Set(state?.removedSearchResultKeys || []);
|
||||
let hashMatchesToRestore = allHashMatches;
|
||||
|
||||
if (removedKeys.size > 0) {
|
||||
const visibleHashes = new Set();
|
||||
const results = state.results || {};
|
||||
const checkResult = (result, sourceKey) => {
|
||||
if (!result || typeof result !== 'object') return;
|
||||
const rowKey = `${sourceKey}:${result.download_url || result.downloadUrl || result.url || result.openUrl || `${result.name || result.model}:${result.filename}`}`;
|
||||
if (removedKeys.has(rowKey)) return;
|
||||
const sha = normalizeSha256(this.getSearchResultSha256?.(result) || result.sha256 || result.hash);
|
||||
if (sha) visibleHashes.add(sha);
|
||||
};
|
||||
|
||||
checkResult(results.popular, 'popular');
|
||||
checkResult(results.model_list, 'model-list');
|
||||
(Array.isArray(results.huggingface) ? results.huggingface : [results.huggingface]).forEach(r => checkResult(r, 'huggingface'));
|
||||
(Array.isArray(results.civitai) ? results.civitai : [results.civitai]).forEach(r => checkResult(r, 'civitai'));
|
||||
(Array.isArray(results.civarchive) ? results.civarchive : [results.civarchive]).forEach(r => checkResult(r, 'civarchive'));
|
||||
(Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive : [results.lora_manager_archive]).forEach(r => checkResult(r, 'lora-archive'));
|
||||
(Array.isArray(results.custom) ? results.custom : []).forEach(r => checkResult(r, r?.source || 'custom'));
|
||||
if (missing.download_source) {
|
||||
checkResult(missing.download_source, missing.download_source.source || 'workflow');
|
||||
}
|
||||
|
||||
hashMatchesToRestore = allHashMatches.filter(match => {
|
||||
const matchHash = normalizeSha256(this.getLocalMatchHash?.(match) || match.sha256);
|
||||
return matchHash && visibleHashes.has(matchHash);
|
||||
});
|
||||
}
|
||||
|
||||
if (!hashMatchesToRestore.length) return missing;
|
||||
|
||||
missing.matches = this.mergeLocalMatches(
|
||||
Array.isArray(missing.matches) ? missing.matches : [],
|
||||
this.cloneLocalMatches(hashMatches)
|
||||
this.cloneLocalMatches(hashMatchesToRestore)
|
||||
);
|
||||
return missing;
|
||||
},
|
||||
@@ -1030,6 +1066,32 @@ export const resolveDownloadMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
setLocalMatchesInAnalysisCache(missing, matches = []) {
|
||||
if (!missing || !Array.isArray(matches)) return;
|
||||
|
||||
const missingKey = this.getMissingModelKey(missing);
|
||||
const patchAnalysisData = (data) => {
|
||||
const patchList = (list) => {
|
||||
if (!Array.isArray(list)) return;
|
||||
|
||||
for (const item of list) {
|
||||
if (this.getMissingModelKey(item) !== missingKey) continue;
|
||||
item.matches = [...matches];
|
||||
}
|
||||
};
|
||||
|
||||
patchList(data?.missing_models);
|
||||
patchList(data?.resolved_models);
|
||||
};
|
||||
|
||||
patchAnalysisData(this.cachedAnalysisData);
|
||||
const workflowKey = this.getWorkflowScopedQueueKey?.();
|
||||
const cached = workflowKey ? this.workflowAnalysisCaches?.get(workflowKey) : null;
|
||||
if (cached?.data) {
|
||||
patchAnalysisData(cached.data);
|
||||
}
|
||||
},
|
||||
|
||||
rememberDownloadUiState(downloadId, info, progress = {}, options = {}) {
|
||||
if (!info?.missing) return null;
|
||||
|
||||
@@ -1713,10 +1775,11 @@ export const resolveDownloadMethods = {
|
||||
return currentMissing.matches || [];
|
||||
}
|
||||
const refreshedMatches = Array.isArray(data.matches) ? data.matches : [];
|
||||
const matches = this.preserveActiveDownloadLocalMatches?.(
|
||||
let matches = this.preserveActiveDownloadLocalMatches?.(
|
||||
currentMissing,
|
||||
refreshedMatches
|
||||
) || refreshedMatches;
|
||||
matches = this.restoreSearchLocalHashMatchesForMissing?.({ ...currentMissing, matches })?.matches || matches;
|
||||
currentMissing.matches = matches;
|
||||
missing.matches = matches;
|
||||
if (currentMissing.is_urn) {
|
||||
@@ -2815,6 +2878,9 @@ export const resolveDownloadMethods = {
|
||||
custom: nextCustom,
|
||||
local_hash_matches: localHashMatches
|
||||
};
|
||||
if (Array.isArray(data.local_hash_matches) && data.local_hash_matches.length) {
|
||||
this.applyLocalHashMatchesFromSearchResponse?.(missing, data, { workflowKey });
|
||||
}
|
||||
if (state.removedSearchResultKeys?.length) {
|
||||
const restoredKeys = new Set(customResults.map(item => {
|
||||
const customRow = this.getCustomUrlResultTableRow(missing, item);
|
||||
@@ -4270,11 +4336,84 @@ export const resolveDownloadMethods = {
|
||||
const html = `${progressHtml}${statusHtml}${tableHtml}`;
|
||||
this.patchSearchResultsContainer(container, html);
|
||||
|
||||
this.syncVisibleSearchResultsLocalMatches?.(missing, rows, state);
|
||||
|
||||
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
||||
this.wireSearchProgressRetryButtons?.(container, missing, state);
|
||||
this.wireSearchDownloadButtons(container, missing);
|
||||
},
|
||||
|
||||
syncVisibleSearchResultsLocalMatches(missing, rows = [], state = null) {
|
||||
if (!missing) return;
|
||||
|
||||
const visibleHashes = new Set();
|
||||
const visibleIdentities = new Set();
|
||||
for (const row of rows) {
|
||||
if (!row) continue;
|
||||
const rowHash = normalizeSha256(
|
||||
this.getSearchResultSha256?.(row.result || row)
|
||||
|| row.detailsContext?.sha256
|
||||
|| row.result?.sha256
|
||||
|| row.sha256
|
||||
);
|
||||
if (rowHash) visibleHashes.add(rowHash);
|
||||
if (Array.isArray(row.localHashMatchIdentities)) {
|
||||
row.localHashMatchIdentities.forEach(id => {
|
||||
const normalized = String(id || '').trim();
|
||||
if (normalized) visibleIdentities.add(normalized);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isSearchHashMatch = (match) => Boolean(
|
||||
match?.hash_lookup_source
|
||||
|| (match?.hash_match && match?.match_type === 'hash')
|
||||
);
|
||||
|
||||
const allCandidateHashMatches = Array.isArray(state?.results?.local_hash_matches)
|
||||
? state.results.local_hash_matches
|
||||
: [];
|
||||
const activeHashMatches = allCandidateHashMatches.filter(match => {
|
||||
const matchHash = normalizeSha256(this.getLocalMatchHash?.(match) || match?.sha256);
|
||||
const matchIdentity = this.getLocalMatchIdentity?.(match);
|
||||
return (matchHash && visibleHashes.has(matchHash))
|
||||
|| (matchIdentity && visibleIdentities.has(matchIdentity));
|
||||
});
|
||||
|
||||
const missingKey = this.getMissingModelKey?.(missing);
|
||||
const currentMissing = (this.missingModels || [])
|
||||
.find(item => this.getMissingModelKey?.(item) === missingKey)
|
||||
|| missing;
|
||||
const currentMatches = Array.isArray(currentMissing.matches)
|
||||
? currentMissing.matches
|
||||
: (Array.isArray(missing.matches) ? missing.matches : []);
|
||||
|
||||
const retainedMatches = currentMatches.filter(match => {
|
||||
if (!isSearchHashMatch(match)) return true;
|
||||
const matchHash = normalizeSha256(this.getLocalMatchHash?.(match) || match?.sha256);
|
||||
const matchIdentity = this.getLocalMatchIdentity?.(match);
|
||||
return (matchHash && visibleHashes.has(matchHash))
|
||||
|| (matchIdentity && visibleIdentities.has(matchIdentity));
|
||||
});
|
||||
|
||||
const mergedMatches = this.mergeLocalMatches
|
||||
? this.mergeLocalMatches(retainedMatches, activeHashMatches)
|
||||
: [...retainedMatches, ...activeHashMatches];
|
||||
|
||||
const matchesChanged = mergedMatches.length !== currentMatches.length
|
||||
|| mergedMatches.some((m, idx) => (
|
||||
(this.getLocalMatchIdentity?.(m) || m.filename || m.path)
|
||||
!== (this.getLocalMatchIdentity?.(currentMatches[idx]) || currentMatches[idx]?.filename || currentMatches[idx]?.path)
|
||||
));
|
||||
|
||||
if (matchesChanged) {
|
||||
currentMissing.matches = mergedMatches;
|
||||
missing.matches = mergedMatches;
|
||||
this.setLocalMatchesInAnalysisCache?.(currentMissing, mergedMatches);
|
||||
this.refreshLocalMatchesUiForMissing?.(currentMissing);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Download from search results
|
||||
*/
|
||||
|
||||
@@ -32,35 +32,43 @@ export const searchHashMethods = {
|
||||
if (!(match.hash_match || match.match_type === 'hash' || match.hash_lookup_source)) return;
|
||||
addHash(this.getLocalMatchHash?.(match) || match.sha256 || match.hash);
|
||||
};
|
||||
const addResult = (result) => {
|
||||
const addResult = (result, sourceKey = '') => {
|
||||
if (Array.isArray(result)) {
|
||||
result.forEach(addResult);
|
||||
result.forEach(r => addResult(r, sourceKey));
|
||||
return;
|
||||
}
|
||||
if (!result || typeof result !== 'object') return;
|
||||
if (sourceKey && removedRowKeys.size > 0) {
|
||||
const rowKey = `${sourceKey}:${result.download_url || result.downloadUrl || result.url || result.openUrl || `${result.name || result.model}:${result.filename}`}`;
|
||||
if (removedRowKeys.has(rowKey)) return;
|
||||
}
|
||||
const matchType = String(result.match_type || '').toLowerCase();
|
||||
if (!(result.hash_verified || result.hash_verified_sha256 || matchType === 'hash')) return;
|
||||
addHash(this.getSearchResultSha256?.(result) || result.sha256 || result.hash);
|
||||
};
|
||||
|
||||
(Array.isArray(missing.matches) ? missing.matches : []).forEach(addMatch);
|
||||
const workflowKey = this.getWorkflowScopedQueueKey?.();
|
||||
const searchState = results
|
||||
? null
|
||||
: (
|
||||
(workflowKey ? this.getSearchStateForWorkflow?.(workflowKey, missing) : null)
|
||||
|| this.getSearchState?.(missing)
|
||||
|| this.searchResultCache?.get?.(this.getMissingSearchKey?.(missing))
|
||||
);
|
||||
const removedRowKeys = new Set(searchState?.removedSearchResultKeys || []);
|
||||
const stateResults = results || searchState?.results || {};
|
||||
|
||||
const stateResults = results
|
||||
|| this.getSearchState?.(missing)?.results
|
||||
|| this.searchResultCache?.get?.(this.getMissingSearchKey?.(missing))?.results
|
||||
|| {};
|
||||
(Array.isArray(missing.matches) ? missing.matches : []).forEach(addMatch);
|
||||
(Array.isArray(stateResults.local_hash_matches) ? stateResults.local_hash_matches : []).forEach(addMatch);
|
||||
|
||||
[
|
||||
missing.download_source,
|
||||
stateResults.popular,
|
||||
stateResults.model_list,
|
||||
stateResults.huggingface,
|
||||
stateResults.civitai,
|
||||
stateResults.civarchive,
|
||||
stateResults.lora_manager_archive,
|
||||
...(Array.isArray(stateResults.custom) ? stateResults.custom : [])
|
||||
].forEach(addResult);
|
||||
addResult(missing.download_source, missing.download_source?.source || 'workflow');
|
||||
addResult(stateResults.popular, 'popular');
|
||||
addResult(stateResults.model_list, 'model-list');
|
||||
addResult(stateResults.huggingface, 'huggingface');
|
||||
addResult(stateResults.civitai, 'civitai');
|
||||
addResult(stateResults.civarchive, 'civarchive');
|
||||
addResult(stateResults.lora_manager_archive, 'lora-archive');
|
||||
(Array.isArray(stateResults.custom) ? stateResults.custom : []).forEach(r => addResult(r, r?.source || 'custom'));
|
||||
|
||||
return hashes;
|
||||
},
|
||||
@@ -132,6 +140,7 @@ export const searchHashMethods = {
|
||||
return String(
|
||||
match.sha256
|
||||
|| match.hash
|
||||
|| match.hash_lookup_sha256
|
||||
|| modelHash
|
||||
|| ''
|
||||
).trim();
|
||||
|
||||
Reference in New Issue
Block a user