From 335f886a4ef12f1ad7f5ef001348a00907671d0c Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 6 Aug 2026 21:19:29 +0200 Subject: [PATCH] Unify local path joining --- tests/test_downloads_tab_workflow_route.mjs | 24 +++++++++++++++++++ .../search/download_target_methods.js | 17 ++++--------- web/resolver/utils/path_utils.js | 23 ++++++++++++++++++ web/resolver/views/missing_browser_methods.js | 22 +++-------------- 4 files changed, 54 insertions(+), 32 deletions(-) create mode 100644 web/resolver/utils/path_utils.js diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 519e7f5..b5c2349 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -9,6 +9,7 @@ import { safeStorage, } from '../web/resolver/utils/html_utils.js'; import { getModelCardUrl } from '../web/resolver/utils/url_utils.js'; +import { joinPathPreservingStyle } from '../web/resolver/utils/path_utils.js'; import { classifyLocalMatches, matchesLocalModelDownload, @@ -4027,6 +4028,29 @@ test('portable subfolder paths use forward slashes and join to the host path sty ); }); +test('shared local path join preserves host separators and empty-base contracts', () => { + assert.equal( + joinPathPreservingStyle('/models/loras', 'Pony\\Styles'), + '/models/loras/Pony/Styles' + ); + assert.equal( + joinPathPreservingStyle('C:\\models\\loras', 'Pony/Styles'), + 'C:\\models\\loras\\Pony\\Styles' + ); + assert.equal( + joinPathPreservingStyle('\\\\server\\share\\', 'Pony/Styles'), + '\\\\server\\share\\Pony\\Styles' + ); + assert.equal( + joinPathPreservingStyle('', 'Pony\\Styles', { normalizeRelativeWhenBaseEmpty: true }), + 'Pony/Styles' + ); + assert.equal( + joinPathPreservingStyle('', 'Pony\\Styles'), + 'Pony\\Styles' + ); +}); + test('download subfolder and path template normalization apply the same segment rules', () => { const normalizeDownloadPathValue = eval(`(${extractMethod(downloadTargetMethodsSource, 'normalizeDownloadPathValue')})`); const dialog = { diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index d24f7b7..39efb3e 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -4,6 +4,7 @@ import { createFloatingTreePicker } from "../utils/tree_picker.js"; import { normalizeDownloadCategoryValue } from "../utils/category_utils.js"; import { safeStorage } from "../utils/html_utils.js"; import { getSha256Field } from "../utils/hash_utils.js"; +import { joinPathPreservingStyle } from "../utils/path_utils.js"; import { matchesSearchText } from "../utils/search_utils.js"; const localStorage = safeStorage; const invalidWindowsPathCharacters = new RegExp( @@ -953,19 +954,9 @@ export const downloadTargetMethods = { }, joinLocalPath(basePath = '', relativePath = '') { - const rawBase = String(basePath || ''); - const relative = String(relativePath || '').replace(/^[/\\]+/, ''); - const usesBackslash = /^[A-Za-z]:\\/.test(rawBase) - || /^\\\\/.test(rawBase) - || (!rawBase.includes('/') && rawBase.includes('\\')); - const separator = usesBackslash ? '\\' : '/'; - const base = rawBase.replace(usesBackslash ? /[/\\]+$/ : /\/+$/, '') - || (usesBackslash ? (/^\\+$/.test(rawBase) ? '\\' : '') : (/^\/+$/.test(rawBase) ? '/' : '')); - if (!base) return this.normalizePathToForward(relative); - if (!relative) return base; - const normalizedRelative = relative.replace(/[/\\]+/g, separator); - const joiner = base.endsWith(separator) ? '' : separator; - return `${base}${joiner}${normalizedRelative}`; + return joinPathPreservingStyle(basePath, relativePath, { + normalizeRelativeWhenBaseEmpty: true, + }); }, getDownloadTargetFolderContext(category = '', subfolder = '', baseDirectory = '') { diff --git a/web/resolver/utils/path_utils.js b/web/resolver/utils/path_utils.js new file mode 100644 index 0000000..8f6f42b --- /dev/null +++ b/web/resolver/utils/path_utils.js @@ -0,0 +1,23 @@ +export function joinPathPreservingStyle( + basePath = '', + relativePath = '', + { normalizeRelativeWhenBaseEmpty = false } = {}, +) { + const rawBase = String(basePath || ''); + const relative = String(relativePath || '').replace(/^[/\\]+/, ''); + const usesBackslash = /^[A-Za-z]:\\/.test(rawBase) + || /^\\\\/.test(rawBase) + || (!rawBase.includes('/') && rawBase.includes('\\')); + const separator = usesBackslash ? '\\' : '/'; + const base = rawBase.replace(usesBackslash ? /[/\\]+$/ : /\/+$/, '') + || (usesBackslash ? (/^\\+$/.test(rawBase) ? '\\' : '') : (/^\/+$/.test(rawBase) ? '/' : '')); + if (!base) { + return normalizeRelativeWhenBaseEmpty + ? relative.trim().replace(/\\/g, '/') + : relative; + } + if (!relative) return base; + const normalizedRelative = relative.replace(/[/\\]+/g, separator); + const joiner = base.endsWith(separator) ? '' : separator; + return `${base}${joiner}${normalizedRelative}`; +} diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 7f04a26..a3b0f6c 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -5,6 +5,7 @@ import { getCivitaiModelUrl } from "../globals.js"; import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js"; import { matchesWorkflowModelReference } from "../node_context_menu.js"; import { classifyLocalMatches } from "../utils/local_match_utils.js"; +import { joinPathPreservingStyle } from "../utils/path_utils.js"; const MISSING_ROW_FALLBACK_HEIGHT = 70; const MISSING_VIRTUAL_INITIAL_ROWS = 24; @@ -1874,23 +1875,6 @@ export const missingBrowserMethods = { return Array.from(byIdentity.values()); }; - const joinLocalModelPath = (basePath = '', relativePath = '') => { - if (typeof this.joinLocalPath === 'function') { - return this.joinLocalPath(basePath, relativePath); - } - const rawBase = String(basePath || ''); - const relative = String(relativePath || '').replace(/^[/\\]+/, ''); - const usesBackslash = /^[A-Za-z]:\\/.test(rawBase) - || /^\\\\/.test(rawBase) - || (!rawBase.includes('/') && rawBase.includes('\\')); - const separator = usesBackslash ? '\\' : '/'; - const base = rawBase.replace(usesBackslash ? /[/\\]+$/ : /\/+$/, '') - || (usesBackslash ? (/^\\+$/.test(rawBase) ? '\\' : '') : (/^\/+$/.test(rawBase) ? '/' : '')); - if (!base) return relative; - if (!relative) return base; - const joiner = base.endsWith(separator) ? '' : separator; - return `${base}${joiner}${relative.replace(/[/\\]+/g, separator)}`; - }; const buildLocalFolderContext = (folderPath = '', name = 'Folder', category = '') => { const path = String(folderPath || '').trim(); if (!path) return null; @@ -1911,7 +1895,7 @@ export const missingBrowserMethods = { const path = entry.fullPath || model.path || model.resolved_path || ''; if (!path) return null; const directory = entry.folderPath - ? joinLocalModelPath(entry.baseDirectory, entry.folderPath) + ? joinPathPreservingStyle(entry.baseDirectory, entry.folderPath) : entry.baseDirectory; return { ...model, @@ -2037,7 +2021,7 @@ export const missingBrowserMethods = { const modelCount = countTreeModels(folder); const shouldExpand = Boolean(filter) || expandedSet.has(stateKey); const folderFullPath = group.baseDirectory - ? joinLocalModelPath(group.baseDirectory, folder.path) + ? joinPathPreservingStyle(group.baseDirectory, folder.path) : ''; const context = buildLocalFolderContext(folderFullPath, folder.path, group.category); const contextAttrs = getLocalPathContextAttrs(context, folderFullPath);