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.
This commit is contained in:
Dariusz L
2026-07-17 18:06:22 +02:00
parent 28d24214aa
commit be12494953
9 changed files with 109 additions and 103 deletions
+4 -25
View File
@@ -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
+3
View File
@@ -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 ""
+3
View File
@@ -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), "")
+7 -7
View File
@@ -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) {
+10
View File
@@ -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
});
}
}
/**
+41
View File
@@ -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);
}
+6 -18
View File
@@ -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;
+15 -32
View File
@@ -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) {
+20 -21
View File
@@ -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();