From be12494953147599e00a7dffd42177ec51a1bca0 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 17 Jul 2026 18:06:22 +0200 Subject: [PATCH] refactor: centralize shared utilities across backend and frontend Backend: - Move SHA256 normalization logic from path_utils._normalize_header_sha256 into core/type_utils.normalize_sha256, adding support for 0x-prefixed hashes and unifying the normalization path used by extract_safetensors_header_sha256. - Add tests for the 0x prefix normalization cases. Frontend: - Introduce copyTextWithFeedback in web/resolver/utils/html_utils.js as a shared clipboard helper with configurable feedback text, CSS class, and duration, replacing repeated inline clipboard copy logic in globals.js, model_info_methods.js, and the notification utility. - Centralize showNotification on ResolverManagerDialog and remove the duplicate implementation from missingBrowserMethods. - Replace local const localStorage = safeStorage aliases in model_info_methods.js, missing_browser_methods.js, and options_methods.js with direct safeStorage usage. --- core/path_utils.py | 29 ++---------- core/type_utils.py | 3 ++ tests/test_unified_helpers.py | 3 ++ web/resolver/globals.js | 14 +++--- web/resolver/resolver_dialog.js | 10 ++++ web/resolver/utils/html_utils.js | 41 ++++++++++++++++ web/resolver/views/missing_browser_methods.js | 24 +++------- web/resolver/views/model_info_methods.js | 47 ++++++------------- web/resolver/views/options_methods.js | 41 ++++++++-------- 9 files changed, 109 insertions(+), 103 deletions(-) diff --git a/core/path_utils.py b/core/path_utils.py index 934dc97..6c41843 100644 --- a/core/path_utils.py +++ b/core/path_utils.py @@ -292,29 +292,6 @@ SAFETENSORS_METADATA_TEXT_MAX_CHARS = 20000 SAFETENSORS_METADATA_THUMBNAIL_MAX_CHARS = 512 * 1024 -def _normalize_header_sha256(value: Any) -> str: - if not isinstance(value, str): - return "" - - candidate = value.strip() - lower_candidate = candidate.lower() - if lower_candidate.startswith("sha256:") or lower_candidate.startswith("sha256="): - if ":" in candidate: - candidate = candidate.split(":", 1)[-1] - else: - candidate = candidate.split("=", 1)[-1] - candidate = candidate.strip() - lower_candidate = candidate.lower() - - if lower_candidate.startswith("0x"): - candidate = candidate[2:].strip() - - if len(candidate) != 64: - return "" - if any(char not in _SHA256_HEX_CHARS for char in candidate): - return "" - return candidate.lower() - def read_safetensors_header( file_path: str, @@ -368,6 +345,8 @@ def extract_safetensors_header_sha256( max_header_size: int = SAFETENSORS_HEADER_MAX_BYTES, ) -> Optional[str]: """Return an embedded SHA256 from a safetensors metadata header when present.""" + from .type_utils import normalize_sha256 + header_json = read_safetensors_header(file_path, max_header_size=max_header_size) if not isinstance(header_json, dict): return None @@ -382,7 +361,7 @@ def extract_safetensors_header_sha256( "sha256", "SHA256", ): - sha256 = _normalize_header_sha256(metadata.get(key)) + sha256 = normalize_sha256(metadata.get(key)) if sha256: return sha256 @@ -396,7 +375,7 @@ def extract_safetensors_header_sha256( continue if any(part in key_lower for part in _NON_SHA256_HASH_KEY_PARTS): continue - sha256 = _normalize_header_sha256(value) + sha256 = normalize_sha256(value) if sha256: return sha256 diff --git a/core/type_utils.py b/core/type_utils.py index 671d83e..9cdfdef 100644 --- a/core/type_utils.py +++ b/core/type_utils.py @@ -708,6 +708,9 @@ def normalize_sha256(value: Any) -> str: if text.lower().startswith(prefix): text = text[len(prefix):].strip() + if text.lower().startswith("0x"): + text = text[2:].strip() + return text.lower() if SHA256_PATTERN.match(text) else "" diff --git a/tests/test_unified_helpers.py b/tests/test_unified_helpers.py index 35d18e8..c6474e6 100644 --- a/tests/test_unified_helpers.py +++ b/tests/test_unified_helpers.py @@ -911,6 +911,9 @@ class UnifiedHelpersTests(unittest.TestCase): self.assertEqual(normalize_sha256("sha256:" + h), h) self.assertEqual(normalize_sha256("sha256=" + h), h) self.assertEqual(normalize_sha256("SHA256:" + h.upper()), h) + self.assertEqual(normalize_sha256("0x" + h), h) + self.assertEqual(normalize_sha256("sha256:0x" + h), h) + self.assertEqual(normalize_sha256("SHA256:0X" + h.upper()), h) self.assertEqual(normalize_sha256("invalid_hash"), "") self.assertEqual(normalize_sha256(None), "") diff --git a/web/resolver/globals.js b/web/resolver/globals.js index cde166b..f8c5dbd 100644 --- a/web/resolver/globals.js +++ b/web/resolver/globals.js @@ -1,3 +1,5 @@ +import { copyTextWithFeedback } from "./utils/html_utils.js"; + export function registerGlobalHelpers() { // Global helper functions for inline onclick handlers window.MLToggleHidden = function(id, trigger, collapsedText, expandedText) { @@ -97,17 +99,15 @@ export function registerGlobalHelpers() { }; window.MLCopy = function(text, btn) { - navigator.clipboard.writeText(text).then(() => { - window.MLSetCopyButtonFeedback(btn, 'Copied'); - }); + copyTextWithFeedback(text, btn, { successText: 'Copied', successClass: '' }); }; window.MLCopyCode = function(sectionId, btn) { const section = document.getElementById(sectionId); - const codeEl = section.querySelector('code'); - navigator.clipboard.writeText(codeEl.textContent).then(() => { - window.MLSetCopyButtonFeedback(btn, 'Copied'); - }); + const codeEl = section ? section.querySelector('code') : null; + if (codeEl) { + copyTextWithFeedback(codeEl.textContent, btn, { successText: 'Copied', successClass: '' }); + } }; window.MLOpenContextMenu = function(event, element) { diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index e4c4e6f..c306bd1 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -1,6 +1,7 @@ import { $el, ComfyDialog } from "../../../../scripts/ui.js"; import { api } from "../../../../scripts/api.js"; import { safeStorage } from "./utils/html_utils.js"; +import { showNotification as showNotificationUtils } from "../utils/notification_utils.js"; import { dialogShellMethods } from "./shell/dialog_shell_methods.js"; import { lifecycleGraphMethods } from "./shell/lifecycle_graph_methods.js"; import { workflowStateMethods } from "./shell/workflow_state_methods.js"; @@ -258,6 +259,15 @@ export class ResolverManagerDialog extends ComfyDialog { throw error; } } + + showNotification(message, type = 'success', options = {}) { + const duration = options?.duration || (type === 'success' ? 4000 : (type === 'error' ? 6000 : 3000)); + return showNotificationUtils(message, type, { + ...options, + duration, + deduplicate: options?.deduplicate || false + }); + } } /** diff --git a/web/resolver/utils/html_utils.js b/web/resolver/utils/html_utils.js index 1364b3f..9537695 100644 --- a/web/resolver/utils/html_utils.js +++ b/web/resolver/utils/html_utils.js @@ -223,3 +223,44 @@ export function normalizePathIdentity(value = '') { .toLowerCase(); } +/** + * Copies text to clipboard and handles time-based button visual feedback. + * @param {string} text - The text content to copy. + * @param {HTMLElement} button - The button to apply feedback to. + * @param {Object} options - Customization parameters. + */ +export async function copyTextWithFeedback(text, button, options = {}) { + if (!button) return; + const successText = options.successText || 'Copied'; + const errorText = options.errorText || 'Failed'; + const duration = options.duration || 1200; + const successClass = options.successClass || 'is-copied'; + const successHtml = options.successHtml || null; + + const originalHtml = button.innerHTML; + + try { + await navigator.clipboard.writeText(text); + if (successClass) { + button.classList.add(successClass); + } + if (successHtml) { + button.innerHTML = successHtml; + } else { + button.textContent = successText; + } + } catch (err) { + console.error('Model Resolver: Copy failed:', err); + button.textContent = errorText; + } + + setTimeout(() => { + if (button.isConnected) { + if (successClass) { + button.classList.remove(successClass); + } + button.innerHTML = originalHtml; + } + }, duration); +} + diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 73cf55b..b029064 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -3,11 +3,9 @@ import { api } from "../../../../../scripts/api.js"; import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { createFloatingTreePicker } from "../utils/tree_picker.js"; -import { showNotification as showNotificationUtils } from "../../utils/notification_utils.js"; import { startSplitterDrag } from "../utils/splitter_drag.js"; import { getCivitaiModelUrl } from "../globals.js"; import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js"; -const localStorage = safeStorage; export const missingBrowserMethods = { getMissingFilename(missing = {}) { @@ -678,7 +676,7 @@ export const missingBrowserMethods = { showResolvedToggle.addEventListener('change', () => { this.showResolvedModels = Boolean(showResolvedToggle.checked); try { - localStorage.setItem(this.showResolvedModelsStorageKey, this.showResolvedModels ? '1' : '0'); + safeStorage.setItem(this.showResolvedModelsStorageKey, this.showResolvedModels ? '1' : '0'); } catch (e) {} this.displayMissingModels(container, data); }); @@ -690,7 +688,7 @@ export const missingBrowserMethods = { showAutoDownloadToggle.addEventListener('change', () => { this.showAutoDownloadModels = Boolean(showAutoDownloadToggle.checked); try { - localStorage.setItem(this.showAutoDownloadModelsStorageKey, this.showAutoDownloadModels ? '1' : '0'); + safeStorage.setItem(this.showAutoDownloadModelsStorageKey, this.showAutoDownloadModels ? '1' : '0'); } catch (e) {} this.displayMissingModels(container, data); }); @@ -811,7 +809,7 @@ export const missingBrowserMethods = { this._missingBrowserLastDetailWidth = null; this.cancelMissingBrowserSplitWidthPersist(); try { - localStorage.removeItem(this.missingBrowserSplitStorageKey); + safeStorage.removeItem(this.missingBrowserSplitStorageKey); } catch (e) {} }); @@ -863,7 +861,7 @@ export const missingBrowserMethods = { let savedWidth = null; try { - const storedWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10); + const storedWidth = parseInt(safeStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10); savedWidth = Number.isFinite(storedWidth) && storedWidth > 0 ? storedWidth : null; } catch (e) {} @@ -1228,7 +1226,7 @@ export const missingBrowserMethods = { this._missingBrowserSplitPersistIdle = null; this._missingBrowserSplitPersistTimer = null; try { - localStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth)); + safeStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth)); } catch (e) {} }; @@ -2174,17 +2172,7 @@ export const missingBrowserMethods = { return html; }, - /** - * Show a notification banner (similar to ComfyUI's "Reconnecting" banner) - */ - showNotification(message, type = 'success', options = {}) { - const duration = options?.duration || (type === 'success' ? 4000 : (type === 'error' ? 6000 : 3000)); - return showNotificationUtils(message, type, { - ...options, - duration, - deduplicate: options?.deduplicate || false - }); - }, + refreshMissingListRow(missing, options = {}) { if (!missing || !this.contentElement) return; diff --git a/web/resolver/views/model_info_methods.js b/web/resolver/views/model_info_methods.js index 93bcb7d..90fe901 100644 --- a/web/resolver/views/model_info_methods.js +++ b/web/resolver/views/model_info_methods.js @@ -2,8 +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 { sanitizeDescriptionHtml, pollBackgroundTask, safeStorage } from "../utils/html_utils.js"; -const localStorage = safeStorage; +import { sanitizeDescriptionHtml, pollBackgroundTask, safeStorage, copyTextWithFeedback } from "../utils/html_utils.js"; import { getModelCardUrl } from "../utils/url_utils.js"; import { getCivitaiModelUrl } from "../globals.js"; export const modelInfoMethods = { @@ -1758,19 +1757,12 @@ export const modelInfoMethods = { if (!words.length) return; - try { - await navigator.clipboard.writeText(words.join(', ')); - copyBtn.textContent = 'Copied'; - } catch (error) { - console.error('Model Resolver: Failed to copy trained words:', error); - copyBtn.textContent = 'Failed'; - } - - setTimeout(() => { - if (copyBtn.isConnected) { - copyBtn.textContent = 'Copy'; - } - }, 1200); + await copyTextWithFeedback(words.join(', '), copyBtn, { + successText: 'Copied', + errorText: 'Failed', + duration: 1200, + successClass: '' + }); return; } @@ -1794,7 +1786,7 @@ export const modelInfoMethods = { if (!panel) return; try { - const saved = JSON.parse(localStorage.getItem('model_resolver_info_dialog_size') || 'null'); + const saved = JSON.parse(safeStorage.getItem('model_resolver_info_dialog_size') || 'null'); if (!saved || typeof saved !== 'object') return; const width = Number(saved.w); @@ -1823,7 +1815,7 @@ export const modelInfoMethods = { if (!width || !height) return; try { - localStorage.setItem('model_resolver_info_dialog_size', JSON.stringify({ w: width, h: height })); + safeStorage.setItem('model_resolver_info_dialog_size', JSON.stringify({ w: width, h: height })); } catch (error) { console.warn('Model Resolver: Failed to save info dialog size:', error); } @@ -2593,21 +2585,12 @@ export const modelInfoMethods = { if (!text) return; - try { - await navigator.clipboard.writeText(String(text)); - button.classList.add('is-copied'); - button.innerHTML = `${getSvgIcon('copy')} Copied`; - } catch (error) { - console.error('Model Resolver: Failed to copy image metadata:', error); - button.innerHTML = `${getSvgIcon('copy')} Failed`; - } - - setTimeout(() => { - if (button.isConnected) { - button.classList.remove('is-copied'); - button.innerHTML = `${getSvgIcon('copy')} Copy${key === 'all' ? ' all' : ''}`; - } - }, 1200); + await copyTextWithFeedback(String(text), button, { + successClass: 'is-copied', + successHtml: `${getSvgIcon('copy')} Copied`, + errorText: 'Failed', + duration: 1200 + }); }, closeInfoImagePreview(preview = null) { diff --git a/web/resolver/views/options_methods.js b/web/resolver/views/options_methods.js index 23a4f0b..08f7a08 100644 --- a/web/resolver/views/options_methods.js +++ b/web/resolver/views/options_methods.js @@ -5,7 +5,6 @@ import { getSvgIcon } from "../../utils/icon_utils.js"; import { LOG_LEVEL as DEFAULT_FRONTEND_LOG_LEVEL } from "../../log_system/config.js"; import { logger as frontendLogger } from "../../log_system/logger.js"; import { pollBackgroundTask, safeStorage } from "../utils/html_utils.js"; -const localStorage = safeStorage; const SETTINGS_MAP = [ { serverKey: 'civitai_key', localKey: 'ModelResolver.civitaiApiKey', type: 'string', default: '' }, { serverKey: 'civitai_session_token', localKey: 'ModelResolver.civitaiSessionToken', type: 'string', default: '' }, @@ -1222,7 +1221,7 @@ export const optionsMethods = { } if (persist) { try { - localStorage.setItem( + safeStorage.setItem( this.metadataSizeControlsCollapsedStorageKey || 'model_resolver_metadata_size_controls_collapsed', isCollapsed ? '1' : '0' ); @@ -1237,7 +1236,7 @@ export const optionsMethods = { } if (typeof this.metadataSizeControlsCollapsed !== 'boolean') { try { - this.metadataSizeControlsCollapsed = localStorage.getItem(this.metadataSizeControlsCollapsedStorageKey) === '1'; + this.metadataSizeControlsCollapsed = safeStorage.getItem(this.metadataSizeControlsCollapsedStorageKey) === '1'; } catch (error) { this.metadataSizeControlsCollapsed = false; } @@ -1427,7 +1426,7 @@ export const optionsMethods = { } if (persist) { try { - localStorage.setItem( + safeStorage.setItem( this.metadataBuildControlsCollapsedStorageKey || 'model_resolver_metadata_build_controls_collapsed', isCollapsed ? '1' : '0' ); @@ -1442,7 +1441,7 @@ export const optionsMethods = { } if (typeof this.metadataBuildControlsCollapsed !== 'boolean') { try { - this.metadataBuildControlsCollapsed = localStorage.getItem(this.metadataBuildControlsCollapsedStorageKey) === '1'; + this.metadataBuildControlsCollapsed = safeStorage.getItem(this.metadataBuildControlsCollapsedStorageKey) === '1'; } catch (error) { this.metadataBuildControlsCollapsed = false; } @@ -2304,8 +2303,8 @@ export const optionsMethods = { tokens.aria2c_path = data.aria2c_path; tokens.download_backend = 'aria2'; - localStorage.setItem('ModelResolver.aria2cPath', data.aria2c_path); - localStorage.setItem('ModelResolver.downloadBackend', 'aria2'); + safeStorage.setItem('ModelResolver.aria2cPath', data.aria2c_path); + safeStorage.setItem('ModelResolver.downloadBackend', 'aria2'); setAria2Status(''); renderAria2Summary({ @@ -3469,25 +3468,25 @@ export const optionsMethods = { search_source_enabled: sourceEnabled, }; - // 1. Write to localStorage (fast local cache) + // 1. Write to safeStorage (fast local cache) for (const opt of SETTINGS_MAP) { const val = newSettings[opt.serverKey]; if (opt.type === 'boolean' || opt.type === 'frontendLogsEnabled' || opt.type === 'backendLogsEnabled') { - localStorage.setItem(opt.localKey, val ? 'true' : 'false'); + safeStorage.setItem(opt.localKey, val ? 'true' : 'false'); } else if (opt.type === 'json') { - localStorage.setItem(opt.localKey, JSON.stringify(val)); + safeStorage.setItem(opt.localKey, JSON.stringify(val)); } else { - localStorage.setItem(opt.localKey, String(val ?? '')); + safeStorage.setItem(opt.localKey, String(val ?? '')); } } defaultRootSelectInputs.forEach((select) => { const storageKey = select.dataset.storageKey; if (storageKey) { - localStorage.setItem(storageKey, select.value || ''); + safeStorage.setItem(storageKey, select.value || ''); } }); Object.entries(sourceEnabled).forEach(([key, val]) => { - localStorage.setItem(key, val ? 'true' : 'false'); + safeStorage.setItem(key, val ? 'true' : 'false'); }); if (civitaiLimitInput) civitaiLimitInput.value = `${civitaiCandidateLimit}`; this.applyFrontendLoggingPreference(newSettings.frontend_logs_enabled, newSettings.frontend_log_level); @@ -3523,7 +3522,7 @@ export const optionsMethods = { getStoredTokens() { const tokens = {}; for (const opt of SETTINGS_MAP) { - const raw = localStorage.getItem(opt.localKey); + const raw = safeStorage.getItem(opt.localKey); if (opt.type === 'boolean') { tokens[opt.serverKey] = raw === null ? opt.default : raw !== 'false'; } else if (opt.type === 'json') { @@ -3578,29 +3577,29 @@ export const optionsMethods = { if (serverValue === undefined) continue; if (opt.type === 'boolean' || opt.type === 'frontendLogsEnabled' || opt.type === 'backendLogsEnabled') { - localStorage.setItem(opt.localKey, serverValue ? 'true' : 'false'); + safeStorage.setItem(opt.localKey, serverValue ? 'true' : 'false'); } else if (opt.type === 'json') { - localStorage.setItem(opt.localKey, JSON.stringify(serverValue || {})); + safeStorage.setItem(opt.localKey, JSON.stringify(serverValue || {})); } else if (opt.type === 'backend') { - localStorage.setItem(opt.localKey, this.normalizeDownloadBackend(serverValue)); + safeStorage.setItem(opt.localKey, this.normalizeDownloadBackend(serverValue)); } else if (opt.type === 'pathMode') { - localStorage.setItem(opt.localKey, this.normalizeDownloadPathMode(serverValue)); + safeStorage.setItem(opt.localKey, this.normalizeDownloadPathMode(serverValue)); } else { if (serverValue !== null && serverValue !== '') { - localStorage.setItem(opt.localKey, String(serverValue)); + safeStorage.setItem(opt.localKey, String(serverValue)); } } } this.getDefaultRootCategoryDefinitions().forEach((item) => { if (data[item.settingKey] !== undefined) { - localStorage.setItem(item.storageKey, String(data[item.settingKey] || '')); + safeStorage.setItem(item.storageKey, String(data[item.settingKey] || '')); } }); // Source-enabled flags stored as a nested object if (data.search_source_enabled && typeof data.search_source_enabled === 'object') { Object.entries(data.search_source_enabled).forEach(([key, val]) => { - if (key) localStorage.setItem(key, val ? 'true' : 'false'); + if (key) safeStorage.setItem(key, val ? 'true' : 'false'); }); } const tokens = this.getStoredTokens();