mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 10:49:29 +00:00
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:
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, defaultDownloadPath: string, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, 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, downloadDirectories: { [key in string]: string }, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, 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<SiteLogin>, autoCheckUpdates: boolean, };
|
||||
|
||||
@@ -12,6 +12,10 @@ import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
} from '../utils/downloadLocations';
|
||||
|
||||
interface RawMediaFormat {
|
||||
format_id?: string;
|
||||
@@ -271,7 +275,7 @@ export const AddDownloadsModal = () => {
|
||||
addDownload,
|
||||
queues
|
||||
} = useDownloadStore();
|
||||
const { defaultDownloadPath } = useSettingsStore();
|
||||
const { baseDownloadFolder } = useSettingsStore();
|
||||
|
||||
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
|
||||
|
||||
@@ -289,7 +293,7 @@ export const AddDownloadsModal = () => {
|
||||
const actionMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Right Form
|
||||
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
|
||||
const [saveLocation, setSaveLocation] = useState(baseDownloadFolder);
|
||||
const [isSaveLocationManual, setIsSaveLocationManual] = useState(false);
|
||||
const [connections, setConnections] = useState(16);
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
@@ -310,7 +314,7 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddModalOpen) {
|
||||
setSaveLocation(defaultDownloadPath);
|
||||
setSaveLocation(baseDownloadFolder);
|
||||
setIsSaveLocationManual(false);
|
||||
setUrls(pendingAddUrls || '');
|
||||
setParsedItems([]);
|
||||
@@ -338,7 +342,7 @@ export const AddDownloadsModal = () => {
|
||||
pendingAddReferer,
|
||||
pendingAddHeaders,
|
||||
pendingAddCookies,
|
||||
defaultDownloadPath,
|
||||
baseDownloadFolder,
|
||||
queues
|
||||
]);
|
||||
|
||||
@@ -477,14 +481,20 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
if (active && firstReadyIndex !== null && !isSaveLocationManual) {
|
||||
setSelectedItemIndex(firstReadyIndex);
|
||||
const firstFile = updatedItems[firstReadyIndex].file;
|
||||
if (firstFile) {
|
||||
const category = categoryForFileName(firstFile);
|
||||
const settingsStore = useSettingsStore.getState();
|
||||
const categoryDir = (settingsStore.downloadDirectories && settingsStore.downloadDirectories[category]) || settingsStore.defaultDownloadPath || '~/Downloads';
|
||||
setSaveLocation(categoryDir);
|
||||
if (lines.length > 1) {
|
||||
setSaveLocation(useSettingsStore.getState().baseDownloadFolder || '~/Downloads');
|
||||
} else {
|
||||
const firstFile = updatedItems[firstReadyIndex].file;
|
||||
if (firstFile) {
|
||||
const category = categoryForFileName(firstFile);
|
||||
const categoryDir = await resolveCategoryDestination(
|
||||
useSettingsStore.getState(),
|
||||
category
|
||||
);
|
||||
if (active) setSaveLocation(categoryDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => {
|
||||
@@ -513,10 +523,7 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
const categoryLocationForFile = (fileName: string) => {
|
||||
const category = categoryForFileName(fileName);
|
||||
const settingsStore = useSettingsStore.getState();
|
||||
return (settingsStore.downloadDirectories && settingsStore.downloadDirectories[category]) ||
|
||||
settingsStore.defaultDownloadPath ||
|
||||
'~/Downloads';
|
||||
return resolveCategoryDestination(useSettingsStore.getState(), category);
|
||||
};
|
||||
|
||||
const handleAction = async (action: AddDownloadAction) => {
|
||||
@@ -554,21 +561,33 @@ export const AddDownloadsModal = () => {
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
||||
}
|
||||
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
|
||||
const itemLocation = useSharedDestination
|
||||
? finalLocation
|
||||
: await categoryLocationForFile(finalFile);
|
||||
|
||||
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
|
||||
if (isUrlDupe) {
|
||||
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
|
||||
} else {
|
||||
const fileExistsInStore = store.downloads.some(d => {
|
||||
const dest = d.destination || settings.defaultDownloadPath || '~/Downloads';
|
||||
return dest === itemLocation && d.fileName === finalFile && d.status !== 'failed';
|
||||
});
|
||||
let fileExistsInStore = false;
|
||||
for (const download of store.downloads) {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(settings, download.category);
|
||||
if (
|
||||
destination === itemLocation &&
|
||||
download.fileName === finalFile &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
fileExistsInStore = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let fileExistsOnDisk = false;
|
||||
try {
|
||||
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
|
||||
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
|
||||
fileExistsOnDisk = await invoke('check_file_exists', {
|
||||
path: await resolveDownloadFilePath(itemLocation, finalFile)
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
if (fileExistsInStore || fileExistsOnDisk) {
|
||||
@@ -607,8 +626,9 @@ export const AddDownloadsModal = () => {
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
||||
}
|
||||
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
|
||||
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
|
||||
const itemLocation = useSharedDestination
|
||||
? finalLocation
|
||||
: await categoryLocationForFile(finalFile);
|
||||
|
||||
let count = 1;
|
||||
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
@@ -618,12 +638,26 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
while (exists && count < 1000) {
|
||||
newName = `${base} (${count})${ext}`;
|
||||
const storeHas = useDownloadStore.getState().downloads.some(d => {
|
||||
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
|
||||
return dest === itemLocation && d.fileName === newName && d.status !== 'failed';
|
||||
});
|
||||
let storeHas = false;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
for (const download of useDownloadStore.getState().downloads) {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
if (
|
||||
destination === itemLocation &&
|
||||
download.fileName === newName &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
storeHas = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let diskHas = false;
|
||||
try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
|
||||
try {
|
||||
diskHas = await invoke('check_file_exists', {
|
||||
path: await resolveDownloadFilePath(itemLocation, newName)
|
||||
});
|
||||
} catch(e) {}
|
||||
exists = storeHas || diskHas;
|
||||
count++;
|
||||
}
|
||||
@@ -640,15 +674,26 @@ export const AddDownloadsModal = () => {
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
||||
}
|
||||
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
|
||||
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
|
||||
const fullPath = `${cleanLocation}/${finalFile}`;
|
||||
const itemLocation = useSharedDestination
|
||||
? finalLocation
|
||||
: await categoryLocationForFile(finalFile);
|
||||
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
|
||||
|
||||
const store = useDownloadStore.getState();
|
||||
const existingItem = store.downloads.find(d => {
|
||||
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
|
||||
return (d.url === item.url || (dest === itemLocation && d.fileName === finalFile)) && d.status !== 'failed';
|
||||
});
|
||||
let existingItem;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
for (const download of store.downloads) {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
if (
|
||||
(download.url === item.url ||
|
||||
(destination === itemLocation && download.fileName === finalFile)) &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
existingItem = download;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
await store.removeDownload(existingItem.id);
|
||||
@@ -941,6 +986,16 @@ export const AddDownloadsModal = () => {
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
{parsedItems.length > 1 && !isSaveLocationManual && (
|
||||
<p className="mt-2 text-[11px] text-text-muted">
|
||||
Files will be organized into category folders.
|
||||
</p>
|
||||
)}
|
||||
{isSaveLocationManual && (
|
||||
<p className="mt-2 text-[11px] text-text-muted">
|
||||
All selected downloads will use this folder.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Transfer Settings */}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { SidebarFilter } from './Sidebar';
|
||||
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
|
||||
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
import {
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
} from '../utils/downloadLocations';
|
||||
|
||||
interface DownloadTableProps {
|
||||
filter: SidebarFilter;
|
||||
@@ -57,18 +60,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [interactionError]);
|
||||
|
||||
const resolvePath = async (dir: string, file: string) => {
|
||||
let resolvedDir = dir;
|
||||
if (dir.startsWith('~/')) {
|
||||
const home = await homeDir();
|
||||
resolvedDir = home + '/' + dir.slice(2);
|
||||
} else if (dir === '~') {
|
||||
resolvedDir = await homeDir();
|
||||
}
|
||||
const separator = resolvedDir.endsWith('/') ? '' : '/';
|
||||
return resolvedDir + separator + file;
|
||||
};
|
||||
|
||||
const showInteractionError = (message: string, error: unknown) => {
|
||||
const detail = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error);
|
||||
setInteractionError(`${message}: ${detail}`);
|
||||
@@ -79,10 +70,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
if (!fileName) return null;
|
||||
const settings = useSettingsStore.getState();
|
||||
const destination = item.destination ||
|
||||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
|
||||
settings.defaultDownloadPath ||
|
||||
'~/Downloads';
|
||||
return resolvePath(destination, fileName);
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
return resolveDownloadFilePath(destination, fileName);
|
||||
};
|
||||
|
||||
const openProperties = (id: string) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
@@ -15,7 +16,7 @@ export const PropertiesModal = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
const { defaultDownloadPath, perServerConnections } = useSettingsStore();
|
||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
||||
|
||||
// Form states
|
||||
const [url, setUrl] = useState('');
|
||||
@@ -46,7 +47,14 @@ export const PropertiesModal = () => {
|
||||
if (activeItem) {
|
||||
setUrl(activeItem.url);
|
||||
setFileName(activeItem.fileName);
|
||||
setSaveLocation(activeItem.destination || defaultDownloadPath || '~/Downloads');
|
||||
if (activeItem.destination) {
|
||||
setSaveLocation(activeItem.destination);
|
||||
} else {
|
||||
void resolveCategoryDestination(
|
||||
useSettingsStore.getState(),
|
||||
activeItem.category
|
||||
).then(setSaveLocation);
|
||||
}
|
||||
setConnections(activeItem.connections || 16);
|
||||
|
||||
if (activeItem.speedLimit) {
|
||||
@@ -85,7 +93,7 @@ export const PropertiesModal = () => {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
}
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, defaultDownloadPath, setSelectedPropertiesDownloadId]);
|
||||
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
|
||||
|
||||
if (!selectedPropertiesDownloadId || !item) return null;
|
||||
|
||||
@@ -179,7 +187,7 @@ export const PropertiesModal = () => {
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
|
||||
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={item.destination}>{item.destination || defaultDownloadPath}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { categoryForFileName } from '../utils/downloads';
|
||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return 'Unknown size';
|
||||
@@ -23,7 +24,7 @@ export const QualityModal = React.memo(() => {
|
||||
|
||||
if (!isParsing && !activeMetadata && !parsingError) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
const handleConfirm = async () => {
|
||||
if (!activeMetadata || !activeMetadataUrl || !selectedFormatId) return;
|
||||
|
||||
const format = activeMetadata.formats.find(f => f.format_id === selectedFormatId);
|
||||
@@ -34,7 +35,7 @@ export const QualityModal = React.memo(() => {
|
||||
const filename = `${activeMetadata.title}.${format.ext}`.replace(/[\/\\?%*:|"<>]/g, '-');
|
||||
|
||||
const category = categoryForFileName(filename);
|
||||
const destination = (settings.downloadDirectories && settings.downloadDirectories[category]) || settings.defaultDownloadPath || '~/Downloads';
|
||||
const destination = await resolveCategoryDestination(settings, category);
|
||||
|
||||
const downloadItem = {
|
||||
id,
|
||||
@@ -51,7 +52,7 @@ export const QualityModal = React.memo(() => {
|
||||
mediaFormatSelector: format.format_id
|
||||
};
|
||||
|
||||
void addDownload(downloadItem, { type: 'start-now' });
|
||||
await addDownload(downloadItem, { type: 'start-now' });
|
||||
clearMetadata();
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SettingsTab,
|
||||
useSettingsStore
|
||||
} from '../store/useSettingsStore';
|
||||
import { useDirectoryPicker } from '../hooks/useDirectoryPicker';
|
||||
import {
|
||||
Download, Palette, Globe, Folder, Key,
|
||||
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code
|
||||
@@ -16,6 +15,11 @@ import { invokeCommand as invoke } from '../ipc';
|
||||
import type { EngineStatusItem } from '../bindings/EngineStatusItem';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import appIcon from '../assets/app-icon.png';
|
||||
import {
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
DOWNLOAD_CATEGORIES,
|
||||
normalizeCategorySubfolder
|
||||
} from '../utils/downloadLocations';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', label: 'Downloads', icon: Download },
|
||||
@@ -97,7 +101,6 @@ const runEngineStatusCheck = (check: EngineCheck, force: boolean) => {
|
||||
|
||||
export default function SettingsView() {
|
||||
const settings = useSettingsStore();
|
||||
const { pickDirectory } = useDirectoryPicker();
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
|
||||
// Local state for engine diagnostics
|
||||
@@ -245,7 +248,7 @@ runEngineChecks(false);
|
||||
};
|
||||
|
||||
const handleBrowseCategory = async (category: string) => {
|
||||
const currentPath = (settings.downloadDirectories || {})[category] || '';
|
||||
const currentPath = settings.categoryDirectoryOverrides[category] || settings.baseDownloadFolder;
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
@@ -253,46 +256,42 @@ runEngineChecks(false);
|
||||
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
settings.setCategoryDirectory(category, selected);
|
||||
settings.setCategoryDirectoryOverride(category, selected);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to select folder for ${category}:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseBulk = async () => {
|
||||
const handleBrowseBase = async () => {
|
||||
try {
|
||||
const base = await open({
|
||||
directory: true,
|
||||
multiple: false
|
||||
multiple: false,
|
||||
defaultPath: settings.baseDownloadFolder.startsWith('~')
|
||||
? undefined
|
||||
: settings.baseDownloadFolder
|
||||
});
|
||||
if (base && typeof base === 'string') {
|
||||
const cleanBase = base.replace(/\/$/, '');
|
||||
const paths = [
|
||||
`${cleanBase}/Musics`,
|
||||
`${cleanBase}/Movies`,
|
||||
`${cleanBase}/Compressed`,
|
||||
`${cleanBase}/Documents`,
|
||||
`${cleanBase}/Pictures`,
|
||||
`${cleanBase}/Applications`,
|
||||
`${cleanBase}/Other`
|
||||
];
|
||||
|
||||
settings.setCategoryDirectory('Musics', paths[0]);
|
||||
settings.setCategoryDirectory('Movies', paths[1]);
|
||||
settings.setCategoryDirectory('Compressed', paths[2]);
|
||||
settings.setCategoryDirectory('Documents', paths[3]);
|
||||
settings.setCategoryDirectory('Pictures', paths[4]);
|
||||
settings.setCategoryDirectory('Applications', paths[5]);
|
||||
settings.setCategoryDirectory('Other', paths[6]);
|
||||
|
||||
settings.setBaseDownloadFolder(base);
|
||||
try {
|
||||
await invoke('create_category_directories', { paths });
|
||||
const safeSubfolders = Object.fromEntries(
|
||||
DOWNLOAD_CATEGORIES.map(category => [
|
||||
category,
|
||||
normalizeCategorySubfolder(
|
||||
settings.categorySubfolders[category] || '',
|
||||
DEFAULT_CATEGORY_SUBFOLDERS[category]
|
||||
)
|
||||
])
|
||||
);
|
||||
await invoke('create_category_directories', {
|
||||
baseFolder: base,
|
||||
subfolders: safeSubfolders
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to create directories on disk:", e);
|
||||
}
|
||||
|
||||
showToast("Updated and created all category folders at base");
|
||||
showToast("Base download folder updated");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to browse base path:", e);
|
||||
@@ -670,20 +669,20 @@ runEngineChecks(false);
|
||||
<div className="settings-pane max-w-[760px]">
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row">
|
||||
<span className="text-[13px] font-semibold text-text-primary">Default Download Path</span>
|
||||
<div>
|
||||
<span className="text-[13px] font-semibold text-text-primary">Base Download Folder</span>
|
||||
<p className="mt-0.5 text-[11px] text-text-muted">Automatic category folders are created inside this folder.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.defaultDownloadPath || ''}
|
||||
onChange={(e) => settings.setDefaultDownloadPath(e.target.value)}
|
||||
value={settings.baseDownloadFolder}
|
||||
onChange={(e) => settings.setBaseDownloadFolder(e.target.value)}
|
||||
className="app-control w-64 text-[11px] px-2"
|
||||
placeholder="~/Downloads"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const path = await pickDirectory(settings.defaultDownloadPath);
|
||||
if (path) settings.setDefaultDownloadPath(path);
|
||||
}}
|
||||
onClick={handleBrowseBase}
|
||||
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
|
||||
>
|
||||
Browse
|
||||
@@ -706,46 +705,56 @@ runEngineChecks(false);
|
||||
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row bg-item-hover/20">
|
||||
<span className="text-[13px] font-semibold text-text-primary">All Categories Base</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text" readOnly placeholder="Choose base folder..."
|
||||
className="app-control w-64 text-text-muted text-[11px] px-2"
|
||||
/>
|
||||
<button
|
||||
onClick={handleBrowseBulk}
|
||||
className="app-button px-3 text-xs font-semibold text-accent border border-accent/20 bg-accent/10 hover:bg-accent/20"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-text-primary">Category Subfolders</span>
|
||||
<span className="text-[11px] text-text-muted">Relative to the base folder</span>
|
||||
</div>
|
||||
|
||||
{['Musics', 'Movies', 'Compressed', 'Documents', 'Pictures', 'Applications', 'Other'].map((category) => (
|
||||
{DOWNLOAD_CATEGORIES.map((category) => (
|
||||
<div key={category} className="mac-settings-row">
|
||||
<span className="text-[13px] text-text-primary pl-4">{category}</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={(settings.downloadDirectories || {})[category] || ''}
|
||||
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
|
||||
value={settings.categorySubfolders[category] || ''}
|
||||
onChange={(e) => settings.setCategorySubfolder(category, e.target.value)}
|
||||
onBlur={(e) => settings.setCategorySubfolder(
|
||||
category,
|
||||
normalizeCategorySubfolder(
|
||||
e.target.value,
|
||||
DEFAULT_CATEGORY_SUBFOLDERS[category]
|
||||
)
|
||||
)}
|
||||
className="app-control w-64 text-[11px] px-2"
|
||||
aria-label={`${category} subfolder`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleBrowseCategory(category)}
|
||||
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
|
||||
>
|
||||
Browse
|
||||
Custom folder
|
||||
</button>
|
||||
{settings.categoryDirectoryOverrides[category] && (
|
||||
<button
|
||||
onClick={() => settings.setCategoryDirectoryOverride(category)}
|
||||
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
|
||||
>
|
||||
Use automatic
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{settings.categoryDirectoryOverrides[category] && (
|
||||
<p className="col-span-full pl-4 text-[10px] text-text-muted">
|
||||
Override: {settings.categoryDirectoryOverrides[category]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mac-settings-row justify-end border-t-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
settings.resetCategoryDirectories();
|
||||
showToast("Reset directories to default");
|
||||
settings.resetCategoryLocations();
|
||||
showToast("Reset category locations to default");
|
||||
}}
|
||||
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1"
|
||||
>
|
||||
|
||||
+4
-1
@@ -101,7 +101,10 @@ type CommandMap = {
|
||||
db_replace_downloads: { args: { data: string }; result: void };
|
||||
db_get_all_queues: { args: undefined; result: string[] };
|
||||
db_replace_queues: { args: { data: string }; result: void };
|
||||
create_category_directories: { args: { paths: string[] }; result: void };
|
||||
create_category_directories: {
|
||||
args: { baseFolder: string; subfolders: Record<string, string> };
|
||||
result: void;
|
||||
};
|
||||
export_logs: { args: { destPath: string }; result: string };
|
||||
get_pending_order: { args: undefined; result: string[] };
|
||||
enqueue_download: { args: { item: any }; result: string };
|
||||
|
||||
@@ -12,6 +12,15 @@ vi.mock('@tauri-apps/plugin-log', () => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/path', () => ({
|
||||
homeDir: vi.fn().mockResolvedValue('/Users/test'),
|
||||
join: vi.fn(async (...parts: string[]) =>
|
||||
parts
|
||||
.map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))
|
||||
.join('/')
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./useSettingsStore', () => ({
|
||||
useSettingsStore: {
|
||||
getState: vi.fn(() => ({
|
||||
@@ -22,6 +31,17 @@ vi.mock('./useSettingsStore', () => ({
|
||||
customUserAgent: '',
|
||||
maxAutomaticRetries: 3,
|
||||
mediaCookieSource: 'none',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfolders: {
|
||||
Musics: 'Musics',
|
||||
Movies: 'Movies',
|
||||
Compressed: 'Compressed',
|
||||
Documents: 'Documents',
|
||||
Pictures: 'Pictures',
|
||||
Applications: 'Applications',
|
||||
Other: 'Other',
|
||||
},
|
||||
categoryDirectoryOverrides: {},
|
||||
})),
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -11,6 +11,10 @@ import type { MediaMetadata } from '../bindings/MediaMetadata';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
resolveCategoryDestination,
|
||||
resolveDownloadFilePath
|
||||
} from '../utils/downloadLocations';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
@@ -124,21 +128,18 @@ const syncSystemIntegrations = () => {
|
||||
const resolveDownloadPath = async (destination: string, fileName: string) => {
|
||||
let resolvedDestination = destination;
|
||||
if (destination.startsWith('~/')) {
|
||||
resolvedDestination = `${await homeDir()}/${destination.slice(2)}`;
|
||||
resolvedDestination = await resolveDownloadFilePath(await homeDir(), destination.slice(2));
|
||||
} else if (destination === '~') {
|
||||
resolvedDestination = await homeDir();
|
||||
}
|
||||
const separator = resolvedDestination.endsWith('/') ? '' : '/';
|
||||
return `${resolvedDestination}${separator}${fileName}`;
|
||||
return resolveDownloadFilePath(resolvedDestination, fileName);
|
||||
};
|
||||
|
||||
const effectiveDestinationForItem = (
|
||||
const effectiveDestinationForItem = async (
|
||||
item: Pick<DownloadItem, 'destination' | 'category'>,
|
||||
settings: ReturnType<typeof useSettingsStore.getState>
|
||||
) => item.destination ||
|
||||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
|
||||
settings.defaultDownloadPath ||
|
||||
'~/Downloads';
|
||||
): Promise<string> =>
|
||||
item.destination || resolveCategoryDestination(settings, item.category);
|
||||
|
||||
export type { DownloadStatus };
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
@@ -312,7 +313,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
},
|
||||
addDownload: async (item, action) => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const destPath = effectiveDestinationForItem(item, settings);
|
||||
const destPath = await effectiveDestinationForItem(item, settings);
|
||||
const ownedItem: DownloadItem = {
|
||||
...item,
|
||||
destination: destPath,
|
||||
@@ -453,9 +454,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
const destPath = targetItem.destination ||
|
||||
(settings.downloadDirectories && settings.downloadDirectories[targetItem.category]) ||
|
||||
settings.defaultDownloadPath ||
|
||||
'~/Downloads';
|
||||
await resolveCategoryDestination(settings, targetItem.category);
|
||||
|
||||
if (!destPath.trim()) {
|
||||
throw new Error('Cannot redownload: destination folder is missing.');
|
||||
@@ -639,10 +638,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
console.warn("Could not fetch keychain password for login:", e);
|
||||
}
|
||||
}
|
||||
const destPath = item.destination ||
|
||||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
|
||||
settings.defaultDownloadPath ||
|
||||
'~/Downloads';
|
||||
const destPath = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
itemsToEnqueue.push({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
|
||||
@@ -13,6 +13,10 @@ import type { SchedulerSettings } from '../bindings/SchedulerSettings';
|
||||
import type { SettingsTab } from '../bindings/SettingsTab';
|
||||
import type { SiteLogin } from '../bindings/SiteLogin';
|
||||
import type { Theme } from '../bindings/Theme';
|
||||
import {
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
normalizeDownloadLocationSettings
|
||||
} from '../utils/downloadLocations';
|
||||
|
||||
let settingsSave = Promise.resolve();
|
||||
|
||||
@@ -66,7 +70,9 @@ export type {
|
||||
|
||||
export interface SettingsState {
|
||||
theme: Theme;
|
||||
defaultDownloadPath: string;
|
||||
baseDownloadFolder: string;
|
||||
categorySubfolders: Record<string, string>;
|
||||
categoryDirectoryOverrides: Record<string, string>;
|
||||
maxConcurrentDownloads: number;
|
||||
globalSpeedLimit: string;
|
||||
isSidebarVisible: boolean;
|
||||
@@ -94,13 +100,12 @@ export interface SettingsState {
|
||||
askWhereToSaveEachFile: boolean;
|
||||
preventsSleepWhileDownloading: boolean;
|
||||
mediaCookieSource: MediaCookieSource;
|
||||
downloadDirectories: Record<string, string>;
|
||||
siteLogins: SiteLogin[];
|
||||
extensionPairingToken: string;
|
||||
autoCheckUpdates: boolean;
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
setDefaultDownloadPath: (path: string) => void;
|
||||
setBaseDownloadFolder: (path: string) => void;
|
||||
setMaxConcurrentDownloads: (count: number) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => void;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
@@ -127,8 +132,9 @@ export interface SettingsState {
|
||||
setAskWhereToSaveEachFile: (ask: boolean) => void;
|
||||
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
|
||||
setMediaCookieSource: (source: MediaCookieSource) => void;
|
||||
setCategoryDirectory: (category: string, path: string) => void;
|
||||
resetCategoryDirectories: () => void;
|
||||
setCategorySubfolder: (category: string, subfolder: string) => void;
|
||||
setCategoryDirectoryOverride: (category: string, path?: string) => void;
|
||||
resetCategoryLocations: () => void;
|
||||
addSiteLogin: (login: SiteLogin) => void;
|
||||
removeSiteLogin: (id: string) => void;
|
||||
regeneratePairingToken: () => void;
|
||||
@@ -136,40 +142,6 @@ export interface SettingsState {
|
||||
hydratePairingToken: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const defaultDirectories = {
|
||||
Musics: '~/Downloads/Musics',
|
||||
Movies: '~/Downloads/Movies',
|
||||
Compressed: '~/Downloads/Compressed',
|
||||
Documents: '~/Downloads/Documents',
|
||||
Pictures: '~/Downloads/Pictures',
|
||||
Applications: '~/Downloads/Applications',
|
||||
Other: '~/Downloads/Other'
|
||||
};
|
||||
|
||||
const normalizeDownloadDirectories = (directories: unknown): Record<string, string> => {
|
||||
if (!directories || typeof directories !== 'object') {
|
||||
return { ...defaultDirectories };
|
||||
}
|
||||
|
||||
const values = directories as Record<string, unknown>;
|
||||
const directory = (current: string, legacy?: string) => {
|
||||
const value = values[current] ?? (legacy ? values[legacy] : undefined);
|
||||
return typeof value === 'string' && value.length > 0
|
||||
? value
|
||||
: defaultDirectories[current as keyof typeof defaultDirectories];
|
||||
};
|
||||
|
||||
return {
|
||||
Musics: directory('Musics', 'Audio'),
|
||||
Movies: directory('Movies', 'Video'),
|
||||
Compressed: directory('Compressed', 'Archives'),
|
||||
Documents: directory('Documents'),
|
||||
Pictures: directory('Pictures', 'Images'),
|
||||
Applications: directory('Applications', 'Apps'),
|
||||
Other: directory('Other')
|
||||
};
|
||||
};
|
||||
|
||||
const generateSecureToken = () => {
|
||||
try {
|
||||
const cryptoObj = typeof window !== 'undefined'
|
||||
@@ -200,7 +172,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
defaultDownloadPath: '~/Downloads',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
|
||||
categoryDirectoryOverrides: {},
|
||||
maxConcurrentDownloads: 3,
|
||||
globalSpeedLimit: '',
|
||||
activeView: 'downloads',
|
||||
@@ -236,13 +210,15 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
askWhereToSaveEachFile: false,
|
||||
preventsSleepWhileDownloading: true,
|
||||
mediaCookieSource: 'none',
|
||||
downloadDirectories: { ...defaultDirectories },
|
||||
siteLogins: [],
|
||||
extensionPairingToken: '',
|
||||
autoCheckUpdates: true,
|
||||
|
||||
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
||||
setDefaultDownloadPath: (path) => { info('Settings updated: defaultDownloadPath'); set({ defaultDownloadPath: path }); },
|
||||
setBaseDownloadFolder: (path) => {
|
||||
info('Settings updated: baseDownloadFolder');
|
||||
set({ baseDownloadFolder: path });
|
||||
},
|
||||
setMaxConcurrentDownloads: (max) => {
|
||||
info('Settings updated: maxConcurrentDownloads');
|
||||
set({ maxConcurrentDownloads: max });
|
||||
@@ -282,13 +258,28 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error);
|
||||
},
|
||||
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
|
||||
setCategoryDirectory: (category, path) => {
|
||||
info(`Settings updated: category directory ${category}`);
|
||||
setCategorySubfolder: (category, subfolder) => {
|
||||
info(`Settings updated: category subfolder ${category}`);
|
||||
set((state) => ({
|
||||
downloadDirectories: { ...state.downloadDirectories, [category]: path }
|
||||
categorySubfolders: { ...state.categorySubfolders, [category]: subfolder }
|
||||
}));
|
||||
},
|
||||
resetCategoryDirectories: () => { info('Settings updated: resetCategoryDirectories'); set({ downloadDirectories: { ...defaultDirectories } }); },
|
||||
setCategoryDirectoryOverride: (category, path) => {
|
||||
info(`Settings updated: category directory override ${category}`);
|
||||
set((state) => {
|
||||
const next = { ...state.categoryDirectoryOverrides };
|
||||
if (path?.trim()) next[category] = path.trim();
|
||||
else delete next[category];
|
||||
return { categoryDirectoryOverrides: next };
|
||||
});
|
||||
},
|
||||
resetCategoryLocations: () => {
|
||||
info('Settings updated: resetCategoryLocations');
|
||||
set({
|
||||
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
|
||||
categoryDirectoryOverrides: {}
|
||||
});
|
||||
},
|
||||
addSiteLogin: (login) => set((state) => ({
|
||||
siteLogins: [...state.siteLogins, login]
|
||||
})),
|
||||
@@ -312,21 +303,29 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
storage: createJSONStorage(() => tauriStorage),
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState as SettingsState;
|
||||
}
|
||||
const persisted = persistedState as Partial<SettingsState>;
|
||||
const locations = normalizeDownloadLocationSettings(
|
||||
persisted as Partial<SettingsState> & {
|
||||
defaultDownloadPath?: unknown;
|
||||
downloadDirectories?: unknown;
|
||||
}
|
||||
);
|
||||
return {
|
||||
...persisted,
|
||||
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
|
||||
...locations,
|
||||
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
|
||||
} as SettingsState;
|
||||
},
|
||||
partialize: (state): PersistedSettings => ({
|
||||
theme: state.theme,
|
||||
defaultDownloadPath: state.defaultDownloadPath,
|
||||
baseDownloadFolder: state.baseDownloadFolder,
|
||||
categorySubfolders: state.categorySubfolders,
|
||||
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
|
||||
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
||||
globalSpeedLimit: state.globalSpeedLimit,
|
||||
isSidebarVisible: state.isSidebarVisible,
|
||||
@@ -351,7 +350,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
|
||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||
mediaCookieSource: state.mediaCookieSource,
|
||||
downloadDirectories: state.downloadDirectories,
|
||||
siteLogins: state.siteLogins,
|
||||
autoCheckUpdates: state.autoCheckUpdates
|
||||
}),
|
||||
@@ -359,12 +357,13 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
const persisted = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Partial<SettingsState>
|
||||
: {};
|
||||
const locations = normalizeDownloadLocationSettings(persisted);
|
||||
return ({
|
||||
...currentState,
|
||||
...persisted,
|
||||
...locations,
|
||||
appFontSize: persisted.appFontSize || currentState.appFontSize,
|
||||
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
|
||||
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
|
||||
siteLogins: Array.isArray(persisted.siteLogins)
|
||||
? persisted.siteLogins
|
||||
: currentState.siteLogins
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user