refactor: centralize getModelCardUrl and HTML/URL utilities in resolver/utils

This commit is contained in:
Dariusz L
2026-07-02 04:18:04 +02:00
parent 86bd6c04fc
commit b7e7b1b295
6 changed files with 181 additions and 133 deletions
@@ -3,6 +3,7 @@ import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { createModuleLogger } from "../../log_system/log_funcs.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import { getModelCardUrl } from "../utils/url_utils.js";
const log = createModuleLogger('resolve_download_methods');
@@ -293,7 +294,7 @@ export const resolveDownloadMethods = {
url,
filename: result[candidate.filenameKey] || missing.civitai_info?.expected_filename || filename,
directory: result[candidate.categoryKey] || result.directory || result.category || missing.category || 'checkpoints',
model_url: this.getModelCardUrl(rawModelUrl) || rawModelUrl,
model_url: getModelCardUrl(rawModelUrl) || rawModelUrl,
name: result.name || result.repo_id || result.repo || result.filename || filename,
size: result.size,
type: result.type || missing.category
@@ -2571,7 +2572,7 @@ export const resolveDownloadMethods = {
downloadUrl: popular.url,
downloadFilename: popularFilename,
category: popular.directory || missingCategory,
openUrl: this.getModelCardUrl(popular.url),
openUrl: getModelCardUrl(popular.url),
searchedAt: this.getSearchResultTimestamp(popular)
});
}
@@ -2589,14 +2590,14 @@ export const resolveDownloadMethods = {
downloadUrl: modelListResult.url,
downloadFilename: modelListResult.filename,
category: modelListResult.directory || missingCategory,
openUrl: this.getModelCardUrl(modelListResult.url),
openUrl: getModelCardUrl(modelListResult.url),
searchedAt: this.getSearchResultTimestamp(modelListResult)
});
}
if (hfResult && hfResult.url) {
const hfRepo = hfResult.repo_id || hfResult.repo || '';
const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : this.getModelCardUrl(hfResult.url);
const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : getModelCardUrl(hfResult.url);
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
addRow({
sourceKey: 'huggingface',
@@ -2670,7 +2671,7 @@ export const resolveDownloadMethods = {
downloadUrl: loraManagerArchiveResult.download_url || '',
downloadFilename: archiveFilename,
category: archiveCategory,
openUrl: loraManagerArchiveResult.url || this.getModelCardUrl(loraManagerArchiveResult.download_url),
openUrl: loraManagerArchiveResult.url || getModelCardUrl(loraManagerArchiveResult.download_url),
searchedAt: this.getSearchResultTimestamp(loraManagerArchiveResult),
detailsContext: {
...loraManagerArchiveResult,
+2 -1
View File
@@ -2,6 +2,7 @@ import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import { getModelCardUrl } from "../utils/url_utils.js";
export const searchPanelMethods = {
/**
* Build stable cache key for a missing model entry
@@ -1701,7 +1702,7 @@ export const searchPanelMethods = {
const rawModelUrl = downloadSource.model_url
|| downloadSource.workflow_model_url
|| downloadSource.url;
const modelUrl = this.getModelCardUrl(rawModelUrl) || rawModelUrl;
const modelUrl = getModelCardUrl(rawModelUrl) || rawModelUrl;
const versionName = downloadSource.version_name || missing.civitai_info?.version_name || '';
const modelParts = this.getModelVersionParts(
downloadSource.name || missing.civitai_info?.model_name || '',
+2 -32
View File
@@ -2,6 +2,7 @@ import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import { getModelCardUrl } from "../utils/url_utils.js";
export const workflowUpdateMethods = {
async refreshComfyModelCatalogAfterApply(workflow = null, resolutions = []) {
if (!this.isAutoRefreshComfyModelsAfterApplyEnabled()) {
@@ -270,38 +271,7 @@ export const workflowUpdateMethods = {
* CivitAI: https://civitai.com/api/download/models/123?type=Model -> https://civitai.com/models/123
*/
getModelCardUrl(downloadUrl) {
if (!downloadUrl) return null;
try {
// HuggingFace URLs
if (downloadUrl.includes('huggingface.co')) {
const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/);
if (fileMatch) {
const repo = fileMatch[1];
const revision = fileMatch[2];
const filePath = fileMatch[3].split(/[?#]/)[0];
return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`;
}
const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/);
if (match) {
return `https://huggingface.co/${match[1]}`;
}
}
// CivitAI URLs
if (downloadUrl.includes('civitai.com')) {
// Format: /api/download/models/123456 or /models/123456/...
const modelIdMatch = downloadUrl.match(/models\/(\d+)/);
if (modelIdMatch) {
return `https://civitai.com/models/${modelIdMatch[1]}`;
}
}
} catch (e) {
console.error('Error parsing model card URL:', e);
}
return null;
return getModelCardUrl(downloadUrl);
},
/**
+121
View File
@@ -0,0 +1,121 @@
/**
* HTML Utilities for Model Resolver
*/
/**
* Escapes special characters for HTML to prevent XSS.
* @param {any} value
* @returns {string} Escaped string
*/
export function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Escapes a value to be safely embedded inside inline JavaScript.
* @param {any} value
* @returns {string} Escaped string
*/
export function escapeJsString(value) {
return escapeHtml(JSON.stringify(String(value ?? '')));
}
/**
* Sanitizes HTML content for display, permitting only safe tags and attributes.
* @param {string} html
* @returns {string} Sanitized HTML
*/
export function sanitizeDescriptionHtml(html) {
const raw = String(html ?? '').trim();
if (!raw) return '';
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${raw}</div>`, 'text/html');
const root = doc.body.firstElementChild;
if (!root) return escapeHtml(raw);
const allowedTags = new Set([
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre',
'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'a', 'span'
]);
const allowedStyles = new Set(['color']);
const sanitizeNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
node.remove();
return;
}
const tag = node.tagName.toLowerCase();
if (!allowedTags.has(tag)) {
const parent = node.parentNode;
if (!parent) {
node.remove();
return;
}
while (node.firstChild) {
parent.insertBefore(node.firstChild, node);
}
parent.removeChild(node);
return;
}
const attrs = Array.from(node.attributes);
for (const attr of attrs) {
const name = attr.name.toLowerCase();
if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) {
node.removeAttribute(attr.name);
continue;
}
if (name === 'href' && tag === 'a') {
const href = node.getAttribute('href') || '';
if (!/^https?:\/\//i.test(href)) {
node.removeAttribute('href');
} else {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
continue;
}
if (name === 'style') {
const safeStyles = [];
const styleValue = node.getAttribute('style') || '';
for (const part of styleValue.split(';')) {
const [prop, value] = part.split(':').map(v => v?.trim());
if (!prop || !value) continue;
if (allowedStyles.has(prop.toLowerCase())) {
safeStyles.push(`${prop}: ${value}`);
}
}
if (safeStyles.length) {
node.setAttribute('style', safeStyles.join('; '));
} else {
node.removeAttribute('style');
}
continue;
}
if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) {
node.removeAttribute(attr.name);
}
}
Array.from(node.childNodes).forEach(child => sanitizeNode(child));
};
Array.from(root.childNodes).forEach(child => sanitizeNode(child));
return root.innerHTML;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* URL Utilities for Model Resolver
*/
/**
* Parsuje adres URL pobierania i zwraca adres karty modelu (Model Card).
* Obsługuje platformy HuggingFace oraz CivitAI.
* @param {string} downloadUrl
* @returns {string|null} URL karty modelu lub null w przypadku niepowodzenia
*/
export function getModelCardUrl(downloadUrl) {
if (!downloadUrl) return null;
try {
// HuggingFace URLs
if (downloadUrl.includes('huggingface.co')) {
const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/);
if (fileMatch) {
const repo = fileMatch[1];
const revision = fileMatch[2];
const filePath = fileMatch[3].split(/[?#]/)[0];
return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`;
}
const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/);
if (match) {
return `https://huggingface.co/${match[1]}`;
}
}
// CivitAI URLs
if (downloadUrl.includes('civitai.com')) {
// Format: /api/download/models/123456 or /models/123456/...
const modelIdMatch = downloadUrl.match(/models\/(\d+)/);
if (modelIdMatch) {
return `https://civitai.com/models/${modelIdMatch[1]}`;
}
}
} catch (e) {
console.error('Error parsing model card URL:', e);
}
return null;
}
+6 -95
View File
@@ -2,6 +2,8 @@ import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import { escapeHtml, escapeJsString, sanitizeDescriptionHtml } from "../utils/html_utils.js";
import { getModelCardUrl } from "../utils/url_utils.js";
export const modelInfoMethods = {
/**
* Handle click outside context menu to hide it
@@ -291,7 +293,7 @@ export const modelInfoMethods = {
const normalizedUrl = this.normalizeContextMenuSourceUrl(candidate, candidateKey);
if (!normalizedUrl) continue;
const cardUrl = this.getModelCardUrl?.(normalizedUrl) || normalizedUrl;
const cardUrl = getModelCardUrl(normalizedUrl) || normalizedUrl;
const sourceKey = this.getContextMenuSourceKeyFromUrl(cardUrl)
|| this.getContextMenuSourceKeyFromUrl(normalizedUrl)
|| candidateKey;
@@ -1259,16 +1261,11 @@ export const modelInfoMethods = {
},
escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return escapeHtml(value);
},
escapeJsString(value) {
return this.escapeHtml(JSON.stringify(String(value ?? '')));
return escapeJsString(value);
},
truncateText(value, maxLength = 160) {
@@ -1278,93 +1275,7 @@ export const modelInfoMethods = {
},
sanitizeDescriptionHtml(html) {
const raw = String(html ?? '').trim();
if (!raw) return '';
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${raw}</div>`, 'text/html');
const root = doc.body.firstElementChild;
if (!root) return this.escapeHtml(raw);
const allowedTags = new Set([
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre',
'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'a', 'span'
]);
const allowedStyles = new Set(['color']);
const sanitizeNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
node.remove();
return;
}
const tag = node.tagName.toLowerCase();
if (!allowedTags.has(tag)) {
const parent = node.parentNode;
if (!parent) {
node.remove();
return;
}
while (node.firstChild) {
parent.insertBefore(node.firstChild, node);
}
parent.removeChild(node);
return;
}
const attrs = Array.from(node.attributes);
for (const attr of attrs) {
const name = attr.name.toLowerCase();
if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) {
node.removeAttribute(attr.name);
continue;
}
if (name === 'href' && tag === 'a') {
const href = node.getAttribute('href') || '';
if (!/^https?:\/\//i.test(href)) {
node.removeAttribute('href');
} else {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
continue;
}
if (name === 'style') {
const safeStyles = [];
const styleValue = node.getAttribute('style') || '';
for (const part of styleValue.split(';')) {
const [prop, value] = part.split(':').map(v => v?.trim());
if (!prop || !value) continue;
if (allowedStyles.has(prop.toLowerCase())) {
safeStyles.push(`${prop}: ${value}`);
}
}
if (safeStyles.length) {
node.setAttribute('style', safeStyles.join('; '));
} else {
node.removeAttribute('style');
}
continue;
}
if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) {
node.removeAttribute(attr.name);
}
}
Array.from(node.childNodes).forEach(child => sanitizeNode(child));
};
Array.from(root.childNodes).forEach(child => sanitizeNode(child));
return root.innerHTML;
return sanitizeDescriptionHtml(html);
},
normalizeTrainedWords(words) {