feat: sync search hash matches with local matches and auto-remove on row deletion

This commit is contained in:
Dariusz L
2026-11-02 01:27:02 +01:00
parent 9227ca2c45
commit 85165379e6
3 changed files with 265 additions and 22 deletions
@@ -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
*/
+26 -17
View File
@@ -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();