diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index cac598a..ce3e8e2 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -168,6 +168,9 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result, pub category_directory_overrides: HashMap, pub approved_download_roots: Vec, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b296007..d66f27d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -236,6 +236,9 @@ fn migrate_location_settings(state: &mut Value) -> Result<(), String> { } state.insert("baseDownloadFolder".to_string(), Value::String(base)); + state + .entry("categorySubfoldersEnabled".to_string()) + .or_insert(Value::Bool(true)); state.insert( "categorySubfolders".to_string(), serde_json::to_value(subfolders) @@ -268,6 +271,7 @@ fn default_settings() -> PersistedSettings { PersistedSettings { theme: Theme::System, base_download_folder: "~/Downloads".to_string(), + category_subfolders_enabled: true, category_subfolders: default_category_subfolders(), category_directory_overrides: HashMap::new(), approved_download_roots: Vec::new(), @@ -378,6 +382,7 @@ mod tests { vec!["00000000-0000-0000-0000-000000000001"] ); assert_eq!(settings.base_download_folder, "~/Downloads"); + assert!(settings.category_subfolders_enabled); } #[test] @@ -475,6 +480,25 @@ mod tests { assert_eq!(settings.category_subfolders["Documents"], "Documents"); } + #[test] + fn decodes_disabled_category_subfolders() { + let stored = json!({ + "state": { + "baseDownloadFolder": "/Users/test/Downloads", + "categorySubfoldersEnabled": false, + "categorySubfolders": { + "Movies": "Movies" + } + }, + "version": 3 + }); + + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + + assert!(!settings.category_subfolders_enabled); + assert_eq!(settings.category_subfolders["Movies"], "Movies"); + } + #[test] fn replaces_zero_concurrency_with_the_safe_default() { let stored = json!({"state": {"maxConcurrentDownloads": 0}, "version": 0}); diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index a78a410..0328de9 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -8,7 +8,7 @@ import type { SettingsTab } from "./SettingsTab"; import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; -export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, +export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, /** * HMAC shared secret for the browser extension. It is persisted in the * settings database so that startup never needs to touch the OS keychain. diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index b919711..c34fa4d 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -168,11 +168,13 @@ const runEngineStatusCheck = (check: EngineCheck, force: boolean) => { const CategoryFolderInput = ({ category, settings, - onBrowse + onBrowse, + disabled = false }: { category: string; settings: SettingsState; onBrowse: () => void; + disabled?: boolean; }) => { const base = settings.baseDownloadFolder || '~/Downloads'; const sub = Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category) @@ -190,6 +192,7 @@ const CategoryFolderInput = ({ setLocalValue(displayPath)} onChange={(e) => setLocalValue(e.target.value)} onBlur={() => { @@ -213,12 +216,13 @@ const CategoryFolderInput = ({ } setLocalValue(null); }} - className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay" + className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay disabled:opacity-60 disabled:cursor-not-allowed" aria-label={`${category} subfolder`} /> @@ -478,21 +482,23 @@ runEngineChecks(false); const approvedBase = await settings.approveDownloadRoot(base); settings.setBaseDownloadFolder(approvedBase); try { - const safeSubfolders = Object.fromEntries( - DOWNLOAD_CATEGORIES.map(category => [ - category, - normalizeCategorySubfolder( - Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category) - ? settings.categorySubfolders[category] - : DEFAULT_CATEGORY_SUBFOLDERS[category], - DEFAULT_CATEGORY_SUBFOLDERS[category] - ) - ]) - ); - await invoke('create_category_directories', { - baseFolder: approvedBase, - subfolders: safeSubfolders - }); + if (settings.categorySubfoldersEnabled) { + const safeSubfolders = Object.fromEntries( + DOWNLOAD_CATEGORIES.map(category => [ + category, + normalizeCategorySubfolder( + Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category) + ? settings.categorySubfolders[category] + : DEFAULT_CATEGORY_SUBFOLDERS[category], + DEFAULT_CATEGORY_SUBFOLDERS[category] + ) + ]) + ); + await invoke('create_category_directories', { + baseFolder: approvedBase, + subfolders: safeSubfolders + }); + } } catch (e) { console.error("Failed to create directories on disk:", e); showToast(`Base folder saved, but category folders could not be created: ${String(e)}`, 'warning'); @@ -936,22 +942,50 @@ runEngineChecks(false); +
+ +
+
Category Subfolders - Relative to the base folder + + {settings.categorySubfoldersEnabled ? 'Relative to the base folder' : 'Disabled'} +
-
+
{DOWNLOAD_CATEGORIES.map((category) => ( -
+
{category} handleBrowseCategory(category)} + disabled={!settings.categorySubfoldersEnabled} />
@@ -964,7 +998,8 @@ runEngineChecks(false); settings.resetCategoryLocations(); showToast("Reset category locations to default", 'success'); }} - className="app-control hover:bg-item-hover text-text-secondary px-4 py-1" + disabled={!settings.categorySubfoldersEnabled} + className="app-control hover:bg-item-hover text-text-secondary px-4 py-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent" > Reset Defaults diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 52d6a3d..71edb46 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -36,6 +36,7 @@ vi.mock('./useSettingsStore', () => ({ maxAutomaticRetries: 3, mediaCookieSource: 'none', baseDownloadFolder: '~/Downloads', + categorySubfoldersEnabled: true, categorySubfolders: { Musics: 'Musics', Movies: 'Movies', diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 1cb0c88..77bcd71 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -73,6 +73,7 @@ export type { export interface SettingsState { theme: Theme; baseDownloadFolder: string; + categorySubfoldersEnabled: boolean; categorySubfolders: Record; categoryDirectoryOverrides: Record; approvedDownloadRoots: string[]; @@ -145,6 +146,7 @@ export interface SettingsState { setAskWhereToSaveEachFile: (ask: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void; setMediaCookieSource: (source: MediaCookieSource) => void; + setCategorySubfoldersEnabled: (enabled: boolean) => void; setCategorySubfolder: (category: string, subfolder: string) => void; setCategoryDirectoryOverride: (category: string, path?: string) => void; resetCategoryLocations: () => void; @@ -187,6 +189,7 @@ export const useSettingsStore = create()( (set, _get) => ({ theme: 'system', baseDownloadFolder: '~/Downloads', + categorySubfoldersEnabled: true, categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS }, categoryDirectoryOverrides: {}, approvedDownloadRoots: [], @@ -295,6 +298,10 @@ export const useSettingsStore = create()( set({ preventsSleepWhileDownloading }); }, setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); }, + setCategorySubfoldersEnabled: (categorySubfoldersEnabled) => { + info('Settings updated: categorySubfoldersEnabled'); + set({ categorySubfoldersEnabled }); + }, setCategorySubfolder: (category, subfolder) => { info(`Settings updated: category subfolder ${category}`); set((state) => ({ @@ -385,6 +392,7 @@ export const useSettingsStore = create()( partialize: (state): PersistedSettings => ({ theme: state.theme, baseDownloadFolder: state.baseDownloadFolder, + categorySubfoldersEnabled: state.categorySubfoldersEnabled, categorySubfolders: state.categorySubfolders, categoryDirectoryOverrides: state.categoryDirectoryOverrides, approvedDownloadRoots: state.approvedDownloadRoots, diff --git a/src/utils/downloadLocations.test.ts b/src/utils/downloadLocations.test.ts index 43ca640..e98c3c6 100644 --- a/src/utils/downloadLocations.test.ts +++ b/src/utils/downloadLocations.test.ts @@ -65,6 +65,26 @@ describe('download locations', () => { expect(await resolveCategoryDestination(automatic, 'Movies')).toBe('/Volumes/Media'); }); + it('defaults category subfolders on and sends every category to the base folder when disabled', async () => { + const automatic = normalizeDownloadLocationSettings({ + baseDownloadFolder: '/Users/test/Downloads' + }); + expect(automatic.categorySubfoldersEnabled).toBe(true); + + const disabled = normalizeDownloadLocationSettings({ + baseDownloadFolder: '/Users/test/Downloads', + categorySubfoldersEnabled: false, + categorySubfolders: { Movies: 'Video Files' }, + categoryDirectoryOverrides: { Movies: '/Volumes/Media' } + }); + + expect(disabled.categorySubfoldersEnabled).toBe(false); + expect(await resolveCategoryDestination(disabled, 'Movies')) + .toBe('/Users/test/Downloads'); + expect(await resolveCategoryDestination(disabled, 'Documents')) + .toBe('/Users/test/Downloads'); + }); + it('keeps an explicit empty category subfolder as the base folder', async () => { const settings = normalizeDownloadLocationSettings({ baseDownloadFolder: '/Users/test/Downloads', diff --git a/src/utils/downloadLocations.ts b/src/utils/downloadLocations.ts index c6cc5e5..a1c1cbe 100644 --- a/src/utils/downloadLocations.ts +++ b/src/utils/downloadLocations.ts @@ -34,12 +34,14 @@ export const DEFAULT_CATEGORY_SUBFOLDERS: Record = { export interface DownloadLocationSettings { baseDownloadFolder: string; + categorySubfoldersEnabled: boolean; categorySubfolders: Record; categoryDirectoryOverrides: Record; } interface LegacyDownloadLocationSettings { baseDownloadFolder?: unknown; + categorySubfoldersEnabled?: unknown; categorySubfolders?: unknown; categoryDirectoryOverrides?: unknown; defaultDownloadPath?: unknown; @@ -114,6 +116,7 @@ export const normalizeDownloadLocationSettings = ( (typeof value.baseDownloadFolder === 'string' && value.baseDownloadFolder.trim()) || (typeof value.defaultDownloadPath === 'string' && value.defaultDownloadPath.trim()) || '~/Downloads'; + const categorySubfoldersEnabled = value.categorySubfoldersEnabled !== false; const persistedSubfolders = stringRecord(value.categorySubfolders); const categorySubfolders = Object.fromEntries( DOWNLOAD_CATEGORIES.map(category => { @@ -150,6 +153,7 @@ export const normalizeDownloadLocationSettings = ( return { baseDownloadFolder, + categorySubfoldersEnabled, categorySubfolders, categoryDirectoryOverrides }; @@ -159,11 +163,13 @@ export const resolveCategoryDestination = async ( settings: DownloadLocationSettings, category: DownloadCategory ): Promise => { + const base = settings.baseDownloadFolder.trim() || '~/Downloads'; + const expandedBase = await expandTilde(base); + if (!settings.categorySubfoldersEnabled) return expandedBase; + const override = settings.categoryDirectoryOverrides[category]?.trim(); if (override) return expandTilde(override); - const base = settings.baseDownloadFolder.trim() || '~/Downloads'; - const expandedBase = await expandTilde(base); const persistedValue = hasOwn(settings.categorySubfolders, category) ? settings.categorySubfolders[category] : DEFAULT_CATEGORY_SUBFOLDERS[category];