From 8a8e2bc3a1109cfe48ab67bbfdecf4d220d8a558 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 7 Aug 2026 04:12:57 +0200 Subject: [PATCH] refactor shared source URL detection --- tests/test_downloads_tab_workflow_route.mjs | 7 +- tests/test_url_utils.mjs | 37 +++++++++- web/resolver/utils/source_labels.js | 10 +++ web/resolver/utils/url_utils.js | 26 +++++++ web/resolver/views/model_info_methods.js | 79 ++++++++------------- 5 files changed, 104 insertions(+), 55 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 2763170..87832b7 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -8,7 +8,7 @@ import { normalizePathIdentity, safeStorage, } from '../web/resolver/utils/html_utils.js'; -import { getModelCardUrl } from '../web/resolver/utils/url_utils.js'; +import { getModelCardUrl, getSourceKeyFromUrl } from '../web/resolver/utils/url_utils.js'; import { joinPathPreservingStyle } from '../web/resolver/utils/path_utils.js'; import { classifyLocalMatches, @@ -4758,11 +4758,6 @@ test('source link prefers the selected provider page over another provider mirro civarchivePage ]; }, - getContextMenuSourceKeyFromUrl(value = '') { - if (value.includes('civarchive.com')) return 'civarchive'; - if (value.includes('huggingface.co')) return 'huggingface'; - return ''; - }, normalizeContextMenuSourceUrl(value = '') { return value; }, diff --git a/tests/test_url_utils.mjs b/tests/test_url_utils.mjs index 374ad54..73629a4 100644 --- a/tests/test_url_utils.mjs +++ b/tests/test_url_utils.mjs @@ -1,7 +1,42 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { getModelCardUrl, parseHuggingFaceFileUrl } from '../web/resolver/utils/url_utils.js'; +import { + getModelCardUrl, + getSourceKeyFromUrl, + parseHuggingFaceFileUrl, +} from '../web/resolver/utils/url_utils.js'; +import { getSourceKeyFromText } from '../web/resolver/utils/source_labels.js'; + +test('source URL parsing normalizes supported provider hosts', () => { + const cases = [ + ['https://huggingface.co/owner/repo', 'huggingface'], + ['https://www.huggingface.co/owner/repo', 'huggingface'], + ['https://cdn.civarchive.com/models/1', 'civarchive'], + ['https://civitai.com/models/1', 'civitai'], + ['https://www.civitai.red/models/1', 'civitai'], + ['https://unknown.example/models/1', ''], + ['not a URL', ''], + ]; + + for (const [value, expected] of cases) { + assert.equal(getSourceKeyFromUrl(value), expected, value); + } +}); + +test('source URL parsing can preserve text fallback semantics', () => { + assert.equal(getSourceKeyFromUrl('CivArchive mirror', { fallbackToText: true }), 'civarchive'); + assert.equal(getSourceKeyFromUrl('LoRA Archive result', { fallbackToText: true }), 'lora_manager_archive'); + assert.equal(getSourceKeyFromUrl('unknown.example/model', { fallbackToText: true }), ''); +}); + +test('source text parsing normalizes provider aliases', () => { + assert.equal(getSourceKeyFromText('Hugging Face result'), 'huggingface'); + assert.equal(getSourceKeyFromText('Civ Archive result'), 'civarchive'); + assert.equal(getSourceKeyFromText('CivitAI result'), 'civitai'); + assert.equal(getSourceKeyFromText('LoRA Archive result'), 'lora_manager_archive'); + assert.equal(getSourceKeyFromText('unknown'), ''); +}); test('Hugging Face file URLs expose repository, revision, path, and filename', () => { assert.deepEqual( diff --git a/web/resolver/utils/source_labels.js b/web/resolver/utils/source_labels.js index 35c51e7..6bf7a88 100644 --- a/web/resolver/utils/source_labels.js +++ b/web/resolver/utils/source_labels.js @@ -28,6 +28,16 @@ export function normalizeSourceKey(value, { trim = true } = {}) { return (trim ? text.trim() : text).toLowerCase().replace(/-/g, '_'); } +export function getSourceKeyFromText(value = '') { + const text = String(value || '').toLowerCase(); + if (!text) return ''; + if (text.includes('huggingface') || text.includes('hugging face')) return 'huggingface'; + if (text.includes('civarchive') || text.includes('civ archive')) return 'civarchive'; + if (text.includes('civitai') || text.includes('civitai.red')) return 'civitai'; + if (text.includes('lora_manager_archive') || text.includes('lora archive')) return 'lora_manager_archive'; + return ''; +} + export function normalizeSourceList(sources = []) { return new Set( (Array.isArray(sources) ? sources : [sources]) diff --git a/web/resolver/utils/url_utils.js b/web/resolver/utils/url_utils.js index 41267f2..2954fb0 100644 --- a/web/resolver/utils/url_utils.js +++ b/web/resolver/utils/url_utils.js @@ -2,6 +2,32 @@ * URL Utilities for Model Resolver */ +import { getSourceKeyFromText } from './source_labels.js'; + +/** + * Resolve a supported provider key from a URL host. + * @param {string} value + * @param {{fallbackToText?: boolean}} options + * @returns {string} + */ +export function getSourceKeyFromUrl(value, { fallbackToText = false } = {}) { + const url = String(value || '').trim(); + if (!url) return ''; + + try { + const baseUrl = globalThis.location?.origin || 'http://localhost'; + const parsed = new URL(url, baseUrl); + const host = parsed.hostname.toLowerCase().replace(/^www\./, ''); + if (host === 'huggingface.co' || host.endsWith('.huggingface.co')) return 'huggingface'; + if (host === 'civarchive.com' || host.endsWith('.civarchive.com')) return 'civarchive'; + if (host === 'civitai.com' || host.endsWith('.civitai.com') || host === 'civitai.red' || host.endsWith('.civitai.red')) return 'civitai'; + } catch { + // Preserve the caller's optional text fallback for malformed URLs. + } + + return fallbackToText ? getSourceKeyFromText(url) : ''; +} + /** * Parse a Hugging Face blob/resolve file URL into repository coordinates. * @param {string} value diff --git a/web/resolver/views/model_info_methods.js b/web/resolver/views/model_info_methods.js index ecd37e7..f92a0ec 100644 --- a/web/resolver/views/model_info_methods.js +++ b/web/resolver/views/model_info_methods.js @@ -1,11 +1,15 @@ import { api } from "../../../../../scripts/api.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { escapeHtml, escapeJsString, getFilenameFromPath, sanitizeDescriptionHtml, pollBackgroundTask, safeStorage, copyTextWithFeedback } from "../utils/html_utils.js"; -import { getModelCardUrl } from "../utils/url_utils.js"; +import { getModelCardUrl, getSourceKeyFromUrl } from "../utils/url_utils.js"; import { extractComfyWorkflow } from "../utils/workflow_metadata.js"; import { getSha256Field, normalizeSha256 } from "../utils/hash_utils.js"; import { parseFiniteNumber } from "../utils/size_utils.js"; -import { getSourceDisplayLabel } from "../utils/source_labels.js"; +import { + getSourceDisplayLabel, + getSourceKeyFromText, + normalizeSourceKey, +} from "../utils/source_labels.js"; import { getCivitaiModelUrl } from "../globals.js"; export const modelInfoMethods = { escapeHtml, @@ -189,50 +193,27 @@ export const modelInfoMethods = { }, getContextMenuSourceConfig(key = '') { - const normalized = String(key || '').toLowerCase().replace(/[-\s]+/g, '_'); + const normalized = normalizeSourceKey(key).replace(/\s+/g, '_'); const configs = { - civitai: { key: 'civitai', label: 'CivitAI', icon: 'civitai' }, - civarchive: { key: 'civarchive', label: 'CivArchive', icon: 'civarchive' }, - huggingface: { key: 'huggingface', label: 'HuggingFace', icon: 'huggingface' }, - lora_manager_archive: { key: 'lora_manager_archive', label: 'LoRA Archive', icon: 'loraManager' } + civitai: { key: 'civitai', icon: 'civitai' }, + civarchive: { key: 'civarchive', icon: 'civarchive' }, + huggingface: { key: 'huggingface', icon: 'huggingface' }, + lora_manager_archive: { key: 'lora_manager_archive', icon: 'loraManager' } }; - return configs[normalized] || { key: normalized || 'source', label: 'Source', icon: 'externalLink' }; - }, - - getContextMenuSourceKeyFromText(value = '') { - const text = String(value || '').toLowerCase(); - if (!text) return ''; - if (text.includes('huggingface') || text.includes('hugging face')) return 'huggingface'; - if (text.includes('civarchive') || text.includes('civ archive')) return 'civarchive'; - if (text.includes('civitai') || text.includes('civitai.red')) return 'civitai'; - if (text.includes('lora_manager_archive') || text.includes('lora archive')) return 'lora_manager_archive'; - return ''; - }, - - getContextMenuSourceKeyFromUrl(value = '') { - const url = String(value || '').trim(); - if (!url) return ''; - - try { - const parsed = new URL(url, window.location.origin); - const host = parsed.hostname.toLowerCase().replace(/^www\./, ''); - if (host === 'huggingface.co' || host.endsWith('.huggingface.co')) return 'huggingface'; - if (host === 'civarchive.com' || host.endsWith('.civarchive.com')) return 'civarchive'; - if (host === 'civitai.com' || host.endsWith('.civitai.com') || host === 'civitai.red' || host.endsWith('.civitai.red')) return 'civitai'; - } catch (_error) { - return this.getContextMenuSourceKeyFromText(url); - } - - return this.getContextMenuSourceKeyFromText(url); + const config = configs[normalized]; + const label = getSourceDisplayLabel(normalized, { fallback: 'Source' }); + return config + ? { ...config, label } + : { key: normalized || 'source', label, icon: 'externalLink' }; }, getContextMenuSourceKey(model = {}) { - return this.getContextMenuSourceKeyFromText(model?.details_source) - || this.getContextMenuSourceKeyFromText(model?.source) - || this.getContextMenuSourceKeyFromText(model?.source_name) - || this.getContextMenuSourceKeyFromText(model?.sourceName) - || this.getContextMenuSourceKeyFromText(model?.metadata_source) - || this.getContextMenuSourceKeyFromText(model?.metadataSource) + return getSourceKeyFromText(model?.details_source) + || getSourceKeyFromText(model?.source) + || getSourceKeyFromText(model?.source_name) + || getSourceKeyFromText(model?.sourceName) + || getSourceKeyFromText(model?.metadata_source) + || getSourceKeyFromText(model?.metadataSource) || ''; }, @@ -328,13 +309,13 @@ export const modelInfoMethods = { const fallbackKey = this.getContextMenuSourceKey(model); const candidates = this.getContextMenuSourceUrlCandidates(model); const resolveCandidate = (candidate) => { - const candidateKey = this.getContextMenuSourceKeyFromUrl(candidate) || fallbackKey; + const candidateKey = getSourceKeyFromUrl(candidate, { fallbackToText: true }) || fallbackKey; const normalizedUrl = this.normalizeContextMenuSourceUrl(candidate, candidateKey); if (!normalizedUrl) return null; const cardUrl = getModelCardUrl(normalizedUrl) || normalizedUrl; - const sourceKey = this.getContextMenuSourceKeyFromUrl(cardUrl) - || this.getContextMenuSourceKeyFromUrl(normalizedUrl) + const sourceKey = getSourceKeyFromUrl(cardUrl, { fallbackToText: true }) + || getSourceKeyFromUrl(normalizedUrl, { fallbackToText: true }) || candidateKey; const config = this.getContextMenuSourceConfig(sourceKey); return { @@ -345,7 +326,7 @@ export const modelInfoMethods = { const preferredCandidates = fallbackKey ? candidates.filter(candidate => { - const candidateKey = this.getContextMenuSourceKeyFromUrl(candidate); + const candidateKey = getSourceKeyFromUrl(candidate, { fallbackToText: true }); return !candidateKey || candidateKey === fallbackKey; }) : candidates; @@ -1868,7 +1849,7 @@ export const modelInfoMethods = { const sourceKey = this.getContextMenuSourceKey(data); if (sourceKey) return sourceKey; for (const url of this.getContextMenuSourceUrlCandidates(data)) { - const key = this.getContextMenuSourceKeyFromUrl(url); + const key = getSourceKeyFromUrl(url, { fallbackToText: true }); if (key) return key; } return 'metadata'; @@ -3183,8 +3164,10 @@ export const modelInfoMethods = { const source = String(mirror.source || '').trim(); if (source) return source; const host = this.getSourceModelMirrorHost(mirror.url || ''); - if (host.includes('huggingface.co')) return 'HuggingFace'; - if (host.includes('civitai.com')) return 'CivitAI'; + const sourceKey = getSourceKeyFromUrl(mirror.url || ''); + if (sourceKey === 'huggingface' || sourceKey === 'civitai') { + return getSourceDisplayLabel(sourceKey); + } return host || `Mirror ${index + 1}`; },