diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 6b481c0..443b977 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -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 => `

${message}

`, + 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 = { diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index a8a93af..5893446 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -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 */ diff --git a/web/resolver/search/search_hash_methods.js b/web/resolver/search/search_hash_methods.js index e791b4e..9c0e754 100644 --- a/web/resolver/search/search_hash_methods.js +++ b/web/resolver/search/search_hash_methods.js @@ -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();