feat(settings): modernize download location settings

- Replace split 'Default Download Path' and 'All Categories Base' with a canonical baseDownloadFolder and categorySubfolders model
- Retain absolute category overrides only where required
- Update Add window to accurately reflect intended destination:
  - Single URL: specific category path
  - Multiple URLs: base folder with explanatory text
  - Manual Browse override: selected folder
- Centralize path resolution logic shared by Rust backend and UI
This commit is contained in:
NimBold
2026-06-20 19:22:20 +03:30
parent 79b579790d
commit 038f31b988
17 changed files with 1103 additions and 380 deletions
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@tauri-apps/api/path', () => ({
join: vi.fn(async (...parts: string[]) =>
parts
.map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))
.join('/')
)
}));
import {
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeCategorySubfolder,
normalizeDownloadLocationSettings,
resolveCategoryDestination
} from './downloadLocations';
describe('download locations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('migrates legacy derived directories without creating overrides', () => {
const settings = normalizeDownloadLocationSettings({
defaultDownloadPath: '/Users/test/Downloads',
downloadDirectories: {
Movies: '/Users/test/Downloads/Movies',
Documents: '/Users/test/Downloads/Documents'
}
});
expect(settings.baseDownloadFolder).toBe('/Users/test/Downloads');
expect(settings.categorySubfolders).toEqual(DEFAULT_CATEGORY_SUBFOLDERS);
expect(settings.categoryDirectoryOverrides).toEqual({});
});
it('preserves legacy custom category directories as overrides', () => {
const settings = normalizeDownloadLocationSettings({
defaultDownloadPath: '/Users/test/Downloads',
downloadDirectories: {
Video: '/Volumes/Media/Movies'
}
});
expect(settings.categoryDirectoryOverrides.Movies).toBe('/Volumes/Media/Movies');
});
it('resolves automatic and overridden category destinations', async () => {
const automatic = normalizeDownloadLocationSettings({
baseDownloadFolder: '/Users/test/Downloads',
categorySubfolders: { Movies: 'Video Files' }
});
expect(await resolveCategoryDestination(automatic, 'Movies'))
.toBe('/Users/test/Downloads/Video Files');
automatic.categoryDirectoryOverrides.Movies = '/Volumes/Media';
expect(await resolveCategoryDestination(automatic, 'Movies')).toBe('/Volumes/Media');
});
it('keeps category subfolders relative and permits nested folders', () => {
expect(normalizeCategorySubfolder('../Media/./Movies', 'Movies')).toBe('Media/Movies');
expect(normalizeCategorySubfolder('C:\\Media\\Movies', 'Movies')).toBe('Media/Movies');
expect(normalizeCategorySubfolder('../../', 'Movies')).toBe('Movies');
});
});
+130
View File
@@ -0,0 +1,130 @@
import { join } from '@tauri-apps/api/path';
import type { DownloadCategory } from '../bindings/DownloadCategory';
export const DOWNLOAD_CATEGORIES: DownloadCategory[] = [
'Musics',
'Movies',
'Compressed',
'Documents',
'Pictures',
'Applications',
'Other'
];
export const DEFAULT_CATEGORY_SUBFOLDERS: Record<DownloadCategory, string> = {
Musics: 'Musics',
Movies: 'Movies',
Compressed: 'Compressed',
Documents: 'Documents',
Pictures: 'Pictures',
Applications: 'Applications',
Other: 'Other'
};
export interface DownloadLocationSettings {
baseDownloadFolder: string;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
}
interface LegacyDownloadLocationSettings {
baseDownloadFolder?: unknown;
categorySubfolders?: unknown;
categoryDirectoryOverrides?: unknown;
defaultDownloadPath?: unknown;
downloadDirectories?: unknown;
}
const stringRecord = (value: unknown): Record<string, string> => {
if (!value || typeof value !== 'object') return {};
return Object.fromEntries(
Object.entries(value)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
.map(([key, path]) => [key, path.trim()])
);
};
const normalizedForComparison = (value: string): string =>
value.replace(/\\/g, '/').replace(/\/+$/, '');
const legacyDerivedPath = (base: string, subfolder: string): string =>
`${normalizedForComparison(base)}/${subfolder.replace(/^[\\/]+|[\\/]+$/g, '')}`;
export const normalizeCategorySubfolder = (
value: string,
fallback: string
): string => {
const parts = value
.trim()
.replace(/\\/g, '/')
.split('/')
.filter(part => part && part !== '.' && part !== '..' && !part.endsWith(':'));
return parts.join('/') || fallback;
};
export const normalizeDownloadLocationSettings = (
value: LegacyDownloadLocationSettings
): DownloadLocationSettings => {
const baseDownloadFolder =
(typeof value.baseDownloadFolder === 'string' && value.baseDownloadFolder.trim()) ||
(typeof value.defaultDownloadPath === 'string' && value.defaultDownloadPath.trim()) ||
'~/Downloads';
const persistedSubfolders = stringRecord(value.categorySubfolders);
const categorySubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => [
category,
normalizeCategorySubfolder(
persistedSubfolders[category] || '',
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
])
);
const categoryDirectoryOverrides = stringRecord(value.categoryDirectoryOverrides);
const legacyDirectories = stringRecord(value.downloadDirectories);
const legacyAliases: Record<DownloadCategory, string> = {
Musics: 'Audio',
Movies: 'Video',
Compressed: 'Archives',
Documents: 'Documents',
Pictures: 'Images',
Applications: 'Apps',
Other: 'Other'
};
for (const category of DOWNLOAD_CATEGORIES) {
const legacyDirectory =
legacyDirectories[category] || legacyDirectories[legacyAliases[category]];
if (categoryDirectoryOverrides[category] || !legacyDirectory) continue;
const expected = legacyDerivedPath(baseDownloadFolder, categorySubfolders[category]);
if (normalizedForComparison(legacyDirectory) !== expected) {
categoryDirectoryOverrides[category] = legacyDirectory;
}
}
return {
baseDownloadFolder,
categorySubfolders,
categoryDirectoryOverrides
};
};
export const resolveCategoryDestination = async (
settings: DownloadLocationSettings,
category: DownloadCategory
): Promise<string> => {
const override = settings.categoryDirectoryOverrides[category]?.trim();
if (override) return override;
const base = settings.baseDownloadFolder.trim() || '~/Downloads';
const subfolder =
normalizeCategorySubfolder(
settings.categorySubfolders[category] || '',
DEFAULT_CATEGORY_SUBFOLDERS[category]
);
return join(base, subfolder);
};
export const resolveDownloadFilePath = async (
destination: string,
fileName: string
): Promise<string> => join(destination, fileName);