diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 047aa55..bde6ca8 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -7178,6 +7178,151 @@ test('search result refreshes preserve keyed progress and result DOM', () => { assert.match(searchPanelMethodsSource, /data-search-result-key=/); }); +test('search provider rows preserve a shared result row contract', () => { + const displaySearchResults = eval(`(${extractMethod(resolveDownloadMethodsSource, 'displaySearchResults')})`); + const buildSearchResultRow = eval(`(${extractMethod(searchPanelMethodsSource, 'buildSearchResultRow')})`); + const previousGlobals = { + getModelCardUrl: globalThis.getModelCardUrl, + getCivitaiModelUrl: globalThis.getCivitaiModelUrl, + parseHuggingFaceFileUrl: globalThis.parseHuggingFaceFileUrl, + }; + const rows = []; + const dialog = { + buildSearchResultRow, + getHashMatchLabelMap() { + return null; + }, + shouldDisplayKnownDownloadSource() { + return false; + }, + getDownloadSourceTableRow() { + return null; + }, + renderSearchProgress() { + return ''; + }, + hasActiveSearchProgress() { + return false; + }, + getLocalHashMatchIdentitiesForResult() { + return []; + }, + getHashMatchLabelForSearchResult() { + return ''; + }, + getSearchResultMatchDisplay(_result, fallbackLabel, fallbackClass) { + return { label: fallbackLabel, className: fallbackClass }; + }, + getFilenameFromPath(value = '') { + return String(value).split(/[\\/]/).at(-1) || ''; + }, + getMissingDownloadCategory() { + return 'checkpoints'; + }, + getSourceResultDownloadCategory(_result, fallback) { + return fallback; + }, + formatSearchResultSize(result = {}) { + return result.size || ''; + }, + getSearchResultTimestamp() { + return ''; + }, + getMissingModelKey() { + return 'missing-key'; + }, + getDownloadPathMetadata() { + return {}; + }, + getDownloadMetadata() { + return {}; + }, + renderStatusMessage() { + return ''; + }, + renderSearchResultsTable(searchRows) { + rows.push(...searchRows); + return ''; + }, + patchSearchResultsContainer() {}, + wireSearchProgressCancelButtons() {}, + wireSearchProgressRetryButtons() {}, + wireSearchDownloadButtons() {}, + }; + + globalThis.getModelCardUrl = value => value || ''; + globalThis.getCivitaiModelUrl = (modelId, versionId) => `civitai/${modelId}/${versionId}`; + globalThis.parseHuggingFaceFileUrl = () => null; + + try { + displaySearchResults.call(dialog, { + original_path: 'models/model.safetensors', + category: 'checkpoints', + matches: [], + }, { + results: { + popular: { + name: 'Popular model', + filename: 'popular.safetensors', + url: 'popular/download', + }, + model_list: { + name: 'Local model', + filename: 'local.safetensors', + url: 'local/download', + }, + huggingface: { + repo_id: 'owner/repo', + filename: 'hf.safetensors', + url: 'hf/download', + }, + civarchive: { + name: 'Archive model', + filename: 'archive.safetensors', + download_url: 'archive/download', + url: 'archive/page', + }, + lora_manager_archive: { + name: 'LoRA model', + filename: 'lora.safetensors', + download_url: 'lora/download', + url: 'lora/page', + }, + civitai: { + name: 'CivitAI model', + filename: 'civitai.safetensors', + download_url: 'civitai/download', + url: 'civitai/page', + }, + }, + }, {}); + } finally { + if (previousGlobals.getModelCardUrl === undefined) delete globalThis.getModelCardUrl; + else globalThis.getModelCardUrl = previousGlobals.getModelCardUrl; + if (previousGlobals.getCivitaiModelUrl === undefined) delete globalThis.getCivitaiModelUrl; + else globalThis.getCivitaiModelUrl = previousGlobals.getCivitaiModelUrl; + if (previousGlobals.parseHuggingFaceFileUrl === undefined) delete globalThis.parseHuggingFaceFileUrl; + else globalThis.parseHuggingFaceFileUrl = previousGlobals.parseHuggingFaceFileUrl; + } + + assert.equal(rows.length, 6); + for (const row of rows) { + assert.equal(typeof row.sourceKey, 'string'); + assert.equal(typeof row.sourceLabel, 'string'); + assert.equal(typeof row.model, 'string'); + assert.equal(typeof row.filename, 'string'); + assert.equal(typeof row.match, 'object'); + assert.equal(typeof row.size, 'string'); + assert.equal(typeof row.downloadUrl, 'string'); + assert.equal(typeof row.downloadFilename, 'string'); + assert.equal(typeof row.category, 'string'); + assert.equal(typeof row.searchedAt, 'string'); + assert.ok(Array.isArray(row.localHashMatchIdentities)); + assert.ok(row.pathMetadata); + assert.ok(row.downloadMetadata); + } +}); + test('source model details file selection includes selected file hash metadata', () => { const renderSourceModelDetailsFiles = eval(`(${extractMethod(modelInfoMethodsSource, 'renderSourceModelDetailsFiles')})`); const sha256 = 'a'.repeat(64); diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index ce7ca0d..493c0de 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -3662,17 +3662,6 @@ export const resolveDownloadMethods = { ...(Array.isArray(missing.matches) ? missing.matches.filter(match => match?.hash_lookup_source) : []) ]; const hashLabelMap = this.getHashMatchLabelMap?.(missing, results) || null; - const getHashMatchIdentities = (sourceKey, result) => ( - this.getLocalHashMatchIdentitiesForResult?.(localHashMatches, sourceKey, result) || [] - ); - const getHashMatchDisplay = (sourceKey, result, fallbackLabel = 'Match', fallbackClass = 'neutral') => { - const identities = getHashMatchIdentities(sourceKey, result); - const hashLabel = this.getHashMatchLabelForSearchResult?.(result, hashLabelMap, identities) || ''; - return { - identities, - match: this.getSearchResultMatchDisplay(result, fallbackLabel, fallbackClass, hashLabel) - }; - }; const knownDownloadRow = this.shouldDisplayKnownDownloadSource(missing, missing.download_source, state) ? this.getDownloadSourceTableRow(missing, missing.download_source, hashLabelMap) : null; @@ -3740,7 +3729,6 @@ export const resolveDownloadMethods = { addRow(knownDownloadRow); if (popular) { - const popularHash = getHashMatchDisplay('popular', popular, 'Known', 'strong'); const popularFilename = popular.filename || this.getFilenameFromPath(missing.original_path); const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints'; const popularSize = popular.size @@ -3751,25 +3739,26 @@ export const resolveDownloadMethods = { ? modelListResult.size : '' ); - addRow({ + addRow(this.buildSearchResultRow(missing, { + result: popular, sourceKey: 'popular', sourceLabel: 'Popular', model: popular.name || popularFilename, filename: popularFilename, secondary: popular.name && popular.name !== popularFilename ? popularFilename : '', - match: popularHash.match, - size: this.formatSearchResultSize({ ...popular, size: popularSize }), + sizeResult: { ...popular, size: popularSize }, downloadUrl: popular.url, downloadFilename: popularFilename, category: popular.directory || missingCategory, openUrl: getModelCardUrl(popular.url), - searchedAt: this.getSearchResultTimestamp(popular), - localHashMatchIdentities: popularHash.identities - }); + hashLabelMap, + localHashMatches, + fallbackLabel: 'Known', + fallbackClass: 'strong', + })); } if (modelListResult && modelListResult.url) { - const modelListHash = getHashMatchDisplay('model_list', modelListResult); const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints'; const huggingFaceFile = parseHuggingFaceFileUrl(modelListResult.url); const modelListDetailsContext = huggingFaceFile @@ -3787,43 +3776,40 @@ export const resolveDownloadMethods = { category: modelListResult.directory || missingCategory } : null; - addRow({ + addRow(this.buildSearchResultRow(missing, { + result: modelListResult, sourceKey: 'model-list', sourceLabel: 'Local Database', model: modelListResult.name || modelListResult.filename, filename: modelListResult.filename, secondary: modelListResult.name && modelListResult.name !== modelListResult.filename ? modelListResult.filename : '', - match: modelListHash.match, - size: this.formatSearchResultSize(modelListResult), downloadUrl: modelListResult.url, downloadFilename: modelListResult.filename, category: modelListResult.directory || missingCategory, openUrl: getModelCardUrl(modelListResult.url), - searchedAt: this.getSearchResultTimestamp(modelListResult), - localHashMatchIdentities: modelListHash.identities, - detailsContext: modelListDetailsContext - }); + hashLabelMap, + localHashMatches, + detailsContext: modelListDetailsContext, + })); } if (hfResult && hfResult.url) { - const hfHash = getHashMatchDisplay('huggingface', hfResult); const hfRepo = hfResult.repo_id || hfResult.repo || ''; const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : getModelCardUrl(hfResult.url); const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints'; - addRow({ + addRow(this.buildSearchResultRow(missing, { + result: hfResult, sourceKey: 'huggingface', sourceLabel: 'HuggingFace', model: hfRepo || hfResult.filename, filename: hfResult.filename, secondary: hfResult.path && hfResult.path !== hfResult.filename ? hfResult.path : '', - match: hfHash.match, - size: this.formatSearchResultSize(hfResult), downloadUrl: hfResult.url, downloadFilename: hfResult.filename, category: missingCategory, openUrl: hfModelUrl, - searchedAt: this.getSearchResultTimestamp(hfResult), - localHashMatchIdentities: hfHash.identities, + hashLabelMap, + localHashMatches, detailsContext: { ...hfResult, name: hfRepo || hfResult.filename, @@ -3832,11 +3818,10 @@ export const resolveDownloadMethods = { missing_key: this.getMissingModelKey(missing), category: missingCategory } - }); + })); } if (civarchiveResult && civarchiveResult.download_url) { - const civarchiveHash = getHashMatchDisplay('civarchive', civarchiveResult); const archiveFilename = civarchiveResult.filename || this.getFilenameFromPath(missing.original_path); const archiveName = civarchiveResult.name || archiveFilename || 'Model'; const archiveCategory = this.getSourceResultDownloadCategory?.( @@ -3849,32 +3834,30 @@ export const resolveDownloadMethods = { civarchiveResult.platform || '', civarchiveResult.base_model || '' ].filter(Boolean).join(' / '); - addRow({ + addRow(this.buildSearchResultRow(missing, { + result: civarchiveResult, sourceKey: 'civarchive', sourceLabel: 'CivArchive', model: archiveName, version: civarchiveResult.version_name || '', filename: archiveFilename, secondary: archiveSecondary, - match: civarchiveHash.match, - size: this.formatSearchResultSize(civarchiveResult), downloadUrl: civarchiveResult.download_url, downloadFilename: archiveFilename, category: archiveCategory, openUrl: civarchiveResult.url, - searchedAt: this.getSearchResultTimestamp(civarchiveResult), - localHashMatchIdentities: civarchiveHash.identities, + hashLabelMap, + localHashMatches, detailsContext: { ...civarchiveResult, details_source: 'civarchive', missing_key: this.getMissingModelKey(missing), category: archiveCategory } - }); + })); } if (loraManagerArchiveResult && loraManagerArchiveResult.download_url) { - const loraArchiveHash = getHashMatchDisplay('lora_manager_archive', loraManagerArchiveResult); const archiveFilename = loraManagerArchiveResult.filename || this.getFilenameFromPath(missing.original_path); const archiveName = loraManagerArchiveResult.name || archiveFilename; const archiveCategory = this.getSourceResultDownloadCategory?.( @@ -3882,21 +3865,21 @@ export const resolveDownloadMethods = { this.getMissingDownloadCategory?.(missing, 'loras') || 'loras', missing ) || this.getMissingDownloadCategory?.(missing, 'loras') || 'loras'; - addRow({ + addRow(this.buildSearchResultRow(missing, { + result: loraManagerArchiveResult, sourceKey: 'lora-archive', sourceLabel: 'LoRA Archive', model: archiveName, version: loraManagerArchiveResult.version_name || '', filename: archiveFilename, secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : '', - match: loraArchiveHash.match, - size: this.formatSearchResultSize(loraManagerArchiveResult), downloadUrl: loraManagerArchiveResult.download_url || '', downloadFilename: archiveFilename, category: archiveCategory, openUrl: loraManagerArchiveResult.url || getModelCardUrl(loraManagerArchiveResult.download_url), - searchedAt: this.getSearchResultTimestamp(loraManagerArchiveResult), - localHashMatchIdentities: loraArchiveHash.identities, + hashSourceKey: 'lora_manager_archive', + hashLabelMap, + localHashMatches, detailsContext: { ...loraManagerArchiveResult, source: 'lora_manager_archive', @@ -3904,11 +3887,10 @@ export const resolveDownloadMethods = { missing_key: this.getMissingModelKey(missing), category: archiveCategory } - }); + })); } if (civitaiResult && civitaiResult.download_url) { - const civitaiHash = getHashMatchDisplay('civitai', civitaiResult); const modelUrl = civitaiResult.url || getCivitaiModelUrl(civitaiResult.model_id, civitaiResult.version_id); const downloadFilename = civitaiResult.filename || missing.civitai_info?.expected_filename || civitaiResult.name; const modelName = civitaiResult.name || missing.civitai_info?.model_name || downloadFilename || 'Model'; @@ -3921,21 +3903,20 @@ export const resolveDownloadMethods = { civitaiResult.type || '', civitaiResult.base_model || missing.civitai_info?.base_model || '' ].filter(Boolean).join(' / '); - addRow({ + addRow(this.buildSearchResultRow(missing, { + result: civitaiResult, sourceKey: 'civitai', sourceLabel: 'CivitAI', model: modelName, version: civitaiResult.version_name || missing.civitai_info?.version_name || '', filename: downloadFilename, secondary: civitaiSecondary, - match: civitaiHash.match, - size: this.formatSearchResultSize(civitaiResult), downloadUrl: civitaiResult.download_url, downloadFilename, category: civitaiCategory, openUrl: modelUrl, - searchedAt: this.getSearchResultTimestamp(civitaiResult), - localHashMatchIdentities: civitaiHash.identities, + hashLabelMap, + localHashMatches, detailsContext: { ...civitaiResult, name: modelName, @@ -3944,7 +3925,7 @@ export const resolveDownloadMethods = { missing_key: this.getMissingModelKey(missing), category: civitaiCategory } - }); + })); } customResults.forEach((customResult) => { diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index 659abe7..92745d5 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -1594,6 +1594,56 @@ export const searchPanelMethods = { return { label: fallbackLabel, className: fallbackClass }; }, + buildSearchResultRow(missing, { + result = {}, + sourceKey, + sourceLabel, + hashSourceKey = sourceKey, + hashLabelMap = null, + localHashMatches = [], + model = '', + version = '', + filename = '', + secondary = '', + sizeResult = result, + downloadUrl = '', + downloadFilename = filename, + category = '', + openUrl = '', + detailsContext = null, + fallbackLabel = 'Match', + fallbackClass = 'neutral', + } = {}) { + const localHashMatchIdentities = this.getLocalHashMatchIdentitiesForResult?.( + localHashMatches, + hashSourceKey, + result + ) || []; + const hashMatchLabel = this.getHashMatchLabelForSearchResult?.( + result, + hashLabelMap, + localHashMatchIdentities + ) || ''; + + return { + sourceKey, + sourceLabel, + model, + version, + filename, + secondary, + match: this.getSearchResultMatchDisplay(result, fallbackLabel, fallbackClass, hashMatchLabel), + size: this.formatSearchResultSize(sizeResult), + downloadUrl, + downloadFilename, + category, + openUrl, + searchedAt: this.getSearchResultTimestamp(result), + localHashMatchIdentities, + detailsContext, + }; + }, + getDownloadSourceTableRow(missing, downloadSource = {}, hashLabelMap = null) { if (!downloadSource?.url) return null;