From 8823049f9e5db8984830498da413fc7d8285aec0 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 16 Jul 2026 10:13:38 +0200 Subject: [PATCH] refactor: centralize filename extraction, remote URL normalization, and storage abstractions --- core/downloader.py | 4 +- core/matcher.py | 10 ++- core/scanner.py | 6 +- core/sources/civarchive.py | 11 ++- core/sources/huggingface.py | 28 +++---- core/sources/lora_manager_archive.py | 5 +- core/type_utils.py | 33 +++++++- core/workflow_updater.py | 4 +- tests/test_refactoring_unification.py | 8 ++ .../actions/resolve_download_methods.js | 7 +- web/resolver/resolver_dialog.js | 25 ++---- .../search/download_target_methods.js | 2 + web/resolver/search/search_panel.js | 4 +- web/resolver/shell/dialog_shell_methods.js | 79 +++++++------------ web/resolver/shell/lifecycle_graph_methods.js | 8 +- web/resolver/utils/html_utils.js | 64 +++++++++++++++ web/resolver/views/missing_browser_methods.js | 7 +- web/resolver/views/model_info_methods.js | 58 ++++++++------ web/resolver/views/options_methods.js | 3 +- 19 files changed, 222 insertions(+), 144 deletions(-) diff --git a/core/downloader.py b/core/downloader.py index 2e297b7..6850f3a 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -855,7 +855,7 @@ def _resolve_aria2c_executable(settings: Optional[Dict[str, Any]] = None) -> str configured = str(active_settings.get("aria2c_path") or "").strip() candidate = os.path.expandvars(os.path.expanduser(configured or "aria2c")) expected_names = {"aria2c", "aria2c.exe"} - candidate_name = os.path.basename(candidate).lower() + candidate_name = get_filename_from_path(candidate).lower() has_path_component = bool( os.path.isabs(candidate) or os.path.dirname(candidate) @@ -877,7 +877,7 @@ def _resolve_aria2c_executable(settings: Optional[Dict[str, Any]] = None) -> str if candidate_name in expected_names: resolved = shutil.which(candidate) - if resolved and os.path.basename(resolved).lower() in expected_names: + if resolved and get_filename_from_path(resolved).lower() in expected_names: return os.path.realpath(os.path.abspath(resolved)) raise Aria2Error( diff --git a/core/matcher.py b/core/matcher.py index c3e2f67..3f72e83 100644 --- a/core/matcher.py +++ b/core/matcher.py @@ -13,6 +13,8 @@ from difflib import SequenceMatcher from .log_system import create_module_logger log = create_module_logger(__name__) +from .path_utils import get_filename_from_path + try: from rapidfuzz import fuzz as rapidfuzz_fuzz except ImportError: @@ -121,7 +123,7 @@ def find_matches( # Extract just the filename from target_model (remove any subfolder paths) # target_model might be just a filename or might include subfolder paths - target_filename = os.path.basename(target_model_normalized) + target_filename = get_filename_from_path(target_model_normalized) # Normalize target filename once for exact match comparisons target_norm = normalize_filename(target_filename) @@ -135,7 +137,7 @@ def find_matches( # If no filename key, try to extract from path or relative_path if not candidate_filename: if candidate_path: - candidate_filename = os.path.basename(candidate_path) + candidate_filename = get_filename_from_path(candidate_path) if not candidate_filename: continue @@ -402,7 +404,7 @@ def clean_filename_for_search(filename: str) -> str: """ if not filename: return "" - basename = os.path.basename(filename).strip() + basename = get_filename_from_path(filename).strip() base = os.path.splitext(basename)[0] pattern = r"[-_]?(" + "|".join(PRECISION_FORMAT_SUFFIXES) + r").*$" return re.sub(pattern, "", base, flags=re.IGNORECASE).strip(" -_") @@ -413,7 +415,7 @@ def build_filename_search_queries(filename: str) -> List[str]: Build a list of candidate search queries from a filename by stripping extensions and common precision/format suffixes (e.g. fp16, bf16, pruned). """ - basename = os.path.basename(filename or "").strip() + basename = get_filename_from_path(filename).strip() if not basename: return [] diff --git a/core/scanner.py b/core/scanner.py index 8176ba9..23b07a0 100644 --- a/core/scanner.py +++ b/core/scanner.py @@ -12,7 +12,7 @@ from .log_system import create_module_logger log = create_module_logger(__name__) -from .path_utils import get_path_identity +from .path_utils import get_path_identity, get_filename_from_path # Import folder_paths lazily - it may not be available until ComfyUI is initialized try: import folder_paths @@ -139,11 +139,11 @@ def scan_directory( try: relative_path = os.path.relpath(root, base_directory) except ValueError: - relative_path = os.path.basename(root) + relative_path = get_filename_from_path(root) models.append( { - "filename": os.path.basename(root), + "filename": get_filename_from_path(root), "path": root, "relative_path": relative_path, "category": category, diff --git a/core/sources/civarchive.py b/core/sources/civarchive.py index 2af6058..da79d36 100644 --- a/core/sources/civarchive.py +++ b/core/sources/civarchive.py @@ -45,6 +45,7 @@ from ..type_utils import ( normalize_model_file_info, build_search_result, normalize_sha256, + prepare_remote_size_probe_url, ) from ..progress import report_progress, get_progress_reporter from ..log_system import create_module_logger @@ -541,16 +542,14 @@ def _prepare_size_probe_url(url: Any) -> Optional[str]: if not normalized: return None + normalized = prepare_remote_size_probe_url(normalized) + if not normalized: + return None + parsed = urlparse(normalized) host = parsed.hostname path = parsed.path or "" - if host_matches_domain(host, "huggingface.co") and "/blob/" in path: - normalized = normalized.replace("/blob/", "/resolve/", 1) - parsed = urlparse(normalized) - host = parsed.hostname - path = parsed.path or "" - if host_matches_domain(host, "civarchive.com") and not path.startswith("/api/download/"): return None if host_matches_domain(host, "civitai.com", "civitai.red") and not path.startswith("/api/download/"): diff --git a/core/sources/huggingface.py b/core/sources/huggingface.py index 27d51e0..b376175 100644 --- a/core/sources/huggingface.py +++ b/core/sources/huggingface.py @@ -17,9 +17,9 @@ from ..progress import report_progress, get_progress_reporter from ..log_system import create_module_logger log = create_module_logger(__name__) -from ..path_utils import write_json_atomic, METADATA_DIR, read_json_safe +from ..path_utils import write_json_atomic, METADATA_DIR, read_json_safe, get_filename_from_path from ..network_utils import host_matches_domain, request_source_response, request_source_json -from ..type_utils import check_credential_http, parse_size_header as _parse_size_header, fetch_remote_file_size, fetch_remote_file_size_cached, clear_remote_size_cache, extract_file_size +from ..type_utils import check_credential_http, parse_size_header as _parse_size_header, fetch_remote_file_size, fetch_remote_file_size_cached, clear_remote_size_cache, extract_file_size, prepare_remote_size_probe_url from ..matcher import build_filename_search_queries, clean_filename_for_search HF_API_URL = "https://huggingface.co/api" @@ -171,7 +171,7 @@ def _build_author_index_from_models( { "repo_id": repo_id, "path": file_path, - "filename": os.path.basename(file_path), + "filename": get_filename_from_path(file_path), "size": sibling.get("size"), } ) @@ -330,15 +330,7 @@ def get_huggingface_download_url(repo: str, filename: str, branch: str = "main") def _normalize_huggingface_size_probe_url(url: str) -> Optional[str]: - if not isinstance(url, str) or not url.strip(): - return None - value = url.strip() - parsed = urlparse(value) - if not host_matches_domain(parsed.hostname, "huggingface.co"): - return None - if "/blob/" in parsed.path: - value = value.replace("/blob/", "/resolve/", 1) - return value + return prepare_remote_size_probe_url(url) @@ -375,7 +367,7 @@ def _build_huggingface_result( return { "source": "huggingface", "repo_id": repo_id, - "filename": os.path.basename(file_path), + "filename": get_filename_from_path(file_path), "path": file_path, "url": download_url, "size": size, @@ -424,7 +416,7 @@ def _find_matching_file_in_repo( if not file_path: continue - file_name = os.path.basename(file_path) + file_name = get_filename_from_path(file_path) file_name_lower = file_name.lower() file_base = os.path.splitext(file_name_lower)[0] @@ -445,7 +437,7 @@ def _find_matching_file_in_repo( if not file_path: continue - file_name = os.path.basename(file_path) + file_name = get_filename_from_path(file_path) file_name_lower = file_name.lower() file_base = os.path.splitext(file_name_lower)[0] @@ -481,7 +473,7 @@ def _find_matching_file_in_author_index( if not repo_id or not file_path: continue - file_name = os.path.basename(file_path) + file_name = get_filename_from_path(file_path) file_name_lower = file_name.lower() if file_name_lower == filename_lower or file_path.lower().endswith( filename_lower @@ -503,7 +495,7 @@ def _find_matching_file_in_author_index( if not repo_id or not file_path: continue - file_name = os.path.basename(file_path) + file_name = get_filename_from_path(file_path) file_name_lower = file_name.lower() file_base = os.path.splitext(file_name_lower)[0] file_path_lower = file_path.lower() @@ -1021,7 +1013,7 @@ def get_repo_files(repo: str, token: Optional[str] = None) -> List[Dict[str, Any size = _fetch_remote_file_size_bytes(url, headers=headers) files.append( { - "filename": os.path.basename(path), + "filename": get_filename_from_path(path), "path": path, "url": url, "size": size, diff --git a/core/sources/lora_manager_archive.py b/core/sources/lora_manager_archive.py index 98d4aad..a4b14ee 100644 --- a/core/sources/lora_manager_archive.py +++ b/core/sources/lora_manager_archive.py @@ -19,6 +19,7 @@ from ..matcher import ( calculate_candidate_rank, should_update_best_match, ) +from ..path_utils import get_filename_from_path from ..type_utils import ( select_primary_model_file, get_generic_filename_tokens, @@ -113,7 +114,7 @@ def _normalize_model_type(model_type: Optional[str]) -> str: def _extract_search_tokens(query: str) -> Dict[str, List[str]]: """Split filename/model query into useful content/version tokens.""" - base_query = os.path.splitext(os.path.basename(query or ""))[0] + base_query = os.path.splitext(get_filename_from_path(query))[0] raw_tokens = [ token.lower() for token in re.split(r"[^a-zA-Z0-9]+", base_query) @@ -662,7 +663,7 @@ def search_lora_manager_archive_for_file( """ Search the archive for the best model/version match for a filename. """ - search_query = os.path.splitext(os.path.basename(filename or ""))[0] + search_query = os.path.splitext(get_filename_from_path(filename))[0] if not search_query: return None diff --git a/core/type_utils.py b/core/type_utils.py index d647017..05bad61 100644 --- a/core/type_utils.py +++ b/core/type_utils.py @@ -605,6 +605,7 @@ def looks_like_model_file(url: str, expected_filename: str = "") -> bool: import os from urllib.parse import unquote, urlparse from .network_utils import host_matches_domain + from .path_utils import get_filename_from_path text = str(url or "").strip() if not text.startswith(("http://", "https://", "hf://")): @@ -628,8 +629,8 @@ def looks_like_model_file(url: str, expected_filename: str = "") -> bool: ) and "/api/download/" in path: return True - basename = os.path.basename(path).lower() - expected = os.path.basename(str(expected_filename or "")).lower() + basename = get_filename_from_path(path).lower() + expected = get_filename_from_path(str(expected_filename or "")).lower() if expected and basename == expected: return True @@ -829,6 +830,34 @@ def extract_trained_words(*values: Any) -> List[str]: _remote_size_cache: Dict[tuple[str, Optional[str]], Optional[int]] = {} +def prepare_remote_size_probe_url(url: Any) -> Optional[str]: + """ + Normalize a remote URL to be suitable for file size probing. + Converts HuggingFace '/blob/' URLs to '/resolve/' URLs. + """ + from urllib.parse import urlparse + from .network_utils import host_matches_domain + + if not isinstance(url, str) or not url.strip(): + return None + + value = url.strip() + if value.startswith("//"): + value = f"https:{value}" + + try: + parsed = urlparse(value) + host = parsed.hostname + path = parsed.path or "" + except Exception: + return None + + if host and host_matches_domain(host, "huggingface.co") and "/blob/" in path: + value = value.replace("/blob/", "/resolve/", 1) + + return value + + def fetch_remote_file_size_cached( url: str, headers: Optional[Dict[str, str]] = None, diff --git a/core/workflow_updater.py b/core/workflow_updater.py index 2b4daef..28540bc 100644 --- a/core/workflow_updater.py +++ b/core/workflow_updater.py @@ -9,7 +9,7 @@ from typing import Dict, Any, List, Optional from .log_system import create_module_logger log = create_module_logger(__name__) -from .path_utils import is_path_within +from .path_utils import is_path_within, get_filename_from_path @@ -78,7 +78,7 @@ def convert_to_relative_path( pass # Fallback: return just the filename - return os.path.basename(absolute_path) + return get_filename_from_path(absolute_path) def get_base_directory_for_model( diff --git a/tests/test_refactoring_unification.py b/tests/test_refactoring_unification.py index 8ddae1a..fdc7322 100644 --- a/tests/test_refactoring_unification.py +++ b/tests/test_refactoring_unification.py @@ -212,6 +212,14 @@ class TestRefactoringUnification(unittest.TestCase): self.assertEqual(state["status"], "running") self.assertEqual(state["percent"], 50) + def test_get_filename_from_path(self): + from core.path_utils import get_filename_from_path + self.assertEqual(get_filename_from_path("some/path/to/model.safetensors"), "model.safetensors") + self.assertEqual(get_filename_from_path("some\\path\\to\\model.safetensors"), "model.safetensors") + self.assertEqual(get_filename_from_path("model.safetensors"), "model.safetensors") + self.assertEqual(get_filename_from_path(""), "") + self.assertEqual(get_filename_from_path(None), "") + if __name__ == "__main__": unittest.main() diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 373f310..346fc07 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -5,6 +5,7 @@ import { createModuleLogger } from "../../log_system/log_funcs.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { getModelCardUrl } from "../utils/url_utils.js"; import { getCivitaiModelUrl } from "../globals.js"; +import { normalizePathIdentity } from "../utils/html_utils.js"; const log = createModuleLogger('resolve_download_methods'); @@ -595,11 +596,7 @@ export const resolveDownloadMethods = { }, normalizeLocalMatchPathIdentity(value = '') { - return String(value || '') - .trim() - .replace(/\\/g, '/') - .replace(/\/+/g, '/') - .toLowerCase(); + return normalizePathIdentity(value); }, getLocalMatchAbsolutePathIdentity(match = {}) { diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index f84b264..e4c4e6f 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -1,5 +1,6 @@ import { $el, ComfyDialog } from "../../../../scripts/ui.js"; import { api } from "../../../../scripts/api.js"; +import { safeStorage } from "./utils/html_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"; @@ -73,24 +74,12 @@ export class ResolverManagerDialog extends ComfyDialog { this.showResolvedModelsStorageKey = "model_resolver_show_resolved_models"; this.showAutoDownloadModelsStorageKey = "model_resolver_show_auto_download_models"; this.localMatchAlternativesCollapsedStorageKey = "model_resolver_local_match_alternatives_collapsed"; - try { - this.showResolvedModels = localStorage.getItem(this.showResolvedModelsStorageKey) === '1'; - } catch (e) { - this.showResolvedModels = false; - } - try { - const storedShowAutoDownload = localStorage.getItem(this.showAutoDownloadModelsStorageKey); - this.showAutoDownloadModels = storedShowAutoDownload === null - ? true - : storedShowAutoDownload === '1'; - } catch (e) { - this.showAutoDownloadModels = true; - } - try { - this.localMatchAlternativesCollapsed = localStorage.getItem(this.localMatchAlternativesCollapsedStorageKey) === '1'; - } catch (e) { - this.localMatchAlternativesCollapsed = false; - } + this.showResolvedModels = safeStorage.getItem(this.showResolvedModelsStorageKey) === '1'; + const storedShowAutoDownload = safeStorage.getItem(this.showAutoDownloadModelsStorageKey); + this.showAutoDownloadModels = storedShowAutoDownload === null + ? true + : storedShowAutoDownload === '1'; + this.localMatchAlternativesCollapsed = safeStorage.getItem(this.localMatchAlternativesCollapsedStorageKey) === '1'; this.dockButton = null; this.undockButton = null; this._floatingRectBeforeDock = null; diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index 035bd65..da0b288 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -3,6 +3,8 @@ 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 { safeStorage } from "../utils/html_utils.js"; +const localStorage = safeStorage; export const downloadTargetMethods = { /** * Ensure all models are loaded for the dropdown. diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index e291399..1592fbd 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -4,6 +4,8 @@ import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { getModelCardUrl } from "../utils/url_utils.js"; import { getCivitaiModelUrl } from "../globals.js"; +import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js"; +const localStorage = safeStorage; export const searchPanelMethods = { /** * Build stable cache key for a missing model entry @@ -2458,7 +2460,7 @@ export const searchPanelMethods = { getActiveDownloadInfoForLocalMatch(match = {}) { const model = match.model || {}; - const normalizePath = (value = '') => this.normalizeLocalMatchPathIdentity?.(value) || String(value || '').trim().replace(/\\/g, '/').replace(/\/+/g, '/').toLowerCase(); + const normalizePath = (value = '') => normalizePathIdentity(value); const joinPath = (...parts) => normalizePath(parts.filter(Boolean).join('/')); const activeStatuses = new Set(['starting', 'downloading', 'paused', 'cancelling']); const matchAbsolutePaths = [ diff --git a/web/resolver/shell/dialog_shell_methods.js b/web/resolver/shell/dialog_shell_methods.js index f4e78bf..e9acb81 100644 --- a/web/resolver/shell/dialog_shell_methods.js +++ b/web/resolver/shell/dialog_shell_methods.js @@ -1,7 +1,8 @@ -import { app } from "../../../../../scripts/app.js"; +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 { safeStorage } from "../utils/html_utils.js"; export const dialogShellMethods = { createHeader() { // Create tabs @@ -141,22 +142,18 @@ export const dialogShellMethods = { return; } - try { - const wh = JSON.parse(localStorage.getItem('model_resolver_modal_size_before_fs') || 'null'); - if (wh?.w && wh?.h) { - el.style.width = `${wh.w}px`; - el.style.height = `${wh.h}px`; - } + const wh = JSON.parse(safeStorage.getItem('model_resolver_modal_size_before_fs') || 'null'); + if (wh?.w && wh?.h) { + el.style.width = `${wh.w}px`; + el.style.height = `${wh.h}px`; + } - const pos = JSON.parse(localStorage.getItem('model_resolver_modal_pos') || 'null'); - if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { - el.style.top = `${pos.top}px`; - el.style.left = `${pos.left}px`; - el.style.transform = 'none'; - return; - } - } catch (e) { - // Fall back to centered floating geometry below. + const pos = JSON.parse(safeStorage.getItem('model_resolver_modal_pos') || 'null'); + if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { + el.style.top = `${pos.top}px`; + el.style.left = `${pos.left}px`; + el.style.transform = 'none'; + return; } el.style.top = '50%'; @@ -165,17 +162,11 @@ export const dialogShellMethods = { }, rememberSidebarOpenMode(mode) { - try { - localStorage.setItem(this.sidebarOpenModeStorageKey, mode === 'floating' ? 'floating' : 'docked'); - } catch (e) {} + safeStorage.setItem(this.sidebarOpenModeStorageKey, mode === 'floating' ? 'floating' : 'docked'); }, shouldOpenFromSidebarFloating() { - try { - return localStorage.getItem(this.sidebarOpenModeStorageKey) === 'floating'; - } catch (e) { - return false; - } + return safeStorage.getItem(this.sidebarOpenModeStorageKey) === 'floating'; }, isVisible() { @@ -415,10 +406,8 @@ export const dialogShellMethods = { const btn = document.getElementById('model-resolver-fullscreen-toggle'); if (enable) { // Save current size - try { - const rect = el.getBoundingClientRect(); - localStorage.setItem('model_resolver_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) })); - } catch (e) {} + const rect = el.getBoundingClientRect(); + safeStorage.setItem('model_resolver_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) })); el.style.top = '0'; el.style.left = '0'; el.style.transform = 'none'; @@ -433,7 +422,7 @@ export const dialogShellMethods = { btn.setAttribute('aria-label', 'Exit full screen'); this.setTooltip(btn, 'Exit full screen'); } - try { localStorage.setItem('model_resolver_modal_fullscreen', '1'); } catch (e) {} + safeStorage.setItem('model_resolver_modal_fullscreen', '1'); } else { this.returnToDockedAfterFullscreen = false; // Restore centered sizing @@ -443,7 +432,7 @@ export const dialogShellMethods = { el.style.resize = 'both'; // Restore saved pre-FS size if available let wh = null; - try { wh = JSON.parse(localStorage.getItem('model_resolver_modal_size_before_fs') || 'null'); } catch (e) {} + try { wh = JSON.parse(safeStorage.getItem('model_resolver_modal_size_before_fs') || 'null'); } catch (e) {} if (wh && wh.w && wh.h) { el.style.width = `${wh.w}px`; el.style.height = `${wh.h}px`; @@ -452,18 +441,12 @@ export const dialogShellMethods = { el.style.height = '700px'; } // Restore last known position if available, else center - try { - const pos = JSON.parse(localStorage.getItem('model_resolver_modal_pos') || 'null'); - if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { - el.style.top = `${pos.top}px`; - el.style.left = `${pos.left}px`; - el.style.transform = 'none'; - } else { - el.style.top = '50%'; - el.style.left = '50%'; - el.style.transform = 'translate(-50%, -50%)'; - } - } catch (e) { + const pos = JSON.parse(safeStorage.getItem('model_resolver_modal_pos') || 'null'); + if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { + el.style.top = `${pos.top}px`; + el.style.left = `${pos.left}px`; + el.style.transform = 'none'; + } else { el.style.top = '50%'; el.style.left = '50%'; el.style.transform = 'translate(-50%, -50%)'; @@ -474,7 +457,7 @@ export const dialogShellMethods = { this.setTooltip(btn, 'Enter full screen'); } this.ensureModalHandleInViewport({ persist: true }); - try { localStorage.setItem('model_resolver_modal_fullscreen', '0'); } catch (e) {} + safeStorage.setItem('model_resolver_modal_fullscreen', '0'); if (shouldReturnToDocked) { this.dockToSidebar(); @@ -518,12 +501,10 @@ export const dialogShellMethods = { saveModalPosition() { if (this.docked) return; - try { - const el = this.element; - if (!el) return; - const rect = el.getBoundingClientRect(); - localStorage.setItem('model_resolver_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) })); - } catch (e) { /* ignore */ } + const el = this.element; + if (!el) return; + const rect = el.getBoundingClientRect(); + safeStorage.setItem('model_resolver_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) })); }, ensureModalHandleInViewport({ persist = false } = {}) { diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index 4c73cd8..2db1ee6 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -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 { safeStorage } from "../utils/html_utils.js"; export const lifecycleGraphMethods = { async show(workflow = null) { this.undockToFloating({ persist: false }); @@ -36,11 +37,8 @@ export const lifecycleGraphMethods = { await this.ensureAllModelsLoaded(); await this.ensureDownloadDirectoriesLoaded(); - // Restore fullscreen state if enabled - try { - const fs = localStorage.getItem('model_resolver_modal_fullscreen'); - if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true); - } catch (e) { } + const fs = safeStorage.getItem('model_resolver_modal_fullscreen'); + if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true); // Attach drag handle event listener (only once) this.attachDragHandleIfNeeded(); diff --git a/web/resolver/utils/html_utils.js b/web/resolver/utils/html_utils.js index c9749c7..1364b3f 100644 --- a/web/resolver/utils/html_utils.js +++ b/web/resolver/utils/html_utils.js @@ -159,3 +159,67 @@ export async function pollBackgroundTask({ await new Promise(resolve => setTimeout(resolve, intervalMs)); } } + +const memoryStorage = new Map(); +let isLocalStorageAvailable = null; + +function checkLocalStorage() { + if (isLocalStorageAvailable !== null) return isLocalStorageAvailable; + try { + localStorage.setItem('__test_storage__', '1'); + localStorage.removeItem('__test_storage__'); + isLocalStorageAvailable = true; + } catch (e) { + isLocalStorageAvailable = false; + console.warn('LocalStorage is not available, falling back to memory storage.', e); + } + return isLocalStorageAvailable; +} + +export const safeStorage = { + getItem(key, defaultValue = null) { + if (checkLocalStorage()) { + try { + const val = localStorage.getItem(key); + return val !== null ? val : defaultValue; + } catch (e) { + console.warn(`safeStorage.getItem failed for key ${key}:`, e); + } + } + return memoryStorage.has(key) ? memoryStorage.get(key) : defaultValue; + }, + setItem(key, value) { + const valStr = String(value); + if (checkLocalStorage()) { + try { + localStorage.setItem(key, valStr); + return true; + } catch (e) { + console.warn(`safeStorage.setItem failed for key ${key}:`, e); + } + } + memoryStorage.set(key, valStr); + return true; + }, + removeItem(key) { + if (checkLocalStorage()) { + try { + localStorage.removeItem(key); + return true; + } catch (e) { + console.warn(`safeStorage.removeItem failed for key ${key}:`, e); + } + } + memoryStorage.delete(key); + return true; + } +}; + +export function normalizePathIdentity(value = '') { + return String(value || '') + .trim() + .replace(/[\\/]+/g, '/') + .replace(/\/+$/g, '') + .toLowerCase(); +} + diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index bd2209e..f382208 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -6,6 +6,8 @@ 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 = {}) { @@ -180,10 +182,7 @@ export const missingBrowserMethods = { const seenPaths = new Set(); const details = []; - const normalizePathIdentity = (value = '') => String(value || '') - .replace(/[\\/]+/g, '/') - .replace(/\/+$/g, '') - .toLowerCase(); + // Uses imported normalizePathIdentity from html_utils.js folderKeys.forEach(folderKey => { const normalizedCategory = this.normalizeDownloadCategory?.(folderKey) || folderKey; diff --git a/web/resolver/views/model_info_methods.js b/web/resolver/views/model_info_methods.js index 680dad2..93bcb7d 100644 --- a/web/resolver/views/model_info_methods.js +++ b/web/resolver/views/model_info_methods.js @@ -2,7 +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 { sanitizeDescriptionHtml } from "../utils/html_utils.js"; +import { sanitizeDescriptionHtml, pollBackgroundTask, safeStorage } from "../utils/html_utils.js"; +const localStorage = safeStorage; import { getModelCardUrl } from "../utils/url_utils.js"; import { getCivitaiModelUrl } from "../globals.js"; export const modelInfoMethods = { @@ -691,30 +692,43 @@ export const modelInfoMethods = { onStart(progressId); - for (;;) { - await new Promise(resolve => setTimeout(resolve, 250)); - if (checkAbort()) { - await onAbort(progressId); - throw new Error('Hash calculation cancelled'); - } - const progress = await this.fetchJson( - `/model_resolver/calculate-file-hash/progress/${encodeURIComponent(progressId)}`, - { silent: true }, - 'Get model hash progress' - ); + let finalResult = null; - onProgress(progress?.percent || 0, progress?.message || ''); + await pollBackgroundTask({ + endpoint: `/model_resolver/calculate-file-hash/progress/${encodeURIComponent(progressId)}`, + tokenCheck: () => { + if (checkAbort()) { + onAbort(progressId); + return false; + } + return true; + }, + onProgress: (progress) => { + onProgress(progress?.percent || 0, progress?.message || ''); + }, + isTerminal: (progress) => { + return ['done', 'cancelled', 'error'].includes(progress?.status); + }, + onTerminal: (progress) => { + if (progress.status === 'done') { + finalResult = progress; + } else if (progress.status === 'cancelled') { + throw new Error('Hash calculation cancelled'); + } else if (progress.status === 'error') { + throw new Error(progress.error || progress.message || 'Hash calculation failed'); + } + }, + onError: (err) => { + throw err; + }, + fetchJson: this.fetchJson.bind(this) + }); - if (progress?.status === 'done') { - return progress; - } - if (progress?.status === 'cancelled') { - throw new Error('Hash calculation cancelled'); - } - if (progress?.status === 'error') { - throw new Error(progress.error || progress.message || 'Hash calculation failed'); - } + if (checkAbort()) { + throw new Error('Hash calculation cancelled'); } + + return finalResult; }, async calculateLocalHashCandidatesForCompare(model = {}) { diff --git a/web/resolver/views/options_methods.js b/web/resolver/views/options_methods.js index 172e828..23a4f0b 100644 --- a/web/resolver/views/options_methods.js +++ b/web/resolver/views/options_methods.js @@ -4,7 +4,8 @@ import { $el } from "../../../../../scripts/ui.js"; 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 } from "../utils/html_utils.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: '' },