diff --git a/__init__.py b/__init__.py index 83790d1..609258c 100644 --- a/__init__.py +++ b/__init__.py @@ -363,6 +363,7 @@ class ModelResolverExtension: is_path_within, prefer_local_base_directory, read_json_safe, + split_path_segments, write_json_atomic, ) from .core.resolver import ( @@ -4596,7 +4597,7 @@ class ModelResolverExtension: ) if target_directory and subfolder: target_directory = _download_os.path.join( - target_directory, subfolder + target_directory, *split_path_segments(subfolder) ) if target_directory: target_path = _download_os.path.join( @@ -4906,9 +4907,7 @@ class ModelResolverExtension: ) def add_subfolder(rel_path, base_dir=""): - rel_path = os.path.normpath(str(rel_path or "")).replace( - os.sep, "\\" - ) + rel_path = "/".join(split_path_segments(rel_path)) if not rel_path or rel_path == ".": return base_dir = os.path.abspath(base_dir) if base_dir else "" @@ -4978,12 +4977,12 @@ class ModelResolverExtension: ) except Exception: base_dir = "" - parts = [p for p in rel_path.replace("/", "\\").split("\\") if p] + parts = split_path_segments(rel_path) if len(parts) <= 1: continue current = "" for part in parts[:-1]: - current = f"{current}\\{part}" if current else part + current = f"{current}/{part}" if current else part add_subfolder(current, base_dir) for base_dir in base_dirs: diff --git a/core/settings.py b/core/settings.py index 5d4df26..c6feeed 100644 --- a/core/settings.py +++ b/core/settings.py @@ -380,7 +380,7 @@ def resolve_download_subfolder( ) -> str: requested = str(requested_subfolder or "").strip() if requested: - return requested + return normalize_relative_subfolder(requested) active_settings = normalize_settings(settings if settings is not None else load_settings()) if active_settings.get("download_path_mode") != "template": diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 93b1b3e..2b8ddf2 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -868,8 +868,8 @@ test('download subfolder tooltip explains automatic suggestion source', () => { getSavedDownloadSubfolderSuggestion, getCurrentDownloadSubfolderSuggestion, normalizeDownloadSubfolderPath, - normalizePathToBackward(value) { - return String(value || '').replace(/[\/]+/g, '\\'); + normalizePathToForward(value) { + return String(value || '').replace(/\\/g, '/'); }, normalizeDownloadCategory(value) { return String(value || 'checkpoints'); @@ -902,10 +902,33 @@ test('download subfolder tooltip explains automatic suggestion source', () => { const tooltip = getDownloadSubfolderTooltip.call(dialog, { node_id: 1 }, 'loras', 'SDXL/Style'); - assert.match(tooltip, /Suggested subfolder: SDXL\\Style/); + assert.match(tooltip, /Suggested subfolder: SDXL\/Style/); assert.match(tooltip, /identified as SDXL and tagged/); }); +test('portable subfolder paths use forward slashes and join to the host path style', () => { + const normalizeDownloadSubfolderPath = eval(`(${extractMethod(downloadTargetMethodsSource, 'normalizeDownloadSubfolderPath')})`); + const joinLocalPath = eval(`(${extractMethod(downloadTargetMethodsSource, 'joinLocalPath')})`); + const dialog = { + normalizeDownloadSubfolderPath, + joinLocalPath, + normalizePathToForward(value) { + return String(value || '').trim().replace(/\\/g, '/'); + } + }; + + assert.equal(normalizeDownloadSubfolderPath.call(dialog, 'Pony\\Styles'), 'Pony/Styles'); + assert.equal(normalizeDownloadSubfolderPath.call(dialog, 'Pony/Styles'), 'Pony/Styles'); + assert.equal( + joinLocalPath.call(dialog, '/models/loras', 'Pony\\Styles'), + '/models/loras/Pony/Styles' + ); + assert.equal( + joinLocalPath.call(dialog, 'C:\\models\\loras', 'Pony/Styles'), + 'C:\\models\\loras\\Pony\\Styles' + ); +}); + test('download subfolder tooltip identifies a folder taken from the workflow model path', () => { const getDownloadSubfolderSuggestionReason = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadSubfolderSuggestionReason')})`); const dialog = { @@ -945,8 +968,8 @@ test('download subfolder tooltip explains Suggest button choice', () => { getSavedDownloadSubfolderSuggestion, getCurrentDownloadSubfolderSuggestion, normalizeDownloadSubfolderPath, - normalizePathToBackward(value) { - return String(value || '').replace(/[\/]+/g, '\\'); + normalizePathToForward(value) { + return String(value || '').replace(/\\/g, '/'); }, normalizeDownloadCategory(value) { return String(value || 'checkpoints'); @@ -967,7 +990,7 @@ test('download subfolder tooltip explains Suggest button choice', () => { const tooltip = getDownloadSubfolderTooltip.call(dialog, { node_id: 1 }, 'loras', 'Pony\\Styles', { saved }); - assert.match(tooltip, /Selected with Suggest: Pony\\Styles/); + assert.match(tooltip, /Selected with Suggest: Pony\/Styles/); assert.match(tooltip, /saved folder rule.*\{base_model\}\/\{first_tag\}/); }); @@ -1025,6 +1048,9 @@ test('post-search auto-fill can refresh earlier suggested subfolder', async () = normalizeDownloadCategory(value) { return String(value || ''); }, + normalizeDownloadSubfolderPath(value) { + return String(value || '').replace(/\\/g, '/'); + }, getDropdownValue(element) { return element.dataset.value; }, @@ -1062,7 +1088,7 @@ test('post-search auto-fill can refresh earlier suggested subfolder', async () = preferTemplate: true }); - assert.equal(subfolderEl.value, 'SDXL\\style'); + assert.equal(subfolderEl.value, 'SDXL/style'); assert.equal(saves.at(-1).subfolderSuggestionSource, 'template'); }); diff --git a/tests/test_path_security.py b/tests/test_path_security.py index 13d245e..767da75 100644 --- a/tests/test_path_security.py +++ b/tests/test_path_security.py @@ -102,6 +102,33 @@ class PathSecurityTests(unittest.TestCase): mock_download_file.call_args.args[1], ) + def test_download_subfolder_accepts_both_separator_styles(self): + with tempfile.TemporaryDirectory() as model_root, patch( + "core.downloader.get_download_directory", return_value=model_root + ), patch("core.downloader.download_file") as mock_download_file: + expected_path = os.path.join( + model_root, + "Pony", + "Styles", + "model.safetensors", + ) + mock_download_file.return_value = { + "success": True, + "download_id": "portable-subfolder", + "path": expected_path, + } + + result = download_model( + "https://example.com/model.safetensors", + "model.safetensors", + "loras", + download_id="portable-subfolder", + subfolder=r"Pony\Styles", + ) + + self.assertTrue(result["success"]) + self.assertEqual(expected_path, mock_download_file.call_args.args[1]) + def test_non_model_executable_download_is_rejected(self): self.assertFalse(is_allowed_model_download_filename("payload.exe")) self.assertTrue(is_allowed_model_download_filename("model.safetensors")) diff --git a/tests/test_settings.py b/tests/test_settings.py index 8d2434f..a2f481b 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,13 +1,16 @@ import unittest + from core.settings import ( - bool_setting, - normalize_settings, - sanitize_folder_name, - normalize_relative_subfolder, - normalize_download_path_template, _resolve_base_model_mapping, + bool_setting, + normalize_download_path_template, + normalize_relative_subfolder, + normalize_settings, + resolve_download_subfolder, + sanitize_folder_name, ) + class SettingsTests(unittest.TestCase): def test_bool_setting(self): @@ -42,6 +45,12 @@ class SettingsTests(unittest.TestCase): self.assertEqual(normalize_relative_subfolder("sub\\dir/name"), "sub/dir/name") self.assertEqual(normalize_relative_subfolder(""), "") + def test_requested_download_subfolder_uses_forward_slashes(self): + self.assertEqual( + resolve_download_subfolder("loras", r"Pony\Styles"), + "Pony/Styles", + ) + def test_normalize_download_path_template(self): self.assertEqual(normalize_download_path_template("some/{category}/template/"), "some/{category}/template") self.assertEqual(normalize_download_path_template(""), "") diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index 138c75f..dca388e 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -571,7 +571,7 @@ export const downloadTargetMethods = { if (String(baseDirectory || '') !== String(saved.subfolderBaseDirectory || '')) return null; return { - value: saved.subfolder || selectedValue, + value: selectedValue, baseDirectory: saved.subfolderBaseDirectory || '', suggestionSource: saved.subfolderSuggestionSource || '', template: saved.subfolderSuggestionTemplate || '', @@ -899,19 +899,19 @@ export const downloadTargetMethods = { }, normalizeDownloadSubfolderPath(value = '') { - return this.normalizePathToBackward(value) - .split('\\') + return this.normalizePathToForward(value) + .split('/') .map(part => part.trim()) .filter(part => part && part !== '.' && part !== '..') - .join('\\'); + .join('/'); }, getDownloadSubfolderPrefixes(value = '') { - const parts = this.normalizeDownloadSubfolderPath(value).split('\\').filter(Boolean); + const parts = this.normalizeDownloadSubfolderPath(value).split('/').filter(Boolean); const prefixes = []; let current = ''; parts.forEach(part => { - current = current ? `${current}\\${part}` : part; + current = current ? `${current}/${part}` : part; prefixes.push(current); }); return prefixes; @@ -965,18 +965,19 @@ export const downloadTargetMethods = { }, getSubfolderOptionValue(option) { - return String( + const value = String( option && typeof option === 'object' ? option.value || '' : option || '' ); + return this.normalizeDownloadSubfolderPath(value); }, getSubfolderOptionLabel(option) { if (option && typeof option === 'object') { - return String(option.label || option.value || ''); + return this.normalizePathToForward(option.label || option.value || ''); } - return String(option || ''); + return this.normalizePathToForward(option || ''); }, getSubfolderOptionBaseDirectory(option) { @@ -1005,10 +1006,11 @@ export const downloadTargetMethods = { joinLocalPath(basePath = '', relativePath = '') { const base = String(basePath || '').replace(/[\/\\]+$/, ''); const relative = String(relativePath || '').replace(/^[\/\\]+/, ''); - if (!base) return relative; + if (!base) return this.normalizePathToForward(relative); if (!relative) return base; const separator = base.includes('\\') ? '\\' : '/'; - return `${base}${separator}${relative}`; + const normalizedRelative = relative.replace(/[\/\\]+/g, separator); + return `${base}${separator}${normalizedRelative}`; }, getDownloadTargetFolderContext(category = '', subfolder = '', baseDirectory = '') { @@ -1016,7 +1018,7 @@ export const downloadTargetMethods = { const targetBaseDirectory = baseDirectory || this.getDownloadTargetBaseDirectory(normalizedCategory); if (!targetBaseDirectory) return null; - const cleanSubfolder = String(subfolder || '').trim(); + const cleanSubfolder = this.normalizeDownloadSubfolderPath(subfolder); const folderPath = cleanSubfolder ? this.joinLocalPath(targetBaseDirectory, cleanSubfolder) : targetBaseDirectory; @@ -1045,7 +1047,7 @@ export const downloadTargetMethods = { syncDownloadTargetFolderContext(categoryEl, subfolderEl) { if (!categoryEl) return; const category = this.normalizeDownloadCategory(this.getDropdownValue(categoryEl) || 'checkpoints'); - const subfolder = (subfolderEl?.value || '').trim(); + const subfolder = this.normalizeDownloadSubfolderPath(subfolderEl?.value || ''); const subfolderBaseDirectory = subfolderEl?.dataset.baseDirectory || ''; this.setDownloadFolderContextTarget( @@ -2068,11 +2070,12 @@ export const downloadTargetMethods = { preferTemplate: Boolean(options.preferTemplate) }); if (suggestion) { - subfolderEl.value = suggestion.value || ''; + const suggestedSubfolder = this.normalizeDownloadSubfolderPath(suggestion.value || ''); + subfolderEl.value = suggestedSubfolder; subfolderEl.dataset.baseDirectory = suggestion.baseDirectory || ''; this.saveDownloadTargetSelection(missing, { category, - subfolder: suggestion.value || '', + subfolder: suggestedSubfolder, subfolderBaseDirectory: suggestion.baseDirectory || '', subfolderTouched: false, ...this.getSubfolderSuggestionTrackingPatch(suggestion, 'auto') @@ -2109,11 +2112,12 @@ export const downloadTargetMethods = { } this.setDropdownValue(categoryEl, category, this.getCategoryDisplayName(category)); - subfolderEl.value = suggestion.value || ''; + const suggestedSubfolder = this.normalizeDownloadSubfolderPath(suggestion.value || ''); + subfolderEl.value = suggestedSubfolder; subfolderEl.dataset.baseDirectory = suggestion.baseDirectory || ''; this.saveDownloadTargetSelection(missing, { category, - subfolder: suggestion.value || '', + subfolder: suggestedSubfolder, subfolderBaseDirectory: suggestion.baseDirectory || '', categoryTouched: Boolean(preserveSavedCategory && category === originalCategory), subfolderTouched: true, @@ -2182,7 +2186,9 @@ export const downloadTargetMethods = { defaultCategory }); const preserveSavedSubfolder = Boolean(saved?.subfolderTouched); - const selectedSubfolder = preserveSavedSubfolder ? saved.subfolder || '' : ''; + const selectedSubfolder = preserveSavedSubfolder + ? this.normalizeDownloadSubfolderPath(saved.subfolder || '') + : ''; const selectedSubfolderBaseDirectory = preserveSavedSubfolder ? saved.subfolderBaseDirectory || '' : ''; const subfolderTooltip = this.getDownloadSubfolderTooltip(missing, selectedCategory, selectedSubfolder, { saved, @@ -2200,7 +2206,7 @@ export const downloadTargetMethods = { html += `
`; html += ``; html += `
`; - html += ``; + html += ``; html += `
`; html += `
`; html += ``; @@ -2216,7 +2222,10 @@ export const downloadTargetMethods = { this.getDropdownValue(categoryEl) || this.getMissingDownloadCategory(missing, fallbackCategory || 'checkpoints') ); - const subfolder = (subfolderEl?.value || '').trim(); + const subfolder = this.normalizeDownloadSubfolderPath(subfolderEl?.value || ''); + if (subfolderEl && subfolderEl.value !== subfolder) { + subfolderEl.value = subfolder; + } const subfolderBaseDirectory = subfolder ? subfolderEl?.dataset.baseDirectory || '' : ''; this.saveDownloadTargetSelection(missing, { category, @@ -2473,11 +2482,11 @@ export const downloadTargetMethods = { }); }; - const normalizeSubfolderPath = (value = '') => this.normalizePathToBackward(value) - .split('\\') + const normalizeSubfolderPath = (value = '') => this.normalizeDownloadSubfolderPath(value) + .split('/') .map(part => part.trim()) .filter(part => part && part !== '.') - .join('\\'); + .join('/'); const getFolderBrowserExpandedSet = (category = '') => { if (!this.downloadFolderBrowserExpandedGroups) { @@ -2507,11 +2516,11 @@ export const downloadTargetMethods = { const value = normalizeSubfolderPath(entry.value); if (!value) return; - const parts = value.split('\\').filter(Boolean); + const parts = value.split('/').filter(Boolean); let current = root; let currentPath = ''; parts.forEach((part, index) => { - currentPath = currentPath ? `${currentPath}\\${part}` : part; + currentPath = currentPath ? `${currentPath}/${part}` : part; if (!current.has(part)) { current.set(part, { name: part, @@ -2561,7 +2570,7 @@ export const downloadTargetMethods = { const shouldExpand = hasChildren && ( Boolean(filter) || expandedSet.has(stateKey) - || selectedValue.toLowerCase().startsWith(`${node.path.toLowerCase()}\\`) + || selectedValue.toLowerCase().startsWith(`${node.path.toLowerCase()}/`) ); const toggle = hasChildren ? `` @@ -2600,7 +2609,7 @@ export const downloadTargetMethods = { const previousScrollTop = previousScrollEl ? previousScrollEl.scrollTop : 0; const rawFilter = String(filterText || '').trim(); const filter = rawFilter.toLowerCase(); - const normalizedFilter = filter.replace(/[\/]+/g, '\\'); + const normalizedFilter = filter.replace(/[\\]+/g, '/'); const category = this.getDropdownValue(categoryEl); const expandedSet = getFolderBrowserExpandedSet(category); const selectedValue = normalizeSubfolderPath(subfolderEl.value || ''); @@ -2839,7 +2848,7 @@ export const downloadTargetMethods = { } this.saveDownloadTargetSelection(missing, { category, - subfolder: subfolderEl.value || '', + subfolder: this.normalizeDownloadSubfolderPath(subfolderEl.value || ''), subfolderBaseDirectory: subfolderEl.dataset.baseDirectory || '', categoryTouched: true, subfolderTouched: Boolean(subfolderEl.value), @@ -2884,9 +2893,10 @@ export const downloadTargetMethods = { subfolderEl.addEventListener('input', () => { subfolderEl.dataset.baseDirectory = ''; + const normalizedSubfolder = this.normalizeDownloadSubfolderPath(subfolderEl.value); this.saveDownloadTargetSelection(missing, { category: this.getDropdownValue(categoryEl), - subfolder: subfolderEl.value, + subfolder: normalizedSubfolder, subfolderBaseDirectory: '', subfolderTouched: true, ...this.getSubfolderSuggestionTrackingPatch(null) @@ -2895,6 +2905,21 @@ export const downloadTargetMethods = { refreshCategoryTooltip(); populateSubfolderOptions(subfolderEl.value); }); + subfolderEl.addEventListener('blur', () => { + const normalizedSubfolder = this.normalizeDownloadSubfolderPath(subfolderEl.value); + if (subfolderEl.value !== normalizedSubfolder) { + subfolderEl.value = normalizedSubfolder; + } + this.saveDownloadTargetSelection(missing, { + category: this.getDropdownValue(categoryEl), + subfolder: normalizedSubfolder, + subfolderBaseDirectory: subfolderEl.dataset.baseDirectory || '', + subfolderTouched: true, + ...this.getSubfolderSuggestionTrackingPatch(null) + }); + this.syncDownloadTargetFolderContext(categoryEl, subfolderEl); + refreshCategoryTooltip(); + }); if (suggestBtn && suggestBtn.dataset.mlSuggestBound !== 'true') { suggestBtn.dataset.mlSuggestBound = 'true'; diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index ec30d68..05f8b64 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -1437,11 +1437,11 @@ export const missingBrowserMethods = { const getAllModels = () => Array.isArray(this.allModels) ? this.allModels : []; let localModelLoadToken = 0; - const normalizeModelPath = (value = '') => this.normalizePathToBackward(value) - .split('\\') + const normalizeModelPath = (value = '') => this.normalizePathToForward(value) + .split('/') .map(part => part.trim()) .filter(Boolean) - .join('\\'); + .join('/'); const getModelCategory = (model = {}) => this.normalizeDownloadCategory(model.category || 'unknown') || 'unknown'; const getDownloadCategoryElement = () => container.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`) || this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`); @@ -1537,7 +1537,8 @@ export const missingBrowserMethods = { const relative = String(relativePath || '').replace(/^[\/\\]+/, ''); if (!base) return relative; if (!relative) return base; - return `${base}${base.includes('\\') ? '\\' : '/'}${relative}`; + const separator = base.includes('\\') ? '\\' : '/'; + return `${base}${separator}${relative.replace(/[\/\\]+/g, separator)}`; }; const buildLocalFolderContext = (folderPath = '', name = 'Folder', category = '') => { const path = String(folderPath || '').trim(); @@ -1581,9 +1582,9 @@ export const missingBrowserMethods = { const category = getModelCategory(model); const categoryLabel = this.getCategoryDisplayName(category); const relativePath = normalizeModelPath(model.relative_path || model.filename || ''); - const pathParts = relativePath.split('\\').filter(Boolean); + const pathParts = relativePath.split('/').filter(Boolean); const filename = pathParts.pop() || model.filename || relativePath || 'model'; - const folderPath = pathParts.join('\\'); + const folderPath = pathParts.join('/'); const baseDirectory = String(model.base_directory || ''); const fullPath = String(model.path || ''); return { @@ -1634,11 +1635,11 @@ export const missingBrowserMethods = { const buildModelTree = (entries = []) => { const root = { folders: new Map(), models: [] }; entries.forEach(entry => { - const folderParts = entry.folderPath.split('\\').filter(Boolean); + const folderParts = entry.folderPath.split('/').filter(Boolean); let current = root; let currentPath = ''; folderParts.forEach(part => { - currentPath = currentPath ? `${currentPath}\\${part}` : part; + currentPath = currentPath ? `${currentPath}/${part}` : part; if (!current.folders.has(part)) { current.folders.set(part, { name: part,