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
+18 -4
View File
@@ -119,12 +119,26 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
destinations.push(destination);
}
let category_destination = settings
.as_ref()
.and_then(|settings| settings.download_directories.get(&category).cloned());
let category_destination = settings.as_ref().map(|settings| {
settings
.category_directory_overrides
.get(&category)
.cloned()
.unwrap_or_else(|| {
let subfolder = settings
.category_subfolders
.get(&category)
.cloned()
.unwrap_or_else(|| category.clone());
std::path::PathBuf::from(&settings.base_download_folder)
.join(subfolder)
.to_string_lossy()
.to_string()
})
});
let default_destination = settings
.as_ref()
.map(|settings| settings.default_download_path.clone());
.map(|settings| settings.base_download_folder.clone());
if destinations.is_empty() {
let fallback_destination = category_destination.clone().or(default_destination.clone());
+3 -2
View File
@@ -227,7 +227,9 @@ pub struct SchedulerSettings {
#[ts(export, export_to = "../../src/bindings/")]
pub struct PersistedSettings {
pub theme: Theme,
pub default_download_path: String,
pub base_download_folder: String,
pub category_subfolders: HashMap<String, String>,
pub category_directory_overrides: HashMap<String, String>,
pub max_concurrent_downloads: usize,
pub global_speed_limit: String,
pub is_sidebar_visible: bool,
@@ -251,7 +253,6 @@ pub struct PersistedSettings {
pub ask_where_to_save_each_file: bool,
pub prevents_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource,
pub download_directories: HashMap<String, String>,
pub site_logins: Vec<SiteLogin>,
// Note: `extension_pairing_token` is intentionally NOT persisted here. It
// is an HMAC shared secret and is stored in the OS keychain by the
+26 -9
View File
@@ -141,21 +141,38 @@ fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
}
#[tauri::command]
pub async fn create_category_directories(app_handle: tauri::AppHandle, paths: Vec<String>) -> Result<(), String> {
use tauri::Manager;
for path in paths {
let mut expanded = std::path::PathBuf::from(&path);
if let Some(stripped) = path.strip_prefix("~/") {
if let Ok(home) = app_handle.path().home_dir() {
expanded = home.join(stripped);
}
pub async fn create_category_directories(
app_handle: tauri::AppHandle,
base_folder: String,
subfolders: std::collections::HashMap<String, String>,
) -> Result<(), String> {
let base = crate::resolve_path(&base_folder, &app_handle);
for subfolder in subfolders.values() {
let normalized = subfolder.replace('\\', "/");
let relative = std::path::Path::new(&normalized);
if relative.is_absolute()
|| normalized
.split('/')
.any(|part| part == ".." || part.ends_with(':'))
{
return Err(format!(
"Category subfolder must be a relative path: {subfolder}"
));
}
let expanded = base.join(relative);
if !expanded.exists() {
if let Err(e) = tokio::fs::create_dir_all(&expanded).await {
eprintln!("Failed to create directory {}: {}", path, e);
log::warn!(
"Failed to create category directory '{}': {}",
expanded.display(),
e
);
}
}
}
Ok(())
}
+174 -16
View File
@@ -16,10 +16,11 @@ pub fn load_settings(app_handle: &AppHandle) -> Result<PersistedSettings, String
pub fn decode_stored_settings(stored: &Value) -> Result<PersistedSettings, String> {
let document = decode_document(stored)?;
let state = settings_state(&document)?;
let mut state = settings_state(&document)?.clone();
migrate_location_settings(&mut state)?;
let mut merged = serde_json::to_value(default_settings())
.map_err(|error| format!("failed to serialize settings defaults: {error}"))?;
merge_json(&mut merged, state);
merge_json(&mut merged, &state);
let mut settings: PersistedSettings = serde_json::from_value(merged)
.map_err(|error| format!("invalid persisted settings: {error}"))?;
@@ -99,23 +100,138 @@ fn validate_settings(settings: &mut PersistedSettings) {
}
}
fn default_settings() -> PersistedSettings {
let download_directories = [
("Musics", "~/Downloads/Musics"),
("Movies", "~/Downloads/Movies"),
("Compressed", "~/Downloads/Compressed"),
("Documents", "~/Downloads/Documents"),
("Pictures", "~/Downloads/Pictures"),
("Applications", "~/Downloads/Applications"),
("Other", "~/Downloads/Other"),
fn default_category_subfolders() -> HashMap<String, String> {
[
("Musics", "Musics"),
("Movies", "Movies"),
("Compressed", "Compressed"),
("Documents", "Documents"),
("Pictures", "Pictures"),
("Applications", "Applications"),
("Other", "Other"),
]
.into_iter()
.map(|(category, path)| (category.to_string(), path.to_string()))
.collect::<HashMap<_, _>>();
.map(|(category, folder)| (category.to_string(), folder.to_string()))
.collect()
}
fn normalize_category_subfolder(value: &str, fallback: &str) -> String {
let parts = value
.split(['/', '\\'])
.filter(|part| {
!part.is_empty() && *part != "." && *part != ".." && !part.ends_with(':')
})
.collect::<Vec<_>>();
if parts.is_empty() {
fallback.to_string()
} else {
parts.join("/")
}
}
fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
let state = state
.as_object_mut()
.ok_or_else(|| "persisted settings state must be an object".to_string())?;
let base = state
.get("baseDownloadFolder")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.or_else(|| {
state
.get("defaultDownloadPath")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
})
.unwrap_or("~/Downloads")
.to_string();
let mut subfolders = default_category_subfolders();
if let Some(persisted) = state.get("categorySubfolders").and_then(Value::as_object) {
for (category, value) in persisted {
if let Some(folder) = value.as_str().filter(|folder| !folder.trim().is_empty()) {
let fallback = subfolders
.get(category)
.cloned()
.unwrap_or_else(|| category.clone());
subfolders.insert(
category.clone(),
normalize_category_subfolder(folder, &fallback),
);
}
}
}
let mut overrides = state
.get("categoryDirectoryOverrides")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if let Some(legacy) = state.get("downloadDirectories").and_then(Value::as_object) {
let aliases = [
("Musics", "Audio"),
("Movies", "Video"),
("Compressed", "Archives"),
("Documents", "Documents"),
("Pictures", "Images"),
("Applications", "Apps"),
("Other", "Other"),
];
for (category, alias) in aliases {
if overrides.contains_key(category) {
continue;
}
let Some(path) = legacy
.get(category)
.or_else(|| legacy.get(alias))
.and_then(Value::as_str)
.filter(|path| !path.trim().is_empty())
else {
continue;
};
let subfolder = subfolders
.get(category)
.map(String::as_str)
.unwrap_or(category);
if normalize_location_path(path) != derived_location_path(&base, subfolder) {
overrides.insert(category.to_string(), Value::String(path.to_string()));
}
}
}
state.insert("baseDownloadFolder".to_string(), Value::String(base));
state.insert(
"categorySubfolders".to_string(),
serde_json::to_value(subfolders)
.map_err(|error| format!("failed to migrate category subfolders: {error}"))?,
);
state.insert(
"categoryDirectoryOverrides".to_string(),
Value::Object(overrides),
);
state.remove("defaultDownloadPath");
state.remove("downloadDirectories");
Ok(())
}
fn normalize_location_path(path: &str) -> String {
path.replace('\\', "/").trim_end_matches('/').to_string()
}
fn derived_location_path(base: &str, subfolder: &str) -> String {
format!(
"{}/{}",
normalize_location_path(base),
subfolder.trim_matches(|character| character == '/' || character == '\\')
)
}
fn default_settings() -> PersistedSettings {
PersistedSettings {
theme: Theme::System,
default_download_path: "~/Downloads".to_string(),
base_download_folder: "~/Downloads".to_string(),
category_subfolders: default_category_subfolders(),
category_directory_overrides: HashMap::new(),
max_concurrent_downloads: 3,
global_speed_limit: String::new(),
is_sidebar_visible: true,
@@ -147,7 +263,6 @@ fn default_settings() -> PersistedSettings {
ask_where_to_save_each_file: false,
prevents_sleep_while_downloading: true,
media_cookie_source: MediaCookieSource::None,
download_directories,
site_logins: Vec::new(),
auto_check_updates: true,
}
@@ -184,7 +299,7 @@ mod tests {
assert!(settings.scheduler.enabled);
assert_eq!(settings.scheduler.start_time, "06:30");
assert_eq!(settings.scheduler.selected_days, vec![1, 3, 5]);
assert_eq!(settings.default_download_path, "~/Downloads");
assert_eq!(settings.base_download_folder, "~/Downloads");
}
#[test]
@@ -201,6 +316,49 @@ mod tests {
assert!(!settings.scheduler.enabled);
}
#[test]
fn migrates_legacy_location_settings_and_preserves_custom_overrides() {
let stored = json!({
"state": {
"defaultDownloadPath": "/Users/test/Downloads",
"downloadDirectories": {
"Movies": "/Users/test/Downloads/Movies",
"Documents": "/Volumes/Archive/Documents"
}
},
"version": 1
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.base_download_folder, "/Users/test/Downloads");
assert_eq!(settings.category_subfolders["Movies"], "Movies");
assert!(!settings.category_directory_overrides.contains_key("Movies"));
assert_eq!(
settings.category_directory_overrides["Documents"],
"/Volumes/Archive/Documents"
);
}
#[test]
fn normalizes_category_subfolders_as_relative_paths() {
let stored = json!({
"state": {
"baseDownloadFolder": "/Users/test/Downloads",
"categorySubfolders": {
"Movies": "../Media/./Movies",
"Documents": "../../"
}
},
"version": 2
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.category_subfolders["Movies"], "Media/Movies");
assert_eq!(settings.category_subfolders["Documents"], "Documents");
}
#[test]
fn replaces_zero_concurrency_with_the_safe_default() {
let stored = json!({"state": {"maxConcurrentDownloads": 0}, "version": 0});