normalize subfolder paths to forward slashes across backend and frontend

This commit is contained in:
Dariusz L
2026-07-24 21:22:31 +02:00
parent 90a3e7583e
commit 6ff9db2af6
7 changed files with 143 additions and 56 deletions
+5 -6
View File
@@ -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:
+1 -1
View File
@@ -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":
+33 -7
View File
@@ -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');
});
+27
View File
@@ -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"))
+14 -5
View File
@@ -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(""), "")
+54 -29
View File
@@ -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 += `<div id="${categoryListId}" class="mr-download-target-list"></div>`;
html += `</div>`;
html += `<div class="mr-download-target-wrap mr-download-target-subfolder-wrap">`;
html += `<input id="${subfolderId}" class="mr-download-target-input" type="text" placeholder="e.g. ponyxl\\styles" autocomplete="off" value="${this.escapeHtml(selectedSubfolder)}" data-base-directory="${this.escapeHtml(selectedSubfolderBaseDirectory)}">`;
html += `<input id="${subfolderId}" class="mr-download-target-input" type="text" placeholder="e.g. ponyxl/styles" autocomplete="off" value="${this.escapeHtml(selectedSubfolder)}" data-base-directory="${this.escapeHtml(selectedSubfolderBaseDirectory)}">`;
html += `<div id="${subfolderListId}" class="mr-download-target-list"></div>`;
html += `</div>`;
html += `<button id="${suggestId}" class="mr-btn mr-btn-secondary mr-btn-sm mr-download-suggest-btn" type="button" data-tooltip="Apply suggested subfolder" aria-label="Apply suggested subfolder">${getSvgIcon('lightbulb', 'currentColor', 'mr-download-suggest-icon')}</button>`;
@@ -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
? `<button class="mr-folder-browser-toggle ${shouldExpand ? 'is-expanded' : ''}" type="button" data-browser-action="toggle" data-state-key="${encodeURIComponent(stateKey)}" aria-label="${shouldExpand ? 'Collapse folder' : 'Expand folder'}"><span class="mr-folder-browser-chevron"></span></button>`
@@ -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';
@@ -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,